Skip to content

Commit e15e507

Browse files
authored
Silence LongBridge checks and clarify buy note (#68)
1 parent cdcaa95 commit e15e507

7 files changed

Lines changed: 278 additions & 10 deletions

File tree

application/execution_service.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -658,15 +658,27 @@ def record_dry_run(symbol, side, quantity, price, *, order_type):
658658
investable_cash = max(0, investable_cash - cost_estimate)
659659
action_done = True
660660
else:
661+
if diff <= investable_cash:
662+
note_kind = "buy_deferred_small_target_gap"
663+
note_kwargs = {
664+
"symbol": f"{symbol}.US",
665+
"diff": f"{diff:.2f}",
666+
"price": f"{price:.2f}",
667+
}
668+
else:
669+
note_kind = "buy_deferred_small_cash"
670+
note_kwargs = {
671+
"symbol": f"{symbol}.US",
672+
"diff": f"{diff:.2f}",
673+
"investable": f"{investable_cash:.2f}",
674+
"price": f"{price:.2f}",
675+
}
661676
record_note_log(
662677
note_logs,
663678
translator=translator,
664679
with_prefix=with_prefix,
665-
kind="buy_deferred_small_cash",
666-
symbol=f"{symbol}.US",
667-
diff=f"{diff:.2f}",
668-
investable=f"{investable_cash:.2f}",
669-
price=f"{price:.2f}",
680+
kind=note_kind,
681+
**note_kwargs,
670682
)
671683

672684
if (

application/runtime_composer.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime
1111
from application.runtime_notification_adapters import build_runtime_notification_adapters
1212
from application.runtime_reporting_adapters import build_runtime_reporting_adapters
13+
from quant_platform_kit.common.port_adapters import CallableNotificationPort
1314
from quant_platform_kit.common.runtime_assembly import build_runtime_assembly
1415
from quant_platform_kit.common.runtime_target import build_runtime_context_fields
1516
from quant_platform_kit.common.runtime_target import RuntimeTarget
@@ -144,8 +145,13 @@ def build_reporting_adapters(self):
144145
printer=lambda line: self.printer(line, flush=True),
145146
)
146147

147-
def build_rebalance_runtime(self) -> LongBridgeRebalanceRuntime:
148+
def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False) -> LongBridgeRebalanceRuntime:
148149
notification_adapters = self.build_notification_adapters()
150+
notifications = (
151+
CallableNotificationPort(lambda _message: None)
152+
if silent_cycle_notifications
153+
else notification_adapters.notification_port
154+
)
149155
return LongBridgeRebalanceRuntime(
150156
bootstrap=self.bootstrap_builder(
151157
project_id=self.project_id,
@@ -160,7 +166,7 @@ def build_rebalance_runtime(self) -> LongBridgeRebalanceRuntime:
160166
resolve_rebalance_plan=self.strategy_adapters.resolve_rebalance_plan,
161167
market_data_port_factory=self.broker_adapters.build_market_data_port,
162168
estimate_max_purchase_quantity=self.estimate_max_purchase_quantity_fn,
163-
notifications=notification_adapters.notification_port,
169+
notifications=notifications,
164170
notify_issue=notification_adapters.notify_issue,
165171
portfolio_port_factory=self.broker_adapters.build_portfolio_port,
166172
execution_port_factory=self.broker_adapters.build_execution_port,

main.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
252252
flush=True,
253253
)
254254
run_rebalance_cycle(
255-
runtime=composer.build_rebalance_runtime(),
255+
runtime=composer.build_rebalance_runtime(
256+
silent_cycle_notifications=validation_only,
257+
),
256258
config=composer.build_rebalance_config(strategy_plugin_signals=strategy_plugin_signals),
257259
)
258260
finalize_runtime_report(report, status="ok")
@@ -290,6 +292,95 @@ def run_strategy(*, force_run: bool = False, validation_only: bool = False, vali
290292
except Exception as persist_exc:
291293
print(f"failed to persist execution report: {persist_exc}", flush=True)
292294

295+
296+
def run_probe(*, response_body: str = "Probe OK"):
297+
composer = None
298+
reporting_adapters = None
299+
log_context = None
300+
report = None
301+
try:
302+
composer = build_composer(dry_run_only_override=True)
303+
reporting_adapters = composer.build_reporting_adapters()
304+
log_context, report = reporting_adapters.start_run()
305+
strategy_plugin_signals, strategy_plugin_error = composer.load_strategy_plugin_signals(
306+
getattr(RUNTIME_SETTINGS, "strategy_plugin_mounts_json", None)
307+
)
308+
composer.attach_strategy_plugin_report(
309+
report,
310+
signals=strategy_plugin_signals,
311+
error=strategy_plugin_error,
312+
)
313+
reporting_adapters.log_event(
314+
log_context,
315+
"health_probe_received",
316+
message="Received health probe request",
317+
execution_window="probe",
318+
)
319+
runtime = composer.build_rebalance_runtime(silent_cycle_notifications=True)
320+
quote_context, trade_context, _indicators = runtime.bootstrap()
321+
snapshot = runtime.portfolio_port_factory(
322+
quote_context,
323+
trade_context,
324+
).get_portfolio_snapshot()
325+
positions = tuple(getattr(snapshot, "positions", ()) or ())
326+
buying_power = float(getattr(snapshot, "buying_power", 0.0) or 0.0)
327+
total_equity = float(getattr(snapshot, "total_equity", 0.0) or 0.0)
328+
finalize_runtime_report(
329+
report,
330+
status="ok",
331+
summary={
332+
"buying_power": buying_power,
333+
"total_equity": total_equity,
334+
"positions_count": len(positions),
335+
},
336+
)
337+
reporting_adapters.log_event(
338+
log_context,
339+
"health_probe_completed",
340+
message="Health probe completed",
341+
execution_window="probe",
342+
buying_power=buying_power,
343+
total_equity=total_equity,
344+
positions_count=len(positions),
345+
)
346+
return response_body, 200
347+
except Exception as exc:
348+
if report is not None:
349+
append_runtime_report_error(
350+
report,
351+
stage="health_probe",
352+
message=str(exc),
353+
error_type=type(exc).__name__,
354+
)
355+
finalize_runtime_report(report, status="error")
356+
if reporting_adapters is not None and log_context is not None:
357+
reporting_adapters.log_event(
358+
log_context,
359+
"health_probe_failed",
360+
message="Health probe failed",
361+
severity="ERROR",
362+
execution_window="probe",
363+
error_type=type(exc).__name__,
364+
error_message=str(exc),
365+
)
366+
err = f"{t('health_probe_title')}\n{t('health_probe_error_prefix')}{traceback.format_exc()}"
367+
if composer is not None:
368+
composer.build_notification_adapters().publish_cycle_notification(
369+
detailed_text=err,
370+
compact_text=err,
371+
)
372+
else:
373+
print(err, flush=True)
374+
return "Error", 500
375+
finally:
376+
try:
377+
if reporting_adapters is not None and report is not None:
378+
report_path = reporting_adapters.persist_execution_report(report)
379+
print(f"execution_report {report_path}", flush=True)
380+
except Exception as persist_exc:
381+
print(f"failed to persist execution report: {persist_exc}", flush=True)
382+
383+
293384
@app.route("/", methods=["POST", "GET"])
294385
def handle_trigger():
295386
"""Entrypoint for Cloud Run / scheduler: run strategy and return 200."""
@@ -311,5 +402,11 @@ def handle_precheck():
311402
return "Precheck OK", 200
312403

313404

405+
@app.route("/probe", methods=["POST", "GET"])
406+
def handle_probe():
407+
"""Post-open broker/account health probe; notify only on failure."""
408+
return run_probe()
409+
410+
314411
if __name__ == "__main__":
315412
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))

notifications/telegram.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
"income_locked": "🏦 收入层锁定占比: {ratio}",
3232
"signal": "🎯 触发信号: {msg}",
3333
"heartbeat_title": "💓 【心跳检测】",
34+
"health_probe_title": "🔎 【连接探针】",
35+
"health_probe_error_prefix": "健康探针异常:\n",
3436
"equity": "💰 净值: ${value}",
3537
"buying_power": "购买力",
3638
"reserved_cash": "预留现金",
@@ -59,6 +61,7 @@
5961
"buy_deferred": "ℹ️ [买入说明] {detail}",
6062
"buy_deferred_no_investable_cash": "账户现金 ${available} 低于策略保留阈值,可投资现金为 ${investable},本轮不发起买单",
6163
"buy_deferred_non_usd_cash": "检测到非 USD 现金({currencies}),但美股策略可用 USD 现金为 ${available}、可投资现金为 ${investable};请先换汇或入金 USD 后再买入",
64+
"buy_deferred_small_target_gap": "{symbol} 目标差额 ${diff} 未超过 1 股价格 ${price};为避免超过目标仓位,本轮不买入",
6265
"buy_deferred_small_cash": "{symbol} 目标差额 ${diff},但可投资现金 ${investable} 不足买入 1 股(价格 ${price})",
6366
"buy_deferred_cash_limit": "{symbol} 目标差额 ${diff},预算可买 {budget_qty} 股,但券商估算可买数量为 0;可能有未完成挂单、结算或购买力占用",
6467
"cash_sweep_rebuy": "🏦 [尾部回补] 剩余可投资现金回补 {symbol}: {qty}股 @ ${price}",
@@ -125,6 +128,8 @@
125128
"income_locked": "🏦 Income Locked: {ratio}",
126129
"signal": "🎯 Signal: {msg}",
127130
"heartbeat_title": "💓 【Heartbeat】",
131+
"health_probe_title": "🔎 【Health Probe】",
132+
"health_probe_error_prefix": "Health probe error:\n",
128133
"equity": "💰 Equity: ${value}",
129134
"buying_power": "Buying Power",
130135
"reserved_cash": "Reserved Cash",
@@ -153,6 +158,7 @@
153158
"buy_deferred": "ℹ️ [Buy note] {detail}",
154159
"buy_deferred_no_investable_cash": "Account cash ${available} is below the strategy reserve threshold, investable cash is ${investable}; no buy order this cycle",
155160
"buy_deferred_non_usd_cash": "Non-USD cash is present ({currencies}), but this US-equity strategy has USD cash ${available} and investable cash ${investable}; convert or deposit USD before buying",
161+
"buy_deferred_small_target_gap": "{symbol} target gap ${diff} does not exceed the 1-share price ${price}; skipped to avoid exceeding the target allocation",
156162
"buy_deferred_small_cash": "{symbol} target gap ${diff}, but investable cash ${investable} is not enough for 1 share at ${price}",
157163
"buy_deferred_cash_limit": "{symbol} target gap ${diff}, budget supports {budget_qty} shares, but broker estimate returned 0; an open order, settlement, or buying-power hold may still be blocking funds",
158164
"cash_sweep_rebuy": "🏦 [tail rebuy] residual investable cash rebought {symbol}: {qty} shares @ ${price}",

tests/test_rebalance_service.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,48 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self):
574574
self.assertEqual(len(sent_messages), 1)
575575
self.assertIn("🔔 【调仓指令】", sent_messages[0])
576576
self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0])
577-
self.assertIn("不足买入 1 股", sent_messages[0])
577+
self.assertIn("SOXX.US 目标差额 $163.14 未超过 1 股价格 $504.60", sent_messages[0])
578+
self.assertNotIn("可投资现金 $891.03 不足买入 1 股", sent_messages[0])
578579
self.assertNotIn("市价卖出] BOXX", sent_messages[0])
579580
self.assertNotIn("市价买入] SOXX", sent_messages[0])
580581
self.assertIn("市价买入] BOXX: 7股", sent_messages[0])
581582
self.assertIn("买入说明", sent_messages[0])
582583
self.assertNotIn("限价买入] SOXX", sent_messages[0])
583584

585+
def test_target_gap_below_one_share_does_not_report_cash_shortage(self):
586+
plan = _build_plan(
587+
strategy_symbols=("SOXL", "SOXX", "BOXX", "QQQI", "SPYI"),
588+
risk_symbols=("SOXL", "SOXX"),
589+
income_symbols=("QQQI", "SPYI"),
590+
safe_haven_symbols=("BOXX",),
591+
targets={"SOXL": 636.28, "SOXX": 218.01, "BOXX": 105.08, "QQQI": 0.0, "SPYI": 0.0},
592+
market_values={"SOXL": 636.28, "SOXX": 218.01, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0},
593+
sellable_quantities={"SOXL": 4, "SOXX": 0.4326, "BOXX": 0, "QQQI": 0, "SPYI": 0},
594+
quantities={"SOXL": 4, "SOXX": 0.4326, "BOXX": 0, "QQQI": 0, "SPYI": 0},
595+
current_min_trade=100.0,
596+
trade_threshold_value=100.0,
597+
investable_cash=164.98,
598+
market_status="🚀 风险开启(SOXX+SOXL)",
599+
deploy_ratio_text="90.0%",
600+
income_ratio_text="0.0%",
601+
income_locked_ratio_text="0.0%",
602+
signal_message="SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%",
603+
available_cash=196.50,
604+
total_strategy_equity=1050.79,
605+
portfolio_rows=(("SOXL", "SOXX"), ("BOXX", "QQQI", "SPYI")),
606+
)
607+
608+
sent_messages, _, _ = self._run_strategy(
609+
plan,
610+
prices={"BOXX.US": 116.74},
611+
dry_run_only=True,
612+
)
613+
614+
self.assertEqual(len(sent_messages), 1)
615+
self.assertIn("💓 【心跳检测】", sent_messages[0])
616+
self.assertIn("BOXX.US 目标差额 $105.08 未超过 1 股价格 $116.74", sent_messages[0])
617+
self.assertNotIn("可投资现金 $164.98 不足买入 1 股", sent_messages[0])
618+
584619
def test_strategy_target_buy_floors_to_cash_backed_whole_shares(self):
585620
plan = _build_plan(
586621
strategy_symbols=("SOXL",),

tests/test_request_handling.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,113 @@ def fake_run_strategy(*, force_run=False, validation_only=False, validation_labe
251251
self.assertTrue(observed["validation_only"])
252252
self.assertEqual(observed["validation_label"], "precheck")
253253

254+
def test_handle_probe_checks_account_snapshot_without_success_notification(self):
255+
module = load_module()
256+
observed = {"override": None, "events": [], "notifications": []}
257+
snapshot = types.SimpleNamespace(
258+
buying_power=123.0,
259+
total_equity=456.0,
260+
positions=(types.SimpleNamespace(symbol="SOXL"),),
261+
)
262+
263+
class FakePortfolioPort:
264+
def get_portfolio_snapshot(self):
265+
observed["snapshot_called"] = True
266+
return snapshot
267+
268+
class FakeRuntime:
269+
def __init__(self):
270+
self.bootstrap = lambda: ("quote-context", "trade-context", {"trend": "ok"})
271+
self.portfolio_port_factory = lambda quote_context, trade_context: FakePortfolioPort()
272+
273+
class FakeComposer:
274+
def build_reporting_adapters(self):
275+
return types.SimpleNamespace(
276+
start_run=lambda: (types.SimpleNamespace(run_id="run-001"), {"status": "pending"}),
277+
log_event=lambda context, event, **fields: observed["events"].append((event, fields)),
278+
persist_execution_report=lambda report: observed.setdefault("report", dict(report)) or "/tmp/report.json",
279+
)
280+
281+
def build_rebalance_runtime(self, *, silent_cycle_notifications=False):
282+
observed["silent_cycle_notifications"] = silent_cycle_notifications
283+
return FakeRuntime()
284+
285+
def build_notification_adapters(self):
286+
raise AssertionError("probe success should stay silent")
287+
288+
def load_strategy_plugin_signals(self, *_args, **_kwargs):
289+
return (), None
290+
291+
def attach_strategy_plugin_report(self, *_args, **_kwargs):
292+
return None
293+
294+
module.build_composer = lambda *, dry_run_only_override=None: observed.__setitem__("override", dry_run_only_override) or FakeComposer()
295+
296+
with module.app.test_request_context("/probe", method="POST"):
297+
body, status = module.handle_probe()
298+
299+
self.assertEqual(status, 200)
300+
self.assertEqual(body, "Probe OK")
301+
self.assertTrue(observed["override"])
302+
self.assertTrue(observed["silent_cycle_notifications"])
303+
self.assertTrue(observed["snapshot_called"])
304+
self.assertEqual(
305+
[event for event, _fields in observed["events"]],
306+
["health_probe_received", "health_probe_completed"],
307+
)
308+
self.assertEqual(observed["report"]["status"], "ok")
309+
self.assertEqual(observed["report"]["summary"]["buying_power"], 123.0)
310+
self.assertEqual(observed["report"]["summary"]["total_equity"], 456.0)
311+
self.assertEqual(observed["report"]["summary"]["positions_count"], 1)
312+
313+
def test_handle_probe_failure_sends_notification(self):
314+
module = load_module()
315+
observed = {"events": [], "notifications": []}
316+
317+
class FakeRuntime:
318+
def bootstrap(self):
319+
raise RuntimeError("probe failed")
320+
321+
class FakeNotifications:
322+
def publish_cycle_notification(self, **kwargs):
323+
observed["notifications"].append(kwargs)
324+
325+
class FakeComposer:
326+
def build_reporting_adapters(self):
327+
return types.SimpleNamespace(
328+
start_run=lambda: (types.SimpleNamespace(run_id="run-001"), {"status": "pending"}),
329+
log_event=lambda context, event, **fields: observed["events"].append((event, fields)),
330+
persist_execution_report=lambda report: observed.setdefault("report", dict(report)) or "/tmp/report.json",
331+
)
332+
333+
def build_rebalance_runtime(self, *, silent_cycle_notifications=False):
334+
return FakeRuntime()
335+
336+
def build_notification_adapters(self):
337+
return FakeNotifications()
338+
339+
def load_strategy_plugin_signals(self, *_args, **_kwargs):
340+
return (), None
341+
342+
def attach_strategy_plugin_report(self, *_args, **_kwargs):
343+
return None
344+
345+
module.build_composer = lambda *, dry_run_only_override=None: FakeComposer()
346+
347+
with module.app.test_request_context("/probe", method="POST"):
348+
body, status = module.handle_probe()
349+
350+
self.assertEqual(status, 500)
351+
self.assertEqual(body, "Error")
352+
self.assertEqual(observed["report"]["status"], "error")
353+
self.assertEqual(observed["report"]["errors"][0]["stage"], "health_probe")
354+
self.assertEqual(
355+
[event for event, _fields in observed["events"]],
356+
["health_probe_received", "health_probe_failed"],
357+
)
358+
self.assertEqual(len(observed["notifications"]), 1)
359+
self.assertIn("probe failed", observed["notifications"][0]["detailed_text"])
360+
254361
def test_run_strategy_emits_structured_runtime_events(self):
255362
module = load_module()
256363
observed = []
@@ -308,7 +415,8 @@ def attach_strategy_plugin_report(self, *_args, **_kwargs):
308415
def with_prefix(self, message):
309416
return message
310417

311-
def build_rebalance_runtime(self):
418+
def build_rebalance_runtime(self, *, silent_cycle_notifications=False):
419+
observed["silent_cycle_notifications"] = silent_cycle_notifications
312420
return types.SimpleNamespace()
313421

314422
def build_rebalance_config(self, *, strategy_plugin_signals=()):
@@ -323,6 +431,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()):
323431
module.run_strategy(force_run=True, validation_only=True)
324432

325433
self.assertTrue(observed["override"])
434+
self.assertTrue(observed["silent_cycle_notifications"])
326435

327436
def test_run_strategy_persists_machine_readable_report(self):
328437
module = load_module()

0 commit comments

Comments
 (0)