diff --git a/application/execution_receipt_adapter.py b/application/execution_receipt_adapter.py index 049ebc7..1e05e19 100644 --- a/application/execution_receipt_adapter.py +++ b/application/execution_receipt_adapter.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from quant_platform_kit.common.execution_receipts import ( @@ -10,6 +11,12 @@ ) +# Only promote these explicit cycle reasons. Broad no-ops stay ``no_action`` +# so existing digests and projections keep digest-compatible outcomes. +_NO_SIGNAL_REASON_HEADS = frozenset({"no_signal"}) +_NO_REBALANCE_REASON_HEADS = frozenset({"no_rebalance", "target_diff_below_threshold"}) + + def attach_cycle_execution_receipt( report: dict[str, Any], cycle_result: object, @@ -22,11 +29,17 @@ def attach_cycle_execution_receipt( """ pending_orders = tuple(getattr(cycle_result, "pending_orders", ()) or ()) + execution = _as_mapping(getattr(cycle_result, "execution", {})) outcome, confirmation = resolve_execution_receipt_fact( dry_run=bool(report.get("dry_run")), submission_attempted=bool(getattr(cycle_result, "action_done", False)), reconciliation_required=bool(pending_orders), ) + if outcome == "no_action" and not bool(report.get("dry_run")): + explicit = _explicit_non_action_outcome(execution) + if explicit is not None: + outcome = explicit + confirmation = "not_applicable" return attach_runtime_execution_receipt( report, outcome=outcome, @@ -48,3 +61,21 @@ def attach_terminal_fallback_execution_receipt(report: dict[str, Any]) -> dict[s outcome=outcome, broker_confirmation=confirmation, ) + + +def _as_mapping(value: object) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _explicit_non_action_outcome(execution: Mapping[str, Any]) -> str | None: + """Map only explicit cycle reasons to no_signal / no_rebalance.""" + + reason = str(execution.get("no_op_reason") or "").strip().lower() + if not reason: + return None + head = reason.split(":", 1)[0].strip() + if head in _NO_SIGNAL_REASON_HEADS: + return "no_signal" + if head in _NO_REBALANCE_REASON_HEADS: + return "no_rebalance" + return None diff --git a/tests/test_execution_receipt_adapter.py b/tests/test_execution_receipt_adapter.py index 08471da..09e5fe1 100644 --- a/tests/test_execution_receipt_adapter.py +++ b/tests/test_execution_receipt_adapter.py @@ -48,3 +48,65 @@ def test_dry_run_never_claims_submission(self) -> None: attach_cycle_execution_receipt(report, SimpleNamespace(action_done=True, pending_orders=())) self.assertEqual(report["execution_receipt"]["outcome"], "no_action") + + def test_explicit_no_signal_reason_is_no_signal(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + SimpleNamespace( + action_done=False, + pending_orders=(), + execution={"no_op_reason": "no_signal"}, + ), + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "no_signal") + self.assertEqual(report["execution_receipt"]["broker_confirmation"], "not_applicable") + + def test_explicit_no_rebalance_reason_is_no_rebalance(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + SimpleNamespace( + action_done=False, + pending_orders=(), + execution={ + "execution_status": "no_op", + "no_op_reason": "target_diff_below_threshold", + }, + ), + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "no_rebalance") + self.assertEqual(report["execution_receipt"]["broker_confirmation"], "not_applicable") + + def test_ambiguous_no_op_reason_stays_no_action(self) -> None: + report = _report() + + attach_cycle_execution_receipt( + report, + SimpleNamespace( + action_done=False, + pending_orders=(), + execution={"execution_status": "no_op", "no_op_reason": "market_closed"}, + ), + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "no_action") + + def test_dry_run_keeps_no_action_even_with_explicit_reason(self) -> None: + report = _report() + report["dry_run"] = True + + attach_cycle_execution_receipt( + report, + SimpleNamespace( + action_done=False, + pending_orders=(), + execution={"no_op_reason": "no_signal"}, + ), + ) + + self.assertEqual(report["execution_receipt"]["outcome"], "no_action")