diff --git a/.github/workflows/execution-report-heartbeat.yml b/.github/workflows/execution-report-heartbeat.yml index ee71fef..72023bc 100644 --- a/.github/workflows/execution-report-heartbeat.yml +++ b/.github/workflows/execution-report-heartbeat.yml @@ -96,7 +96,7 @@ jobs: run: python scripts/execution_report_heartbeat.py - name: Publish read-only runtime execution evidence - uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@361341c23dd5cd638651d1766411efd98e1885fc + uses: QuantStrategyLab/QuantRuntimeSettings/actions/publish-runtime-execution-evidence@813ba38c4adb759e143503c8e6b70715090391cc with: source-id: runtime-reports-ibkr report-platform: interactive_brokers diff --git a/application/execution_receipt_adapter.py b/application/execution_receipt_adapter.py new file mode 100644 index 0000000..16b7976 --- /dev/null +++ b/application/execution_receipt_adapter.py @@ -0,0 +1,78 @@ +"""Bounded execution-receipt facts derived from IBKR reconciliation results.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from quant_platform_kit.common.execution_receipts import ( + attach_runtime_execution_receipt, + resolve_execution_receipt_fact, +) + + +_SUBMITTED_KEYS = ("orders_submitted", "option_orders_submitted") +_PENDING_KEYS = ("orders_pending", "option_orders_pending") +_PARTIAL_FILL_KEYS = ("orders_partially_filled", "option_orders_partially_filled") +_FILLED_KEYS = ("orders_filled", "option_orders_filled") +_FAILURE_STATUSES = frozenset({"error", "failed", "failure"}) + + +def attach_cycle_execution_receipt( + report: dict[str, Any], + execution_summary: Mapping[str, Any], + reconciliation_record: Mapping[str, Any], + *, + execution_failed: bool, +) -> dict[str, Any]: + """Attach the strongest fact supplied by IBKR's own reconciliation data. + + Submission arrays are intentionally only submission evidence. A fill is + emitted solely when the reconciliation result contains its explicit filled + array; no status label or local marker is promoted to a fill. + """ + + summary = _combined_summary(execution_summary, reconciliation_record) + status = str(summary.get("execution_status") or "").strip().lower() + reconciliation_required = status == "pending_reconciliation" or _has_any(summary, _PENDING_KEYS) + outcome, confirmation = resolve_execution_receipt_fact( + dry_run=bool(report.get("dry_run")), + submission_attempted=_has_any(summary, _SUBMITTED_KEYS), + partially_filled=_has_any(summary, _PARTIAL_FILL_KEYS), + filled=_has_any(summary, _FILLED_KEYS), + reconciliation_required=reconciliation_required, + risk_blocked=status == "blocked" and not execution_failed, + failed=execution_failed or status in _FAILURE_STATUSES, + ) + return attach_runtime_execution_receipt( + report, + outcome=outcome, + broker_confirmation=confirmation, + ) + + +def attach_terminal_fallback_execution_receipt(report: dict[str, Any]) -> dict[str, Any]: + """Attach a no-action or uncertainty fact for a report that exited early.""" + + failed = str(report.get("status") or "").strip().lower() == "error" + outcome, confirmation = resolve_execution_receipt_fact( + dry_run=bool(report.get("dry_run")), + submission_attempted=failed, + failed=failed, + ) + return attach_runtime_execution_receipt( + report, + outcome=outcome, + broker_confirmation=confirmation, + ) + + +def _combined_summary( + execution_summary: Mapping[str, Any], + reconciliation_record: Mapping[str, Any], +) -> dict[str, Any]: + return {**dict(reconciliation_record or {}), **dict(execution_summary or {})} + + +def _has_any(summary: Mapping[str, Any], keys: tuple[str, ...]) -> bool: + return any(bool(tuple(summary.get(key) or ())) for key in keys) diff --git a/main.py b/main.py index 6f139ac..936f853 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,10 @@ get_compute_discovery = None from application.cycle_result import coerce_strategy_cycle_result +from application.execution_receipt_adapter import ( + attach_cycle_execution_receipt, + attach_terminal_fallback_execution_receipt, +) from application.broker_reconciliation import ( IBKRReconciliationReadError, build_reconciliation_candidate, @@ -1594,6 +1598,12 @@ def _handle_request( } if execution_failed: report_diagnostics["failure_category"] = "strategy_execution_blocked" + attach_cycle_execution_receipt( + report, + execution_summary, + reconciliation_record, + execution_failed=execution_failed, + ) finalize_runtime_report( report, status="error" if execution_failed else "ok", @@ -1726,6 +1736,13 @@ def _handle_request( finally: if lock_acquired: STRATEGY_RUN_LOCK.release() + if "execution_receipt" not in report: + try: + attach_terminal_fallback_execution_receipt(report) + except ValueError: + # A legacy runtime target remains visible as evidence-missing; + # reporting metadata must never change trading behavior. + pass if not deadline_exceeded and persist_report: try: if dry_run_only_override is None: diff --git a/pyproject.toml b/pyproject.toml index a18e0bd..acbe92b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "google-cloud-secret-manager", "google-cloud-storage", "yfinance", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@25bc80681f09e5ae1316ebafc3adb31307ed1399", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b567acd52a6afa6caca3e35648d4347a1ce13ab1", "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@4584de176ca27955270471284663a13a2ce7f828", "hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@c526f2ed869c59f37a494d5fd3a64be299855ec8", ] @@ -64,5 +64,5 @@ include = [ [tool.uv] override-dependencies = [ - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@25bc80681f09e5ae1316ebafc3adb31307ed1399", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@b567acd52a6afa6caca3e35648d4347a1ce13ab1", ] diff --git a/qsl.toml b/qsl.toml index e90ccd0..be6c84e 100644 --- a/qsl.toml +++ b/qsl.toml @@ -5,7 +5,7 @@ upgrade_ring = "ring_d" allow_legacy = false [qsl.requires] -quant_platform_kit = "ee8d996392f96e8bdf40988bd68ae30bf5911d2d" +quant_platform_kit = "b567acd52a6afa6caca3e35648d4347a1ce13ab1" us_equity_strategies = "4584de176ca27955270471284663a13a2ce7f828" hk_equity_strategies = "c526f2ed869c59f37a494d5fd3a64be299855ec8" diff --git a/tests/test_execution_receipt_adapter.py b/tests/test_execution_receipt_adapter.py new file mode 100644 index 0000000..37877bf --- /dev/null +++ b/tests/test_execution_receipt_adapter.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import unittest + +from application.execution_receipt_adapter import attach_cycle_execution_receipt + + +REVISION = "a" * 40 + + +def _report() -> dict[str, object]: + return { + "platform": "interactive_brokers", + "strategy_profile": "soxl_soxx_trend_income", + "dry_run": False, + "runtime_target": {"execution_mode": "live"}, + "runtime_release_receipt": { + "attestation_state": "self_attested", + "strategy_release": {"strategy_revision": REVISION}, + }, + } + + +class ExecutionReceiptAdapterTest(unittest.TestCase): + def test_explicit_fill_is_the_only_fill_claim(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + {"orders_filled": [{"symbol": "SOXL"}]}, + {}, + execution_failed=False, + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "filled") + + def test_submission_is_not_reported_as_acknowledged_or_filled(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + {"orders_submitted": [{"symbol": "SOXL"}]}, + {}, + execution_failed=False, + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "submitted") + self.assertEqual(report["execution_receipt"]["broker_confirmation"], "not_observed") + + def test_pending_order_requires_reconciliation(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + {"orders_pending": [{"symbol": "SOXL"}]}, + {}, + execution_failed=False, + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "reconciliation_required") + + def test_expected_block_is_risk_blocked(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + {"execution_status": "blocked"}, + {}, + execution_failed=False, + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "risk_blocked") diff --git a/uv.lock b/uv.lock index 917ca6e..85c8e73 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ resolution-markers = [ ] [manifest] -overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=25bc80681f09e5ae1316ebafc3adb31307ed1399" }] +overrides = [{ name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=b567acd52a6afa6caca3e35648d4347a1ce13ab1" }] [[package]] name = "beautifulsoup4" @@ -791,7 +791,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytz" }, - { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=25bc80681f09e5ae1316ebafc3adb31307ed1399" }, + { name = "quant-platform-kit", git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=b567acd52a6afa6caca3e35648d4347a1ce13ab1" }, { name = "requests" }, { name = "ruff", marker = "extra == 'test'" }, { name = "us-equity-strategies", git = "https://github.com/QuantStrategyLab/UsEquityStrategies.git?rev=4584de176ca27955270471284663a13a2ce7f828" }, @@ -1327,7 +1327,7 @@ wheels = [ [[package]] name = "quant-platform-kit" version = "0.10.0" -source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=25bc80681f09e5ae1316ebafc3adb31307ed1399#25bc80681f09e5ae1316ebafc3adb31307ed1399" } +source = { git = "https://github.com/QuantStrategyLab/QuantPlatformKit.git?rev=b567acd52a6afa6caca3e35648d4347a1ce13ab1#b567acd52a6afa6caca3e35648d4347a1ce13ab1" } [[package]] name = "requests"