diff --git a/application/execution_state.py b/application/execution_state.py index 9c34c45..41700b6 100644 --- a/application/execution_state.py +++ b/application/execution_state.py @@ -152,6 +152,7 @@ def has_prior_execution_report( effective = _first_non_empty(effective_date) if not signal and not effective: return False + month_segment = _month_segment(signal, effective) bucket_name, prefix = _parse_gcs_uri(str(self.gcs_prefix_uri or "")) object_prefix = "/".join( part.strip("/") @@ -160,6 +161,7 @@ def has_prior_execution_report( _runtime_report_segment(platform), _runtime_report_segment(strategy_profile), _runtime_report_segment(account_scope), + month_segment, ) if part and part.strip("/") ) @@ -247,9 +249,16 @@ def resolve_execution_dedup_enabled( *, env_reader: Callable[[str, str | None], str | None], dry_run_only: bool, + account_scope: object = None, ) -> bool: raw_value = env_reader("LONGBRIDGE_EXECUTION_DEDUP_ENABLED", None) - return _env_bool(raw_value, default=bool(dry_run_only)) + if raw_value is not None and str(raw_value).strip(): + return _env_bool(raw_value, default=bool(dry_run_only)) + return bool(dry_run_only) or _is_paper_account_scope(account_scope) + + +def _is_paper_account_scope(value: object) -> bool: + return str(value or "").strip().upper() == "PAPER" def _runtime_report_segment(value: object) -> str: @@ -258,6 +267,14 @@ def _runtime_report_segment(value: object) -> str: return safe or "unknown" +def _month_segment(*values: object) -> str: + for value in values: + text = _optional_str(value) + if len(text) >= 7 and text[4] == "-" and text[:4].isdigit() and text[5:7].isdigit(): + return text[:7] + return "" + + def _optional_str(value: object) -> str: return str(value or "").strip() @@ -282,12 +299,59 @@ def _report_matches_execution( if bool(report.get("dry_run")) != bool(dry_run_only): return False summary = dict(report.get("summary") or {}) - if signal_date and _optional_str(summary.get("signal_date")) != signal_date: + if signal_date and _date_key(signal_date) not in _report_signal_date_keys(report, summary): return False - if effective_date and _optional_str(summary.get("effective_date")) != effective_date: + if effective_date and _date_key(effective_date) not in _report_effective_date_keys(report, summary): return False return ( bool(summary.get("action_done")) or int(float(summary.get("orders_previewed_count") or 0)) > 0 or int(float(summary.get("order_events_count") or 0)) > 0 + or _is_successful_no_action_report(report, summary) + ) + + +def _is_successful_no_action_report(report: Mapping[str, Any], summary: Mapping[str, Any]) -> bool: + if _optional_str(report.get("status")).lower() != "ok": + return False + if int(float(summary.get("orders_skipped_count") or 0)) > 0: + return False + return bool("action_done" in summary and not summary.get("action_done")) + + +def _report_signal_date_keys(report: Mapping[str, Any], summary: Mapping[str, Any]) -> set[str]: + signal_snapshot = _report_signal_snapshot(report) + return _date_keys( + summary.get("signal_date"), + signal_snapshot.get("signal_as_of"), + signal_snapshot.get("market_date"), + signal_snapshot.get("price_as_of"), + signal_snapshot.get("snapshot_as_of"), + ) + + +def _report_effective_date_keys(report: Mapping[str, Any], summary: Mapping[str, Any]) -> set[str]: + signal_snapshot = _report_signal_snapshot(report) + return _date_keys( + summary.get("effective_date"), + signal_snapshot.get("effective_date"), ) + + +def _report_signal_snapshot(report: Mapping[str, Any]) -> dict[str, Any]: + diagnostics = report.get("diagnostics") + if not isinstance(diagnostics, Mapping): + return {} + signal_snapshot = diagnostics.get("signal_snapshot") + return dict(signal_snapshot) if isinstance(signal_snapshot, Mapping) else {} + + +def _date_keys(*values: object) -> set[str]: + return {key for value in values if (key := _date_key(value))} + + +def _date_key(value: object) -> str: + text = _optional_str(value) + if len(text) >= 10 and text[4] == "-" and text[7] == "-": + return text[:10] + return text diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 06b0e90..957a3b8 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -216,6 +216,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb execution_dedup_enabled=resolve_execution_dedup_enabled( env_reader=self.env_reader, dry_run_only=self.dry_run_only, + account_scope=self.account_region, ), execution_state_store=build_execution_marker_store_from_env( env_reader=self.env_reader, diff --git a/tests/test_execution_state.py b/tests/test_execution_state.py new file mode 100644 index 0000000..d2096b9 --- /dev/null +++ b/tests/test_execution_state.py @@ -0,0 +1,154 @@ +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from application.execution_state import ( # noqa: E402 + ExecutionMarkerStore, + _report_matches_execution, + resolve_execution_dedup_enabled, +) + + +def _env_with_dedup(raw_value): + def reader(name, default=None): + if name == "LONGBRIDGE_EXECUTION_DEDUP_ENABLED": + return raw_value + return default + + return reader + + +def test_execution_dedup_defaults_to_enabled_for_paper_account_scope(): + assert ( + resolve_execution_dedup_enabled( + env_reader=_env_with_dedup(None), + dry_run_only=False, + account_scope="PAPER", + ) + is True + ) + + +def test_execution_dedup_defaults_to_disabled_for_live_non_paper_scope(): + assert ( + resolve_execution_dedup_enabled( + env_reader=_env_with_dedup(None), + dry_run_only=False, + account_scope="HK", + ) + is False + ) + + +def test_execution_dedup_env_override_wins_for_paper_scope(): + assert ( + resolve_execution_dedup_enabled( + env_reader=_env_with_dedup("false"), + dry_run_only=False, + account_scope="PAPER", + ) + is False + ) + + +def test_prior_report_match_treats_successful_no_action_as_completed(): + payload = { + "platform": "longbridge", + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "account_scope": "PAPER", + "dry_run": False, + "status": "ok", + "summary": { + "signal_date": "2026-06-04", + "action_done": False, + "orders_previewed_count": 0, + "order_events_count": 0, + "orders_skipped_count": 0, + }, + "diagnostics": { + "signal_snapshot": { + "signal_as_of": "2026-06-01", + "market_date": "2026-06-01", + }, + }, + } + + assert ( + _report_matches_execution( + payload, + platform="longbridge", + strategy_profile="mega_cap_leader_rotation_top50_balanced", + account_scope="PAPER", + signal_date="2026-06-01", + effective_date="", + dry_run_only=False, + ) + is True + ) + + +def test_prior_report_match_does_not_treat_blocked_no_action_as_completed(): + payload = { + "platform": "longbridge", + "strategy_profile": "mega_cap_leader_rotation_top50_balanced", + "account_scope": "PAPER", + "dry_run": False, + "status": "ok", + "summary": { + "signal_date": "2026-06-04", + "action_done": False, + "orders_skipped_count": 1, + }, + } + + assert ( + _report_matches_execution( + payload, + platform="longbridge", + strategy_profile="mega_cap_leader_rotation_top50_balanced", + account_scope="PAPER", + signal_date="2026-06-04", + effective_date="", + dry_run_only=False, + ) + is False + ) + + +def test_prior_report_scan_is_scoped_to_signal_month(): + observed = {} + + class FakeClient: + def list_blobs(self, bucket_name, *, prefix): + observed["bucket_name"] = bucket_name + observed["prefix"] = prefix + return () + + store = ExecutionMarkerStore( + local_dir=None, + gcs_prefix_uri="gs://bucket/execution-reports", + client_factory=lambda **_kwargs: FakeClient(), + ) + + assert ( + store.has_prior_execution_report( + platform="longbridge", + strategy_profile="mega_cap_leader_rotation_top50_balanced", + account_scope="PAPER", + signal_date="2026-06-04", + effective_date="", + dry_run_only=False, + ) + is False + ) + assert observed == { + "bucket_name": "bucket", + "prefix": ( + "execution-reports/longbridge/" + "mega_cap_leader_rotation_top50_balanced/PAPER/2026-06" + ), + }