diff --git a/application/execution_receipt_adapter.py b/application/execution_receipt_adapter.py index caa711e..78f4f96 100644 --- a/application/execution_receipt_adapter.py +++ b/application/execution_receipt_adapter.py @@ -15,6 +15,15 @@ _RISK_BLOCKED_STAGES = frozenset({"EXECUTION_BLOCKED", "FUNDING_BLOCKED"}) +def _is_legacy_unattested(report: Mapping[str, Any]) -> bool: + receipt = report.get("runtime_release_receipt") + return ( + isinstance(receipt, Mapping) + and receipt.get("attestation_state") == "legacy_unattested" + and receipt.get("strategy_release") is None + ) + + def attach_strategy_result_execution_receipt( report: dict[str, Any], result: Mapping[str, Any], @@ -28,6 +37,9 @@ def attach_strategy_result_execution_receipt( never infers an acknowledgement or fill from it. """ + if _is_legacy_unattested(report): + return report + stage = str(result.get("strategy_run_stage") or "").strip().upper() submitted_orders = _as_sequence(result.get("submitted_orders")) submission_attempted = bool( @@ -67,6 +79,9 @@ def attach_strategy_result_execution_receipt( def attach_unknown_failure_execution_receipt(report: dict[str, Any]) -> dict[str, Any]: """Preserve uncertainty when an exception escapes the strategy cycle.""" + if _is_legacy_unattested(report): + return report + outcome, confirmation = resolve_execution_receipt_fact( dry_run=bool(report.get("dry_run")), submission_attempted=True, diff --git a/main.py b/main.py index 65dd9a6..7972cf6 100644 --- a/main.py +++ b/main.py @@ -217,7 +217,11 @@ def _runtime_settings(*, dry_run_override: bool | None = None) -> PlatformRuntim return settings runtime_target = settings.runtime_target if runtime_target is not None: - runtime_target = replace(runtime_target, dry_run_only=bool(dry_run_override)) + runtime_target = replace( + runtime_target, + dry_run_only=bool(dry_run_override), + execution_environment="dry_run" if dry_run_override else runtime_target.execution_environment, + ) return replace( settings, dry_run_only=bool(dry_run_override), diff --git a/tests/test_execution_receipt_adapter.py b/tests/test_execution_receipt_adapter.py index 59d80b8..04a59e1 100644 --- a/tests/test_execution_receipt_adapter.py +++ b/tests/test_execution_receipt_adapter.py @@ -2,7 +2,10 @@ import unittest -from application.execution_receipt_adapter import attach_strategy_result_execution_receipt +from application.execution_receipt_adapter import ( + attach_strategy_result_execution_receipt, + attach_unknown_failure_execution_receipt, +) REVISION = "a" * 40 @@ -22,6 +25,33 @@ def _report() -> dict[str, object]: class ExecutionReceiptAdapterTest(unittest.TestCase): + def test_legacy_unattested_results_do_not_fabricate_receipts(self) -> None: + for attach in ( + lambda report: attach_strategy_result_execution_receipt(report, {}, dry_run=False), + attach_unknown_failure_execution_receipt, + ): + report = _report() + report["runtime_release_receipt"] = {"attestation_state": "legacy_unattested"} + self.assertIs(attach(report), report) + self.assertNotIn("execution_receipt", report) + + def test_invalid_attested_revision_still_fails(self) -> None: + for revision in (None, "abc1234", "A" * 40): + for attach in ( + lambda report: attach_strategy_result_execution_receipt(report, {}, dry_run=False), + attach_unknown_failure_execution_receipt, + ): + report = _report() + report["runtime_release_receipt"]["strategy_release"]["strategy_revision"] = revision + with self.assertRaisesRegex(ValueError, "strategy_revision"): + attach(report) + + def test_missing_attestation_is_not_assumed_legacy(self) -> None: + report = _report() + del report["runtime_release_receipt"] + with self.assertRaisesRegex(ValueError, "strategy_revision"): + attach_unknown_failure_execution_receipt(report) + def test_submission_is_not_reported_as_a_fill(self) -> None: report = _report() diff --git a/tests/test_request_handling.py b/tests/test_request_handling.py index dc2b3a9..3e0cb30 100644 --- a/tests/test_request_handling.py +++ b/tests/test_request_handling.py @@ -29,6 +29,49 @@ def route_methods(): return {route: sorted(methods) for route, methods in methods_by_route.items()} +@pytest.mark.parametrize("dry_run", [False, True]) +@pytest.mark.parametrize("outcome", ["submitted", "blocked", "exception"]) +def test_legacy_cycle_preserves_result_and_report(monkeypatch, dry_run, outcome): + persisted = [] + cycle_calls = [] + failure = RuntimeError("offline strategy failure") + result = { + "ok": outcome == "submitted", + "strategy_run_stage": "SUBMITTED" if outcome == "submitted" else "EXECUTION_BLOCKED", + "submitted_orders": [{"symbol": "IBIT"}] if outcome == "submitted" else [], + "execution_blocked": outcome == "blocked", + } + + def cycle(**kwargs): + cycle_calls.append(kwargs) + if outcome == "exception": + raise failure + return result + + monkeypatch.setattr(main, "run_strategy_cycle", cycle) + monkeypatch.setattr(main, "_persist_runtime_report", lambda report: persisted.append(report)) + if outcome == "exception": + with pytest.raises(RuntimeError) as caught: + main._run_strategy_cycle_with_report(dry_run_override=dry_run) + assert caught.value is failure + else: + assert main._run_strategy_cycle_with_report(dry_run_override=dry_run) is result + + assert len(cycle_calls) == 1 + if dry_run: + settings = cycle_calls[0]["runtime_settings"] + assert settings.live_trading_enabled is False + assert settings.runtime_target.execution_environment.value == "dry_run" + assert len(persisted) == 1 + report = persisted[0] + assert report["runtime_release_receipt"]["attestation_state"] == "legacy_unattested" + assert "execution_receipt" not in report + assert report["dry_run"] is dry_run + assert report["status"] == ("ok" if outcome == "submitted" else "error") + if outcome == "exception": + assert report["errors"][0]["error_type"] == "RuntimeError" + + def test_cloud_run_route_contracts_are_registered(): assert route_methods() == { "/": ["GET"],