Skip to content

Commit 531aea6

Browse files
authored
Silence IBKR precheck notifications and add probe (#66)
* Silence IBKR precheck cycle notifications * Add IBKR probe endpoint
1 parent f0ea5ff commit 531aea6

5 files changed

Lines changed: 194 additions & 4 deletions

File tree

application/runtime_composer.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from application.runtime_reporting_adapters import build_runtime_reporting_adapters
1212
from quant_platform_kit.common.runtime_assembly import build_runtime_assembly
1313
from quant_platform_kit.common.runtime_target import build_runtime_context_fields
14-
from quant_platform_kit.common.port_adapters import CallablePortfolioPort
14+
from quant_platform_kit.common.port_adapters import CallableNotificationPort, CallablePortfolioPort
1515
from quant_platform_kit.common.runtime_target import RuntimeTarget
1616

1717

@@ -124,16 +124,21 @@ def build_reporting_adapters(self):
124124
printer=lambda line: self.printer(line, flush=True),
125125
)
126126

127-
def build_rebalance_runtime(self):
127+
def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False):
128128
notification_adapters = self.build_notification_adapters()
129+
notifications = (
130+
CallableNotificationPort(lambda _message: None)
131+
if silent_cycle_notifications
132+
else notification_adapters.notification_port
133+
)
129134
return IBKRRebalanceRuntime(
130135
connect_ib=self.connect_ib_fn,
131136
portfolio_port_factory=lambda ib: CallablePortfolioPort(
132137
lambda: self.build_portfolio_snapshot_fn(ib)
133138
),
134139
compute_signals=self.compute_signals_fn,
135140
execute_rebalance=self.execute_rebalance_fn,
136-
notifications=notification_adapters.notification_port,
141+
notifications=notifications,
137142
)
138143

139144
def build_rebalance_config(self, *, extra_notification_lines=()):

main.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -536,7 +536,9 @@ def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool
536536
return run_paper_liquidation_cycle()
537537
composer = build_composer(dry_run_only_override=dry_run_only_override)
538538
return run_rebalance_cycle(
539-
runtime=composer.build_rebalance_runtime(),
539+
runtime=composer.build_rebalance_runtime(
540+
silent_cycle_notifications=bool(dry_run_only_override),
541+
),
540542
config=composer.build_rebalance_config(extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals)),
541543
)
542544

@@ -701,6 +703,86 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body:
701703
print(f"failed to persist execution report: {persist_exc}", flush=True)
702704

703705

706+
def _handle_probe(*, response_body: str = "Probe OK"):
707+
ib = None
708+
log_context = None
709+
report = None
710+
try:
711+
log_context = build_request_log_context()
712+
report = build_execution_report(log_context, dry_run_only_override=True)
713+
strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals()
714+
attach_strategy_plugin_report(
715+
report,
716+
signals=strategy_plugin_signals,
717+
error=strategy_plugin_error,
718+
)
719+
log_runtime_event(
720+
log_context,
721+
"health_probe_received",
722+
message="Received health probe request",
723+
http_method=request.method,
724+
execution_window="probe",
725+
)
726+
ib = connect_ib()
727+
snapshot = build_portfolio_snapshot(ib)
728+
positions = tuple(getattr(snapshot, "positions", ()) or ())
729+
buying_power = float(getattr(snapshot, "buying_power", 0.0) or 0.0)
730+
total_equity = float(getattr(snapshot, "total_equity", 0.0) or 0.0)
731+
finalize_runtime_report(
732+
report,
733+
status="ok",
734+
summary={
735+
"buying_power": buying_power,
736+
"total_equity": total_equity,
737+
"positions_count": len(positions),
738+
},
739+
)
740+
log_runtime_event(
741+
log_context,
742+
"health_probe_completed",
743+
message="Health probe completed",
744+
execution_window="probe",
745+
buying_power=buying_power,
746+
total_equity=total_equity,
747+
positions_count=len(positions),
748+
)
749+
return response_body, 200
750+
except Exception as exc:
751+
if report is not None:
752+
append_runtime_report_error(
753+
report,
754+
stage="health_probe",
755+
message=str(exc),
756+
error_type=type(exc).__name__,
757+
)
758+
finalize_runtime_report(report, status="error")
759+
if log_context is not None:
760+
log_runtime_event(
761+
log_context,
762+
"health_probe_failed",
763+
message="Health probe failed",
764+
severity="ERROR",
765+
execution_window="probe",
766+
error_type=type(exc).__name__,
767+
error_message=str(exc),
768+
)
769+
error_msg = f"{t('health_probe_title')}\n{t('health_probe_error_prefix')}{traceback.format_exc()}"
770+
publish_notification(detailed_text=error_msg, compact_text=error_msg)
771+
return "Error", 500
772+
finally:
773+
if ib is not None and hasattr(ib, "disconnect"):
774+
try:
775+
ib.disconnect()
776+
except Exception as disconnect_exc:
777+
print(f"failed to disconnect IBKR probe client: {disconnect_exc}", flush=True)
778+
try:
779+
if report is not None:
780+
report_path = persist_execution_report(report, dry_run_only_override=True)
781+
print(f"execution_report {report_path}", flush=True)
782+
except Exception as persist_exc:
783+
print(f"failed to persist execution report: {persist_exc}", flush=True)
784+
785+
704786
@app.route("/", methods=["POST", "GET"])
705787
def handle_request():
706788
return _handle_request()
@@ -711,6 +793,11 @@ def handle_precheck():
711793
return _handle_request(dry_run_only_override=True, response_body="Precheck OK")
712794

713795

796+
@app.route("/probe", methods=["POST", "GET"])
797+
def handle_probe():
798+
return _handle_probe()
799+
800+
714801
@app.route("/health", methods=["GET"])
715802
def health():
716803
return "OK", 200

notifications/telegram.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"rebalance_title": "🔔 【调仓指令】",
99
"heartbeat_title": "💓 【心跳检测】",
1010
"error_title": "🚨 【策略异常】",
11+
"health_probe_title": "🔎 【连接探针】",
12+
"health_probe_error_prefix": "健康探针异常:\n",
1113
"canary_title": "🐤 【金丝雀检查】",
1214
"strategy_label": "🧭 策略: {name}",
1315
"account_ids_detail": "🆔 账户: {account_ids}",
@@ -114,6 +116,8 @@
114116
"rebalance_title": "🔔 【Trade Execution Report】",
115117
"heartbeat_title": "💓 【Heartbeat】",
116118
"error_title": "🚨 【Strategy Error】",
119+
"health_probe_title": "🔎 【Health Probe】",
120+
"health_probe_error_prefix": "Health probe error:\n",
117121
"canary_title": "🐤 【Canary Check】",
118122
"strategy_label": "🧭 Strategy: {name}",
119123
"account_ids_detail": "🆔 Account: {account_ids}",

tests/test_request_handling.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,97 @@ def test_handle_precheck_get_does_not_execute(strategy_module, monkeypatch):
103103
assert observed["called"] is False
104104

105105

106+
def test_handle_probe_checks_account_snapshot_without_success_notification(strategy_module, monkeypatch):
107+
observed = {"events": [], "disconnects": 0, "notifications": []}
108+
109+
class FakeIB:
110+
def disconnect(self):
111+
observed["disconnects"] += 1
112+
113+
snapshot = types.SimpleNamespace(
114+
buying_power=123.0,
115+
total_equity=456.0,
116+
positions=(types.SimpleNamespace(symbol="SOXL"),),
117+
)
118+
119+
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
120+
monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"})
121+
monkeypatch.setattr(
122+
strategy_module,
123+
"persist_execution_report",
124+
lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json",
125+
)
126+
monkeypatch.setattr(
127+
strategy_module,
128+
"log_runtime_event",
129+
lambda context, event, **fields: observed["events"].append((event, fields)),
130+
)
131+
monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None))
132+
monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None)
133+
monkeypatch.setattr(strategy_module, "connect_ib", lambda: FakeIB())
134+
monkeypatch.setattr(strategy_module, "build_portfolio_snapshot", lambda ib: snapshot)
135+
monkeypatch.setattr(
136+
strategy_module,
137+
"publish_notification",
138+
lambda **_kwargs: observed["notifications"].append(_kwargs),
139+
)
140+
141+
with strategy_module.app.test_request_context("/probe", method="POST"):
142+
body, status = strategy_module.handle_probe()
143+
144+
assert status == 200
145+
assert body == "Probe OK"
146+
assert [event for event, _fields in observed["events"]] == [
147+
"health_probe_received",
148+
"health_probe_completed",
149+
]
150+
assert observed["report"]["status"] == "ok"
151+
assert observed["report"]["summary"]["buying_power"] == 123.0
152+
assert observed["report"]["summary"]["total_equity"] == 456.0
153+
assert observed["report"]["summary"]["positions_count"] == 1
154+
assert observed["disconnects"] == 1
155+
assert observed["notifications"] == []
156+
157+
158+
def test_handle_probe_failure_sends_notification(strategy_module, monkeypatch):
159+
observed = {"events": [], "notifications": []}
160+
161+
monkeypatch.setattr(strategy_module, "build_request_log_context", lambda: types.SimpleNamespace(run_id="run-001"))
162+
monkeypatch.setattr(strategy_module, "build_execution_report", lambda log_context, **_kwargs: {"status": "pending"})
163+
monkeypatch.setattr(strategy_module, "persist_execution_report", lambda report, **_kwargs: observed.setdefault("report", dict(report)) or "/tmp/runtime-report.json")
164+
monkeypatch.setattr(
165+
strategy_module,
166+
"log_runtime_event",
167+
lambda context, event, **fields: observed["events"].append((event, fields)),
168+
)
169+
monkeypatch.setattr(strategy_module, "load_strategy_plugin_signals", lambda: ((), None))
170+
monkeypatch.setattr(strategy_module, "attach_strategy_plugin_report", lambda *args, **kwargs: None)
171+
monkeypatch.setattr(
172+
strategy_module,
173+
"connect_ib",
174+
lambda: (_ for _ in ()).throw(RuntimeError("probe failed")),
175+
)
176+
monkeypatch.setattr(
177+
strategy_module,
178+
"publish_notification",
179+
lambda **kwargs: observed["notifications"].append(kwargs),
180+
)
181+
182+
with strategy_module.app.test_request_context("/probe", method="POST"):
183+
body, status = strategy_module.handle_probe()
184+
185+
assert status == 500
186+
assert body == "Error"
187+
assert observed["report"]["status"] == "error"
188+
assert observed["report"]["errors"][0]["stage"] == "health_probe"
189+
assert [event for event, _fields in observed["events"]] == [
190+
"health_probe_received",
191+
"health_probe_failed",
192+
]
193+
assert len(observed["notifications"]) == 1
194+
assert "probe failed" in observed["notifications"][0]["detailed_text"]
195+
196+
106197
def test_build_extra_notification_lines_includes_account_id(strategy_module):
107198
lines = strategy_module.build_extra_notification_lines(())
108199
assert any("U18308207" in line for line in lines)

tests/test_runtime_composer.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ def fake_reporting_builder(**kwargs):
8282
notification_adapters = composer.build_notification_adapters()
8383
reporting_adapters = composer.build_reporting_adapters()
8484
runtime = composer.build_rebalance_runtime()
85+
silent_runtime = composer.build_rebalance_runtime(silent_cycle_notifications=True)
8586
config = composer.build_rebalance_config(extra_notification_lines=("plugin-line",))
8687

8788
assert notification_adapters.notification_port == "notification-port"
@@ -97,6 +98,8 @@ def fake_reporting_builder(**kwargs):
9798
assert runtime.compute_signals == "compute-signals"
9899
assert runtime.execute_rebalance == "execute-rebalance"
99100
assert runtime.notifications == "notification-port"
101+
silent_runtime.notifications.send_text("precheck heartbeat")
102+
assert "sent_message" not in observed
100103
assert config.separator == "━━━━━━━━━━━━━━━━━━"
101104
assert config.strategy_display_name == "全球 ETF 轮动"
102105
assert config.reconciliation_output_path == "/tmp/reconciliation.json"

0 commit comments

Comments
 (0)