From f2e8452d008bca52b614e2f3915817f9a1732805 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:32:36 +0800 Subject: [PATCH 1/3] Add IBKR dry-run report summary --- main.py | 67 ++++++++++++++++++++++------------ tests/test_request_handling.py | 24 ++++++++++++ 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/main.py b/main.py index 8d317b9..bee82dc 100644 --- a/main.py +++ b/main.py @@ -723,6 +723,43 @@ def _has_signal_snapshot_details(snapshot: dict[str, object]) -> bool: ) +def _count_orders(*candidates) -> int: + for candidate in candidates: + if isinstance(candidate, (list, tuple)): + return len(candidate) + return 0 + + +def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_record, *, dry_run: bool) -> dict: + orders_submitted_count = _count_orders( + execution_summary.get("orders_submitted"), + reconciliation_record.get("orders_submitted"), + ) + orders_skipped_count = _count_orders( + execution_summary.get("orders_skipped"), + reconciliation_record.get("orders_skipped"), + ) + orders_previewed_count = orders_submitted_count if dry_run else 0 + return { + "result": cycle_result.result, + "execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"), + "no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"), + "orders_submitted_count": orders_submitted_count, + "orders_previewed_count": orders_previewed_count, + "orders_skipped_count": orders_skipped_count, + "dry_run_order_preview_available": bool(dry_run and orders_previewed_count > 0), + "snapshot_price_fallback_used": bool( + execution_summary.get("snapshot_price_fallback_used") + or reconciliation_record.get("snapshot_price_fallback_used") + ), + "snapshot_price_fallback_count": int( + execution_summary.get("snapshot_price_fallback_count") + or reconciliation_record.get("snapshot_price_fallback_count") + or 0 + ), + } + + def publish_strategy_plugin_alerts(signals, *, report=None): result = dispatch_strategy_plugin_alerts( signals, @@ -934,30 +971,12 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body: finalize_runtime_report( report, status="ok", - summary={ - "result": cycle_result.result, - "execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"), - "no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"), - "orders_submitted_count": len( - execution_summary.get("orders_submitted") - or reconciliation_record.get("orders_submitted") - or () - ), - "orders_skipped_count": len( - execution_summary.get("orders_skipped") - or reconciliation_record.get("orders_skipped") - or () - ), - "snapshot_price_fallback_used": bool( - execution_summary.get("snapshot_price_fallback_used") - or reconciliation_record.get("snapshot_price_fallback_used") - ), - "snapshot_price_fallback_count": int( - execution_summary.get("snapshot_price_fallback_count") - or reconciliation_record.get("snapshot_price_fallback_count") - or 0 - ), - }, + summary=_build_cycle_report_summary( + cycle_result, + execution_summary, + reconciliation_record, + dry_run=bool(report.get("dry_run")), + ), diagnostics={ "result": cycle_result.result, "price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"), diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 672811c..57b3f91 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -392,6 +392,9 @@ def fake_run_strategy_core(**_kwargs): assert status == 200 assert body == "OK - executed" assert observed["report"]["summary"]["execution_status"] == "executed" + assert observed["report"]["summary"]["orders_submitted_count"] == 1 + assert observed["report"]["summary"]["orders_previewed_count"] == 0 + assert observed["report"]["summary"]["dry_run_order_preview_available"] is False assert observed["report"]["summary"]["snapshot_price_fallback_used"] is True assert observed["report"]["summary"]["snapshot_price_fallback_count"] == 1 assert observed["report"]["diagnostics"]["price_source_mode"] == "mixed_market_quote_snapshot_close" @@ -399,6 +402,27 @@ def fake_run_strategy_core(**_kwargs): assert observed["report"]["artifacts"]["reconciliation_record_path"] == "/tmp/reconciliation.json" +def test_cycle_report_summary_counts_dry_run_order_previews(strategy_module): + cycle_result = StrategyCycleResult(result="Precheck OK") + execution_summary = { + "execution_status": "dry_run", + "orders_submitted": [{"symbol": "AAA"}, {"symbol": "BBB"}], + "orders_skipped": [{"symbol": "CCC", "reason": "min_notional"}], + } + + summary = strategy_module._build_cycle_report_summary( + cycle_result, + execution_summary, + {}, + dry_run=True, + ) + + assert summary["orders_submitted_count"] == 2 + assert summary["orders_previewed_count"] == 2 + assert summary["orders_skipped_count"] == 1 + assert summary["dry_run_order_preview_available"] is True + + def test_handle_request_post_returns_market_closed_when_schedule_empty(strategy_module, monkeypatch): observed = {} From fb5c18a220f6097489f34f1e564522e293884ffe Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 3 Jun 2026 05:50:46 +0800 Subject: [PATCH 2/3] Expose quote snapshots in IBKR dry-run reports --- application/execution_service.py | 27 +++++++++++++++++++++++++++ application/reconciliation_service.py | 1 + main.py | 6 +++++- tests/test_execution_service.py | 1 + tests/test_request_handling.py | 2 ++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/application/execution_service.py b/application/execution_service.py index 2257c24..7b31b64 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -215,12 +215,27 @@ def get_market_prices( symbols, *, fetch_quote_snapshots, + quote_recorder=None, ): """Fetch market prices for multiple symbols in one pass.""" quotes = fetch_quote_snapshots(ib, symbols) + if quote_recorder is not None: + for symbol, quote in quotes.items(): + quote_recorder(symbol, quote) return {symbol: quote.last_price for symbol, quote in quotes.items()} +def _serialize_quote_snapshot(snapshot, *, symbol: str | None = None) -> dict: + return { + "symbol": str(getattr(snapshot, "symbol", "") or symbol or "").strip().upper(), + "as_of": str(getattr(snapshot, "as_of", "") or ""), + "last_price": float(getattr(snapshot, "last_price", 0.0) or 0.0), + "bid_price": getattr(snapshot, "bid_price", None), + "ask_price": getattr(snapshot, "ask_price", None), + "currency": str(getattr(snapshot, "currency", "") or "").strip(), + } + + def check_order_submitted(report, *, translator): """Check if order was accepted. DAY orders auto-expire at close if not filled.""" order_id = report.broker_order_id @@ -1005,6 +1020,14 @@ def execute_rebalance( equity = account_values.get("equity", 0) normalized_account_ids = _normalize_account_ids(account_ids) order_account_id = _resolve_order_account_id(normalized_account_ids) + quote_snapshots_by_symbol: dict[str, dict] = {} + + def record_quote_snapshot(symbol, snapshot) -> None: + payload = _serialize_quote_snapshot(snapshot, symbol=symbol) + symbol = payload.get("symbol") + if symbol: + quote_snapshots_by_symbol[symbol] = payload + execution_summary = { "mode": "dry_run" if dry_run_only else "paper", "strategy_profile": strategy_profile, @@ -1091,6 +1114,7 @@ def execute_rebalance( ib, all_symbols, fetch_quote_snapshots=fetch_quote_snapshots, + quote_recorder=record_quote_snapshot, ) prices, snapshot_price_fallback_symbols = _apply_snapshot_price_fallbacks( prices, @@ -1105,6 +1129,9 @@ def execute_rebalance( execution_summary["price_fallback_symbols"] = list(snapshot_price_fallback_symbols) execution_summary["price_fallback_count"] = len(snapshot_price_fallback_symbols) execution_summary["price_fallback_source"] = price_fallback_source if snapshot_price_fallback_symbols else None + execution_summary["quote_snapshot"] = { + "quotes": list(quote_snapshots_by_symbol.values()), + } if snapshot_price_fallback_symbols: execution_summary["price_source_mode"] = f"mixed_market_quote_{price_fallback_source}" diff --git a/application/reconciliation_service.py b/application/reconciliation_service.py index 82bff1d..1392c35 100644 --- a/application/reconciliation_service.py +++ b/application/reconciliation_service.py @@ -78,6 +78,7 @@ def build_reconciliation_record( "current_stock_weight": execution_summary.get("current_stock_weight"), "current_safe_haven_weight": execution_summary.get("current_safe_haven_weight"), "price_source_mode": execution_summary.get("price_source_mode"), + "quote_snapshot": execution_summary.get("quote_snapshot") or {}, "snapshot_price_fallback_used": execution_summary.get("snapshot_price_fallback_used"), "snapshot_price_fallback_count": execution_summary.get("snapshot_price_fallback_count"), "snapshot_price_fallback_symbols": execution_summary.get("snapshot_price_fallback_symbols") or [], diff --git a/main.py b/main.py index bee82dc..d681715 100644 --- a/main.py +++ b/main.py @@ -740,7 +740,7 @@ def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_ reconciliation_record.get("orders_skipped"), ) orders_previewed_count = orders_submitted_count if dry_run else 0 - return { + summary = { "result": cycle_result.result, "execution_status": execution_summary.get("execution_status") or reconciliation_record.get("execution_status"), "no_op_reason": execution_summary.get("no_op_reason") or reconciliation_record.get("no_op_reason"), @@ -758,6 +758,10 @@ def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_ or 0 ), } + quote_snapshot = execution_summary.get("quote_snapshot") or reconciliation_record.get("quote_snapshot") + if quote_snapshot: + summary["quote_snapshot"] = quote_snapshot + return summary def publish_strategy_plugin_alerts(signals, *, report=None): diff --git a/tests/test_execution_service.py b/tests/test_execution_service.py index 1e98cd2..1db04f2 100644 --- a/tests/test_execution_service.py +++ b/tests/test_execution_service.py @@ -302,6 +302,7 @@ def accountValues(self): assert {order["symbol"] for order in summary["orders_submitted"]} == {"02834", "03110"} assert all(order["status"] == "dry_run" for order in summary["orders_submitted"]) assert all(float(order["quantity"]).is_integer() for order in summary["orders_submitted"]) + assert {quote["symbol"] for quote in summary["quote_snapshot"]["quotes"]} == {"02834", "03110"} assert any(log.startswith("DRY_RUN buy 02834") for log in trade_logs) assert any(log.startswith("DRY_RUN buy 03110") for log in trade_logs) diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 57b3f91..8e13bfb 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -408,6 +408,7 @@ def test_cycle_report_summary_counts_dry_run_order_previews(strategy_module): "execution_status": "dry_run", "orders_submitted": [{"symbol": "AAA"}, {"symbol": "BBB"}], "orders_skipped": [{"symbol": "CCC", "reason": "min_notional"}], + "quote_snapshot": {"quotes": [{"symbol": "AAA", "last_price": 100.0}]}, } summary = strategy_module._build_cycle_report_summary( @@ -421,6 +422,7 @@ def test_cycle_report_summary_counts_dry_run_order_previews(strategy_module): assert summary["orders_previewed_count"] == 2 assert summary["orders_skipped_count"] == 1 assert summary["dry_run_order_preview_available"] is True + assert summary["quote_snapshot"]["quotes"][0]["symbol"] == "AAA" def test_handle_request_post_returns_market_closed_when_schedule_empty(strategy_module, monkeypatch): From 4deeac620355c49784e0d2c07c614618a0f5927a Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Wed, 3 Jun 2026 06:17:18 +0800 Subject: [PATCH 3/3] Record dry-run notification delivery evidence --- application/runtime_composer.py | 12 ++- application/runtime_notification_adapters.py | 23 +++++- main.py | 78 +++++++++++++++++--- tests/test_request_handling.py | 41 ++++++++++ 4 files changed, 139 insertions(+), 15 deletions(-) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index e36fb5e..246355f 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -64,10 +64,11 @@ class IBKRRuntimeComposer: runtime_target: RuntimeTarget | None = None extra_reporting_fields: Mapping[str, Any] = field(default_factory=dict) - def build_notification_adapters(self): + def build_notification_adapters(self, *, delivery_events: list[dict[str, Any]] | None = None): return self.notification_builder( send_message=self.send_message, log_message=lambda message: self.printer(message, flush=True), + delivery_events=delivery_events, ) def build_reporting_adapters(self): @@ -124,8 +125,13 @@ def build_reporting_adapters(self): printer=lambda line: self.printer(line, flush=True), ) - def build_rebalance_runtime(self, *, silent_cycle_notifications: bool = False): - notification_adapters = self.build_notification_adapters() + def build_rebalance_runtime( + self, + *, + silent_cycle_notifications: bool = False, + notification_delivery_events: list[dict[str, Any]] | None = None, + ): + notification_adapters = self.build_notification_adapters(delivery_events=notification_delivery_events) notifications = ( CallableNotificationPort(lambda _message: None) if silent_cycle_notifications diff --git a/application/runtime_notification_adapters.py b/application/runtime_notification_adapters.py index 3d6908b..3efca6b 100644 --- a/application/runtime_notification_adapters.py +++ b/application/runtime_notification_adapters.py @@ -2,7 +2,9 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass +from typing import Any from notifications.events import NotificationPublisher, RenderedNotification from quant_platform_kit.common.port_adapters import CallableNotificationPort @@ -13,6 +15,7 @@ class IBKRNotificationAdapters: notification_port: NotificationPort cycle_publisher: NotificationPublisher + delivery_events: list[dict[str, Any]] def publish_cycle_notification(self, *, detailed_text: str, compact_text: str) -> None: self.cycle_publisher.publish( @@ -27,11 +30,27 @@ def build_runtime_notification_adapters( *, send_message, log_message=None, + delivery_events: list[dict[str, Any]] | None = None, ) -> IBKRNotificationAdapters: + recorded_delivery_events = delivery_events if delivery_events is not None else [] + + def send_recorded_message(message: str) -> None: + send_message(message) + compact = str(message or "") + recorded_delivery_events.append( + { + "sink": "telegram", + "delivery_status": "sent", + "compact_text_sha256": hashlib.sha256(compact.encode("utf-8")).hexdigest(), + "compact_text_length": len(compact), + } + ) + return IBKRNotificationAdapters( - notification_port=CallableNotificationPort(send_message), + notification_port=CallableNotificationPort(send_recorded_message), cycle_publisher=NotificationPublisher( log_message=log_message or (lambda message: print(message, flush=True)), - send_message=send_message, + send_message=send_recorded_message, ), + delivery_events=recorded_delivery_events, ) diff --git a/main.py b/main.py index d681715..49cdf1e 100644 --- a/main.py +++ b/main.py @@ -764,6 +764,37 @@ def _build_cycle_report_summary(cycle_result, execution_summary, reconciliation_ return summary +def _build_notification_delivery_log_for_report( + *, + platform: str, + strategy_profile: str, + run_id: str, + dry_run: bool, + orders_previewed_count: int, + delivery_events: list[dict], +) -> dict: + events = [dict(event) for event in delivery_events if dict(event).get("delivery_status") == "sent"] + if not dry_run or orders_previewed_count <= 0 or not events: + return {} + return { + "notification_schema_version": "hk_live_enablement_notification.v1", + "notification_event_type": "hk_snapshot_live_enablement_dry_run", + "notification_correlation_id": str(run_id or ""), + "locales": ["en", "zh-Hans"], + "profile": str(strategy_profile or ""), + "platform": str(platform or ""), + "validation_status": "passed", + "orders_previewed": int(orders_previewed_count), + "delivery_events": events, + "notification_contains_profile": True, + "notification_contains_platform": True, + "notification_contains_validation_status": True, + "notification_contains_order_preview_summary": True, + "notification_redacts_sensitive_fields": True, + "redaction_policy": "raw notification text is not persisted; only sha256 and length are recorded", + } + + def publish_strategy_plugin_alerts(signals, *, report=None): result = dispatch_strategy_plugin_alerts( signals, @@ -854,17 +885,31 @@ def run_paper_liquidation_cycle(): ) -def run_strategy_core(*, strategy_plugin_signals=(), dry_run_only_override: bool | None = None): +def run_strategy_core( + *, + strategy_plugin_signals=(), + dry_run_only_override: bool | None = None, + notification_delivery_events: list[dict] | None = None, +): if PAPER_LIQUIDATE_ONLY and dry_run_only_override is None: return run_paper_liquidation_cycle() composer = build_composer( dry_run_only_override=dry_run_only_override, strategy_plugin_signals=strategy_plugin_signals, ) - return run_rebalance_cycle( - runtime=composer.build_rebalance_runtime( + try: + rebalance_runtime = composer.build_rebalance_runtime( silent_cycle_notifications=bool(dry_run_only_override), - ), + notification_delivery_events=notification_delivery_events, + ) + except TypeError as exc: + if "notification_delivery_events" not in str(exc): + raise + rebalance_runtime = composer.build_rebalance_runtime( + silent_cycle_notifications=bool(dry_run_only_override), + ) + return run_rebalance_cycle( + runtime=rebalance_runtime, config=composer.build_rebalance_config( extra_notification_lines=build_extra_notification_lines(strategy_plugin_signals), ), @@ -935,10 +980,12 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body: ) if dry_run_only_override is None: publish_strategy_plugin_alerts(strategy_plugin_signals, report=report) + notification_delivery_events: list[dict] = [] cycle_result = coerce_strategy_cycle_result( run_strategy_core( strategy_plugin_signals=strategy_plugin_signals, dry_run_only_override=dry_run_only_override, + notification_delivery_events=notification_delivery_events, ) ) execution_summary = dict(cycle_result.execution_summary or {}) @@ -972,15 +1019,26 @@ def _handle_request(*, dry_run_only_override: bool | None = None, response_body: execution_window="precheck" if dry_run_only_override else "execution", **signal_snapshot, ) + report_summary = _build_cycle_report_summary( + cycle_result, + execution_summary, + reconciliation_record, + dry_run=bool(report.get("dry_run")), + ) + notification_delivery_log = _build_notification_delivery_log_for_report( + platform="interactive_brokers", + strategy_profile=STRATEGY_PROFILE, + run_id=str(report.get("run_id") or ""), + dry_run=bool(report.get("dry_run")), + orders_previewed_count=int(report_summary.get("orders_previewed_count") or 0), + delivery_events=notification_delivery_events, + ) + if notification_delivery_log: + report_summary["notification_delivery_log"] = notification_delivery_log finalize_runtime_report( report, status="ok", - summary=_build_cycle_report_summary( - cycle_result, - execution_summary, - reconciliation_record, - dry_run=bool(report.get("dry_run")), - ), + summary=report_summary, diagnostics={ "result": cycle_result.result, "price_source_mode": execution_summary.get("price_source_mode") or reconciliation_record.get("price_source_mode"), diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index 8e13bfb..25651a8 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -425,6 +425,47 @@ def test_cycle_report_summary_counts_dry_run_order_previews(strategy_module): assert summary["quote_snapshot"]["quotes"][0]["symbol"] == "AAA" +def test_notification_delivery_log_summary_records_sent_dry_run_without_raw_text(strategy_module): + payload = strategy_module._build_notification_delivery_log_for_report( + platform="interactive_brokers", + strategy_profile="hk_low_vol_dividend_quality", + run_id="run-001", + dry_run=True, + orders_previewed_count=2, + delivery_events=[ + { + "sink": "telegram", + "delivery_status": "sent", + "compact_text_sha256": "a" * 64, + "compact_text_length": 42, + } + ], + ) + + assert payload["notification_schema_version"] == "hk_live_enablement_notification.v1" + assert payload["notification_event_type"] == "hk_snapshot_live_enablement_dry_run" + assert payload["notification_correlation_id"] == "run-001" + assert payload["locales"] == ["en", "zh-Hans"] + assert payload["profile"] == "hk_low_vol_dividend_quality" + assert payload["platform"] == "interactive_brokers" + assert payload["orders_previewed"] == 2 + assert payload["notification_redacts_sensitive_fields"] is True + assert "compact_text" not in payload["delivery_events"][0] + + +def test_notification_delivery_log_summary_stays_empty_without_sent_event(strategy_module): + payload = strategy_module._build_notification_delivery_log_for_report( + platform="interactive_brokers", + strategy_profile="hk_low_vol_dividend_quality", + run_id="run-001", + dry_run=True, + orders_previewed_count=2, + delivery_events=[], + ) + + assert payload == {} + + def test_handle_request_post_returns_market_closed_when_schedule_empty(strategy_module, monkeypatch): observed = {}