From 5bbad7ac6d451219e0e44a44b0b3976816552d9b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 11:35:20 +0000 Subject: [PATCH 01/18] fix(strategy_runner): process control-time before popping intents --- .../backtest/engine/strategy_runner.py | 6 +- ...rategy_runner_canonical_market_adoption.py | 82 ++++++++++++++++++- 2 files changed, 83 insertions(+), 5 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index cd02717..1f41d09 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -330,9 +330,6 @@ def run( and sim_now_ns >= self._next_send_ts_ns_local ): scheduled_deadline_ns = self._next_send_ts_ns_local - raw_intents.extend( - self.strategy_state.pop_queued_intents(instrument) - ) if ( scheduled_deadline_ns != self._last_injected_control_deadline_ns @@ -342,6 +339,9 @@ def run( scheduled_deadline_ns=scheduled_deadline_ns, ) self._last_injected_control_deadline_ns = scheduled_deadline_ns + raw_intents.extend( + self.strategy_state.pop_queued_intents(instrument) + ) # ----------------------------------------------------------------- # Gate + execution diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index d3b315d..b2d42d2 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -977,7 +977,7 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj assert control_count == 1 -def test_control_time_event_processed_after_pop_and_before_gate( +def test_control_time_event_processed_before_pop_and_gate( monkeypatch: pytest.MonkeyPatch, ) -> None: queued_intent = _new_intent() @@ -1017,11 +1017,89 @@ def _spy_decide_intents(**kwargs: Any) -> GateDecision: ) runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert ordering == ["pop", "control", "gate"] + assert ordering == ["control", "pop", "gate"] assert len(captured_raw_inputs) == 1 assert [it.client_order_id for it in captured_raw_inputs[0]] == [queued_intent.client_order_id] +def test_control_time_processing_failure_does_not_pop_or_mark_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + + pop_count = 0 + + def _fail_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, ControlTimeEvent): + raise RuntimeError("boom") + + def _spy_pop_queued_intents(instrument: str) -> list[Any]: + nonlocal pop_count + _ = instrument + pop_count += 1 + return [] + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _fail_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + + with pytest.raises(RuntimeError, match="boom"): + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert pop_count == 0 + assert runner._last_injected_control_deadline_ns is None + + +def test_realized_old_deadline_does_not_pop_without_new_canonical_injection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + + control_count = 0 + pop_count = 0 + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal control_count + _ = (state, configuration) + if isinstance(entry.event, ControlTimeEvent): + control_count += 1 + + def _spy_pop_queued_intents(instrument: str) -> list[Any]: + nonlocal pop_count + _ = instrument + pop_count += 1 + return [] + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + + venue = _StubVenue( + rc_sequence=[0, 0, 0, 1], + ts_sequence=[1, 10, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert control_count == 1 + assert pop_count == 1 + + def test_global_canonical_counter_shared_with_control_time_market_and_submitted( monkeypatch: pytest.MonkeyPatch, ) -> None: From c3268485869a1dcf1144411d7006c67d5117811a Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 13:09:22 +0000 Subject: [PATCH 02/18] feat(strategy_runner): consume structured control scheduling obligations --- .../backtest/engine/strategy_runner.py | 36 ++++++- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 94 ++++++++++++++++++- 4 files changed, 127 insertions(+), 7 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 1f41d09..5d75dbc 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -26,9 +26,16 @@ ) from tradingchassis_core.core.events.event_bus import EventBus from tradingchassis_core.core.events.sinks.sink_logging import LoggingEventSink +from tradingchassis_core.core.execution_control.types import ( + ControlSchedulingObligation, +) from tradingchassis_core.core.ports.venue_adapter import VenueAdapter from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RejectedIntent, RiskEngine +from tradingchassis_core.core.risk.risk_engine import ( + GateDecision, + RejectedIntent, + RiskEngine, +) from core_runtime.backtest.adapters.protocols import OrderSubmissionGateway from core_runtime.backtest.engine.event_stream_cursor import EventStreamCursor @@ -80,6 +87,7 @@ def __init__( self._next_send_ts_ns_local: int | None = None self._event_stream_cursor = EventStreamCursor() self._last_injected_control_deadline_ns: int | None = None + self._selected_control_scheduling_obligation: ControlSchedulingObligation | None = None def _process_canonical_event(self, event: object) -> None: position = self._event_stream_cursor.attempt_position() @@ -162,18 +170,35 @@ def _process_canonical_control_time_event( *, sim_now_ns: int, scheduled_deadline_ns: int, + scheduling_obligation: ControlSchedulingObligation | None = None, ) -> None: + obligation_reason = "rate_limit" + obligation_due_ts_ns_local = scheduled_deadline_ns + if scheduling_obligation is not None: + obligation_reason = scheduling_obligation.reason + obligation_due_ts_ns_local = scheduling_obligation.due_ts_ns_local control_time_event = ControlTimeEvent( ts_ns_local_control=sim_now_ns, reason="scheduled_control_recheck", due_ts_ns_local=scheduled_deadline_ns, realized_ts_ns_local=sim_now_ns, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=scheduled_deadline_ns, + obligation_reason=obligation_reason, + obligation_due_ts_ns_local=obligation_due_ts_ns_local, runtime_correlation=None, ) self._process_canonical_event(control_time_event) + @staticmethod + def _select_control_scheduling_obligation( + decision: GateDecision, + ) -> ControlSchedulingObligation | None: + if decision.next_send_ts_ns_local is None: + return None + for obligation in decision.control_scheduling_obligations: + if obligation.due_ts_ns_local == decision.next_send_ts_ns_local: + return obligation + return None + def run( self, venue: VenueAdapter, @@ -337,6 +362,7 @@ def run( self._process_canonical_control_time_event( sim_now_ns=sim_now_ns, scheduled_deadline_ns=scheduled_deadline_ns, + scheduling_obligation=self._selected_control_scheduling_obligation, ) self._last_injected_control_deadline_ns = scheduled_deadline_ns raw_intents.extend( @@ -388,10 +414,14 @@ def run( self.strategy.on_risk_decision(decision) self._next_send_ts_ns_local = decision.next_send_ts_ns_local + self._selected_control_scheduling_obligation = ( + self._select_control_scheduling_obligation(decision) + ) # If there are queued intents but the gate did not provide a next_send_ts_ns_local, # wake up at the next second boundary to ensure progress. if self._next_send_ts_ns_local is None: + self._selected_control_scheduling_obligation = None queue = self.strategy_state.queued_intents.setdefault( instrument, deque(), diff --git a/requirements-dev.txt b/requirements-dev.txt index 0690c4f..d199ce4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@2c317546473a2f295d6b747cd42b897aadf2c1c7 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@922d47e2835145efbd70e6be5f1199b1520f2430 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index 603a65a..3be081f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@2c317546473a2f295d6b747cd42b897aadf2c1c7 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@922d47e2835145efbd70e6be5f1199b1520f2430 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index b2d42d2..1ab52f9 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -21,6 +21,9 @@ ReplaceOrderIntent, ) from tradingchassis_core.core.events.event_bus import EventBus +from tradingchassis_core.core.execution_control.types import ( + ControlSchedulingObligation, +) from tradingchassis_core.core.risk.risk_config import RiskConfig from tradingchassis_core.core.risk.risk_engine import GateDecision from tradingchassis_core.strategies.base import Strategy @@ -236,7 +239,12 @@ def on_risk_decision(self, decision: Any) -> None: _ = decision -def _decision_for(accepted_now: list[Any]) -> GateDecision: +def _decision_for( + accepted_now: list[Any], + *, + next_send_ts_ns_local: int | None = None, + control_scheduling_obligations: tuple[ControlSchedulingObligation, ...] = (), +) -> GateDecision: return GateDecision( ts_ns_local=2, accepted_now=accepted_now, @@ -246,7 +254,8 @@ def _decision_for(accepted_now: list[Any]) -> GateDecision: dropped_in_queue=[], handled_in_queue=[], execution_rejected=[], - next_send_ts_ns_local=None, + next_send_ts_ns_local=next_send_ts_ns_local, + control_scheduling_obligations=control_scheduling_obligations, ) @@ -893,6 +902,87 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj assert event.runtime_correlation is None +def test_control_time_event_uses_structured_obligation_fields_when_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([_new_intent()]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + obligation = ControlSchedulingObligation( + due_ts_ns_local=5, + reason="custom_backpressure_reason", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="execution_control_rate_limit", + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: _decision_for( + [], + next_send_ts_ns_local=5, + control_scheduling_obligations=(obligation,), + ), + ) + + control_events: list[ControlTimeEvent] = [] + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, ControlTimeEvent): + control_events.append(entry.event) + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + venue = _StubVenue( + rc_sequence=[0, 2, 0, 1], + ts_sequence=[1, 2, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(control_events) == 1 + event = control_events[0] + assert event.reason == "scheduled_control_recheck" + assert event.due_ts_ns_local == 5 + assert event.realized_ts_ns_local == 10 + assert event.obligation_reason == "custom_backpressure_reason" + assert event.obligation_due_ts_ns_local == 5 + + +def test_control_time_event_falls_back_when_structured_obligation_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + runner._selected_control_scheduling_obligation = None + + control_events: list[ControlTimeEvent] = [] + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, ControlTimeEvent): + control_events.append(entry.event) + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(control_events) == 1 + event = control_events[0] + assert event.obligation_reason == "rate_limit" + assert event.obligation_due_ts_ns_local == 5 + + def test_no_control_time_event_when_no_deadline_scheduled( monkeypatch: pytest.MonkeyPatch, ) -> None: From 4c2afe0fad8fd2168d8276e40ee61875cc5db847 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 13:58:03 +0000 Subject: [PATCH 03/18] feat: implement pending control obligation consume semantics --- .../backtest/engine/strategy_runner.py | 68 +++++++-- ...rategy_runner_canonical_market_adoption.py | 138 +++++++++++++++++- 2 files changed, 188 insertions(+), 18 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 5d75dbc..0ec5fbf 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -87,7 +87,7 @@ def __init__( self._next_send_ts_ns_local: int | None = None self._event_stream_cursor = EventStreamCursor() self._last_injected_control_deadline_ns: int | None = None - self._selected_control_scheduling_obligation: ControlSchedulingObligation | None = None + self._pending_control_scheduling_obligation: ControlSchedulingObligation | None = None def _process_canonical_event(self, event: object) -> None: position = self._event_stream_cursor.attempt_position() @@ -189,15 +189,45 @@ def _process_canonical_control_time_event( self._process_canonical_event(control_time_event) @staticmethod - def _select_control_scheduling_obligation( + def _select_effective_control_scheduling_obligation( decision: GateDecision, ) -> ControlSchedulingObligation | None: - if decision.next_send_ts_ns_local is None: + obligations = decision.control_scheduling_obligations + if not obligations: return None - for obligation in decision.control_scheduling_obligations: - if obligation.due_ts_ns_local == decision.next_send_ts_ns_local: - return obligation - return None + return min( + obligations, + key=lambda obligation: ( + obligation.due_ts_ns_local, + obligation.obligation_key, + ), + ) + + def _clear_pending_control_scheduling_obligation(self) -> None: + self._pending_control_scheduling_obligation = None + self._next_send_ts_ns_local = None + + def _consume_pending_control_scheduling_obligation( + self, + ) -> ControlSchedulingObligation | None: + pending = self._pending_control_scheduling_obligation + self._clear_pending_control_scheduling_obligation() + return pending + + def _apply_control_scheduling_decision( + self, + decision: GateDecision, + ) -> None: + selected = self._select_effective_control_scheduling_obligation(decision) + if selected is None: + self._pending_control_scheduling_obligation = None + self._next_send_ts_ns_local = decision.next_send_ts_ns_local + return + if self._pending_control_scheduling_obligation == selected: + self._next_send_ts_ns_local = selected.due_ts_ns_local + return + self._pending_control_scheduling_obligation = selected + self._next_send_ts_ns_local = selected.due_ts_ns_local def run( self, @@ -350,11 +380,17 @@ def run( # Queue flush # ----------------------------------------------------------------- scheduled_deadline_ns: int | None = None + scheduling_obligation: ControlSchedulingObligation | None = None + if self._pending_control_scheduling_obligation is not None: + scheduling_obligation = self._pending_control_scheduling_obligation + scheduled_deadline_ns = scheduling_obligation.due_ts_ns_local + elif self._next_send_ts_ns_local is not None: + # Transitional compatibility for scalar-only decisions. + scheduled_deadline_ns = self._next_send_ts_ns_local if ( - self._next_send_ts_ns_local is not None - and sim_now_ns >= self._next_send_ts_ns_local + scheduled_deadline_ns is not None + and sim_now_ns >= scheduled_deadline_ns ): - scheduled_deadline_ns = self._next_send_ts_ns_local if ( scheduled_deadline_ns != self._last_injected_control_deadline_ns @@ -362,9 +398,13 @@ def run( self._process_canonical_control_time_event( sim_now_ns=sim_now_ns, scheduled_deadline_ns=scheduled_deadline_ns, - scheduling_obligation=self._selected_control_scheduling_obligation, + scheduling_obligation=scheduling_obligation, ) self._last_injected_control_deadline_ns = scheduled_deadline_ns + if scheduling_obligation is not None: + self._consume_pending_control_scheduling_obligation() + else: + self._next_send_ts_ns_local = None raw_intents.extend( self.strategy_state.pop_queued_intents(instrument) ) @@ -413,15 +453,11 @@ def run( ) self.strategy.on_risk_decision(decision) - self._next_send_ts_ns_local = decision.next_send_ts_ns_local - self._selected_control_scheduling_obligation = ( - self._select_control_scheduling_obligation(decision) - ) + self._apply_control_scheduling_decision(decision) # If there are queued intents but the gate did not provide a next_send_ts_ns_local, # wake up at the next second boundary to ensure progress. if self._next_send_ts_ns_local is None: - self._selected_control_scheduling_obligation = None queue = self.strategy_state.queued_intents.setdefault( instrument, deque(), diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 1ab52f9..c95451f 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -259,6 +259,96 @@ def _decision_for( ) +def _obligation( + *, + due_ts_ns_local: int, + obligation_key: str, + reason: str = "rate_limit", +) -> ControlSchedulingObligation: + return ControlSchedulingObligation( + due_ts_ns_local=due_ts_ns_local, + reason=reason, + scope_key="instrument:BTC_USDC-PERPETUAL", + source="execution_control_rate_limit", + obligation_key=obligation_key, + ) + + +def _runner_for_scheduling_helpers() -> HftStrategyRunner: + runner = object.__new__(HftStrategyRunner) + runner._pending_control_scheduling_obligation = None + runner._next_send_ts_ns_local = None + runner._last_injected_control_deadline_ns = None + return runner + + +def test_select_effective_control_scheduling_obligation_collapses_deterministically() -> None: + decision = _decision_for( + [], + control_scheduling_obligations=( + _obligation(due_ts_ns_local=7, obligation_key="z-key"), + _obligation(due_ts_ns_local=5, obligation_key="z-key"), + _obligation(due_ts_ns_local=5, obligation_key="a-key"), + ), + ) + + selected = HftStrategyRunner._select_effective_control_scheduling_obligation(decision) + + assert selected is not None + assert selected.due_ts_ns_local == 5 + assert selected.obligation_key == "a-key" + + +def test_apply_control_scheduling_decision_sets_pending_and_mirror() -> None: + runner = _runner_for_scheduling_helpers() + obligation = _obligation(due_ts_ns_local=5, obligation_key="k1") + decision = _decision_for([], control_scheduling_obligations=(obligation,)) + + runner._apply_control_scheduling_decision(decision) + + assert runner._pending_control_scheduling_obligation == obligation + assert runner._next_send_ts_ns_local == 5 + + +def test_apply_control_scheduling_decision_replaces_pending_same_due_different_key() -> None: + runner = _runner_for_scheduling_helpers() + obligation_a = _obligation(due_ts_ns_local=5, obligation_key="a-key") + obligation_b = _obligation(due_ts_ns_local=5, obligation_key="b-key") + + runner._apply_control_scheduling_decision( + _decision_for([], control_scheduling_obligations=(obligation_a,)) + ) + runner._apply_control_scheduling_decision( + _decision_for([], control_scheduling_obligations=(obligation_b,)) + ) + + assert runner._pending_control_scheduling_obligation == obligation_b + assert runner._next_send_ts_ns_local == 5 + + +def test_apply_control_scheduling_decision_clears_pending_when_no_obligation() -> None: + runner = _runner_for_scheduling_helpers() + obligation = _obligation(due_ts_ns_local=5, obligation_key="k1") + runner._apply_control_scheduling_decision( + _decision_for([], control_scheduling_obligations=(obligation,)) + ) + + runner._apply_control_scheduling_decision(_decision_for([])) + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + +def test_apply_control_scheduling_decision_scalar_fallback_without_structured_obligation() -> None: + runner = _runner_for_scheduling_helpers() + decision = _decision_for([], next_send_ts_ns_local=12) + + runner._apply_control_scheduling_decision(decision) + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local == 12 + + def test_process_market_event_routes_through_event_entry_with_core_configuration( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -961,7 +1051,7 @@ def test_control_time_event_falls_back_when_structured_obligation_missing( core_cfg=_core_cfg(), ) runner._next_send_ts_ns_local = 5 - runner._selected_control_scheduling_obligation = None + runner._pending_control_scheduling_obligation = None control_events: list[ControlTimeEvent] = [] @@ -1121,7 +1211,9 @@ def test_control_time_processing_failure_does_not_pop_or_mark_deadline( risk_cfg=_risk_cfg(), core_cfg=_core_cfg(), ) - runner._next_send_ts_ns_local = 5 + obligation = _obligation(due_ts_ns_local=5, obligation_key="k-failure") + runner._pending_control_scheduling_obligation = obligation + runner._next_send_ts_ns_local = obligation.due_ts_ns_local pop_count = 0 @@ -1149,6 +1241,48 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: assert pop_count == 0 assert runner._last_injected_control_deadline_ns is None + assert runner._pending_control_scheduling_obligation == obligation + assert runner._next_send_ts_ns_local == 5 + + +def test_control_time_success_consumes_pending_before_pop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + obligation = _obligation(due_ts_ns_local=5, obligation_key="k-success") + runner._pending_control_scheduling_obligation = obligation + runner._next_send_ts_ns_local = obligation.due_ts_ns_local + + pop_count = 0 + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + + def _spy_pop_queued_intents(instrument: str) -> list[Any]: + nonlocal pop_count + _ = instrument + pop_count += 1 + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + return [] + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert pop_count == 1 + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None def test_realized_old_deadline_does_not_pop_without_new_canonical_injection( From dadba73aebf12f382f929f383de7e9f7cf8d5136 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 20:45:32 +0000 Subject: [PATCH 04/18] feat(runtime): route control-time processing through run_core_step --- .../backtest/engine/strategy_runner.py | 13 +- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 115 +++++++++++++++--- 4 files changed, 115 insertions(+), 17 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 0ec5fbf..a7224c8 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -7,6 +7,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +from tradingchassis_core import run_core_step from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import ( @@ -186,7 +187,17 @@ def _process_canonical_control_time_event( obligation_due_ts_ns_local=obligation_due_ts_ns_local, runtime_correlation=None, ) - self._process_canonical_event(control_time_event) + position = self._event_stream_cursor.attempt_position() + entry = EventStreamEntry( + position=position, + event=control_time_event, + ) + _ = run_core_step( + self.strategy_state, + entry, + configuration=self._core_cfg, + ) + self._event_stream_cursor.commit_success(position) @staticmethod def _select_effective_control_scheduling_obligation( diff --git a/requirements-dev.txt b/requirements-dev.txt index d199ce4..3fe4b57 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@922d47e2835145efbd70e6be5f1199b1520f2430 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@aa91f9273f1a739de1ada5f3283025c1c21f58d7 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index 3be081f..64ab87e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@922d47e2835145efbd70e6be5f1199b1520f2430 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@aa91f9273f1a739de1ada5f3283025c1c21f58d7 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index c95451f..763fd1d 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -7,6 +7,7 @@ import pytest from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( BookLevel, BookPayload, @@ -969,12 +970,13 @@ def test_control_time_event_injected_when_scheduled_deadline_is_realized( control_events: list[ControlTimeEvent] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) + return CoreStepResult() - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 0, 1], ts_sequence=[1, 10, 11], @@ -992,6 +994,39 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj assert event.runtime_correlation is None +def test_control_time_realization_routes_through_run_core_step_with_expected_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + + captured_calls: list[tuple[object, object, object]] = [] + + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + captured_calls.append((state, entry, configuration)) + return CoreStepResult() + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(captured_calls) == 1 + state, entry, configuration = captured_calls[0] + assert state is runner.strategy_state + assert configuration is runner._core_cfg + assert isinstance(entry.event, ControlTimeEvent) + assert entry.position.index == 0 + assert runner._event_stream_cursor.next_index == 1 + + def test_control_time_event_uses_structured_obligation_fields_when_available( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1019,12 +1054,13 @@ def test_control_time_event_uses_structured_obligation_fields_when_available( control_events: list[ControlTimeEvent] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) + return CoreStepResult() - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 2, 0, 1], ts_sequence=[1, 2, 10, 11], @@ -1055,12 +1091,13 @@ def test_control_time_event_falls_back_when_structured_obligation_missing( control_events: list[ControlTimeEvent] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) + return CoreStepResult() - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 0, 1], ts_sequence=[1, 10, 11], @@ -1141,13 +1178,14 @@ def test_control_time_deadline_injection_is_not_periodic_for_same_deadline( runner._next_send_ts_ns_local = 5 control_count = 0 - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: nonlocal control_count _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): control_count += 1 + return CoreStepResult() - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 0, 0, 1], ts_sequence=[1, 10, 10, 11], @@ -1177,10 +1215,11 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: ordering.append("pop") return [queued_intent] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): ordering.append("control") + return CoreStepResult() def _spy_decide_intents(**kwargs: Any) -> GateDecision: ordering.append("gate") @@ -1188,7 +1227,7 @@ def _spy_decide_intents(**kwargs: Any) -> GateDecision: return _decision_for([]) monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) venue = _StubVenue( @@ -1217,10 +1256,11 @@ def test_control_time_processing_failure_does_not_pop_or_mark_deadline( pop_count = 0 - def _fail_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _fail_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): raise RuntimeError("boom") + return CoreStepResult() def _spy_pop_queued_intents(instrument: str) -> list[Any]: nonlocal pop_count @@ -1228,7 +1268,7 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: pop_count += 1 return [] - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _fail_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) venue = _StubVenue( @@ -1243,6 +1283,46 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: assert runner._last_injected_control_deadline_ns is None assert runner._pending_control_scheduling_obligation == obligation assert runner._next_send_ts_ns_local == 5 + assert runner._event_stream_cursor.next_index == 0 + + +def test_market_and_order_submitted_paths_remain_on_process_event_entry_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + new_intent = _new_intent() + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([new_intent]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: _decision_for([new_intent]), + ) + + process_event_names: list[str] = [] + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + process_event_names.append(type(entry.event).__name__) + + def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: + _ = (args, kwargs) + raise AssertionError("run_core_step must not be used for market/order-submitted path") + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert process_event_names == ["MarketEvent", "OrderSubmittedEvent"] def test_control_time_success_consumes_pending_before_pop( @@ -1299,11 +1379,12 @@ def test_realized_old_deadline_does_not_pop_without_new_canonical_injection( control_count = 0 pop_count = 0 - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: nonlocal control_count _ = (state, configuration) if isinstance(entry.event, ControlTimeEvent): control_count += 1 + return CoreStepResult() def _spy_pop_queued_intents(instrument: str) -> list[Any]: nonlocal pop_count @@ -1311,7 +1392,7 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: pop_count += 1 return [] - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) venue = _StubVenue( @@ -1348,7 +1429,13 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj _ = (state, configuration) positions.append((entry.position.index, type(entry.event).__name__)) + def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + _ = (state, configuration) + positions.append((entry.position.index, type(entry.event).__name__)) + return CoreStepResult() + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 2, 1], ts_sequence=[1, 10, 11], From 5e01b666b46178c58eda35d730eb56841ee7a8ad Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 21:58:38 +0000 Subject: [PATCH 05/18] feat(runtime): consume control-time CoreStepResult --- .../backtest/engine/strategy_runner.py | 169 ++++++--- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 350 ++++++++++++++---- 4 files changed, 400 insertions(+), 123 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index a7224c8..a1d0e77 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -7,13 +7,14 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from tradingchassis_core import run_core_step +from tradingchassis_core import ControlTimeQueueReevaluationContext, run_core_step from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import ( EventStreamEntry, ) from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( BookLevel, BookPayload, @@ -169,10 +170,12 @@ def _process_canonical_order_submitted_event( def _process_canonical_control_time_event( self, *, + instrument: str, + now_ts_ns_local: int, sim_now_ns: int, scheduled_deadline_ns: int, scheduling_obligation: ControlSchedulingObligation | None = None, - ) -> None: + ) -> CoreStepResult: obligation_reason = "rate_limit" obligation_due_ts_ns_local = scheduled_deadline_ns if scheduling_obligation is not None: @@ -192,12 +195,18 @@ def _process_canonical_control_time_event( position=position, event=control_time_event, ) - _ = run_core_step( + result = run_core_step( self.strategy_state, entry, configuration=self._core_cfg, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=self.risk, + instrument=instrument, + now_ts_ns_local=now_ts_ns_local, + ), ) self._event_stream_cursor.commit_success(position) + return result @staticmethod def _select_effective_control_scheduling_obligation( @@ -240,6 +249,84 @@ def _apply_control_scheduling_decision( self._pending_control_scheduling_obligation = selected self._next_send_ts_ns_local = selected.due_ts_ns_local + def _apply_control_scheduling_obligation( + self, + obligation: ControlSchedulingObligation | None, + ) -> None: + if obligation is None: + return + if self._pending_control_scheduling_obligation == obligation: + self._next_send_ts_ns_local = obligation.due_ts_ns_local + return + self._pending_control_scheduling_obligation = obligation + self._next_send_ts_ns_local = obligation.due_ts_ns_local + + def _dispatch_accepted_intents( + self, + accepted_now: list[OrderIntent], + execution: OrderSubmissionGateway, + *, + sim_now_ns: int, + ) -> list[tuple[OrderIntent, str]]: + if not accepted_now: + return [] + + execution_errors = execution.apply_intents(accepted_now) + failed_keys = { + (it.instrument, it.client_order_id) + for it, _ in execution_errors + } + + for it in accepted_now: + if (it.instrument, it.client_order_id) in failed_keys: + continue + if it.intent_type == "new": + self._process_canonical_order_submitted_event( + it, + ts_ns_local_dispatch=sim_now_ns, + ) + self.strategy_state.mark_intent_sent( + it.instrument, + it.client_order_id, + it.intent_type, + ) + + return execution_errors + + def _finalize_decision_effects( + self, + *, + decision: GateDecision, + execution: OrderSubmissionGateway, + sim_now_ns: int, + instrument: str, + ) -> None: + execution_errors = self._dispatch_accepted_intents( + decision.accepted_now, + execution, + sim_now_ns=sim_now_ns, + ) + + if execution_errors: + for it, reason in execution_errors: + decision.execution_rejected.append( + RejectedIntent(it, reason) + ) + + self.strategy.on_risk_decision(decision) + self._apply_control_scheduling_decision(decision) + + # If there are queued intents but the gate did not provide a next_send_ts_ns_local, + # wake up at the next second boundary to ensure progress. + if self._next_send_ts_ns_local is None: + queue = self.strategy_state.queued_intents.setdefault( + instrument, + deque(), + ) + if queue: + sec = sim_now_ns // 1_000_000_000 + self._next_send_ts_ns_local = (sec + 1) * 1_000_000_000 + def run( self, venue: VenueAdapter, @@ -270,6 +357,7 @@ def run( sim_now_ns = self.strategy_state.sim_ts_ns_local raw_intents: list[OrderIntent] = [] + control_step_result: CoreStepResult | None = None # ----------------------------------------------------------------- # Market update @@ -406,7 +494,9 @@ def run( scheduled_deadline_ns != self._last_injected_control_deadline_ns ): - self._process_canonical_control_time_event( + control_step_result = self._process_canonical_control_time_event( + instrument=instrument, + now_ts_ns_local=sim_now_ns, sim_now_ns=sim_now_ns, scheduled_deadline_ns=scheduled_deadline_ns, scheduling_obligation=scheduling_obligation, @@ -416,8 +506,24 @@ def run( self._consume_pending_control_scheduling_obligation() else: self._next_send_ts_ns_local = None - raw_intents.extend( - self.strategy_state.pop_queued_intents(instrument) + + if control_step_result is not None: + if control_step_result.compat_gate_decision is not None: + self._finalize_decision_effects( + decision=control_step_result.compat_gate_decision, + execution=execution, + sim_now_ns=sim_now_ns, + instrument=instrument, + ) + elif control_step_result.dispatchable_intents: + self._dispatch_accepted_intents( + list(control_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, + ) + elif control_step_result.control_scheduling_obligation is not None: + self._apply_control_scheduling_obligation( + control_step_result.control_scheduling_obligation ) # ----------------------------------------------------------------- @@ -431,50 +537,11 @@ def run( state=self.strategy_state, now_ts_ns_local=sim_now_ns, ) - - execution_errors: list[tuple[OrderIntent, str]] = [] - if decision.accepted_now: - execution_errors = execution.apply_intents( - decision.accepted_now - ) - - failed_keys = { - (it.instrument, it.client_order_id) - for it, _ in execution_errors - } - - for it in decision.accepted_now: - if (it.instrument, it.client_order_id) in failed_keys: - continue - if it.intent_type == "new": - self._process_canonical_order_submitted_event( - it, - ts_ns_local_dispatch=sim_now_ns, - ) - self.strategy_state.mark_intent_sent( - it.instrument, - it.client_order_id, - it.intent_type, - ) - - if execution_errors: - for it, reason in execution_errors: - decision.execution_rejected.append( - RejectedIntent(it, reason) - ) - - self.strategy.on_risk_decision(decision) - self._apply_control_scheduling_decision(decision) - - # If there are queued intents but the gate did not provide a next_send_ts_ns_local, - # wake up at the next second boundary to ensure progress. - if self._next_send_ts_ns_local is None: - queue = self.strategy_state.queued_intents.setdefault( - instrument, - deque(), - ) - if queue: - sec = sim_now_ns // 1_000_000_000 - self._next_send_ts_ns_local = (sec + 1) * 1_000_000_000 + self._finalize_decision_effects( + decision=decision, + execution=execution, + sim_now_ns=sim_now_ns, + instrument=instrument, + ) venue.record(recorder) diff --git a/requirements-dev.txt b/requirements-dev.txt index 3fe4b57..0f466af 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@aa91f9273f1a739de1ada5f3283025c1c21f58d7 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@852babf6cfc87e0a935d8edd488581ba83ff0ed1 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index 64ab87e..d95d74a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@aa91f9273f1a739de1ada5f3283025c1c21f58d7 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@852babf6cfc87e0a935d8edd488581ba83ff0ed1 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 763fd1d..9072ec4 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -6,6 +6,9 @@ import pytest from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.processing_step import ( + ControlTimeQueueReevaluationContext, +) from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( @@ -970,8 +973,15 @@ def test_control_time_event_injected_when_scheduled_deadline_is_realized( control_events: list[ControlTimeEvent] = [] - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) return CoreStepResult() @@ -1005,10 +1015,16 @@ def test_control_time_realization_routes_through_run_core_step_with_expected_arg ) runner._next_send_ts_ns_local = 5 - captured_calls: list[tuple[object, object, object]] = [] + captured_calls: list[tuple[object, object, object, object | None]] = [] - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: - captured_calls.append((state, entry, configuration)) + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: + captured_calls.append((state, entry, configuration, control_time_queue_context)) return CoreStepResult() monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) @@ -1019,11 +1035,15 @@ def _spy_run_core_step(state: object, entry: object, *, configuration: object) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) assert len(captured_calls) == 1 - state, entry, configuration = captured_calls[0] + state, entry, configuration, control_time_queue_context = captured_calls[0] assert state is runner.strategy_state assert configuration is runner._core_cfg assert isinstance(entry.event, ControlTimeEvent) assert entry.position.index == 0 + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + assert control_time_queue_context.risk_engine is runner.risk + assert control_time_queue_context.instrument == runner.engine_cfg.instrument + assert control_time_queue_context.now_ts_ns_local == 10 assert runner._event_stream_cursor.next_index == 1 @@ -1054,8 +1074,15 @@ def test_control_time_event_uses_structured_obligation_fields_when_available( control_events: list[ControlTimeEvent] = [] - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) return CoreStepResult() @@ -1091,8 +1118,15 @@ def test_control_time_event_falls_back_when_structured_obligation_missing( control_events: list[ControlTimeEvent] = [] - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) return CoreStepResult() @@ -1178,9 +1212,16 @@ def test_control_time_deadline_injection_is_not_periodic_for_same_deadline( runner._next_send_ts_ns_local = 5 control_count = 0 - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: nonlocal control_count _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_count += 1 return CoreStepResult() @@ -1195,10 +1236,9 @@ def _spy_run_core_step(state: object, entry: object, *, configuration: object) - assert control_count == 1 -def test_control_time_event_processed_before_pop_and_gate( +def test_control_time_event_processed_through_core_step_context_without_runtime_pop( monkeypatch: pytest.MonkeyPatch, ) -> None: - queued_intent = _new_intent() runner = HftStrategyRunner( engine_cfg=_engine_cfg(), strategy=_NoopStrategy(), @@ -1207,28 +1247,34 @@ def test_control_time_event_processed_before_pop_and_gate( ) runner._next_send_ts_ns_local = 5 - ordering: list[str] = [] - captured_raw_inputs: list[list[Any]] = [] - - def _spy_pop_queued_intents(instrument: str) -> list[Any]: - _ = instrument - ordering.append("pop") - return [queued_intent] - - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): - ordering.append("control") + assert entry.position.index == 0 return CoreStepResult() - def _spy_decide_intents(**kwargs: Any) -> GateDecision: - ordering.append("gate") - captured_raw_inputs.append(list(kwargs["raw_intents"])) - return _decision_for([]) - - monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + monkeypatch.setattr( + runner.strategy_state, + "pop_queued_intents", + lambda _: (_ for _ in ()).throw( + AssertionError("runtime must not pop queued intents for control-time re-evaluation") + ), + ) monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("runtime must not run risk gate for control-time queue directly") + ), + ) venue = _StubVenue( rc_sequence=[0, 0, 1], @@ -1236,12 +1282,10 @@ def _spy_decide_intents(**kwargs: Any) -> GateDecision: ) runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert ordering == ["control", "pop", "gate"] - assert len(captured_raw_inputs) == 1 - assert [it.client_order_id for it in captured_raw_inputs[0]] == [queued_intent.client_order_id] + assert runner._event_stream_cursor.next_index == 1 -def test_control_time_processing_failure_does_not_pop_or_mark_deadline( +def test_control_time_processing_failure_does_not_mark_deadline_or_dispatch( monkeypatch: pytest.MonkeyPatch, ) -> None: runner = HftStrategyRunner( @@ -1254,22 +1298,27 @@ def test_control_time_processing_failure_does_not_pop_or_mark_deadline( runner._pending_control_scheduling_obligation = obligation runner._next_send_ts_ns_local = obligation.due_ts_ns_local - pop_count = 0 - - def _fail_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _fail_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): raise RuntimeError("boom") return CoreStepResult() - def _spy_pop_queued_intents(instrument: str) -> list[Any]: - nonlocal pop_count - _ = instrument - pop_count += 1 - return [] - monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) - monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + monkeypatch.setattr( + runner.strategy_state, + "pop_queued_intents", + lambda _: (_ for _ in ()).throw( + AssertionError("runtime queue pop must not run on control-time failure") + ), + ) venue = _StubVenue( rc_sequence=[0, 0, 1], @@ -1279,7 +1328,6 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: with pytest.raises(RuntimeError, match="boom"): runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert pop_count == 0 assert runner._last_injected_control_deadline_ns is None assert runner._pending_control_scheduling_obligation == obligation assert runner._next_send_ts_ns_local == 5 @@ -1325,7 +1373,7 @@ def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: assert process_event_names == ["MarketEvent", "OrderSubmittedEvent"] -def test_control_time_success_consumes_pending_before_pop( +def test_control_time_success_consumes_pending_obligation_after_core_step( monkeypatch: pytest.MonkeyPatch, ) -> None: runner = HftStrategyRunner( @@ -1338,21 +1386,25 @@ def test_control_time_success_consumes_pending_before_pop( runner._pending_control_scheduling_obligation = obligation runner._next_send_ts_ns_local = obligation.due_ts_ns_local - pop_count = 0 - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + return CoreStepResult() - def _spy_pop_queued_intents(instrument: str) -> list[Any]: - nonlocal pop_count - _ = instrument - pop_count += 1 - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - return [] - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.strategy_state, + "pop_queued_intents", + lambda _: (_ for _ in ()).throw( + AssertionError("runtime must not directly pop queued intents") + ), + ) venue = _StubVenue( rc_sequence=[0, 0, 1], @@ -1360,12 +1412,11 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: ) runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert pop_count == 1 assert runner._pending_control_scheduling_obligation is None assert runner._next_send_ts_ns_local is None -def test_realized_old_deadline_does_not_pop_without_new_canonical_injection( +def test_realized_old_deadline_does_not_runtime_pop_without_new_canonical_injection( monkeypatch: pytest.MonkeyPatch, ) -> None: runner = HftStrategyRunner( @@ -1377,23 +1428,28 @@ def test_realized_old_deadline_does_not_pop_without_new_canonical_injection( runner._next_send_ts_ns_local = 5 control_count = 0 - pop_count = 0 - - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: nonlocal control_count _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_count += 1 return CoreStepResult() - def _spy_pop_queued_intents(instrument: str) -> list[Any]: - nonlocal pop_count - _ = instrument - pop_count += 1 - return [] - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr(runner.strategy_state, "pop_queued_intents", _spy_pop_queued_intents) + monkeypatch.setattr( + runner.strategy_state, + "pop_queued_intents", + lambda _: (_ for _ in ()).throw( + AssertionError("runtime must not pop control-time queue directly") + ), + ) venue = _StubVenue( rc_sequence=[0, 0, 0, 1], @@ -1402,7 +1458,7 @@ def _spy_pop_queued_intents(instrument: str) -> list[Any]: runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) assert control_count == 1 - assert pop_count == 1 + assert runner._event_stream_cursor.next_index == 1 def test_global_canonical_counter_shared_with_control_time_market_and_submitted( @@ -1429,8 +1485,15 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj _ = (state, configuration) positions.append((entry.position.index, type(entry.event).__name__)) - def _spy_run_core_step(state: object, entry: object, *, configuration: object) -> CoreStepResult: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) positions.append((entry.position.index, type(entry.event).__name__)) return CoreStepResult() @@ -1451,6 +1514,153 @@ def _spy_run_core_step(state: object, entry: object, *, configuration: object) - assert runner._event_stream_cursor.next_index == 3 +def test_control_time_core_step_result_dispatches_via_existing_execution_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + control_intent = _new_intent(ts_ns_local=10) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + + obligation = _obligation(due_ts_ns_local=25, obligation_key="control-obligation") + control_decision = _decision_for( + [control_intent], + next_send_ts_ns_local=25, + control_scheduling_obligations=(obligation,), + ) + + callbacks: list[GateDecision] = [] + mark_calls: list[tuple[str, str, str]] = [] + submitted_events: list[OrderSubmittedEvent] = [] + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: + _ = (state, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + return CoreStepResult( + dispatchable_intents=(control_intent,), + compat_gate_decision=control_decision, + control_scheduling_obligation=obligation, + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_events.append(entry.event) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda d: callbacks.append(d)) + monkeypatch.setattr( + runner.strategy_state, + "mark_intent_sent", + lambda i, c, t: mark_calls.append((i, c, t)), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("raw path risk gate should not run in this control-only wakeup") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(submitted_events) == 1 + assert submitted_events[0].client_order_id == control_intent.client_order_id + assert mark_calls == [(control_intent.instrument, control_intent.client_order_id, "new")] + assert callbacks == [control_decision] + assert runner._pending_control_scheduling_obligation == obligation + assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local + + +def test_same_wakeup_strategy_and_control_time_intents_are_processed_in_two_decisions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + strategy_intent = _new_intent(ts_ns_local=10) + control_intent = _new_intent(ts_ns_local=10) + control_intent = control_intent.model_copy(update={"client_order_id": "cid-control-1"}) + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([strategy_intent]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + runner._next_send_ts_ns_local = 5 + + control_decision = _decision_for([control_intent]) + strategy_decision = _decision_for([strategy_intent]) + + callback_order: list[str] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + ) -> CoreStepResult: + _ = (state, entry, configuration) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + return CoreStepResult( + dispatchable_intents=(control_intent,), + compat_gate_decision=control_decision, + ) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: strategy_decision, + ) + monkeypatch.setattr( + runner.strategy, + "on_risk_decision", + lambda decision: callback_order.append( + "control" + if decision is control_decision + else "strategy" + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert execution.batches == [ + [control_intent.client_order_id], + [strategy_intent.client_order_id], + ] + assert callback_order == ["control", "strategy"] + + def test_fallback_second_boundary_wakeup_behavior_unchanged( monkeypatch: pytest.MonkeyPatch, ) -> None: From de643259f7dafe3629bfe4926c3a9af6b87174db Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 11:37:39 +0000 Subject: [PATCH 06/18] feat(runtime): route market strategy through core step --- .../backtest/engine/strategy_runner.py | 63 +++- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 298 ++++++++++++++++-- 4 files changed, 317 insertions(+), 48 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index a1d0e77..b03731a 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -52,6 +52,31 @@ MAX_TIMEOUT_NS = 1 << 62 # Effectively "wait forever" without a heartbeat +class _LegacyOnFeedStrategyEvaluator: + """Runtime-local adapter from legacy Strategy.on_feed to CoreStep evaluator.""" + + def __init__( + self, + *, + strategy: Strategy, + engine_cfg: HftEngineConfig, + constraints: object, + ) -> None: + self._strategy = strategy + self._engine_cfg = engine_cfg + self._constraints = constraints + + def evaluate(self, context: object) -> tuple[OrderIntent, ...]: + return tuple( + self._strategy.on_feed( + context.state, + context.event, + self._engine_cfg, + self._constraints, + ) + ) + + class HftStrategyRunner: """Strategy runner for HFT backtests. @@ -143,8 +168,29 @@ def intent_priority(intent: OrderIntent) -> int: return sorted(intents, key=lambda it: (intent_priority(it), it.ts_ns_local)) - def _process_canonical_market_event(self, market_event: MarketEvent) -> None: - self._process_canonical_event(market_event) + def _process_canonical_market_event( + self, + market_event: MarketEvent, + *, + constraints: object, + ) -> CoreStepResult: + position = self._event_stream_cursor.attempt_position() + entry = EventStreamEntry( + position=position, + event=market_event, + ) + result = run_core_step( + self.strategy_state, + entry, + configuration=self._core_cfg, + strategy_evaluator=_LegacyOnFeedStrategyEvaluator( + strategy=self.strategy, + engine_cfg=self.engine_cfg, + constraints=constraints, + ), + ) + self._event_stream_cursor.commit_success(position) + return result def _process_canonical_order_submitted_event( self, @@ -434,17 +480,12 @@ def run( ), ) - self._process_canonical_market_event(market_event) - constraints = self.risk.build_constraints(sim_now_ns) - raw_intents.extend( - self.strategy.on_feed( - self.strategy_state, - market_event, - self.engine_cfg, - constraints, - ) + market_step_result = self._process_canonical_market_event( + market_event, + constraints=constraints, ) + raw_intents.extend(market_step_result.generated_intents) # ----------------------------------------------------------------- # Order / account update diff --git a/requirements-dev.txt b/requirements-dev.txt index 0f466af..e0abd73 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@852babf6cfc87e0a935d8edd488581ba83ff0ed1 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@e57e3305753efbb4233b3aeb7c98d592a11ee7e1 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index d95d74a..af38a3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@852babf6cfc87e0a935d8edd488581ba83ff0ed1 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@e57e3305753efbb4233b3aeb7c98d592a11ee7e1 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 9072ec4..000a497 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -358,27 +358,42 @@ def test_process_market_event_routes_through_event_entry_with_core_configuration ) -> None: runner = object.__new__(HftStrategyRunner) runner.strategy_state = object() + runner.strategy = _NoopStrategy() + runner.engine_cfg = _engine_cfg() runner._core_cfg = _core_cfg() runner._event_stream_cursor = EventStreamCursor() - captured: list[tuple[int, object]] = [] + captured: list[tuple[int, object, object | None]] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: _ = state - captured.append((entry.position.index, configuration)) + _ = core_decision_context + captured.append((entry.position.index, configuration, strategy_evaluator)) + assert control_time_queue_context is None + return CoreStepResult() monkeypatch.setattr( strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, + "run_core_step", + _spy_run_core_step, ) - runner._process_canonical_market_event(_market_event(1)) - runner._process_canonical_market_event(_market_event(2)) + runner._process_canonical_market_event(_market_event(1), constraints=SimpleNamespace()) + runner._process_canonical_market_event(_market_event(2), constraints=SimpleNamespace()) - assert [idx for idx, _ in captured] == [0, 1] + assert [idx for idx, _, _ in captured] == [0, 1] assert captured[0][1] is runner._core_cfg assert captured[1][1] is runner._core_cfg + assert captured[0][2] is not None + assert captured[1][2] is not None assert runner._event_stream_cursor.next_index == 2 @@ -387,23 +402,35 @@ def test_first_canonical_event_uses_processing_position_zero( ) -> None: runner = object.__new__(HftStrategyRunner) runner.strategy_state = object() + runner.strategy = _NoopStrategy() + runner.engine_cfg = _engine_cfg() runner._core_cfg = _core_cfg() runner._event_stream_cursor = EventStreamCursor() captured: list[int] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: _ = state + _ = (control_time_queue_context, core_decision_context, strategy_evaluator) assert configuration is runner._core_cfg captured.append(entry.position.index) + return CoreStepResult() monkeypatch.setattr( strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, + "run_core_step", + _spy_run_core_step, ) - runner._process_canonical_market_event(_market_event(1)) + runner._process_canonical_market_event(_market_event(1), constraints=SimpleNamespace()) assert captured == [0] assert runner._event_stream_cursor.next_index == 1 @@ -429,16 +456,27 @@ def test_market_branch_calls_canonical_boundary_not_update_market( lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("apply_fill_event must not be called")), ) - captured: list[tuple[int, object]] = [] + captured: list[tuple[int, object, str]] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: _ = state - captured.append((entry.position.index, configuration)) + _ = (control_time_queue_context, core_decision_context) + assert strategy_evaluator is not None + captured.append((entry.position.index, configuration, type(entry.event).__name__)) + return CoreStepResult() monkeypatch.setattr( strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, + "run_core_step", + _spy_run_core_step, ) venue = _StubVenue( @@ -448,7 +486,7 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj ) runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert captured == [(0, runner._core_cfg)] + assert captured == [(0, runner._core_cfg, "MarketEvent")] def test_wait_next_bootstrap_uses_include_order_resp_false_then_true_in_loop() -> None: @@ -485,15 +523,25 @@ def test_market_mapping_from_depth_snapshot_is_deterministic_golden( captured_market_events: list[MarketEvent] = [] - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: _ = (state, configuration) + _ = (control_time_queue_context, core_decision_context, strategy_evaluator) if isinstance(entry.event, MarketEvent): captured_market_events.append(entry.event) + return CoreStepResult() monkeypatch.setattr( strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, + "run_core_step", + _spy_run_core_step, ) venue = _StubVenue( @@ -515,14 +563,127 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj assert market_event.book.asks[0].quantity.value == 0.0 +def test_market_branch_strategy_evaluator_preserves_legacy_on_feed_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + generated_intent = _new_intent(ts_ns_local=2_000_000_000) + constraints_obj = SimpleNamespace(tag="constraints") + on_feed_calls: list[tuple[object, object, object, object]] = [] + risk_calls: list[list[object]] = [] + + class _SpyStrategy(Strategy): + def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + on_feed_calls.append((state, event, engine_cfg, constraints)) + return [generated_intent] + + def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, engine_cfg, constraints) + return [] + + def on_risk_decision(self, decision: Any) -> None: + _ = decision + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_SpyStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + monkeypatch.setattr(runner.risk, "build_constraints", lambda _ts: constraints_obj) + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = core_decision_context + assert control_time_queue_context is None + assert strategy_evaluator is not None + evaluated = strategy_evaluator.evaluate( + SimpleNamespace( + state=state, + event=entry.event, + position=entry.position, + configuration=configuration, + ) + ) + return CoreStepResult(generated_intents=tuple(evaluated)) + + def _spy_decide_intents(**kwargs: object) -> GateDecision: + risk_calls.append(list(kwargs["raw_intents"])) + return _decision_for([]) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 2_000_000_000, 2_000_000_001], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(on_feed_calls) == 1 + state_arg, event_arg, engine_cfg_arg, constraints_arg = on_feed_calls[0] + assert state_arg is runner.strategy_state + assert isinstance(event_arg, MarketEvent) + assert engine_cfg_arg is runner.engine_cfg + assert constraints_arg is constraints_obj + assert len(risk_calls) == 1 + assert risk_calls[0] == [generated_intent] + + +def test_market_run_core_step_failure_does_not_commit_or_reach_risk_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + + def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: + _ = (args, kwargs) + raise RuntimeError("boom-market-core-step") + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("risk gate must not run after market core-step failure") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 2, 3], + depth=_depth_snapshot(), + ) + with pytest.raises(RuntimeError, match="boom-market-core-step"): + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert runner._event_stream_cursor.next_index == 0 + + def test_missing_core_cfg_fails_before_market_mutation() -> None: runner = object.__new__(HftStrategyRunner) runner.strategy_state = StrategyState(event_bus=EventBus(sinks=[])) + runner.strategy = _NoopStrategy() + runner.engine_cfg = _engine_cfg() runner._core_cfg = None runner._event_stream_cursor = EventStreamCursor() with pytest.raises(ValueError, match="CoreConfiguration is required"): - runner._process_canonical_market_event(_market_event(42)) + runner._process_canonical_market_event( + _market_event(42), + constraints=SimpleNamespace(), + ) assert runner.strategy_state.market == {} assert runner.strategy_state._last_processing_position_index is None @@ -532,11 +693,16 @@ def test_missing_core_cfg_fails_before_market_mutation() -> None: def test_invalid_core_cfg_type_fails_before_market_mutation() -> None: runner = object.__new__(HftStrategyRunner) runner.strategy_state = StrategyState(event_bus=EventBus(sinks=[])) + runner.strategy = _NoopStrategy() + runner.engine_cfg = _engine_cfg() runner._core_cfg = object() runner._event_stream_cursor = EventStreamCursor() with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - runner._process_canonical_market_event(_market_event(42)) + runner._process_canonical_market_event( + _market_event(42), + constraints=SimpleNamespace(), + ) assert runner.strategy_state.market == {} assert runner.strategy_state._last_processing_position_index is None @@ -915,7 +1081,22 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj event_name = type(entry.event).__name__ positions.append((entry.position.index, event_name)) + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = (state, configuration, core_decision_context, strategy_evaluator) + assert control_time_queue_context is None + positions.append((entry.position.index, type(entry.event).__name__)) + return CoreStepResult(generated_intents=(new_intent,)) + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 2, 1], @@ -936,26 +1117,35 @@ def test_canonical_counter_increments_only_after_successful_canonical_processing ) -> None: runner = object.__new__(HftStrategyRunner) runner.strategy_state = object() + runner.strategy = _NoopStrategy() + runner.engine_cfg = _engine_cfg() runner._core_cfg = _core_cfg() runner._event_stream_cursor = EventStreamCursor() - def _fail(*args: object, **kwargs: object) -> None: + def _fail(*args: object, **kwargs: object) -> CoreStepResult: _ = (args, kwargs) raise RuntimeError("boom") - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _fail) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail) with pytest.raises(RuntimeError, match="boom"): - runner._process_canonical_market_event(_market_event(1)) + runner._process_canonical_market_event( + _market_event(1), + constraints=SimpleNamespace(), + ) assert runner._event_stream_cursor.next_index == 0 called = {"count": 0} - def _ok(*args: object, **kwargs: object) -> None: + def _ok(*args: object, **kwargs: object) -> CoreStepResult: _ = (args, kwargs) called["count"] += 1 + return CoreStepResult() - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _ok) - runner._process_canonical_market_event(_market_event(2)) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _ok) + runner._process_canonical_market_event( + _market_event(2), + constraints=SimpleNamespace(), + ) assert called["count"] == 1 assert runner._event_stream_cursor.next_index == 1 @@ -979,8 +1169,15 @@ def _spy_run_core_step( *, configuration: object, control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + _ = core_decision_context + if isinstance(entry.event, MarketEvent): + assert control_time_queue_context is None + assert strategy_evaluator is not None + return CoreStepResult(generated_intents=(_new_intent(),)) assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) @@ -1080,8 +1277,15 @@ def _spy_run_core_step( *, configuration: object, control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + _ = core_decision_context + if isinstance(entry.event, MarketEvent): + assert control_time_queue_context is None + assert strategy_evaluator is not None + return CoreStepResult(generated_intents=(_new_intent(),)) assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) @@ -1351,17 +1555,28 @@ def test_market_and_order_submitted_paths_remain_on_process_event_entry_path( ) process_event_names: list[str] = [] + run_core_step_names: list[str] = [] def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: _ = (state, configuration) process_event_names.append(type(entry.event).__name__) - def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - raise AssertionError("run_core_step must not be used for market/order-submitted path") + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object, + control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = (state, configuration, core_decision_context, strategy_evaluator) + assert control_time_queue_context is None + run_core_step_names.append(type(entry.event).__name__) + return CoreStepResult(generated_intents=(new_intent,)) monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) venue = _StubVenue( rc_sequence=[0, 2, 1], @@ -1370,7 +1585,8 @@ def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: ) runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert process_event_names == ["MarketEvent", "OrderSubmittedEvent"] + assert run_core_step_names == ["MarketEvent"] + assert process_event_names == ["OrderSubmittedEvent"] def test_control_time_success_consumes_pending_obligation_after_core_step( @@ -1491,10 +1707,16 @@ def _spy_run_core_step( *, configuration: object, control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + _ = (core_decision_context, strategy_evaluator) positions.append((entry.position.index, type(entry.event).__name__)) + if isinstance(entry.event, MarketEvent): + assert control_time_queue_context is None + return CoreStepResult(generated_intents=(new_intent,)) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) return CoreStepResult() monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) @@ -1623,8 +1845,14 @@ def _spy_run_core_step( *, configuration: object, control_time_queue_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, entry, configuration) + _ = (core_decision_context, strategy_evaluator) + if isinstance(entry.event, MarketEvent): + assert control_time_queue_context is None + return CoreStepResult(generated_intents=(strategy_intent,)) assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) return CoreStepResult( dispatchable_intents=(control_intent,), From 09e6508f9de7800b1f852421b7865dfb25e08daf Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:48:11 +0000 Subject: [PATCH 07/18] feat(runtime): add flagged MarketEvent core-step dispatch path --- .../backtest/engine/strategy_runner.py | 69 +- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 611 +++++++++++++++++- 4 files changed, 656 insertions(+), 28 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index b03731a..3ac9df8 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -7,7 +7,12 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -from tradingchassis_core import ControlTimeQueueReevaluationContext, run_core_step +from tradingchassis_core import ( + ControlTimeQueueReevaluationContext, + CoreExecutionControlApplyContext, + CorePolicyAdmissionContext, + run_core_step, +) from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import ( @@ -93,10 +98,12 @@ def __init__( strategy: Strategy, risk_cfg: RiskConfig, core_cfg: CoreConfiguration, + enable_core_step_market_dispatch: bool = False, ) -> None: self.engine_cfg = engine_cfg self.strategy = strategy self._core_cfg = core_cfg + self._enable_core_step_market_dispatch = enable_core_step_market_dispatch event_bus = self._build_event_bus( path=Path(engine_cfg.event_bus_path), @@ -179,15 +186,45 @@ def _process_canonical_market_event( position=position, event=market_event, ) - result = run_core_step( - self.strategy_state, - entry, - configuration=self._core_cfg, - strategy_evaluator=_LegacyOnFeedStrategyEvaluator( + policy_admission_context = None + execution_control_apply_context = None + if getattr(self, "_enable_core_step_market_dispatch", False): + rate_cfg = self.risk.risk_cfg.order_rate_limits + max_orders_per_sec = ( + None if rate_cfg is None else rate_cfg.max_orders_per_second + ) + max_cancels_per_sec = ( + None if rate_cfg is None else rate_cfg.max_cancels_per_second + ) + policy_admission_context = CorePolicyAdmissionContext( + policy_evaluator=self.risk, + now_ts_ns_local=market_event.ts_ns_local, + ) + execution_control_apply_context = CoreExecutionControlApplyContext( + execution_control=self.risk.execution_control, + now_ts_ns_local=market_event.ts_ns_local, + max_orders_per_sec=max_orders_per_sec, + max_cancels_per_sec=max_cancels_per_sec, + activate_dispatchable_outputs=True, + ) + run_core_step_kwargs: dict[str, object] = { + "configuration": self._core_cfg, + "strategy_evaluator": _LegacyOnFeedStrategyEvaluator( strategy=self.strategy, engine_cfg=self.engine_cfg, constraints=constraints, ), + } + if policy_admission_context is not None: + run_core_step_kwargs["policy_admission_context"] = policy_admission_context + if execution_control_apply_context is not None: + run_core_step_kwargs["execution_control_apply_context"] = ( + execution_control_apply_context + ) + result = run_core_step( + self.strategy_state, + entry, + **run_core_step_kwargs, ) self._event_stream_cursor.commit_success(position) return result @@ -403,6 +440,7 @@ def run( sim_now_ns = self.strategy_state.sim_ts_ns_local raw_intents: list[OrderIntent] = [] + market_step_result: CoreStepResult | None = None control_step_result: CoreStepResult | None = None # ----------------------------------------------------------------- @@ -485,7 +523,8 @@ def run( market_event, constraints=constraints, ) - raw_intents.extend(market_step_result.generated_intents) + if not getattr(self, "_enable_core_step_market_dispatch", False): + raw_intents.extend(market_step_result.generated_intents) # ----------------------------------------------------------------- # Order / account update @@ -567,6 +606,22 @@ def run( control_step_result.control_scheduling_obligation ) + if ( + market_step_result is not None + and getattr(self, "_enable_core_step_market_dispatch", False) + ): + self._dispatch_accepted_intents( + list(market_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, + ) + if market_step_result.control_scheduling_obligation is None: + self._clear_pending_control_scheduling_obligation() + else: + self._apply_control_scheduling_obligation( + market_step_result.control_scheduling_obligation + ) + # ----------------------------------------------------------------- # Gate + execution # ----------------------------------------------------------------- diff --git a/requirements-dev.txt b/requirements-dev.txt index e0abd73..930ee70 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@e57e3305753efbb4233b3aeb7c98d592a11ee7e1 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@a48430bd637a8fa967efb40ebdd5055412ec3152 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index af38a3f..fd02c24 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@e57e3305753efbb4233b3aeb7c98d592a11ee7e1 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@a48430bd637a8fa967efb40ebdd5055412ec3152 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 000a497..a91243a 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -8,6 +8,8 @@ from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, + CoreExecutionControlApplyContext, + CorePolicyAdmissionContext, ) from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult @@ -151,6 +153,21 @@ def _risk_cfg() -> RiskConfig: ) +def _risk_cfg_with_rate_limits( + *, + max_orders_per_second: float, + max_cancels_per_second: float, +) -> RiskConfig: + return RiskConfig( + scope="test", + notional_limits={"currency": "USDC", "max_gross_notional": 1.0}, + order_rate_limits={ + "max_orders_per_second": max_orders_per_second, + "max_cancels_per_second": max_cancels_per_second, + }, + ) + + def _market_event(ts_ns: int) -> MarketEvent: return MarketEvent( ts_ns_exch=ts_ns, @@ -369,13 +386,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = state _ = core_decision_context + assert policy_admission_context is None + assert execution_control_apply_context is None captured.append((entry.position.index, configuration, strategy_evaluator)) assert control_time_queue_context is None return CoreStepResult() @@ -413,13 +434,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = state _ = (control_time_queue_context, core_decision_context, strategy_evaluator) + assert policy_admission_context is None + assert execution_control_apply_context is None assert configuration is runner._core_cfg captured.append(entry.position.index) return CoreStepResult() @@ -462,13 +487,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = state _ = (control_time_queue_context, core_decision_context) + assert policy_admission_context is None + assert execution_control_apply_context is None assert strategy_evaluator is not None captured.append((entry.position.index, configuration, type(entry.event).__name__)) return CoreStepResult() @@ -527,13 +556,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) _ = (control_time_queue_context, core_decision_context, strategy_evaluator) + assert policy_admission_context is None + assert execution_control_apply_context is None if isinstance(entry.event, MarketEvent): captured_market_events.append(entry.event) return CoreStepResult() @@ -595,13 +628,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = core_decision_context assert control_time_queue_context is None + assert policy_admission_context is None + assert execution_control_apply_context is None assert strategy_evaluator is not None evaluated = strategy_evaluator.evaluate( SimpleNamespace( @@ -637,6 +674,486 @@ def _spy_decide_intents(**kwargs: object) -> GateDecision: assert risk_calls[0] == [generated_intent] +def test_market_core_step_mode_calls_run_core_step_with_policy_and_apply_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg_with_rate_limits( + max_orders_per_second=7.0, + max_cancels_per_second=3.0, + ), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + + captured: list[tuple[object, object, object, object]] = [] + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = core_decision_context + assert control_time_queue_context is None + assert strategy_evaluator is not None + captured.append( + ( + state, + configuration, + policy_admission_context, + execution_control_apply_context, + ) + ) + return CoreStepResult(generated_intents=(_new_intent(),)) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 2_000_000_000, 2_000_000_001], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(captured) == 1 + state, configuration, policy_ctx, apply_ctx = captured[0] + assert state is runner.strategy_state + assert configuration is runner._core_cfg + assert isinstance(policy_ctx, CorePolicyAdmissionContext) + assert policy_ctx.policy_evaluator is runner.risk + assert policy_ctx.now_ts_ns_local == 2_000_000_000 + assert isinstance(apply_ctx, CoreExecutionControlApplyContext) + assert apply_ctx.execution_control is runner.risk.execution_control + assert apply_ctx.now_ts_ns_local == 2_000_000_000 + assert apply_ctx.max_orders_per_sec == 7.0 + assert apply_ctx.max_cancels_per_sec == 3.0 + assert apply_ctx.activate_dispatchable_outputs is True + + +def test_market_core_step_mode_dispatches_core_step_dispatchable_intents_only( + monkeypatch: pytest.MonkeyPatch, +) -> None: + generated_intent = _new_intent(ts_ns_local=2) + dispatchable_intent = _new_intent(ts_ns_local=2).model_copy( + update={"client_order_id": "cid-dispatchable-only"} + ) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([generated_intent]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = ( + state, + entry, + configuration, + control_time_queue_context, + policy_admission_context, + execution_control_apply_context, + core_decision_context, + strategy_evaluator, + ) + return CoreStepResult( + generated_intents=(generated_intent,), + dispatchable_intents=(dispatchable_intent,), + ) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 2, 3], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert execution.batches == [[dispatchable_intent.client_order_id]] + + +def test_market_core_step_mode_applies_obligation_and_clears_when_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + seeded = _obligation(due_ts_ns_local=9_999_999_999, obligation_key="seeded") + runner._pending_control_scheduling_obligation = seeded + runner._next_send_ts_ns_local = seeded.due_ts_ns_local + obligation = _obligation(due_ts_ns_local=25, obligation_key="core-step-obligation") + + results = [ + CoreStepResult(control_scheduling_obligation=obligation), + CoreStepResult(control_scheduling_obligation=None), + ] + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = ( + state, + entry, + configuration, + control_time_queue_context, + policy_admission_context, + execution_control_apply_context, + core_decision_context, + strategy_evaluator, + ) + return results.pop(0) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 2, 1], + ts_sequence=[1, 2, 3, 4], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + +def test_market_core_step_mode_preserves_order_submitted_before_mark_sent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent() + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + ordering: list[str] = [] + submitted_events: list[OrderSubmittedEvent] = [] + marks: list[tuple[str, str, str]] = [] + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + ordering.append("submitted") + submitted_events.append(entry.event) + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + ordering.append("mark") + marks.append((instrument, client_order_id, intent_type)) + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1_111, 5_000_000_000, 5_000_000_001], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(submitted_events) == 1 + assert ordering == ["submitted", "mark"] + assert marks == [ + (dispatchable_new.instrument, dispatchable_new.client_order_id, "new") + ] + + +def test_market_core_step_mode_failed_new_dispatch_emits_no_order_submitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent() + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + submitted_event_count = 0 + marked_count = 0 + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_event_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_event_count += 1 + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + nonlocal marked_count + _ = (instrument, client_order_id, intent_type) + marked_count += 1 + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + class _ExecutionFailNew: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + return [(dispatchable_new, "EXCHANGE_REJECT")] + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[10, 20, 30], + depth=_depth_snapshot(), + ) + runner.run( + venue=venue, + execution=_ExecutionFailNew(), + recorder=_RecorderWrapper(), + ) + + assert submitted_event_count == 0 + assert marked_count == 0 + + +def test_market_core_step_mode_replace_cancel_emit_no_order_submitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + replace_intent = _replace_intent() + cancel_intent = _cancel_intent() + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + submitted_event_count = 0 + marks: list[tuple[str, str, str]] = [] + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(replace_intent, cancel_intent), + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("market core-step mode must not call runtime risk gate") + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_event_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_event_count += 1 + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr( + runner.strategy_state, + "mark_intent_sent", + lambda i, c, t: marks.append((i, c, t)), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[100, 200, 300], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert submitted_event_count == 0 + assert marks == [ + ( + replace_intent.instrument, + replace_intent.client_order_id, + "replace", + ), + ( + cancel_intent.instrument, + cancel_intent.client_order_id, + "cancel", + ), + ] + + +def test_market_core_step_mode_failure_does_not_commit_or_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("boom-market-core-step") + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("risk gate must not run after market core-step failure") + ), + ) + + class _ExecutionMustNotRun: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + raise AssertionError("dispatch must not run after market core-step failure") + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 2, 3], + depth=_depth_snapshot(), + ) + with pytest.raises(RuntimeError, match="boom-market-core-step"): + runner.run(venue=venue, execution=_ExecutionMustNotRun(), recorder=_RecorderWrapper()) + + assert runner._event_stream_cursor.next_index == 0 + + +def test_order_update_path_remains_legacy_when_market_core_step_mode_enabled() -> None: + order_update_intent = _new_intent(ts_ns_local=2) + risk_calls: list[list[object]] = [] + + class _OrderUpdateStrategy(Strategy): + def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, event, engine_cfg, constraints) + return [] + + def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, engine_cfg, constraints) + return [order_update_intent] + + def on_risk_decision(self, decision: Any) -> None: + _ = decision + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_OrderUpdateStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + ) + + def _spy_decide_intents(**kwargs: object) -> GateDecision: + risk_calls.append(list(kwargs["raw_intents"])) + return _decision_for([]) + + runner.risk.decide_intents = _spy_decide_intents # type: ignore[method-assign] + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(risk_calls) == 1 + assert risk_calls[0] == [order_update_intent] + + def test_market_run_core_step_failure_does_not_commit_or_reach_risk_dispatch( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1085,13 +1602,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration, core_decision_context, strategy_evaluator) assert control_time_queue_context is None + assert policy_admission_context is None + assert execution_control_apply_context is None positions.append((entry.position.index, type(entry.event).__name__)) return CoreStepResult(generated_intents=(new_intent,)) @@ -1167,13 +1688,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) _ = core_decision_context + assert policy_admission_context is None + assert execution_control_apply_context is None if isinstance(entry.event, MarketEvent): assert control_time_queue_context is None assert strategy_evaluator is not None @@ -1218,10 +1743,14 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: captured_calls.append((state, entry, configuration, control_time_queue_context)) + assert policy_admission_context is None + assert execution_control_apply_context is None return CoreStepResult() monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) @@ -1275,13 +1804,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) _ = core_decision_context + assert policy_admission_context is None + assert execution_control_apply_context is None if isinstance(entry.event, MarketEvent): assert control_time_queue_context is None assert strategy_evaluator is not None @@ -1326,10 +1859,14 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_events.append(entry.event) @@ -1420,11 +1957,15 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: nonlocal control_count _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_count += 1 @@ -1455,10 +1996,14 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): assert entry.position.index == 0 @@ -1506,10 +2051,14 @@ def _fail_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): raise RuntimeError("boom") @@ -1565,13 +2114,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration, core_decision_context, strategy_evaluator) assert control_time_queue_context is None + assert policy_admission_context is None + assert execution_control_apply_context is None run_core_step_names.append(type(entry.event).__name__) return CoreStepResult(generated_intents=(new_intent,)) @@ -1606,10 +2159,14 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) return CoreStepResult() @@ -1648,11 +2205,15 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: nonlocal control_count _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) if isinstance(entry.event, ControlTimeEvent): control_count += 1 @@ -1705,13 +2266,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, configuration) _ = (core_decision_context, strategy_evaluator) + assert policy_admission_context is None + assert execution_control_apply_context is None positions.append((entry.position.index, type(entry.event).__name__)) if isinstance(entry.event, MarketEvent): assert control_time_queue_context is None @@ -1763,10 +2328,14 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, ) -> CoreStepResult: _ = (state, configuration) + assert policy_admission_context is None + assert execution_control_apply_context is None assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) return CoreStepResult( dispatchable_intents=(control_intent,), @@ -1843,13 +2412,17 @@ def _spy_run_core_step( state: object, entry: object, *, - configuration: object, + configuration: object | None = None, control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, core_decision_context: object | None = None, strategy_evaluator: object | None = None, ) -> CoreStepResult: _ = (state, entry, configuration) _ = (core_decision_context, strategy_evaluator) + assert policy_admission_context is None + assert execution_control_apply_context is None if isinstance(entry.event, MarketEvent): assert control_time_queue_context is None return CoreStepResult(generated_intents=(strategy_intent,)) From 61885df023ce3a4c456b62c799762693918e3f89 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:56:36 +0000 Subject: [PATCH 08/18] feat(runtime): track core-step dispatch execution errors --- .../backtest/engine/strategy_runner.py | 3 ++- ...rategy_runner_canonical_market_adoption.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 3ac9df8..f63fd48 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -122,6 +122,7 @@ def __init__( self._event_stream_cursor = EventStreamCursor() self._last_injected_control_deadline_ns: int | None = None self._pending_control_scheduling_obligation: ControlSchedulingObligation | None = None + self._last_core_step_execution_errors: list[tuple[OrderIntent, str]] = [] def _process_canonical_event(self, event: object) -> None: position = self._event_stream_cursor.attempt_position() @@ -610,7 +611,7 @@ def run( market_step_result is not None and getattr(self, "_enable_core_step_market_dispatch", False) ): - self._dispatch_accepted_intents( + self._last_core_step_execution_errors = self._dispatch_accepted_intents( list(market_step_result.dispatchable_intents), execution, sim_now_ns=sim_now_ns, diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index a91243a..177a4b6 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -920,6 +920,13 @@ def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: st monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + monkeypatch.setattr( + runner.strategy, + "on_risk_decision", + lambda _decision: (_ for _ in ()).throw( + AssertionError("market core-step mode must not synthesize GateDecision callbacks") + ), + ) venue = _StubVenue( rc_sequence=[0, 2, 1], @@ -933,6 +940,7 @@ def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: st assert marks == [ (dispatchable_new.instrument, dispatchable_new.client_order_id, "new") ] + assert runner._last_core_step_execution_errors == [] def test_market_core_step_mode_failed_new_dispatch_emits_no_order_submitted( @@ -977,6 +985,13 @@ def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: st monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + monkeypatch.setattr( + runner.strategy, + "on_risk_decision", + lambda _decision: (_ for _ in ()).throw( + AssertionError("market core-step mode must not synthesize GateDecision callbacks") + ), + ) class _ExecutionFailNew: def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: @@ -996,6 +1011,10 @@ def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: assert submitted_event_count == 0 assert marked_count == 0 + assert len(runner._last_core_step_execution_errors) == 1 + failed_intent, failure_reason = runner._last_core_step_execution_errors[0] + assert failed_intent.client_order_id == dispatchable_new.client_order_id + assert failure_reason == "EXCHANGE_REJECT" def test_market_core_step_mode_replace_cancel_emit_no_order_submitted( @@ -1061,6 +1080,7 @@ def _spy_process_event_entry(state: object, entry: object, *, configuration: obj "cancel", ), ] + assert runner._last_core_step_execution_errors == [] def test_market_core_step_mode_failure_does_not_commit_or_dispatch( From 71e12948aef7f2571846b32760c52fca1476d6e1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 14:19:05 +0000 Subject: [PATCH 09/18] feat(runtime): add flagged ControlTime core-step dispatch path --- .../backtest/engine/strategy_runner.py | 128 +++++--- ...rategy_runner_canonical_market_adoption.py | 278 ++++++++++++++++++ 2 files changed, 368 insertions(+), 38 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index f63fd48..5e06c97 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -99,11 +99,15 @@ def __init__( risk_cfg: RiskConfig, core_cfg: CoreConfiguration, enable_core_step_market_dispatch: bool = False, + enable_core_step_control_time_dispatch: bool = False, ) -> None: self.engine_cfg = engine_cfg self.strategy = strategy self._core_cfg = core_cfg self._enable_core_step_market_dispatch = enable_core_step_market_dispatch + self._enable_core_step_control_time_dispatch = ( + enable_core_step_control_time_dispatch + ) event_bus = self._build_event_bus( path=Path(engine_cfg.event_bus_path), @@ -176,6 +180,32 @@ def intent_priority(intent: OrderIntent) -> int: return sorted(intents, key=lambda it: (intent_priority(it), it.ts_ns_local)) + def _build_policy_and_apply_context( + self, + *, + now_ts_ns_local: int, + ) -> tuple[CorePolicyAdmissionContext, CoreExecutionControlApplyContext]: + rate_cfg = self.risk.risk_cfg.order_rate_limits + max_orders_per_sec = ( + None if rate_cfg is None else rate_cfg.max_orders_per_second + ) + max_cancels_per_sec = ( + None if rate_cfg is None else rate_cfg.max_cancels_per_second + ) + return ( + CorePolicyAdmissionContext( + policy_evaluator=self.risk, + now_ts_ns_local=now_ts_ns_local, + ), + CoreExecutionControlApplyContext( + execution_control=self.risk.execution_control, + now_ts_ns_local=now_ts_ns_local, + max_orders_per_sec=max_orders_per_sec, + max_cancels_per_sec=max_cancels_per_sec, + activate_dispatchable_outputs=True, + ), + ) + def _process_canonical_market_event( self, market_event: MarketEvent, @@ -190,23 +220,11 @@ def _process_canonical_market_event( policy_admission_context = None execution_control_apply_context = None if getattr(self, "_enable_core_step_market_dispatch", False): - rate_cfg = self.risk.risk_cfg.order_rate_limits - max_orders_per_sec = ( - None if rate_cfg is None else rate_cfg.max_orders_per_second - ) - max_cancels_per_sec = ( - None if rate_cfg is None else rate_cfg.max_cancels_per_second - ) - policy_admission_context = CorePolicyAdmissionContext( - policy_evaluator=self.risk, - now_ts_ns_local=market_event.ts_ns_local, - ) - execution_control_apply_context = CoreExecutionControlApplyContext( - execution_control=self.risk.execution_control, + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( now_ts_ns_local=market_event.ts_ns_local, - max_orders_per_sec=max_orders_per_sec, - max_cancels_per_sec=max_cancels_per_sec, - activate_dispatchable_outputs=True, ) run_core_step_kwargs: dict[str, object] = { "configuration": self._core_cfg, @@ -279,15 +297,34 @@ def _process_canonical_control_time_event( position=position, event=control_time_event, ) + run_core_step_kwargs: dict[str, object] = { + "configuration": self._core_cfg, + } + if getattr(self, "_enable_core_step_control_time_dispatch", False): + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( + now_ts_ns_local=now_ts_ns_local, + ) + run_core_step_kwargs["policy_admission_context"] = ( + policy_admission_context + ) + run_core_step_kwargs["execution_control_apply_context"] = ( + execution_control_apply_context + ) + else: + run_core_step_kwargs["control_time_queue_context"] = ( + ControlTimeQueueReevaluationContext( + risk_engine=self.risk, + instrument=instrument, + now_ts_ns_local=now_ts_ns_local, + ) + ) result = run_core_step( self.strategy_state, entry, - configuration=self._core_cfg, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=self.risk, - instrument=instrument, - now_ts_ns_local=now_ts_ns_local, - ), + **run_core_step_kwargs, ) self._event_stream_cursor.commit_success(position) return result @@ -589,23 +626,38 @@ def run( self._next_send_ts_ns_local = None if control_step_result is not None: - if control_step_result.compat_gate_decision is not None: - self._finalize_decision_effects( - decision=control_step_result.compat_gate_decision, - execution=execution, - sim_now_ns=sim_now_ns, - instrument=instrument, - ) - elif control_step_result.dispatchable_intents: - self._dispatch_accepted_intents( - list(control_step_result.dispatchable_intents), - execution, - sim_now_ns=sim_now_ns, - ) - elif control_step_result.control_scheduling_obligation is not None: - self._apply_control_scheduling_obligation( - control_step_result.control_scheduling_obligation + if getattr(self, "_enable_core_step_control_time_dispatch", False): + self._last_core_step_execution_errors = ( + self._dispatch_accepted_intents( + list(control_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, + ) ) + if control_step_result.control_scheduling_obligation is None: + self._clear_pending_control_scheduling_obligation() + else: + self._apply_control_scheduling_obligation( + control_step_result.control_scheduling_obligation + ) + else: + if control_step_result.compat_gate_decision is not None: + self._finalize_decision_effects( + decision=control_step_result.compat_gate_decision, + execution=execution, + sim_now_ns=sim_now_ns, + instrument=instrument, + ) + elif control_step_result.dispatchable_intents: + self._dispatch_accepted_intents( + list(control_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, + ) + elif control_step_result.control_scheduling_obligation is not None: + self._apply_control_scheduling_obligation( + control_step_result.control_scheduling_obligation + ) if ( market_step_result is not None diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 177a4b6..963c7ca 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -2482,6 +2482,284 @@ def _spy_run_core_step( assert callback_order == ["control", "strategy"] +def test_control_time_core_step_mode_calls_run_core_step_with_policy_and_apply_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg_with_rate_limits( + max_orders_per_second=5.0, + max_cancels_per_second=2.0, + ), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + runner._next_send_ts_ns_local = 5 + captured: list[tuple[object, object, object, object]] = [] + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = core_decision_context + assert control_time_queue_context is None + assert strategy_evaluator is None + captured.append( + ( + state, + configuration, + policy_admission_context, + execution_control_apply_context, + ) + ) + return CoreStepResult() + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("control-time core-step mode must not call runtime risk gate") + ), + ) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert len(captured) == 1 + state, configuration, policy_ctx, apply_ctx = captured[0] + assert state is runner.strategy_state + assert configuration is runner._core_cfg + assert isinstance(policy_ctx, CorePolicyAdmissionContext) + assert policy_ctx.policy_evaluator is runner.risk + assert policy_ctx.now_ts_ns_local == 10 + assert isinstance(apply_ctx, CoreExecutionControlApplyContext) + assert apply_ctx.execution_control is runner.risk.execution_control + assert apply_ctx.now_ts_ns_local == 10 + assert apply_ctx.max_orders_per_sec == 5.0 + assert apply_ctx.max_cancels_per_sec == 2.0 + assert apply_ctx.activate_dispatchable_outputs is True + + +def test_control_time_core_step_mode_dispatches_from_dispatchables_and_ignores_compat_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable = _new_intent(ts_ns_local=10) + obligation = _obligation(due_ts_ns_local=25, obligation_key="control-core-step") + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + runner._next_send_ts_ns_local = 5 + callbacks: list[GateDecision] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + compat_decision = _decision_for([]) + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable,), + control_scheduling_obligation=obligation, + compat_gate_decision=compat_decision, + ), + ) + monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda d: callbacks.append(d)) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("control-time core-step mode must not call runtime risk gate") + ), + ) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert execution.batches == [[dispatchable.client_order_id]] + assert callbacks == [] + assert runner._pending_control_scheduling_obligation == obligation + assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local + + +def test_control_time_core_step_mode_none_obligation_clears_pending( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + seeded = _obligation(due_ts_ns_local=5, obligation_key="seeded") + runner._pending_control_scheduling_obligation = seeded + runner._next_send_ts_ns_local = seeded.due_ts_ns_local + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(), + control_scheduling_obligation=None, + ), + ) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + +def test_control_time_core_step_mode_failure_preserves_pending_cursor_and_deadline_marker( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + obligation = _obligation(due_ts_ns_local=5, obligation_key="k-failure-core-step") + runner._pending_control_scheduling_obligation = obligation + runner._next_send_ts_ns_local = obligation.due_ts_ns_local + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: (_ for _ in ()).throw( + RuntimeError("boom-control-time-core-step") + ), + ) + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + with pytest.raises(RuntimeError, match="boom-control-time-core-step"): + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert runner._last_injected_control_deadline_ns is None + assert runner._pending_control_scheduling_obligation == obligation + assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local + assert runner._event_stream_cursor.next_index == 0 + + +def test_control_time_core_step_mode_same_deadline_injected_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + runner._next_send_ts_ns_local = 5 + calls = {"count": 0} + + def _spy_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: + _ = (args, kwargs) + calls["count"] += 1 + return CoreStepResult() + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + venue = _StubVenue( + rc_sequence=[0, 0, 0, 1], + ts_sequence=[1, 10, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert calls["count"] == 1 + + +def test_control_time_core_step_mode_failed_dispatch_records_execution_errors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent(ts_ns_local=10) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_control_time_dispatch=True, + ) + runner._next_send_ts_ns_local = 5 + submitted_event_count = 0 + marked_count = 0 + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_event_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_event_count += 1 + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + nonlocal marked_count + _ = (instrument, client_order_id, intent_type) + marked_count += 1 + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + class _ExecutionFailNew: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + return [(dispatchable_new, "EXCHANGE_REJECT")] + + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run( + venue=venue, + execution=_ExecutionFailNew(), + recorder=_RecorderWrapper(), + ) + + assert submitted_event_count == 0 + assert marked_count == 0 + assert len(runner._last_core_step_execution_errors) == 1 + failed_intent, failure_reason = runner._last_core_step_execution_errors[0] + assert failed_intent.client_order_id == dispatchable_new.client_order_id + assert failure_reason == "EXCHANGE_REJECT" + + def test_fallback_second_boundary_wakeup_behavior_unchanged( monkeypatch: pytest.MonkeyPatch, ) -> None: From 1e0e403eb79276af6fa599110231faef6d738b94 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 14:36:57 +0000 Subject: [PATCH 10/18] test(runtime): characterize mixed market/control wakeups --- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 226 ++++++++++++++++++ 3 files changed, 228 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 930ee70..9be139a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@a48430bd637a8fa967efb40ebdd5055412ec3152 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@abc4f908bf383608c33d04acaad2beb3d5ec9803 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index fd02c24..f3d39e3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@a48430bd637a8fa967efb40ebdd5055412ec3152 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@abc4f908bf383608c33d04acaad2beb3d5ec9803 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 963c7ca..27d9075 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -2482,6 +2482,232 @@ def _spy_run_core_step( assert callback_order == ["control", "strategy"] +@pytest.mark.parametrize( + ("market_flag", "control_flag", "expected_dispatch_batches", "expected_raw_risk_calls"), + [ + ( + False, + False, + [["cid-control-compat"], ["cid-strategy-gated"]], + [["cid-strategy-generated"]], + ), + ( + True, + False, + [["cid-control-compat"], ["cid-market-core-step"]], + [], + ), + ( + False, + True, + [["cid-control-core-step"], ["cid-strategy-gated"]], + [["cid-strategy-generated"]], + ), + ( + True, + True, + [["cid-control-core-step"], ["cid-market-core-step"]], + [], + ), + ], + ids=( + "market_off_control_off", + "market_on_control_off", + "market_off_control_on", + "market_on_control_on", + ), +) +def test_mixed_wakeup_matrix_characterization_keeps_split_dispatch_paths( + monkeypatch: pytest.MonkeyPatch, + market_flag: bool, + control_flag: bool, + expected_dispatch_batches: list[list[str]], + expected_raw_risk_calls: list[list[str]], +) -> None: + strategy_generated = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-strategy-generated"} + ) + strategy_gated = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-strategy-gated"} + ) + market_dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-market-core-step"} + ) + control_compat = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-control-compat"} + ) + control_dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-control-core-step"} + ) + control_obligation = _obligation(due_ts_ns_local=25, obligation_key="control") + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([strategy_generated]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=market_flag, + enable_core_step_control_time_dispatch=control_flag, + ) + runner._next_send_ts_ns_local = 5 + seeded_core_step_errors = [(_new_intent(), "stale")] + runner._last_core_step_execution_errors = list(seeded_core_step_errors) + + control_compat_decision = _decision_for( + [control_compat], + control_scheduling_obligations=(control_obligation,), + ) + strategy_decision = _decision_for([strategy_gated]) + + run_core_step_calls: list[dict[str, object]] = [] + callback_decisions: list[GateDecision] = [] + raw_risk_calls: list[list[str]] = [] + ordering: list[str] = [] + submitted_positions: list[tuple[int, str]] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = state + _ = core_decision_context + assert configuration is runner._core_cfg + run_core_step_calls.append( + { + "event": type(entry.event).__name__, + "position": entry.position.index, + "strategy_evaluator": strategy_evaluator, + "control_time_queue_context": control_time_queue_context, + "policy_admission_context": policy_admission_context, + "execution_control_apply_context": execution_control_apply_context, + } + ) + if isinstance(entry.event, MarketEvent): + assert strategy_evaluator is not None + if market_flag: + assert isinstance(policy_admission_context, CorePolicyAdmissionContext) + assert isinstance( + execution_control_apply_context, + CoreExecutionControlApplyContext, + ) + else: + assert policy_admission_context is None + assert execution_control_apply_context is None + assert control_time_queue_context is None + return CoreStepResult( + generated_intents=(strategy_generated,), + dispatchable_intents=( + (market_dispatchable,) if market_flag else () + ), + ) + assert isinstance(entry.event, ControlTimeEvent) + assert strategy_evaluator is None + if control_flag: + assert control_time_queue_context is None + assert isinstance(policy_admission_context, CorePolicyAdmissionContext) + assert isinstance( + execution_control_apply_context, + CoreExecutionControlApplyContext, + ) + return CoreStepResult( + dispatchable_intents=(control_dispatchable,), + compat_gate_decision=control_compat_decision, + control_scheduling_obligation=control_obligation, + ) + assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) + assert policy_admission_context is None + assert execution_control_apply_context is None + return CoreStepResult( + dispatchable_intents=(control_dispatchable,), + compat_gate_decision=control_compat_decision, + control_scheduling_obligation=control_obligation, + ) + + def _spy_decide_intents(**kwargs: object) -> GateDecision: + raw_intents = kwargs["raw_intents"] + raw_risk_calls.append([intent.client_order_id for intent in raw_intents]) + return strategy_decision + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + ordering.append(f"submitted:{entry.event.client_order_id}") + submitted_positions.append((entry.position.index, entry.event.client_order_id)) + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + _ = (instrument, intent_type) + ordering.append(f"mark:{client_order_id}") + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) + monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda decision: callback_decisions.append(decision)) + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + venue = _StubVenue( + rc_sequence=[0, 2, 0, 1], + ts_sequence=[1, 10, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert [call["event"] for call in run_core_step_calls] == ["MarketEvent", "ControlTimeEvent"] + assert [call["position"] for call in run_core_step_calls] == [0, 1] + assert runner._last_injected_control_deadline_ns == 5 + assert runner._event_stream_cursor.next_index == 4 + + # Dispatch remains split across market/control wakeups in all four flag combinations. + assert execution.batches == expected_dispatch_batches + assert raw_risk_calls == expected_raw_risk_calls + + if control_flag: + assert control_compat_decision not in callback_decisions + else: + assert control_compat_decision in callback_decisions + if market_flag: + assert strategy_decision not in callback_decisions + else: + assert strategy_decision in callback_decisions + + submitted_ids = [client_order_id for _, client_order_id in submitted_positions] + expected_submitted_ids = [batch[0] for batch in expected_dispatch_batches] + assert submitted_ids == expected_submitted_ids + assert [position for position, _ in submitted_positions] == [2, 3] + assert ordering == [ + f"submitted:{expected_submitted_ids[0]}", + f"mark:{expected_submitted_ids[0]}", + f"submitted:{expected_submitted_ids[1]}", + f"mark:{expected_submitted_ids[1]}", + ] + + # _last_core_step_execution_errors is runtime-owned observability for the latest + # CoreStep dispatch batch only. Legacy/compat paths must not mutate this field. + if market_flag or control_flag: + assert runner._last_core_step_execution_errors == [] + else: + assert runner._last_core_step_execution_errors == seeded_core_step_errors + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + def test_control_time_core_step_mode_calls_run_core_step_with_policy_and_apply_context( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8fe54b3794149f4c2ff66267c23abba11580205e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 14:58:24 +0000 Subject: [PATCH 11/18] feat(runtime): add flagged core wakeup collapse path --- .../backtest/engine/strategy_runner.py | 177 ++++++-- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 428 ++++++++++++++++++ 4 files changed, 578 insertions(+), 31 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 5e06c97..e08cde8 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -12,11 +12,13 @@ CoreExecutionControlApplyContext, CorePolicyAdmissionContext, run_core_step, + run_core_wakeup_step, ) from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import ( EventStreamEntry, + ProcessingPosition, ) from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult @@ -100,6 +102,7 @@ def __init__( core_cfg: CoreConfiguration, enable_core_step_market_dispatch: bool = False, enable_core_step_control_time_dispatch: bool = False, + enable_core_step_wakeup_collapse: bool = False, ) -> None: self.engine_cfg = engine_cfg self.strategy = strategy @@ -108,6 +111,20 @@ def __init__( self._enable_core_step_control_time_dispatch = ( enable_core_step_control_time_dispatch ) + self._enable_core_step_wakeup_collapse = enable_core_step_wakeup_collapse + if self._enable_core_step_wakeup_collapse and not self._enable_core_step_market_dispatch: + raise ValueError( + "enable_core_step_wakeup_collapse=True requires " + "enable_core_step_market_dispatch=True" + ) + if ( + self._enable_core_step_wakeup_collapse + and not self._enable_core_step_control_time_dispatch + ): + raise ValueError( + "enable_core_step_wakeup_collapse=True requires " + "enable_core_step_control_time_dispatch=True" + ) event_bus = self._build_event_bus( path=Path(engine_cfg.event_bus_path), @@ -278,19 +295,10 @@ def _process_canonical_control_time_event( scheduled_deadline_ns: int, scheduling_obligation: ControlSchedulingObligation | None = None, ) -> CoreStepResult: - obligation_reason = "rate_limit" - obligation_due_ts_ns_local = scheduled_deadline_ns - if scheduling_obligation is not None: - obligation_reason = scheduling_obligation.reason - obligation_due_ts_ns_local = scheduling_obligation.due_ts_ns_local - control_time_event = ControlTimeEvent( - ts_ns_local_control=sim_now_ns, - reason="scheduled_control_recheck", - due_ts_ns_local=scheduled_deadline_ns, - realized_ts_ns_local=sim_now_ns, - obligation_reason=obligation_reason, - obligation_due_ts_ns_local=obligation_due_ts_ns_local, - runtime_correlation=None, + control_time_event = self._build_control_time_event( + sim_now_ns=sim_now_ns, + scheduled_deadline_ns=scheduled_deadline_ns, + scheduling_obligation=scheduling_obligation, ) position = self._event_stream_cursor.attempt_position() entry = EventStreamEntry( @@ -329,6 +337,45 @@ def _process_canonical_control_time_event( self._event_stream_cursor.commit_success(position) return result + def _build_control_time_event( + self, + *, + sim_now_ns: int, + scheduled_deadline_ns: int, + scheduling_obligation: ControlSchedulingObligation | None = None, + ) -> ControlTimeEvent: + obligation_reason = "rate_limit" + obligation_due_ts_ns_local = scheduled_deadline_ns + if scheduling_obligation is not None: + obligation_reason = scheduling_obligation.reason + obligation_due_ts_ns_local = scheduling_obligation.due_ts_ns_local + return ControlTimeEvent( + ts_ns_local_control=sim_now_ns, + reason="scheduled_control_recheck", + due_ts_ns_local=scheduled_deadline_ns, + realized_ts_ns_local=sim_now_ns, + obligation_reason=obligation_reason, + obligation_due_ts_ns_local=obligation_due_ts_ns_local, + runtime_correlation=None, + ) + + def _allocate_wakeup_entries( + self, + events: list[object], + ) -> tuple[EventStreamEntry, ...]: + base_index = self._event_stream_cursor.next_index + return tuple( + EventStreamEntry( + position=ProcessingPosition(index=base_index + offset), + event=event, + ) + for offset, event in enumerate(events) + ) + + def _commit_wakeup_entries(self, entries: tuple[EventStreamEntry, ...]) -> None: + for entry in entries: + self._event_stream_cursor.commit_success(entry.position) + @staticmethod def _select_effective_control_scheduling_obligation( decision: GateDecision, @@ -480,6 +527,7 @@ def run( raw_intents: list[OrderIntent] = [] market_step_result: CoreStepResult | None = None control_step_result: CoreStepResult | None = None + market_event: MarketEvent | None = None # ----------------------------------------------------------------- # Market update @@ -557,12 +605,15 @@ def run( ) constraints = self.risk.build_constraints(sim_now_ns) - market_step_result = self._process_canonical_market_event( - market_event, - constraints=constraints, - ) - if not getattr(self, "_enable_core_step_market_dispatch", False): - raw_intents.extend(market_step_result.generated_intents) + if getattr(self, "_enable_core_step_wakeup_collapse", False): + pass + else: + market_step_result = self._process_canonical_market_event( + market_event, + constraints=constraints, + ) + if not getattr(self, "_enable_core_step_market_dispatch", False): + raw_intents.extend(market_step_result.generated_intents) # ----------------------------------------------------------------- # Order / account update @@ -612,20 +663,86 @@ def run( scheduled_deadline_ns != self._last_injected_control_deadline_ns ): - control_step_result = self._process_canonical_control_time_event( - instrument=instrument, + if getattr(self, "_enable_core_step_wakeup_collapse", False): + pass + else: + control_step_result = self._process_canonical_control_time_event( + instrument=instrument, + now_ts_ns_local=sim_now_ns, + sim_now_ns=sim_now_ns, + scheduled_deadline_ns=scheduled_deadline_ns, + scheduling_obligation=scheduling_obligation, + ) + self._last_injected_control_deadline_ns = scheduled_deadline_ns + if scheduling_obligation is not None: + self._consume_pending_control_scheduling_obligation() + else: + self._next_send_ts_ns_local = None + + if getattr(self, "_enable_core_step_wakeup_collapse", False): + collapse_events: list[object] = [] + included_control_time = False + if market_event is not None: + collapse_events.append(market_event) + if ( + scheduled_deadline_ns is not None + and sim_now_ns >= scheduled_deadline_ns + and scheduled_deadline_ns != self._last_injected_control_deadline_ns + ): + collapse_events.append( + self._build_control_time_event( + sim_now_ns=sim_now_ns, + scheduled_deadline_ns=scheduled_deadline_ns, + scheduling_obligation=scheduling_obligation, + ) + ) + included_control_time = True + + if collapse_events: + wakeup_entries = self._allocate_wakeup_entries(collapse_events) + collapse_constraints = self.risk.build_constraints(sim_now_ns) + strategy_evaluator = None + if market_event is not None: + strategy_evaluator = _LegacyOnFeedStrategyEvaluator( + strategy=self.strategy, + engine_cfg=self.engine_cfg, + constraints=collapse_constraints, + ) + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( now_ts_ns_local=sim_now_ns, + ) + wakeup_result = run_core_wakeup_step( + self.strategy_state, + wakeup_entries, + configuration=self._core_cfg, + strategy_evaluator=strategy_evaluator, + strategy_event_filter=lambda event: isinstance(event, MarketEvent), + snapshot_instrument=instrument, + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, + ) + self._commit_wakeup_entries(wakeup_entries) + if included_control_time and scheduled_deadline_ns is not None: + self._last_injected_control_deadline_ns = scheduled_deadline_ns + if scheduling_obligation is not None: + self._consume_pending_control_scheduling_obligation() + else: + self._next_send_ts_ns_local = None + self._last_core_step_execution_errors = self._dispatch_accepted_intents( + list(wakeup_result.dispatchable_intents), + execution, sim_now_ns=sim_now_ns, - scheduled_deadline_ns=scheduled_deadline_ns, - scheduling_obligation=scheduling_obligation, ) - self._last_injected_control_deadline_ns = scheduled_deadline_ns - if scheduling_obligation is not None: - self._consume_pending_control_scheduling_obligation() + if wakeup_result.control_scheduling_obligation is None: + self._clear_pending_control_scheduling_obligation() else: - self._next_send_ts_ns_local = None - - if control_step_result is not None: + self._apply_control_scheduling_obligation( + wakeup_result.control_scheduling_obligation + ) + elif control_step_result is not None: if getattr(self, "_enable_core_step_control_time_dispatch", False): self._last_core_step_execution_errors = ( self._dispatch_accepted_intents( @@ -660,6 +777,8 @@ def run( ) if ( + not getattr(self, "_enable_core_step_wakeup_collapse", False) + and market_step_result is not None and getattr(self, "_enable_core_step_market_dispatch", False) ): diff --git a/requirements-dev.txt b/requirements-dev.txt index 9be139a..bb2a6d2 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@abc4f908bf383608c33d04acaad2beb3d5ec9803 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b9d27754339b0e2000a330a94d1bc25a4345b241 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index f3d39e3..533e281 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@abc4f908bf383608c33d04acaad2beb3d5ec9803 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b9d27754339b0e2000a330a94d1bc25a4345b241 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 27d9075..e8c469d 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -370,6 +370,67 @@ def test_apply_control_scheduling_decision_scalar_fallback_without_structured_ob assert runner._next_send_ts_ns_local == 12 +def test_wakeup_collapse_flag_defaults_to_false() -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + ) + assert runner._enable_core_step_wakeup_collapse is False + + +def test_wakeup_collapse_flag_requires_market_core_step_flag() -> None: + with pytest.raises( + ValueError, + match=( + "enable_core_step_wakeup_collapse=True requires " + "enable_core_step_market_dispatch=True" + ), + ): + HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=False, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + + +def test_wakeup_collapse_flag_requires_control_core_step_flag() -> None: + with pytest.raises( + ValueError, + match=( + "enable_core_step_wakeup_collapse=True requires " + "enable_core_step_control_time_dispatch=True" + ), + ): + HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=False, + enable_core_step_wakeup_collapse=True, + ) + + +def test_wakeup_collapse_flag_accepts_when_both_core_step_flags_enabled() -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + assert runner._enable_core_step_wakeup_collapse is True + + def test_process_market_event_routes_through_event_entry_with_core_configuration( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -2986,6 +3047,373 @@ def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: assert failure_reason == "EXCHANGE_REJECT" +def test_wakeup_collapse_mixed_wakeup_uses_single_core_wakeup_call_and_single_dispatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + market_dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-market-collapse"} + ) + control_dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-control-collapse"} + ) + prior_pending = _obligation(due_ts_ns_local=5, obligation_key="pending") + next_obligation = _obligation(due_ts_ns_local=25, obligation_key="next") + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_EmitIntentsStrategy([_new_intent(ts_ns_local=10)]), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + runner._pending_control_scheduling_obligation = prior_pending + runner._next_send_ts_ns_local = prior_pending.due_ts_ns_local + + ordering: list[str] = [] + submitted_positions: list[tuple[int, str]] = [] + wakeup_calls: list[dict[str, object]] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_wakeup_step( + state: object, + entries: tuple[object, ...], + **kwargs: object, + ) -> CoreStepResult: + _ = state + strategy_event_filter = kwargs["strategy_event_filter"] + wakeup_calls.append( + { + "entries": tuple(type(entry.event).__name__ for entry in entries), + "positions": tuple(entry.position.index for entry in entries), + "strategy_event_filter_results": tuple( + strategy_event_filter(entry.event) for entry in entries + ), + "strategy_evaluator": kwargs["strategy_evaluator"], + "snapshot_instrument": kwargs["snapshot_instrument"], + "policy_admission_context": kwargs["policy_admission_context"], + "execution_control_apply_context": kwargs["execution_control_apply_context"], + } + ) + return CoreStepResult( + dispatchable_intents=(market_dispatchable, control_dispatchable), + control_scheduling_obligation=next_obligation, + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + ordering.append(f"submitted:{entry.event.client_order_id}") + submitted_positions.append((entry.position.index, entry.event.client_order_id)) + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + _ = (instrument, intent_type) + ordering.append(f"mark:{client_order_id}") + + monkeypatch.setattr( + strategy_runner_module, + "run_core_wakeup_step", + _spy_run_core_wakeup_step, + ) + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("collapse mode must not call run_core_step for market/control path") + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("collapse mode must not call runtime risk gate for market/control work") + ), + ) + monkeypatch.setattr( + runner.strategy, + "on_risk_decision", + lambda *_: (_ for _ in ()).throw( + AssertionError("collapse mode must not synthesize GateDecision callbacks") + ), + ) + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + venue = _StubVenue( + rc_sequence=[0, 2, 0, 1], + ts_sequence=[1, 10, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert len(wakeup_calls) == 1 + wakeup_call = wakeup_calls[0] + assert wakeup_call["entries"] == ("MarketEvent", "ControlTimeEvent") + assert wakeup_call["positions"] == (0, 1) + assert wakeup_call["strategy_event_filter_results"] == (True, False) + assert wakeup_call["strategy_evaluator"] is not None + assert wakeup_call["snapshot_instrument"] == "BTC_USDC-PERPETUAL" + assert isinstance(wakeup_call["policy_admission_context"], CorePolicyAdmissionContext) + assert isinstance( + wakeup_call["execution_control_apply_context"], + CoreExecutionControlApplyContext, + ) + assert execution.batches == [[ + market_dispatchable.client_order_id, + control_dispatchable.client_order_id, + ]] + assert [position for position, _ in submitted_positions] == [2, 3] + assert ordering == [ + f"submitted:{market_dispatchable.client_order_id}", + f"mark:{market_dispatchable.client_order_id}", + f"submitted:{control_dispatchable.client_order_id}", + f"mark:{control_dispatchable.client_order_id}", + ] + assert runner._last_core_step_execution_errors == [] + assert runner._pending_control_scheduling_obligation == next_obligation + assert runner._next_send_ts_ns_local == next_obligation.due_ts_ns_local + assert runner._last_injected_control_deadline_ns == 5 + assert runner._event_stream_cursor.next_index == 4 + + +def test_wakeup_collapse_market_only_path_dispatches_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-market-only-collapse"} + ) + seeded = _obligation(due_ts_ns_local=99, obligation_key="seeded-market-only") + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + runner._pending_control_scheduling_obligation = seeded + runner._next_send_ts_ns_local = seeded.due_ts_ns_local + calls: list[tuple[str, ...]] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_wakeup_step( + state: object, + entries: tuple[object, ...], + **kwargs: object, + ) -> CoreStepResult: + _ = (state, kwargs) + calls.append(tuple(type(entry.event).__name__ for entry in entries)) + return CoreStepResult( + dispatchable_intents=(dispatchable,), + control_scheduling_obligation=None, + ) + + monkeypatch.setattr(strategy_runner_module, "run_core_wakeup_step", _spy_run_core_wakeup_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("collapse market-only path must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert calls == [("MarketEvent",)] + assert execution.batches == [[dispatchable.client_order_id]] + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + +def test_wakeup_collapse_control_only_path_dispatches_once_without_strategy_eval( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-control-only-collapse"} + ) + pending = _obligation(due_ts_ns_local=5, obligation_key="pending-control-only") + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + runner._pending_control_scheduling_obligation = pending + runner._next_send_ts_ns_local = pending.due_ts_ns_local + wakeup_calls: list[dict[str, object]] = [] + + class _ExecutionCapture: + def __init__(self) -> None: + self.batches: list[list[str]] = [] + + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.batches.append([it.client_order_id for it in intents]) + return [] + + execution = _ExecutionCapture() + + def _spy_run_core_wakeup_step( + state: object, + entries: tuple[object, ...], + **kwargs: object, + ) -> CoreStepResult: + _ = state + wakeup_calls.append( + { + "entries": tuple(type(entry.event).__name__ for entry in entries), + "strategy_evaluator": kwargs["strategy_evaluator"], + } + ) + return CoreStepResult( + dispatchable_intents=(dispatchable,), + control_scheduling_obligation=None, + ) + + monkeypatch.setattr(strategy_runner_module, "run_core_wakeup_step", _spy_run_core_wakeup_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("collapse control-only path must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 0, 1], + ts_sequence=[1, 10, 11], + ) + runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + + assert len(wakeup_calls) == 1 + wakeup_call = wakeup_calls[0] + assert wakeup_call["entries"] == ("ControlTimeEvent",) + assert wakeup_call["strategy_evaluator"] is None + assert execution.batches == [[dispatchable.client_order_id]] + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + assert runner._last_injected_control_deadline_ns == 5 + + +def test_wakeup_collapse_failure_before_result_preserves_pending_and_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pending = _obligation(due_ts_ns_local=5, obligation_key="pending-failure") + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + runner._pending_control_scheduling_obligation = pending + runner._next_send_ts_ns_local = pending.due_ts_ns_local + submitted_count = 0 + mark_count = 0 + + monkeypatch.setattr( + strategy_runner_module, + "run_core_wakeup_step", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom-collapse")), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_count += 1 + + def _spy_mark(*args: object, **kwargs: object) -> None: + nonlocal mark_count + _ = (args, kwargs) + mark_count += 1 + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark) + + class _ExecutionMustNotRun: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + raise AssertionError("dispatch must not run after collapse wakeup failure") + + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + with pytest.raises(RuntimeError, match="boom-collapse"): + runner.run(venue=venue, execution=_ExecutionMustNotRun(), recorder=_RecorderWrapper()) + + assert submitted_count == 0 + assert mark_count == 0 + assert runner._pending_control_scheduling_obligation == pending + assert runner._next_send_ts_ns_local == pending.due_ts_ns_local + assert runner._last_injected_control_deadline_ns is None + assert runner._event_stream_cursor.next_index == 0 + + +def test_wakeup_collapse_same_deadline_injected_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + runner._next_send_ts_ns_local = 5 + calls = {"count": 0} + + def _spy_run_core_wakeup_step(*args: object, **kwargs: object) -> CoreStepResult: + _ = (args, kwargs) + calls["count"] += 1 + return CoreStepResult() + + monkeypatch.setattr( + strategy_runner_module, + "run_core_wakeup_step", + _spy_run_core_wakeup_step, + ) + venue = _StubVenue( + rc_sequence=[0, 0, 0, 1], + ts_sequence=[1, 10, 10, 11], + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert calls["count"] == 1 + + def test_fallback_second_boundary_wakeup_behavior_unchanged( monkeypatch: pytest.MonkeyPatch, ) -> None: From 73bef039e3571d78229bd8c6d4946c7f9c303573 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 15:02:46 +0000 Subject: [PATCH 12/18] test(runtime): close flagged wakeup collapse semantics --- .../backtest/engine/event_stream_cursor.py | 8 ++ .../backtest/engine/strategy_runner.py | 7 +- tests/runtime/test_event_stream_cursor.py | 24 ++++ ...rategy_runner_canonical_market_adoption.py | 116 ++++++++++++++++++ 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/core_runtime/backtest/engine/event_stream_cursor.py b/core_runtime/backtest/engine/event_stream_cursor.py index a7f3b8e..3550cab 100644 --- a/core_runtime/backtest/engine/event_stream_cursor.py +++ b/core_runtime/backtest/engine/event_stream_cursor.py @@ -20,6 +20,14 @@ def next_index(self) -> int: def attempt_position(self) -> ProcessingPosition: return ProcessingPosition(index=self._next_index) + def attempt_positions(self, count: int) -> tuple[ProcessingPosition, ...]: + if count < 0: + raise ValueError("count must be >= 0") + return tuple( + ProcessingPosition(index=self._next_index + offset) + for offset in range(count) + ) + def commit_success(self, position: ProcessingPosition) -> None: if position.index != self._next_index: raise ValueError( diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index e08cde8..9d47f1c 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -18,7 +18,6 @@ from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import ( EventStreamEntry, - ProcessingPosition, ) from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult @@ -363,13 +362,13 @@ def _allocate_wakeup_entries( self, events: list[object], ) -> tuple[EventStreamEntry, ...]: - base_index = self._event_stream_cursor.next_index + positions = self._event_stream_cursor.attempt_positions(len(events)) return tuple( EventStreamEntry( - position=ProcessingPosition(index=base_index + offset), + position=position, event=event, ) - for offset, event in enumerate(events) + for position, event in zip(positions, events, strict=True) ) def _commit_wakeup_entries(self, entries: tuple[EventStreamEntry, ...]) -> None: diff --git a/tests/runtime/test_event_stream_cursor.py b/tests/runtime/test_event_stream_cursor.py index b94b9b7..9a9a4aa 100644 --- a/tests/runtime/test_event_stream_cursor.py +++ b/tests/runtime/test_event_stream_cursor.py @@ -18,6 +18,30 @@ def test_attempt_position_does_not_advance_cursor() -> None: assert cursor.next_index == 0 +def test_attempt_positions_does_not_advance_cursor_and_returns_batch() -> None: + cursor = EventStreamCursor(start_index=5) + attempted = cursor.attempt_positions(3) + assert tuple(position.index for position in attempted) == (5, 6, 7) + assert cursor.next_index == 5 + + +def test_attempt_positions_rejects_negative_count() -> None: + cursor = EventStreamCursor() + with pytest.raises(ValueError, match="count must be >= 0"): + cursor.attempt_positions(-1) + assert cursor.next_index == 0 + + +def test_attempt_positions_commit_success_advances_in_batch_order() -> None: + cursor = EventStreamCursor() + attempted = cursor.attempt_positions(2) + + cursor.commit_success(attempted[0]) + cursor.commit_success(attempted[1]) + + assert cursor.next_index == 2 + + def test_commit_success_advances_by_one() -> None: cursor = EventStreamCursor() attempted = cursor.attempt_position() diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index e8c469d..1a5429e 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -3102,11 +3102,14 @@ def _spy_run_core_wakeup_step( "snapshot_instrument": kwargs["snapshot_instrument"], "policy_admission_context": kwargs["policy_admission_context"], "execution_control_apply_context": kwargs["execution_control_apply_context"], + "has_control_time_queue_context": "control_time_queue_context" in kwargs, + "has_core_decision_context": "core_decision_context" in kwargs, } ) return CoreStepResult( dispatchable_intents=(market_dispatchable, control_dispatchable), control_scheduling_obligation=next_obligation, + compat_gate_decision=_decision_for([]), ) def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: @@ -3167,6 +3170,9 @@ def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: st wakeup_call["execution_control_apply_context"], CoreExecutionControlApplyContext, ) + assert wakeup_call["execution_control_apply_context"].activate_dispatchable_outputs is True + assert wakeup_call["has_control_time_queue_context"] is False + assert wakeup_call["has_core_decision_context"] is False assert execution.batches == [[ market_dispatchable.client_order_id, control_dispatchable.client_order_id, @@ -3414,6 +3420,116 @@ def _spy_run_core_wakeup_step(*args: object, **kwargs: object) -> CoreStepResult assert calls["count"] == 1 +def test_wakeup_collapse_failed_new_dispatch_records_errors_without_submitted_or_mark( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent(ts_ns_local=10).model_copy( + update={"client_order_id": "cid-collapse-failed-new"} + ) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + submitted_count = 0 + marked_count = 0 + + monkeypatch.setattr( + strategy_runner_module, + "run_core_wakeup_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_count += 1 + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + nonlocal marked_count + _ = (instrument, client_order_id, intent_type) + marked_count += 1 + + class _ExecutionFailNew: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + return [(dispatchable_new, "EXCHANGE_REJECT")] + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_ExecutionFailNew(), recorder=_RecorderWrapper()) + + assert submitted_count == 0 + assert marked_count == 0 + assert len(runner._last_core_step_execution_errors) == 1 + failed_intent, failure_reason = runner._last_core_step_execution_errors[0] + assert failed_intent.client_order_id == dispatchable_new.client_order_id + assert failure_reason == "EXCHANGE_REJECT" + + +def test_wakeup_collapse_replace_cancel_emit_no_order_submitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + replace_intent = _replace_intent(ts_ns_local=10) + cancel_intent = _cancel_intent(ts_ns_local=10) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_market_dispatch=True, + enable_core_step_control_time_dispatch=True, + enable_core_step_wakeup_collapse=True, + ) + submitted_count = 0 + marks: list[tuple[str, str, str]] = [] + + monkeypatch.setattr( + strategy_runner_module, + "run_core_wakeup_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(replace_intent, cancel_intent), + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + nonlocal submitted_count + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_count += 1 + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + marks.append((instrument, client_order_id, intent_type)) + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + venue = _StubVenue( + rc_sequence=[0, 2, 1], + ts_sequence=[1, 10, 11], + depth=_depth_snapshot(), + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert submitted_count == 0 + assert marks == [ + (replace_intent.instrument, replace_intent.client_order_id, "replace"), + (cancel_intent.instrument, cancel_intent.client_order_id, "cancel"), + ] + assert runner._last_core_step_execution_errors == [] + + def test_fallback_second_boundary_wakeup_behavior_unchanged( monkeypatch: pytest.MonkeyPatch, ) -> None: From d09305b599e7d362989626689dfcab18e17549d1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 16:09:48 +0000 Subject: [PATCH 13/18] chore(core_runtime): cleanup --- .github/argo-launchers/run-backtest.yaml | 2 +- README.md | 10 +++++----- argo/templates/workflowtemplate-backtest-fanout.yaml | 2 +- core_runtime/argo/{argo.json => bt_config_argo.json} | 0 core_runtime/backtest/engine/strategy_runner.py | 2 +- .../{core => backtest}/events/sinks/file_recorder.py | 1 + core_runtime/core/__init__.py | 2 -- core_runtime/core/events/__init__.py | 2 -- core_runtime/core/events/sinks/__init__.py | 2 -- .../local/{local.json => bt_config_local.json} | 2 +- .../test_runtime_core_configuration_integration.py | 6 +++--- 11 files changed, 13 insertions(+), 18 deletions(-) rename core_runtime/argo/{argo.json => bt_config_argo.json} (100%) rename core_runtime/{core => backtest}/events/sinks/file_recorder.py (99%) delete mode 100644 core_runtime/core/__init__.py delete mode 100644 core_runtime/core/events/__init__.py delete mode 100644 core_runtime/core/events/sinks/__init__.py rename core_runtime/local/{local.json => bt_config_local.json} (97%) diff --git a/.github/argo-launchers/run-backtest.yaml b/.github/argo-launchers/run-backtest.yaml index ac695dd..8d9980b 100644 --- a/.github/argo-launchers/run-backtest.yaml +++ b/.github/argo-launchers/run-backtest.yaml @@ -12,6 +12,6 @@ spec: - name: image_tag value: "${IMAGE_TAG}" - name: experiment_config - value: "/usr/local/lib/python3.11/site-packages/core_runtime/argo/argo.json" + value: "/usr/local/lib/python3.11/site-packages/core_runtime/argo/bt_config_argo.json" - name: scratch_root value: "/mnt/scratch" diff --git a/README.md b/README.md index fd066af..da626b5 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Order, State, or Risk Engine. Current local smoke is usable from the `core-runtime` repository root: ```bash -python -m core_runtime.local.backtest --config core_runtime/local/local.json +python -m core_runtime.local.backtest --config core_runtime/local/bt_config_local.json ``` Default output location: @@ -67,7 +67,7 @@ From the `core-runtime` repository root: ```bash python -m pip install -e . -python -m core_runtime.local.backtest --config core_runtime/local/local.json +python -m core_runtime.local.backtest --config core_runtime/local/bt_config_local.json ``` If `tradingchassis_core` is not already resolvable in your environment, install `core` as a @@ -83,8 +83,8 @@ python -m pip install -e ../core | Mode | Entrypoint | Command shape | Notes | | --- | --- | --- | --- | -| Local backtest | `core_runtime/local/backtest.py` | `python -m core_runtime.local.backtest --config core_runtime/local/local.json` | Main local runner. | -| Argo plan/run orchestration | `core_runtime/backtest/runtime/entrypoint.py` | `python -m core_runtime.backtest.runtime.entrypoint --config core_runtime/argo/argo.json --plan` | Planner and sweep-context emitter for Argo flow. | +| Local backtest | `core_runtime/local/backtest.py` | `python -m core_runtime.local.backtest --config core_runtime/local/bt_config_local.json` | Main local runner. | +| Argo plan/run orchestration | `core_runtime/backtest/runtime/entrypoint.py` | `python -m core_runtime.backtest.runtime.entrypoint --config core_runtime/argo/bt_config_argo.json --plan` | Planner and sweep-context emitter for Argo flow. | | Sweep worker | `core_runtime/backtest/runtime/run_sweep.py` | `python -m core_runtime.backtest.runtime.run_sweep --context ` | Executes one sweep context. | --- @@ -160,7 +160,7 @@ tests/ Runtime tests and deterministic fixtures Primary local config: -- `core_runtime/local/local.json` +- `core_runtime/local/bt_config_local.json` - OCI config template (for local object storage auth setups): `core_runtime/local/oci.config.example` Note: local JSON configs use cwd-relative paths for `tests/data/...` inputs and `.runtime/...` diff --git a/argo/templates/workflowtemplate-backtest-fanout.yaml b/argo/templates/workflowtemplate-backtest-fanout.yaml index a6b16d7..3d20af7 100644 --- a/argo/templates/workflowtemplate-backtest-fanout.yaml +++ b/argo/templates/workflowtemplate-backtest-fanout.yaml @@ -24,7 +24,7 @@ spec: - name: experiment_config description: "Path to experiment JSON inside the container" - value: /usr/local/lib/python3.11/site-packages/core_runtime/argo/argo.json + value: /usr/local/lib/python3.11/site-packages/core_runtime/argo/bt_config_argo.json - name: scratch_root description: "Scratch root inside the container" diff --git a/core_runtime/argo/argo.json b/core_runtime/argo/bt_config_argo.json similarity index 100% rename from core_runtime/argo/argo.json rename to core_runtime/argo/bt_config_argo.json diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 9d47f1c..ea35a71 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -47,7 +47,7 @@ from core_runtime.backtest.adapters.protocols import OrderSubmissionGateway from core_runtime.backtest.engine.event_stream_cursor import EventStreamCursor -from core_runtime.core.events.sinks.file_recorder import FileRecorderSink +from core_runtime.backtest.events.sinks.file_recorder import FileRecorderSink if TYPE_CHECKING: from tradingchassis_core.strategies.base import Strategy diff --git a/core_runtime/core/events/sinks/file_recorder.py b/core_runtime/backtest/events/sinks/file_recorder.py similarity index 99% rename from core_runtime/core/events/sinks/file_recorder.py rename to core_runtime/backtest/events/sinks/file_recorder.py index bfe46bf..d48ca85 100644 --- a/core_runtime/core/events/sinks/file_recorder.py +++ b/core_runtime/backtest/events/sinks/file_recorder.py @@ -28,3 +28,4 @@ def close(self) -> None: self._fh.flush() self._fh.close() self._closed = True + diff --git a/core_runtime/core/__init__.py b/core_runtime/core/__init__.py deleted file mode 100644 index 27d3575..0000000 --- a/core_runtime/core/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Runtime-owned modules that are not part of the semantic core.""" - diff --git a/core_runtime/core/events/__init__.py b/core_runtime/core/events/__init__.py deleted file mode 100644 index f8d3dcb..0000000 --- a/core_runtime/core/events/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Runtime event plumbing (sinks, emitters, wiring).""" - diff --git a/core_runtime/core/events/sinks/__init__.py b/core_runtime/core/events/sinks/__init__.py deleted file mode 100644 index 8749baf..0000000 --- a/core_runtime/core/events/sinks/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Concrete runtime event sinks (I/O).""" - diff --git a/core_runtime/local/local.json b/core_runtime/local/bt_config_local.json similarity index 97% rename from core_runtime/local/local.json rename to core_runtime/local/bt_config_local.json index 83ef50b..3af644e 100644 --- a/core_runtime/local/local.json +++ b/core_runtime/local/bt_config_local.json @@ -33,7 +33,7 @@ "roi_ub": 80000, "stats_npz_path": ".runtime/local/results/stats.npz", - "event_bus_path": ".runtime/local/results/events.json" + "event_bus_path": ".runtime/local/results/events.jsonl" }, "risk": { diff --git a/tests/runtime/test_runtime_core_configuration_integration.py b/tests/runtime/test_runtime_core_configuration_integration.py index f9a4ee2..37cf2e7 100644 --- a/tests/runtime/test_runtime_core_configuration_integration.py +++ b/tests/runtime/test_runtime_core_configuration_integration.py @@ -54,7 +54,7 @@ def _from_file(*, file_location: str, profile_name: str) -> dict[str, object]: def test_local_loader_fails_early_when_core_missing(tmp_path: Path) -> None: - sample_path = _repo_root() / "core_runtime/local/local.json" + sample_path = _repo_root() / "core_runtime/local/bt_config_local.json" config = _load_sample_config(sample_path) config.pop("core", None) @@ -66,7 +66,7 @@ def test_local_loader_fails_early_when_core_missing(tmp_path: Path) -> None: def test_local_loader_succeeds_with_valid_core() -> None: - sample_path = _repo_root() / "core_runtime/local/local.json" + sample_path = _repo_root() / "core_runtime/local/bt_config_local.json" cfg = load_config(str(sample_path)) assert isinstance(cfg.core_cfg, CoreConfiguration) @@ -81,7 +81,7 @@ def test_argo_entrypoint_rejects_invalid_run_config_before_planning( from core_runtime.backtest.runtime.entrypoint import main as argo_entrypoint_main - sample_path = _repo_root() / "core_runtime/argo/argo.json" + sample_path = _repo_root() / "core_runtime/argo/bt_config_argo.json" config = _load_sample_config(sample_path) config.pop("core", None) From f39df158a602c7e61ab5540f5117df82f4a40dbf Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 16:31:16 +0000 Subject: [PATCH 14/18] chore: fix SHA --- README.md | 7 ------- requirements-dev.txt | 2 +- requirements.txt | 2 +- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index da626b5..695b490 100644 --- a/README.md +++ b/README.md @@ -70,13 +70,6 @@ python -m pip install -e . python -m core_runtime.local.backtest --config core_runtime/local/bt_config_local.json ``` -If `tradingchassis_core` is not already resolvable in your environment, install `core` as a -sibling editable package in a monorepo workspace: - -```bash -python -m pip install -e ../core -``` - --- ## Entrypoint matrix diff --git a/requirements-dev.txt b/requirements-dev.txt index bb2a6d2..4332e8d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b9d27754339b0e2000a330a94d1bc25a4345b241 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@43db205e30d18a244b46e6784166303bfb6cacfa # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index 533e281..f481ccd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b9d27754339b0e2000a330a94d1bc25a4345b241 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@43db205e30d18a244b46e6784166303bfb6cacfa # via -r _git_deps.in typing-extensions==4.15.0 # via From 2e9099908c6e165f679f6f4ad5f0d2679e014de3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 00:45:58 +0000 Subject: [PATCH 15/18] feat(runtime): gate rc3 order feedback dispatch through CoreStep --- .../backtest/engine/strategy_runner.py | 187 +++++++++-- requirements-dev.txt | 2 +- requirements.txt | 2 +- ...rategy_runner_canonical_market_adoption.py | 291 ++++++++++++++++++ 4 files changed, 459 insertions(+), 23 deletions(-) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index ea35a71..0eb57c0 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -27,6 +27,8 @@ ControlTimeEvent, MarketEvent, NewOrderIntent, + OrderExecutionFeedbackEvent, + OrderExecutionFeedbackSnapshot, OrderIntent, OrderSubmittedEvent, Price, @@ -83,6 +85,30 @@ def evaluate(self, context: object) -> tuple[OrderIntent, ...]: ) +class _LegacyOnOrderUpdateStrategyEvaluator: + """Runtime-local adapter from legacy Strategy.on_order_update to CoreStep.""" + + def __init__( + self, + *, + strategy: Strategy, + engine_cfg: HftEngineConfig, + constraints: object, + ) -> None: + self._strategy = strategy + self._engine_cfg = engine_cfg + self._constraints = constraints + + def evaluate(self, context: object) -> tuple[OrderIntent, ...]: + return tuple( + self._strategy.on_order_update( + context.state, + self._engine_cfg, + self._constraints, + ) + ) + + class HftStrategyRunner: """Strategy runner for HFT backtests. @@ -102,6 +128,7 @@ def __init__( enable_core_step_market_dispatch: bool = False, enable_core_step_control_time_dispatch: bool = False, enable_core_step_wakeup_collapse: bool = False, + enable_core_step_order_feedback_dispatch: bool = False, ) -> None: self.engine_cfg = engine_cfg self.strategy = strategy @@ -111,6 +138,9 @@ def __init__( enable_core_step_control_time_dispatch ) self._enable_core_step_wakeup_collapse = enable_core_step_wakeup_collapse + self._enable_core_step_order_feedback_dispatch = ( + enable_core_step_order_feedback_dispatch + ) if self._enable_core_step_wakeup_collapse and not self._enable_core_step_market_dispatch: raise ValueError( "enable_core_step_wakeup_collapse=True requires " @@ -285,6 +315,93 @@ def _process_canonical_order_submitted_event( ) self._process_canonical_event(order_submitted_event) + @staticmethod + def _iter_order_snapshot_rows(order_snapshots: object) -> list[object]: + if hasattr(order_snapshots, "values"): + values = order_snapshots.values + if callable(values): + return list(values()) + if hasattr(order_snapshots, "has_next") and hasattr(order_snapshots, "get"): + rows: list[object] = [] + while order_snapshots.has_next(): + row = order_snapshots.get() + if row is None: + break + rows.append(row) + return rows + return list(order_snapshots) + + def _build_order_execution_feedback_event( + self, + *, + instrument: str, + sim_now_ns: int, + state_values: object, + order_snapshots: object, + ) -> OrderExecutionFeedbackEvent: + snapshots = tuple( + OrderExecutionFeedbackSnapshot( + order_id=str(row.order_id), + order_type=int(row.order_type), + side=int(row.side), + time_in_force=int(row.time_in_force), + status=int(row.status), + req=int(row.req), + price=float(row.price), + qty=float(row.qty), + exec_price=float(row.exec_price), + exec_qty=float(row.exec_qty), + leaves_qty=float(row.leaves_qty), + ts_ns_exch=int(row.exch_timestamp), + ts_ns_local=int(row.local_timestamp), + ) + for row in self._iter_order_snapshot_rows(order_snapshots) + ) + return OrderExecutionFeedbackEvent( + ts_ns_local_feedback=sim_now_ns, + instrument=instrument, + position=float(state_values.position), + balance=float(state_values.balance), + fee=float(state_values.fee), + trading_volume=float(state_values.trading_volume), + trading_value=float(state_values.trading_value), + num_trades=int(state_values.num_trades), + order_snapshots=snapshots, + runtime_correlation=None, + ) + + def _process_canonical_order_feedback_event( + self, + order_feedback_event: OrderExecutionFeedbackEvent, + *, + constraints: object, + ) -> CoreStepResult: + position = self._event_stream_cursor.attempt_position() + entry = EventStreamEntry( + position=position, + event=order_feedback_event, + ) + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( + now_ts_ns_local=order_feedback_event.ts_ns_local_feedback, + ) + result = run_core_step( + self.strategy_state, + entry, + configuration=self._core_cfg, + strategy_evaluator=_LegacyOnOrderUpdateStrategyEvaluator( + strategy=self.strategy, + engine_cfg=self.engine_cfg, + constraints=constraints, + ), + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, + ) + self._event_stream_cursor.commit_success(position) + return result + def _process_canonical_control_time_event( self, *, @@ -526,6 +643,7 @@ def run( raw_intents: list[OrderIntent] = [] market_step_result: CoreStepResult | None = None control_step_result: CoreStepResult | None = None + order_feedback_step_result: CoreStepResult | None = None market_event: MarketEvent | None = None # ----------------------------------------------------------------- @@ -619,29 +737,43 @@ def run( # ----------------------------------------------------------------- if rc == 3: state_values, orders = venue.read_orders_snapshot() + if getattr(self, "_enable_core_step_order_feedback_dispatch", False): + constraints = self.risk.build_constraints(sim_now_ns) + order_feedback_event = self._build_order_execution_feedback_event( + instrument=instrument, + sim_now_ns=sim_now_ns, + state_values=state_values, + order_snapshots=orders, + ) + order_feedback_step_result = ( + self._process_canonical_order_feedback_event( + order_feedback_event, + constraints=constraints, + ) + ) + else: + self.strategy_state.update_account( + instrument=instrument, + position=state_values.position, + balance=state_values.balance, + fee=state_values.fee, + trading_volume=state_values.trading_volume, + trading_value=state_values.trading_value, + num_trades=state_values.num_trades, + ) + self.strategy_state.ingest_order_snapshots( + instrument, + orders.values(), + ) - self.strategy_state.update_account( - instrument=instrument, - position=state_values.position, - balance=state_values.balance, - fee=state_values.fee, - trading_volume=state_values.trading_volume, - trading_value=state_values.trading_value, - num_trades=state_values.num_trades, - ) - self.strategy_state.ingest_order_snapshots( - instrument, - orders.values(), - ) - - constraints = self.risk.build_constraints(sim_now_ns) - raw_intents.extend( - self.strategy.on_order_update( - self.strategy_state, - self.engine_cfg, - constraints, + constraints = self.risk.build_constraints(sim_now_ns) + raw_intents.extend( + self.strategy.on_order_update( + self.strategy_state, + self.engine_cfg, + constraints, + ) ) - ) # ----------------------------------------------------------------- # Queue flush @@ -793,6 +925,19 @@ def run( market_step_result.control_scheduling_obligation ) + if order_feedback_step_result is not None: + self._last_core_step_execution_errors = self._dispatch_accepted_intents( + list(order_feedback_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, + ) + if order_feedback_step_result.control_scheduling_obligation is None: + self._clear_pending_control_scheduling_obligation() + else: + self._apply_control_scheduling_obligation( + order_feedback_step_result.control_scheduling_obligation + ) + # ----------------------------------------------------------------- # Gate + execution # ----------------------------------------------------------------- diff --git a/requirements-dev.txt b/requirements-dev.txt index 4332e8d..64f0e7c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@43db205e30d18a244b46e6784166303bfb6cacfa +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@9798fe189265d4421aadb92b58090cf9599db851 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index f481ccd..a259cb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@43db205e30d18a244b46e6784166303bfb6cacfa +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@9798fe189265d4421aadb92b58090cf9599db851 # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 1a5429e..ca1b807 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -21,6 +21,7 @@ FillEvent, MarketEvent, NewOrderIntent, + OrderExecutionFeedbackEvent, OrderSubmittedEvent, Price, Quantity, @@ -1235,6 +1236,296 @@ def _spy_decide_intents(**kwargs: object) -> GateDecision: assert risk_calls[0] == [order_update_intent] +def test_order_update_core_step_mode_routes_rc3_through_canonical_event_and_dispatchables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable = _cancel_intent(ts_ns_local=2) + captured_positions: list[int] = [] + strategy_order_update_calls = {"count": 0} + execution_batches: list[list[str]] = [] + + class _OrderUpdateSpyStrategy(Strategy): + def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, event, engine_cfg, constraints) + return [] + + def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, engine_cfg, constraints) + strategy_order_update_calls["count"] += 1 + return [] + + def on_risk_decision(self, decision: Any) -> None: + _ = decision + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_OrderUpdateSpyStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + def _spy_run_core_step( + state: object, + entry: object, + *, + configuration: object | None = None, + control_time_queue_context: object | None = None, + policy_admission_context: object | None = None, + execution_control_apply_context: object | None = None, + core_decision_context: object | None = None, + strategy_evaluator: object | None = None, + ) -> CoreStepResult: + _ = state + _ = core_decision_context + assert isinstance(entry.event, OrderExecutionFeedbackEvent) + captured_positions.append(entry.position.index) + assert configuration is runner._core_cfg + assert control_time_queue_context is None + assert isinstance(policy_admission_context, CorePolicyAdmissionContext) + assert isinstance(execution_control_apply_context, CoreExecutionControlApplyContext) + assert strategy_evaluator is not None + evaluated = strategy_evaluator.evaluate( + SimpleNamespace( + state=runner.strategy_state, + event=entry.event, + position=entry.position, + configuration=configuration, + ) + ) + assert evaluated == () + return CoreStepResult( + generated_intents=tuple(evaluated), + dispatchable_intents=(dispatchable,), + ) + + class _ExecutionCapture: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + execution_batches.append([it.client_order_id for it in intents]) + return [] + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={ + "101": SimpleNamespace( + order_id="101", + order_type=0, + side=1, + time_in_force=0, + status=1, + req=0, + price=100.0, + qty=1.0, + exec_price=100.25, + exec_qty=0.25, + leaves_qty=0.75, + exch_timestamp=2, + local_timestamp=2, + ) + }, + ) + runner.run(venue=venue, execution=_ExecutionCapture(), recorder=_RecorderWrapper()) + + assert captured_positions == [0] + assert strategy_order_update_calls["count"] == 1 + assert execution_batches == [[dispatchable.client_order_id]] + + +def test_order_update_core_step_mode_preserves_order_submitted_before_mark_sent( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent() + ordering: list[str] = [] + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + ordering.append("submitted") + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + _ = (instrument, client_order_id, intent_type) + ordering.append("mark") + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[11, 12, 13], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert ordering == ["submitted", "mark"] + + +def test_order_update_core_step_mode_failed_new_dispatch_emits_no_order_submitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + dispatchable_new = _new_intent() + submitted_count = {"count": 0} + marked_count = {"count": 0} + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + monkeypatch.setattr( + strategy_runner_module, + "run_core_step", + lambda *args, **kwargs: CoreStepResult( + dispatchable_intents=(dispatchable_new,), + ), + ) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: + _ = (state, configuration) + if isinstance(entry.event, OrderSubmittedEvent): + submitted_count["count"] += 1 + + def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + _ = (instrument, client_order_id, intent_type) + marked_count["count"] += 1 + + class _ExecutionFailNew: + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + _ = intents + return [(dispatchable_new, "EXCHANGE_REJECT")] + + monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[21, 22, 23], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run( + venue=venue, + execution=_ExecutionFailNew(), + recorder=_RecorderWrapper(), + ) + + assert submitted_count["count"] == 0 + assert marked_count["count"] == 0 + assert len(runner._last_core_step_execution_errors) == 1 + + +def test_order_update_core_step_mode_applies_and_clears_control_scheduling_obligation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + obligation = _obligation(due_ts_ns_local=9, obligation_key="rc3") + results = iter( + ( + CoreStepResult(control_scheduling_obligation=obligation), + CoreStepResult(control_scheduling_obligation=None), + ) + ) + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *args, **kwargs: next(results)) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 3, 3, 1], + ts_sequence=[1, 5, 6, 7], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None + + def test_market_run_core_step_failure_does_not_commit_or_reach_risk_dispatch( monkeypatch: pytest.MonkeyPatch, ) -> None: From 984bb16af760724d8bf070b46b7834a355492661 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 14:14:26 +0000 Subject: [PATCH 16/18] test: add rc3 core-step guardrails --- ...rategy_runner_canonical_market_adoption.py | 222 ++++++++++++++++++ 1 file changed, 222 insertions(+) diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index ca1b807..5c45a16 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -1236,6 +1236,228 @@ def _spy_decide_intents(**kwargs: object) -> GateDecision: assert risk_calls[0] == [order_update_intent] +def test_order_update_core_step_mode_does_not_execute_legacy_rc3_state_mutation_branch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Guardrail: rc3 core-step flag must not call legacy update_account/ingest_order_snapshots directly.""" + dispatchable = _cancel_intent(ts_ns_local=2) + legacy_calls: list[str] = [] + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + def _legacy_update_account(*args: object, **kwargs: object) -> None: + _ = (args, kwargs) + legacy_calls.append("update_account") + + def _legacy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: + _ = (args, kwargs) + legacy_calls.append("ingest_order_snapshots") + + monkeypatch.setattr(runner.strategy_state, "update_account", _legacy_update_account) + monkeypatch.setattr( + runner.strategy_state, + "ingest_order_snapshots", + _legacy_ingest_order_snapshots, + ) + + def _spy_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: + # Important: this stub does not reduce the event, so any legacy-branch + # update_account/ingest_order_snapshots calls would be runtime-direct. + return CoreStepResult(dispatchable_intents=(dispatchable,)) + + monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert legacy_calls == [] + + +def test_order_update_core_step_mode_does_not_execute_legacy_finalize_gate_decision_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Guardrail: rc3 core-step flag must not reach _finalize_decision_effects via raw_intents gate.""" + dispatchable = _cancel_intent(ts_ns_local=2) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + monkeypatch.setattr( + runner, + "_finalize_decision_effects", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call legacy _finalize_decision_effects") + ), + ) + monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *a, **k: CoreStepResult(dispatchable_intents=(dispatchable,))) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + +def test_order_update_core_step_mode_does_not_call_strategy_on_order_update_directly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Guardrail: rc3 core-step flag must not call Strategy.on_order_update from legacy branch.""" + dispatchable = _cancel_intent(ts_ns_local=2) + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_NoopStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=True, + ) + + # If the legacy rc3 else-branch executes, it will call strategy.on_order_update directly. + monkeypatch.setattr( + runner.strategy, + "on_order_update", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError("legacy rc3 branch must not call strategy.on_order_update when flag is on") + ), + ) + + # Stub run_core_step so evaluator is not invoked (it would call on_order_update by design). + monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *a, **k: CoreStepResult(dispatchable_intents=(dispatchable,))) + monkeypatch.setattr( + runner.risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("rc3 core-step mode must not call runtime risk gate") + ), + ) + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + +def test_order_update_legacy_flag_off_still_calls_state_and_strategy_and_gate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: when flag is off, rc3 retains legacy state mutation + strategy hook + risk gate.""" + generated = _new_intent(ts_ns_local=2) + seen: list[str] = [] + + class _LegacySpyStrategy(Strategy): + def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, event, engine_cfg, constraints) + return [] + + def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: + _ = (state, engine_cfg, constraints) + seen.append("strategy.on_order_update") + return [generated] + + def on_risk_decision(self, decision: Any) -> None: + _ = decision + + runner = HftStrategyRunner( + engine_cfg=_engine_cfg(), + strategy=_LegacySpyStrategy(), + risk_cfg=_risk_cfg(), + core_cfg=_core_cfg(), + enable_core_step_order_feedback_dispatch=False, + ) + + def _spy_update_account(*args: object, **kwargs: object) -> None: + _ = (args, kwargs) + seen.append("state.update_account") + + def _spy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: + _ = (args, kwargs) + seen.append("state.ingest_order_snapshots") + + monkeypatch.setattr(runner.strategy_state, "update_account", _spy_update_account) + monkeypatch.setattr(runner.strategy_state, "ingest_order_snapshots", _spy_ingest_order_snapshots) + + def _spy_decide_intents(**kwargs: object) -> GateDecision: + _ = kwargs + seen.append("risk.decide_intents") + return _decision_for([]) + + runner.risk.decide_intents = _spy_decide_intents # type: ignore[method-assign] + + venue = _StubVenue( + rc_sequence=[0, 3, 1], + ts_sequence=[1, 2, 3], + state_values=SimpleNamespace( + position=0.0, + balance=1000.0, + fee=0.0, + trading_volume=0.0, + trading_value=0.0, + num_trades=0, + ), + orders={}, + ) + runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + + assert "state.update_account" in seen + assert "state.ingest_order_snapshots" in seen + assert "strategy.on_order_update" in seen + assert "risk.decide_intents" in seen + + def test_order_update_core_step_mode_routes_rc3_through_canonical_event_and_dispatchables( monkeypatch: pytest.MonkeyPatch, ) -> None: From 1642e27302105379bb865fc776e639b5a9207ba1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 15 May 2026 22:15:02 +0000 Subject: [PATCH 17/18] fix(core-runtime): align local backtest with clean Core and stabilize run loop Replace removed Core APIs in runner path, fix rc=14 no-work termination, recorder exhaustion handling, inflight/dispatch ordering, and RiskConfig metadata after pydantic migration. --- README.md | 10 +- core_runtime/backtest/adapters/protocols.py | 17 + core_runtime/backtest/adapters/venue.py | 13 +- core_runtime/backtest/engine/hft_engine.py | 29 +- .../backtest/engine/strategy_runner.py | 610 ++- core_runtime/backtest/runtime/run_sweep.py | 4 +- core_runtime/backtest/strategy_api.py | 57 + core_runtime/local/backtest.py | 4 +- core_runtime/strategies/debug_strategy.py | 26 +- docs/venue-adapter-abstraction-design-v1.md | 20 +- .../test_debug_strategy_state_api_compat.py | 83 + ...est_hftbacktest_venue_adapter_recording.py | 34 + ...rategy_runner_canonical_market_adoption.py | 4296 ++--------------- 13 files changed, 978 insertions(+), 4225 deletions(-) create mode 100644 core_runtime/backtest/strategy_api.py create mode 100644 tests/runtime/test_debug_strategy_state_api_compat.py create mode 100644 tests/runtime/test_hftbacktest_venue_adapter_recording.py diff --git a/README.md b/README.md index 695b490..e193d64 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ python -m core_runtime.local.backtest --config core_runtime/local/bt_config_loca | Capability area | Status | Notes | | --- | --- | --- | | Canonical runtime paths | Active | `MarketEvent`, `OrderSubmittedEvent`, `ControlTimeEvent` | -| Compatibility paths | Active | Post-submission order/fill progression via snapshots, `OrderStateEvent`, and `DerivedFillEvent` | +| Runtime-local compatibility handling | Active | Raw venue order snapshots stay in runtime bookkeeping; Core receives canonical `OrderExecutionFeedbackEvent` (account-level only). | | Deferred capabilities | Deferred | Runtime `FillEvent` ingress, `ExecutionFeedbackRecordSource`, replay/storage/Event Stream persistence, `ProcessingContext` | --- @@ -108,11 +108,11 @@ python -m core_runtime.local.backtest --config core_runtime/local/bt_config_loca --- -## Compatibility paths +## Runtime-local compatibility handling -- snapshot-based post-submission progression -- `OrderStateEvent` -- `DerivedFillEvent` +- snapshot-based post-submission bookkeeping remains runtime-local +- Core ingestion uses account-level `OrderExecutionFeedbackEvent` +- no snapshot row payload is pushed into Core --- diff --git a/core_runtime/backtest/adapters/protocols.py b/core_runtime/backtest/adapters/protocols.py index 9a8dee3..202e3cc 100644 --- a/core_runtime/backtest/adapters/protocols.py +++ b/core_runtime/backtest/adapters/protocols.py @@ -74,6 +74,23 @@ def read_orders_snapshot(self) -> tuple[Any, Any]: """Return (state_values, orders) from current snapshot boundary.""" +class VenueAdapter( + VenueEventWaiter, + VenueClock, + MarketInputSource, + OrderSnapshotSource, + Protocol, +): + """Composite runtime venue adapter contract for strategy runner.""" + + def record(self, recorder: Any) -> bool: + """Persist current simulation state. + + Returns: + True when recorder capacity is exhausted and no record was written. + """ + + class OrderSubmissionGateway(Protocol): """Outbound order command submission capability. diff --git a/core_runtime/backtest/adapters/venue.py b/core_runtime/backtest/adapters/venue.py index 33e1997..8dd20a8 100644 --- a/core_runtime/backtest/adapters/venue.py +++ b/core_runtime/backtest/adapters/venue.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: from hftbacktest import ROIVectorMarketDepthBacktest -from tradingchassis_core.core.ports.venue_adapter import VenueAdapter +from core_runtime.backtest.adapters.protocols import VenueAdapter @dataclass(frozen=True) @@ -43,7 +43,14 @@ def read_orders_snapshot(self) -> tuple[Any, Any]: self.hbt.orders(self.asset_no), ) - def record(self, recorder: Any) -> None: + def record(self, recorder: Any) -> bool: """Record the current backtest state using the given recorder.""" # hftbacktest recorder is a thin wrapper exposing .recorder.record(hbt). - recorder.recorder.record(self.hbt) + try: + recorder.recorder.record(self.hbt) + except IndexError: + # hftbacktest Recorder has a fixed capacity and raises IndexError + # when the record buffer is exhausted. Runtime treats this as + # recording exhaustion and keeps the backtest loop alive. + return True + return False diff --git a/core_runtime/backtest/engine/hft_engine.py b/core_runtime/backtest/engine/hft_engine.py index 62cfd67..d15a996 100644 --- a/core_runtime/backtest/engine/hft_engine.py +++ b/core_runtime/backtest/engine/hft_engine.py @@ -4,21 +4,12 @@ import importlib from dataclasses import dataclass -from typing import TYPE_CHECKING - -from hftbacktest import ( - BacktestAsset, - Recorder, - ROIVectorMarketDepthBacktest, -) +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.strategies.base import Strategy -from tradingchassis_core.strategies.strategy_config import StrategyConfig - from core_runtime.backtest.adapters.execution import HftBacktestExecutionAdapter from core_runtime.backtest.adapters.venue import HftBacktestVenueAdapter from core_runtime.backtest.engine.engine_base import ( @@ -27,6 +18,7 @@ BacktestResult, ) from core_runtime.backtest.engine.strategy_runner import HftStrategyRunner +from core_runtime.backtest.strategy_api import Strategy, StrategyConfig # pylint: disable=too-many-instance-attributes @@ -81,8 +73,10 @@ class HftBacktestConfig(BacktestConfig): core_cfg: CoreConfiguration -def _build_backtester(engine_cfg: HftEngineConfig) -> ROIVectorMarketDepthBacktest: +def _build_backtester(engine_cfg: HftEngineConfig) -> Any: """Create an ROIVectorMarketDepthBacktest from the engine configuration.""" + from hftbacktest import BacktestAsset, ROIVectorMarketDepthBacktest + asset = BacktestAsset() # For now we assume file paths. Later this can be replaced with an S3 resolver. @@ -126,10 +120,13 @@ def _load_strategy_class(self, class_path: str) -> type[Strategy]: module_path, class_name = class_path.split(":") module = importlib.import_module(module_path) cls = getattr(module, class_name) - - if not issubclass(cls, Strategy): + if not callable(getattr(cls, "on_feed", None)): + raise TypeError( + f"Loaded class {class_name} does not implement on_feed." + ) + if not callable(getattr(cls, "on_order_update", None)): raise TypeError( - f"Loaded class {class_name} is not a subclass of Strategy." + f"Loaded class {class_name} does not implement on_order_update." ) return cls @@ -150,6 +147,8 @@ def run(self) -> BacktestResult: hbt = _build_backtester(engine_cfg) # 2) Prepare recorder (single asset, record every step) + from hftbacktest import Recorder + recorder = Recorder(1, engine_cfg.max_steps) # 3) Build strategy and runner @@ -182,6 +181,6 @@ def run(self) -> BacktestResult: "strategy_name": strategy_cfg.class_path, "strategy_params": strategy_cfg.params, "risk_scope": risk_cfg.scope, - "risk_params": risk_cfg.params, + "risk_params": risk_cfg.model_dump(), }, ) diff --git a/core_runtime/backtest/engine/strategy_runner.py b/core_runtime/backtest/engine/strategy_runner.py index 0eb57c0..716e843 100644 --- a/core_runtime/backtest/engine/strategy_runner.py +++ b/core_runtime/backtest/engine/strategy_runner.py @@ -3,12 +3,11 @@ from __future__ import annotations import logging -from collections import deque +import os from pathlib import Path from typing import TYPE_CHECKING, Any from tradingchassis_core import ( - ControlTimeQueueReevaluationContext, CoreExecutionControlApplyContext, CorePolicyAdmissionContext, run_core_step, @@ -28,7 +27,6 @@ MarketEvent, NewOrderIntent, OrderExecutionFeedbackEvent, - OrderExecutionFeedbackSnapshot, OrderIntent, OrderSubmittedEvent, Price, @@ -36,24 +34,19 @@ ) from tradingchassis_core.core.events.event_bus import EventBus from tradingchassis_core.core.events.sinks.sink_logging import LoggingEventSink +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl from tradingchassis_core.core.execution_control.types import ( ControlSchedulingObligation, ) -from tradingchassis_core.core.ports.venue_adapter import VenueAdapter from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import ( - GateDecision, - RejectedIntent, - RiskEngine, -) +from tradingchassis_core.core.risk.risk_engine import RiskEngine -from core_runtime.backtest.adapters.protocols import OrderSubmissionGateway +from core_runtime.backtest.adapters.protocols import OrderSubmissionGateway, VenueAdapter from core_runtime.backtest.engine.event_stream_cursor import EventStreamCursor from core_runtime.backtest.events.sinks.file_recorder import FileRecorderSink +from core_runtime.backtest.strategy_api import Strategy if TYPE_CHECKING: - from tradingchassis_core.strategies.base import Strategy - from core_runtime.backtest.engine.hft_engine import HftEngineConfig @@ -109,6 +102,33 @@ def evaluate(self, context: object) -> tuple[OrderIntent, ...]: ) +class _LegacyWakeupStrategyEvaluator: + """Runtime-local adapter for one strategy evaluation per wakeup reduction.""" + + def __init__( + self, + *, + strategy: Strategy, + engine_cfg: HftEngineConfig, + constraints: object, + market_event: MarketEvent, + ) -> None: + self._strategy = strategy + self._engine_cfg = engine_cfg + self._constraints = constraints + self._market_event = market_event + + def evaluate(self, context: object) -> tuple[OrderIntent, ...]: + return tuple( + self._strategy.on_feed( + context.state, + self._market_event, + self._engine_cfg, + self._constraints, + ) + ) + + class HftStrategyRunner: """Strategy runner for HFT backtests. @@ -125,10 +145,10 @@ def __init__( strategy: Strategy, risk_cfg: RiskConfig, core_cfg: CoreConfiguration, - enable_core_step_market_dispatch: bool = False, - enable_core_step_control_time_dispatch: bool = False, + enable_core_step_market_dispatch: bool = True, + enable_core_step_control_time_dispatch: bool = True, enable_core_step_wakeup_collapse: bool = False, - enable_core_step_order_feedback_dispatch: bool = False, + enable_core_step_order_feedback_dispatch: bool = True, ) -> None: self.engine_cfg = engine_cfg self.strategy = strategy @@ -141,6 +161,16 @@ def __init__( self._enable_core_step_order_feedback_dispatch = ( enable_core_step_order_feedback_dispatch ) + if not self._enable_core_step_market_dispatch: + raise ValueError("clean-core runtime requires enable_core_step_market_dispatch=True") + if not self._enable_core_step_control_time_dispatch: + raise ValueError( + "clean-core runtime requires enable_core_step_control_time_dispatch=True" + ) + if not self._enable_core_step_order_feedback_dispatch: + raise ValueError( + "clean-core runtime requires enable_core_step_order_feedback_dispatch=True" + ) if self._enable_core_step_wakeup_collapse and not self._enable_core_step_market_dispatch: raise ValueError( "enable_core_step_wakeup_collapse=True requires " @@ -165,8 +195,8 @@ def __init__( self.risk = RiskEngine( risk_cfg=risk_cfg, - event_bus=event_bus, ) + self.execution_control = ExecutionControl() self._next_send_ts_ns_local: int | None = None self._event_stream_cursor = EventStreamCursor() @@ -203,7 +233,6 @@ def _build_event_bus( def _close_event_bus(self) -> None: self.strategy_state._event_bus.close() - self.risk._event_bus.close() def _compute_timeout_ns(self, now_local_ns: int) -> int: """Compute wait timeout in nanoseconds.""" @@ -244,7 +273,7 @@ def _build_policy_and_apply_context( now_ts_ns_local=now_ts_ns_local, ), CoreExecutionControlApplyContext( - execution_control=self.risk.execution_control, + execution_control=self.execution_control, now_ts_ns_local=now_ts_ns_local, max_orders_per_sec=max_orders_per_sec, max_cancels_per_sec=max_cancels_per_sec, @@ -263,33 +292,23 @@ def _process_canonical_market_event( position=position, event=market_event, ) - policy_admission_context = None - execution_control_apply_context = None - if getattr(self, "_enable_core_step_market_dispatch", False): - ( - policy_admission_context, - execution_control_apply_context, - ) = self._build_policy_and_apply_context( - now_ts_ns_local=market_event.ts_ns_local, - ) - run_core_step_kwargs: dict[str, object] = { - "configuration": self._core_cfg, - "strategy_evaluator": _LegacyOnFeedStrategyEvaluator( + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( + now_ts_ns_local=market_event.ts_ns_local, + ) + result = run_core_step( + self.strategy_state, + entry, + configuration=self._core_cfg, + strategy_evaluator=_LegacyOnFeedStrategyEvaluator( strategy=self.strategy, engine_cfg=self.engine_cfg, constraints=constraints, ), - } - if policy_admission_context is not None: - run_core_step_kwargs["policy_admission_context"] = policy_admission_context - if execution_control_apply_context is not None: - run_core_step_kwargs["execution_control_apply_context"] = ( - execution_control_apply_context - ) - result = run_core_step( - self.strategy_state, - entry, - **run_core_step_kwargs, + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, ) self._event_stream_cursor.commit_success(position) return result @@ -315,48 +334,13 @@ def _process_canonical_order_submitted_event( ) self._process_canonical_event(order_submitted_event) - @staticmethod - def _iter_order_snapshot_rows(order_snapshots: object) -> list[object]: - if hasattr(order_snapshots, "values"): - values = order_snapshots.values - if callable(values): - return list(values()) - if hasattr(order_snapshots, "has_next") and hasattr(order_snapshots, "get"): - rows: list[object] = [] - while order_snapshots.has_next(): - row = order_snapshots.get() - if row is None: - break - rows.append(row) - return rows - return list(order_snapshots) - def _build_order_execution_feedback_event( self, *, instrument: str, sim_now_ns: int, state_values: object, - order_snapshots: object, ) -> OrderExecutionFeedbackEvent: - snapshots = tuple( - OrderExecutionFeedbackSnapshot( - order_id=str(row.order_id), - order_type=int(row.order_type), - side=int(row.side), - time_in_force=int(row.time_in_force), - status=int(row.status), - req=int(row.req), - price=float(row.price), - qty=float(row.qty), - exec_price=float(row.exec_price), - exec_qty=float(row.exec_qty), - leaves_qty=float(row.leaves_qty), - ts_ns_exch=int(row.exch_timestamp), - ts_ns_local=int(row.local_timestamp), - ) - for row in self._iter_order_snapshot_rows(order_snapshots) - ) return OrderExecutionFeedbackEvent( ts_ns_local_feedback=sim_now_ns, instrument=instrument, @@ -366,7 +350,6 @@ def _build_order_execution_feedback_event( trading_volume=float(state_values.trading_volume), trading_value=float(state_values.trading_value), num_trades=int(state_values.num_trades), - order_snapshots=snapshots, runtime_correlation=None, ) @@ -405,7 +388,6 @@ def _process_canonical_order_feedback_event( def _process_canonical_control_time_event( self, *, - instrument: str, now_ts_ns_local: int, sim_now_ns: int, scheduled_deadline_ns: int, @@ -421,34 +403,18 @@ def _process_canonical_control_time_event( position=position, event=control_time_event, ) - run_core_step_kwargs: dict[str, object] = { - "configuration": self._core_cfg, - } - if getattr(self, "_enable_core_step_control_time_dispatch", False): - ( - policy_admission_context, - execution_control_apply_context, - ) = self._build_policy_and_apply_context( - now_ts_ns_local=now_ts_ns_local, - ) - run_core_step_kwargs["policy_admission_context"] = ( - policy_admission_context - ) - run_core_step_kwargs["execution_control_apply_context"] = ( - execution_control_apply_context - ) - else: - run_core_step_kwargs["control_time_queue_context"] = ( - ControlTimeQueueReevaluationContext( - risk_engine=self.risk, - instrument=instrument, - now_ts_ns_local=now_ts_ns_local, - ) - ) + ( + policy_admission_context, + execution_control_apply_context, + ) = self._build_policy_and_apply_context( + now_ts_ns_local=now_ts_ns_local, + ) result = run_core_step( self.strategy_state, entry, - **run_core_step_kwargs, + configuration=self._core_cfg, + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, ) self._event_stream_cursor.commit_success(position) return result @@ -492,21 +458,6 @@ def _commit_wakeup_entries(self, entries: tuple[EventStreamEntry, ...]) -> None: for entry in entries: self._event_stream_cursor.commit_success(entry.position) - @staticmethod - def _select_effective_control_scheduling_obligation( - decision: GateDecision, - ) -> ControlSchedulingObligation | None: - obligations = decision.control_scheduling_obligations - if not obligations: - return None - return min( - obligations, - key=lambda obligation: ( - obligation.due_ts_ns_local, - obligation.obligation_key, - ), - ) - def _clear_pending_control_scheduling_obligation(self) -> None: self._pending_control_scheduling_obligation = None self._next_send_ts_ns_local = None @@ -518,21 +469,6 @@ def _consume_pending_control_scheduling_obligation( self._clear_pending_control_scheduling_obligation() return pending - def _apply_control_scheduling_decision( - self, - decision: GateDecision, - ) -> None: - selected = self._select_effective_control_scheduling_obligation(decision) - if selected is None: - self._pending_control_scheduling_obligation = None - self._next_send_ts_ns_local = decision.next_send_ts_ns_local - return - if self._pending_control_scheduling_obligation == selected: - self._next_send_ts_ns_local = selected.due_ts_ns_local - return - self._pending_control_scheduling_obligation = selected - self._next_send_ts_ns_local = selected.due_ts_ns_local - def _apply_control_scheduling_obligation( self, obligation: ControlSchedulingObligation | None, @@ -569,6 +505,12 @@ def _dispatch_accepted_intents( it, ts_ns_local_dispatch=sim_now_ns, ) + # Clean-Core runtime treats OrderSubmittedEvent as the successful + # NEW dispatch acknowledgment boundary. Calling mark_intent_sent + # here would recreate NEW inflight state immediately after the + # submitted reducer clears it and can cause non-terminating + # inflight-only loops near end-of-data. + continue self.strategy_state.mark_intent_sent( it.instrument, it.client_order_id, @@ -577,40 +519,6 @@ def _dispatch_accepted_intents( return execution_errors - def _finalize_decision_effects( - self, - *, - decision: GateDecision, - execution: OrderSubmissionGateway, - sim_now_ns: int, - instrument: str, - ) -> None: - execution_errors = self._dispatch_accepted_intents( - decision.accepted_now, - execution, - sim_now_ns=sim_now_ns, - ) - - if execution_errors: - for it, reason in execution_errors: - decision.execution_rejected.append( - RejectedIntent(it, reason) - ) - - self.strategy.on_risk_decision(decision) - self._apply_control_scheduling_decision(decision) - - # If there are queued intents but the gate did not provide a next_send_ts_ns_local, - # wake up at the next second boundary to ensure progress. - if self._next_send_ts_ns_local is None: - queue = self.strategy_state.queued_intents.setdefault( - instrument, - deque(), - ) - if queue: - sec = sim_now_ns // 1_000_000_000 - self._next_send_ts_ns_local = (sec + 1) * 1_000_000_000 - def run( self, venue: VenueAdapter, @@ -619,6 +527,37 @@ def run( ) -> None: """Run the backtest loop.""" # pylint: disable=too-many-locals,too-many-branches,too-many-statements + debug_loop = os.getenv("TRADINGCHASSIS_DEBUG_LOOP", "").strip() in { + "1", + "true", + "TRUE", + "yes", + "YES", + } + debug_max_iterations_raw = os.getenv("TRADINGCHASSIS_DEBUG_MAX_ITERATIONS", "").strip() + debug_max_iterations: int | None = None + if debug_max_iterations_raw: + parsed = int(debug_max_iterations_raw) + if parsed <= 0: + raise ValueError("TRADINGCHASSIS_DEBUG_MAX_ITERATIONS must be > 0") + debug_max_iterations = parsed + debug_every_raw = os.getenv("TRADINGCHASSIS_DEBUG_LOOP_EVERY", "").strip() + debug_every = 100 + if debug_every_raw: + parsed_every = int(debug_every_raw) + if parsed_every <= 0: + raise ValueError("TRADINGCHASSIS_DEBUG_LOOP_EVERY must be > 0") + debug_every = parsed_every + debug_logger = logging.getLogger("core_runtime.backtest.loop") + loop_iteration = 0 + last_rc: int | None = None + last_timeout_ns: int | None = None + last_sim_ts_before_wait: int | None = None + last_sim_ts_after_wait: int | None = None + no_progress_iterations = 0 + recorder_exhausted_count = 0 + end_signal_count = 0 + last_debug_signature: tuple[object, ...] | None = None instrument = self.engine_cfg.instrument # Initialize hftbacktest engine @@ -629,22 +568,68 @@ def run( sim_now_ns = self.strategy_state.sim_ts_ns_local while True: - timeout_ns = self._compute_timeout_ns(self.strategy_state.sim_ts_ns_local) + loop_iteration += 1 + if ( + debug_max_iterations is not None + and loop_iteration > debug_max_iterations + ): + pending_due = ( + None + if self._pending_control_scheduling_obligation is None + else self._pending_control_scheduling_obligation.due_ts_ns_local + ) + pending_reason = ( + None + if self._pending_control_scheduling_obligation is None + else self._pending_control_scheduling_obligation.reason + ) + queued_count = sum( + len(queue) + for queue in self.strategy_state.queued_intents.values() + ) + inflight_count = sum( + len(bucket) + for bucket in self.strategy_state.inflight.values() + ) + raise RuntimeError( + "TRADINGCHASSIS_DEBUG_MAX_ITERATIONS exceeded in HftStrategyRunner.run: " + f"iteration={loop_iteration}, sim_ts_ns_local={self.strategy_state.sim_ts_ns_local}, " + f"pending_due={pending_due}, pending_reason={pending_reason}, " + f"last_injected_control_deadline_ns={self._last_injected_control_deadline_ns}, " + f"queued_count={queued_count}, inflight_count={inflight_count}, " + f"last_rc={last_rc}, last_timeout_ns={last_timeout_ns}, " + f"sim_ts_before_wait={last_sim_ts_before_wait}, sim_ts_after_wait={last_sim_ts_after_wait}, " + f"no_progress_iterations={no_progress_iterations}, " + f"recorder_exhausted_count={recorder_exhausted_count}, end_signal_count={end_signal_count}" + ) + + sim_ts_before_wait = self.strategy_state.sim_ts_ns_local + timeout_ns = self._compute_timeout_ns(sim_ts_before_wait) rc = venue.wait_next(timeout_ns=timeout_ns, include_order_resp=True) + last_rc = rc + last_timeout_ns = timeout_ns + last_sim_ts_before_wait = sim_ts_before_wait if rc == 1: + end_signal_count += 1 self._close_event_bus() break observed_local_ns = venue.current_timestamp_ns() self.strategy_state.update_timestamp(observed_local_ns) sim_now_ns = self.strategy_state.sim_ts_ns_local + last_sim_ts_after_wait = sim_now_ns + venue_progressed = sim_now_ns > sim_ts_before_wait - raw_intents: list[OrderIntent] = [] market_step_result: CoreStepResult | None = None control_step_result: CoreStepResult | None = None order_feedback_step_result: CoreStepResult | None = None market_event: MarketEvent | None = None + market_processed = False + order_feedback_processed = False + control_time_injected = False + dispatch_attempted_count = 0 + stale_obligation_cleared = False # ----------------------------------------------------------------- # Market update @@ -729,51 +714,25 @@ def run( market_event, constraints=constraints, ) - if not getattr(self, "_enable_core_step_market_dispatch", False): - raw_intents.extend(market_step_result.generated_intents) + market_processed = True # ----------------------------------------------------------------- # Order / account update # ----------------------------------------------------------------- if rc == 3: state_values, orders = venue.read_orders_snapshot() - if getattr(self, "_enable_core_step_order_feedback_dispatch", False): - constraints = self.risk.build_constraints(sim_now_ns) - order_feedback_event = self._build_order_execution_feedback_event( - instrument=instrument, - sim_now_ns=sim_now_ns, - state_values=state_values, - order_snapshots=orders, - ) - order_feedback_step_result = ( - self._process_canonical_order_feedback_event( - order_feedback_event, - constraints=constraints, - ) - ) - else: - self.strategy_state.update_account( - instrument=instrument, - position=state_values.position, - balance=state_values.balance, - fee=state_values.fee, - trading_volume=state_values.trading_volume, - trading_value=state_values.trading_value, - num_trades=state_values.num_trades, - ) - self.strategy_state.ingest_order_snapshots( - instrument, - orders.values(), - ) - - constraints = self.risk.build_constraints(sim_now_ns) - raw_intents.extend( - self.strategy.on_order_update( - self.strategy_state, - self.engine_cfg, - constraints, - ) - ) + _ = orders # runtime keeps raw snapshot ownership; core receives canonical feedback + constraints = self.risk.build_constraints(sim_now_ns) + order_feedback_event = self._build_order_execution_feedback_event( + instrument=instrument, + sim_now_ns=sim_now_ns, + state_values=state_values, + ) + order_feedback_step_result = self._process_canonical_order_feedback_event( + order_feedback_event, + constraints=constraints, + ) + order_feedback_processed = True # ----------------------------------------------------------------- # Queue flush @@ -786,6 +745,19 @@ def run( elif self._next_send_ts_ns_local is not None: # Transitional compatibility for scalar-only decisions. scheduled_deadline_ns = self._next_send_ts_ns_local + if ( + scheduling_obligation is not None + and scheduled_deadline_ns is not None + and sim_now_ns >= scheduled_deadline_ns + and scheduled_deadline_ns == self._last_injected_control_deadline_ns + ): + # The same control deadline has already been realized once. + # Keeping it pending would force timeout_ns=0 and can spin forever + # when venue time no longer advances near end-of-data. + self._consume_pending_control_scheduling_obligation() + scheduling_obligation = None + scheduled_deadline_ns = None + stale_obligation_cleared = True if ( scheduled_deadline_ns is not None and sim_now_ns >= scheduled_deadline_ns @@ -798,12 +770,12 @@ def run( pass else: control_step_result = self._process_canonical_control_time_event( - instrument=instrument, now_ts_ns_local=sim_now_ns, sim_now_ns=sim_now_ns, scheduled_deadline_ns=scheduled_deadline_ns, scheduling_obligation=scheduling_obligation, ) + control_time_injected = True self._last_injected_control_deadline_ns = scheduled_deadline_ns if scheduling_obligation is not None: self._consume_pending_control_scheduling_obligation() @@ -834,10 +806,11 @@ def run( collapse_constraints = self.risk.build_constraints(sim_now_ns) strategy_evaluator = None if market_event is not None: - strategy_evaluator = _LegacyOnFeedStrategyEvaluator( + strategy_evaluator = _LegacyWakeupStrategyEvaluator( strategy=self.strategy, engine_cfg=self.engine_cfg, constraints=collapse_constraints, + market_event=market_event, ) ( policy_admission_context, @@ -849,9 +822,8 @@ def run( self.strategy_state, wakeup_entries, configuration=self._core_cfg, - strategy_evaluator=strategy_evaluator, - strategy_event_filter=lambda event: isinstance(event, MarketEvent), - snapshot_instrument=instrument, + wakeup_strategy_evaluator=strategy_evaluator, + queued_instrument=instrument, policy_admission_context=policy_admission_context, execution_control_apply_context=execution_control_apply_context, ) @@ -867,6 +839,7 @@ def run( execution, sim_now_ns=sim_now_ns, ) + dispatch_attempted_count += len(wakeup_result.dispatchable_intents) if wakeup_result.control_scheduling_obligation is None: self._clear_pending_control_scheduling_obligation() else: @@ -874,50 +847,31 @@ def run( wakeup_result.control_scheduling_obligation ) elif control_step_result is not None: - if getattr(self, "_enable_core_step_control_time_dispatch", False): - self._last_core_step_execution_errors = ( - self._dispatch_accepted_intents( - list(control_step_result.dispatchable_intents), - execution, - sim_now_ns=sim_now_ns, - ) + self._last_core_step_execution_errors = ( + self._dispatch_accepted_intents( + list(control_step_result.dispatchable_intents), + execution, + sim_now_ns=sim_now_ns, ) - if control_step_result.control_scheduling_obligation is None: - self._clear_pending_control_scheduling_obligation() - else: - self._apply_control_scheduling_obligation( - control_step_result.control_scheduling_obligation - ) + ) + dispatch_attempted_count += len(control_step_result.dispatchable_intents) + if control_step_result.control_scheduling_obligation is None: + self._clear_pending_control_scheduling_obligation() else: - if control_step_result.compat_gate_decision is not None: - self._finalize_decision_effects( - decision=control_step_result.compat_gate_decision, - execution=execution, - sim_now_ns=sim_now_ns, - instrument=instrument, - ) - elif control_step_result.dispatchable_intents: - self._dispatch_accepted_intents( - list(control_step_result.dispatchable_intents), - execution, - sim_now_ns=sim_now_ns, - ) - elif control_step_result.control_scheduling_obligation is not None: - self._apply_control_scheduling_obligation( - control_step_result.control_scheduling_obligation - ) + self._apply_control_scheduling_obligation( + control_step_result.control_scheduling_obligation + ) if ( not getattr(self, "_enable_core_step_wakeup_collapse", False) - and - market_step_result is not None - and getattr(self, "_enable_core_step_market_dispatch", False) + and market_step_result is not None ): self._last_core_step_execution_errors = self._dispatch_accepted_intents( list(market_step_result.dispatchable_intents), execution, sim_now_ns=sim_now_ns, ) + dispatch_attempted_count += len(market_step_result.dispatchable_intents) if market_step_result.control_scheduling_obligation is None: self._clear_pending_control_scheduling_obligation() else: @@ -931,6 +885,7 @@ def run( execution, sim_now_ns=sim_now_ns, ) + dispatch_attempted_count += len(order_feedback_step_result.dispatchable_intents) if order_feedback_step_result.control_scheduling_obligation is None: self._clear_pending_control_scheduling_obligation() else: @@ -938,22 +893,155 @@ def run( order_feedback_step_result.control_scheduling_obligation ) - # ----------------------------------------------------------------- - # Gate + execution - # ----------------------------------------------------------------- - if raw_intents: - combined = self._sort_intents_for_gate(raw_intents) + pending_due = ( + None + if self._pending_control_scheduling_obligation is None + else self._pending_control_scheduling_obligation.due_ts_ns_local + ) + pending_reason = ( + None + if self._pending_control_scheduling_obligation is None + else self._pending_control_scheduling_obligation.reason + ) + dispatchable_market = ( + 0 + if market_step_result is None + else len(market_step_result.dispatchable_intents) + ) + dispatchable_control = ( + 0 + if control_step_result is None + else len(control_step_result.dispatchable_intents) + ) + dispatchable_feedback = ( + 0 + if order_feedback_step_result is None + else len(order_feedback_step_result.dispatchable_intents) + ) + queued_count = sum( + len(queue) + for queue in self.strategy_state.queued_intents.values() + ) + inflight_count = sum( + len(bucket) + for bucket in self.strategy_state.inflight.values() + ) + queued_keys = tuple( + f"{instrument_key}:{queued.logical_key}" + for instrument_key in sorted(self.strategy_state.queued_intents) + for queued in list(self.strategy_state.queued_intents[instrument_key])[:3] + )[:6] + inflight_keys = tuple( + f"{instrument_key}:{client_order_id}" + for instrument_key in sorted(self.strategy_state.inflight) + for client_order_id in sorted(self.strategy_state.inflight[instrument_key])[:3] + )[:6] + event_processed = market_processed or order_feedback_processed or control_time_injected + has_pending_core_work = ( + queued_count > 0 + or inflight_count > 0 + or self._pending_control_scheduling_obligation is not None + or self._next_send_ts_ns_local is not None + ) + no_event_rc = rc not in {1, 2, 3} + no_progress_iteration = ( + not venue_progressed + and not event_processed + and dispatch_attempted_count == 0 + and no_event_rc + ) + if no_progress_iteration: + no_progress_iterations += 1 + else: + no_progress_iterations = 0 + termination_no_work_no_progress = ( + not has_pending_core_work + and no_progress_iteration + ) - decision = self.risk.decide_intents( - raw_intents=combined, - state=self.strategy_state, - now_ts_ns_local=sim_now_ns, + if debug_loop: + debug_signature = ( + rc, + timeout_ns, + sim_ts_before_wait, + sim_now_ns, + has_pending_core_work, + queued_count, + inflight_count, + pending_due, + pending_reason, + market_processed, + order_feedback_processed, + control_time_injected, + dispatch_attempted_count, + termination_no_work_no_progress, ) - self._finalize_decision_effects( - decision=decision, - execution=execution, - sim_now_ns=sim_now_ns, - instrument=instrument, + should_emit_debug = ( + loop_iteration == 1 + or (loop_iteration % debug_every) == 0 + or debug_signature != last_debug_signature + or termination_no_work_no_progress ) + if should_emit_debug: + debug_logger.info( + "loop=%s rc=%s sim_ts_ns_local=%s timeout_ns=%s market_processed=%s " + "order_feedback_processed=%s control_time_injected=%s " + "pending_due=%s pending_reason=%s stale_obligation_cleared=%s " + "dispatchable_market=%s dispatchable_control=%s dispatchable_feedback=%s " + "dispatch_attempted=%s queued_count=%s inflight_count=%s " + "queued_keys=%s inflight_keys=%s sim_ts_before_wait=%s " + "sim_ts_after_wait=%s no_progress_iterations=%s has_pending_core_work=%s " + "termination_no_work_no_progress=%s no_event_rc=%s", + loop_iteration, + rc, + sim_now_ns, + timeout_ns, + market_processed, + order_feedback_processed, + control_time_injected, + pending_due, + pending_reason, + stale_obligation_cleared, + dispatchable_market, + dispatchable_control, + dispatchable_feedback, + dispatch_attempted_count, + queued_count, + inflight_count, + queued_keys, + inflight_keys, + sim_ts_before_wait, + sim_now_ns, + no_progress_iterations, + has_pending_core_work, + termination_no_work_no_progress, + no_event_rc, + ) + print( + "[TRADINGCHASSIS_DEBUG_LOOP] " + f"loop={loop_iteration} rc={rc} timeout_ns={timeout_ns} " + f"sim_ts_before_wait={sim_ts_before_wait} sim_ts_after_wait={sim_now_ns} " + f"market_processed={market_processed} order_feedback_processed={order_feedback_processed} " + f"control_time_injected={control_time_injected} dispatch_attempted={dispatch_attempted_count} " + f"pending_due={pending_due} pending_reason={pending_reason} " + f"queued_count={queued_count} inflight_count={inflight_count} " + f"queued_keys={queued_keys} inflight_keys={inflight_keys} " + f"no_progress_iterations={no_progress_iterations} " + f"termination_no_work_no_progress={termination_no_work_no_progress} " + f"no_event_rc={no_event_rc}", + flush=True, + ) + last_debug_signature = debug_signature + + if termination_no_work_no_progress: + if debug_loop: + print( + "[TRADINGCHASSIS_DEBUG_LOOP] breaking no-work/no-progress loop", + flush=True, + ) + self._close_event_bus() + break - venue.record(recorder) + recorder_exhausted = bool(venue.record(recorder)) + if recorder_exhausted: + recorder_exhausted_count += 1 diff --git a/core_runtime/backtest/runtime/run_sweep.py b/core_runtime/backtest/runtime/run_sweep.py index 30865d0..dc421c4 100644 --- a/core_runtime/backtest/runtime/run_sweep.py +++ b/core_runtime/backtest/runtime/run_sweep.py @@ -14,7 +14,6 @@ from typing import Any from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.strategies.strategy_config import StrategyConfig from core_runtime.backtest.engine.hft_engine import ( HftBacktestConfig, @@ -26,6 +25,7 @@ from core_runtime.backtest.runtime.core_configuration_mapper import ( build_core_configuration_from_run_config, ) +from core_runtime.backtest.strategy_api import StrategyConfig class SweepMaterializer: @@ -462,7 +462,7 @@ def main() -> None: materializer.materialize(ctx) engine_cfg = HftEngineConfig(**ctx.parameters["engine"]) - strategy_cfg = StrategyConfig(**ctx.parameters["strategy"]) + strategy_cfg = StrategyConfig.from_mapping(ctx.parameters["strategy"]) risk_cfg = RiskConfig(**ctx.parameters["risk"]) runner = SweepEngineRunner( diff --git a/core_runtime/backtest/strategy_api.py b/core_runtime/backtest/strategy_api.py new file mode 100644 index 0000000..ba5190e --- /dev/null +++ b/core_runtime/backtest/strategy_api.py @@ -0,0 +1,57 @@ +"""Runtime-local Strategy protocol and config model. + +Core no longer exports strategy construction interfaces; runtime owns these. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Protocol, runtime_checkable + +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import MarketEvent, OrderIntent, RiskConstraints + + +@runtime_checkable +class Strategy(Protocol): + """Runtime strategy callback contract.""" + + def on_feed( + self, + state: StrategyState, + event: MarketEvent, + engine_cfg: object, + constraints: RiskConstraints, + ) -> list[OrderIntent]: + """Return intents generated for one market event.""" + + def on_order_update( + self, + state: StrategyState, + engine_cfg: object, + constraints: RiskConstraints, + ) -> list[OrderIntent]: + """Return intents generated for one execution-feedback update.""" + + +@dataclass(frozen=True, slots=True) +class StrategyConfig: + """Runtime-local strategy constructor config.""" + + class_path: str + params: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_mapping(cls, raw: Mapping[str, Any]) -> StrategyConfig: + if "class_path" not in raw: + raise ValueError("strategy.class_path is required") + class_path = str(raw["class_path"]) + params = { + key: value + for key, value in raw.items() + if key != "class_path" + } + return cls(class_path=class_path, params=params) + + def to_engine_params(self) -> dict[str, Any]: + return dict(self.params) diff --git a/core_runtime/local/backtest.py b/core_runtime/local/backtest.py index 62a58fb..8b66190 100644 --- a/core_runtime/local/backtest.py +++ b/core_runtime/local/backtest.py @@ -11,7 +11,6 @@ from core_runtime.backtest.engine.engine_base import BacktestResult from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.strategies.strategy_config import StrategyConfig from core_runtime.backtest.engine.hft_engine import ( HftBacktestConfig, @@ -21,6 +20,7 @@ from core_runtime.backtest.runtime.core_configuration_mapper import ( build_core_configuration_from_run_config, ) +from core_runtime.backtest.strategy_api import StrategyConfig def load_config(path: str) -> HftBacktestConfig: @@ -38,7 +38,7 @@ def load_config(path: str) -> HftBacktestConfig: ) from exc engine_cfg = HftEngineConfig(**engine_raw) - strategy_cfg = StrategyConfig(**strategy_raw) + strategy_cfg = StrategyConfig.from_mapping(strategy_raw) risk_cfg = RiskConfig(**risk_raw) core_cfg = build_core_configuration_from_run_config(raw_json) diff --git a/core_runtime/strategies/debug_strategy.py b/core_runtime/strategies/debug_strategy.py index 42486d1..226b453 100644 --- a/core_runtime/strategies/debug_strategy.py +++ b/core_runtime/strategies/debug_strategy.py @@ -3,13 +3,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from tradingchassis_core import ( - EngineContext, - GateDecision, - MarketEvent, - RiskConstraints, - StrategyState, - ) + from tradingchassis_core import MarketEvent, RiskConstraints, StrategyState from tradingchassis_core import ( NewOrderIntent, @@ -18,10 +12,11 @@ Quantity, ReplaceOrderIntent, SlotKey, - Strategy, stable_slot_order_id, ) +from core_runtime.backtest.strategy_api import Strategy + _SLOT_NAMESPACE = "debug_strategy_v1" @@ -52,7 +47,7 @@ def on_feed( self, state: StrategyState, event: MarketEvent, - engine_cfg: EngineContext, + engine_cfg: object, constraints: RiskConstraints, ) -> list[OrderIntent]: """Feed-triggered logic (rc=2). Inputs are read-only for Strategy, otherwise considered a bug.""" @@ -84,7 +79,11 @@ def on_feed( instrument = str(event.instrument) def is_slot_busy(client_order_id: str) -> bool: - return state.is_order_id_busy(instrument, client_order_id) + return ( + state.has_working_order(instrument, client_order_id) + or state.has_inflight(instrument, client_order_id) + or state.has_queued_intent(instrument, client_order_id) + ) def bid_price_for_level(level_index: int) -> float: if level_index < len(event.book.bids): @@ -174,11 +173,12 @@ def ask_price_for_level(level_index: int) -> float: def on_order_update( self, state: StrategyState, - engine_cfg: EngineContext, + engine_cfg: object, constraints: RiskConstraints, ) -> list[OrderIntent]: """Order-update-triggered logic (rc=3). Inputs are read-only for Strategy, otherwise considered a bug.""" return [] - def on_risk_decision(self, decision: GateDecision) -> None: - self.intents_after_risk = decision.accepted_now + def on_risk_decision(self, decision: object) -> None: + accepted_now = getattr(decision, "accepted_now", []) + self.intents_after_risk = list(accepted_now) diff --git a/docs/venue-adapter-abstraction-design-v1.md b/docs/venue-adapter-abstraction-design-v1.md index e43d623..e8a2b49 100644 --- a/docs/venue-adapter-abstraction-design-v1.md +++ b/docs/venue-adapter-abstraction-design-v1.md @@ -13,8 +13,7 @@ This is a docs-only slice: - it does not modify production code or tests; - it does not change runtime behavior; - it does not implement canonical `FillEvent` ingress; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; +- it does not expand canonical account feedback beyond current `OrderExecutionFeedbackEvent`; - it does not change snapshot ingestion behavior; - it does not change reducers or event taxonomy; - it does not implement `ProcessingContext`; @@ -80,8 +79,8 @@ they do not define production protocol signatures yet. | `VenueClock` (runtime clock boundary view) | provide adopted venue-local timestamp axis used by runtime timestamp update | runtime/internal only | mapped by `current_timestamp_ns()` wrapper | may expose richer venue receipt/event-time metadata while runtime keeps canonical ordering by `ProcessingPosition` | clock/timestamp must not be treated as `ProcessingOrder` authority | | `MarketInputSource` | provide market snapshots/deltas for canonical market mapping | canonical event capable | `read_market_snapshot()` mapped to canonical `MarketEvent` in runner | live adapters may map native book/trade feeds into canonical market events under runtime mapping | no hidden mutable snapshot promotion to canonical semantics outside boundary mapping | | `OrderSubmissionGateway` | submit/modify/cancel outbound intents and expose dispatch result boundary | canonical event capable (submission boundary), plus runtime/internal transport | `apply_intents(...)`; successful `new` dispatch leads to canonical `OrderSubmittedEvent` | live adapters may provide richer dispatch metadata while preserving current canonical submission boundary semantics | no post-submission execution authority from synchronous return codes | -| `OrderSnapshotSource` | provide order snapshots for compatibility lifecycle materialization | compatibility projection only | `read_orders_snapshot()` -> `ingest_order_snapshots()` -> `OrderStateEvent` path | may remain compatibility sidecar where canonical execution feedback is unavailable | no `OrderStateEvent` canonicalization; no snapshot-to-canonical promotion | -| `AccountSnapshotSource` | provide account snapshots for runtime/account views and compatibility projections | compatibility projection only / runtime/internal only | `state_values` adoption into `update_account(...)` | live adapters may offer richer account views without canonical authority by default | no implicit canonical account event expansion in this slice | +| `OrderSnapshotSource` | provide raw order snapshots for runtime-local bookkeeping only | runtime/internal only | `read_orders_snapshot()` -> runtime-side bookkeeping (no Core snapshot reducer input) | may remain runtime sidecar where canonical execution feedback is unavailable | no snapshot row payload promotion into Core | +| `AccountSnapshotSource` | provide account snapshots for canonical execution feedback mapping | canonical feedback input + runtime/internal support | `state_values` -> canonical `OrderExecutionFeedbackEvent` | live adapters may offer richer account views without changing Core boundaries | no implicit expansion beyond current canonical feedback event schema | | `ExecutionFeedbackRecordSource` | provide authoritative execution-feedback records for future canonical `FillEvent` mapping | optional future capability (canonical only after REFC/RAEFSC gates) | unsupported/ineligible today for hftbacktest integration | live adapters may satisfy this with native execution reports and deterministic source sequencing | no `FillEvent` ingress implementation here; no synthetic required-field authority | --- @@ -93,14 +92,13 @@ they do not define production protocol signatures yet. - `MarketInputSource`: supported; canonical `MarketEvent` mapping path exists. - `OrderSubmissionGateway`: supported for successful `new` dispatch boundary via canonical `OrderSubmittedEvent` path. -- `OrderSnapshotSource`: supported; remains compatibility-only. -- `AccountSnapshotSource`: supported for compatibility/runtime-internal account - snapshot adoption. +- `OrderSnapshotSource`: supported; remains runtime-internal bookkeeping only. +- `AccountSnapshotSource`: supported for canonical account-level feedback mapping. - `VenueEventWaiter` + `VenueClock`: supported through existing wrappers. - `ExecutionFeedbackRecordSource`: unsupported/ineligible today. -`VADN-07` - Compatibility authority remains frozen for post-submission lifecycle -progression (`OrderStateEvent` / `DerivedFillEvent` path unchanged). +`VADN-07` - Runtime keeps post-submission snapshot handling local; Core receives +only canonical account-level execution feedback. --- @@ -162,9 +160,9 @@ decided at runtime boundary mapping under existing contracts. `VADN-21` - No canonical `FillEvent` ingress implementation. -`VADN-22` - No `OrderStateEvent` canonicalization. +`VADN-22` - No snapshot row canonicalization into Core event inputs. -`VADN-23` - No `DerivedFillEvent` behavior change. +`VADN-23` - No canonical feedback schema expansion in this slice. `VADN-24` - No snapshot lifecycle rewrite. diff --git a/tests/runtime/test_debug_strategy_state_api_compat.py b/tests/runtime/test_debug_strategy_state_api_compat.py new file mode 100644 index 0000000..c057322 --- /dev/null +++ b/tests/runtime/test_debug_strategy_state_api_compat.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import ( + BookLevel, + BookPayload, + MarketEvent, + Price, + Quantity, + RiskConstraints, +) + +from core_runtime.strategies.debug_strategy import DebugStrategyV1 + + +@dataclass +class _StateStub: + busy: bool = False + + def has_working_order(self, instrument: str, client_order_id: str) -> bool: + _ = (instrument, client_order_id) + return self.busy + + def has_inflight(self, instrument: str, client_order_id: str) -> bool: + _ = (instrument, client_order_id) + return self.busy + + def has_queued_intent(self, instrument: str, client_order_id: str) -> bool: + _ = (instrument, client_order_id) + return self.busy + + +@dataclass(frozen=True) +class _EngineCfgStub: + tick_size: float = 0.1 + + +def test_debug_strategy_uses_clean_state_busy_checks_without_legacy_method() -> None: + strategy = DebugStrategyV1( + spread=5.0, + order_qty=0.1, + use_price_tick_levels=1, + post_only=True, + ) + state = _StateStub(busy=False) + event = MarketEvent( + ts_ns_exch=1, + ts_ns_local=1, + instrument="BTC_USDC-PERPETUAL", + event_type="book", + book=BookPayload( + book_type="snapshot", + bids=( + BookLevel( + price=Price(currency="UNKNOWN", value=100.0), + quantity=Quantity(value=1.0, unit="contracts"), + ), + ), + asks=( + BookLevel( + price=Price(currency="UNKNOWN", value=101.0), + quantity=Quantity(value=1.0, unit="contracts"), + ), + ), + depth=1, + ), + ) + constraints = RiskConstraints( + ts_ns_local=1, + scope="test", + trading_enabled=True, + ) + + intents = strategy.on_feed( + state=state, # type: ignore[arg-type] + event=event, + engine_cfg=_EngineCfgStub(), + constraints=constraints, + ) + + assert len(intents) == 2 + assert all(intent.intent_type == "new" for intent in intents) diff --git a/tests/runtime/test_hftbacktest_venue_adapter_recording.py b/tests/runtime/test_hftbacktest_venue_adapter_recording.py new file mode 100644 index 0000000..84deaa3 --- /dev/null +++ b/tests/runtime/test_hftbacktest_venue_adapter_recording.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from core_runtime.backtest.adapters.venue import HftBacktestVenueAdapter + + +def test_record_swallows_expected_recorder_index_error() -> None: + calls: list[object] = [] + + def _record(hbt: object) -> None: + calls.append(hbt) + raise IndexError + + hbt = object() + recorder = SimpleNamespace(recorder=SimpleNamespace(record=_record)) + venue = HftBacktestVenueAdapter(hbt=hbt, asset_no=0) # type: ignore[arg-type] + + venue.record(recorder) + + assert calls == [hbt] + + +def test_record_propagates_unexpected_recorder_exceptions() -> None: + def _record(_hbt: object) -> None: + raise ValueError("unexpected recorder failure") + + recorder = SimpleNamespace(recorder=SimpleNamespace(record=_record)) + venue = HftBacktestVenueAdapter(hbt=object(), asset_no=0) # type: ignore[arg-type] + + with pytest.raises(ValueError, match="unexpected recorder failure"): + venue.record(recorder) diff --git a/tests/runtime/test_strategy_runner_canonical_market_adoption.py b/tests/runtime/test_strategy_runner_canonical_market_adoption.py index 5c45a16..3a90785 100644 --- a/tests/runtime/test_strategy_runner_canonical_market_adoption.py +++ b/tests/runtime/test_strategy_runner_canonical_market_adoption.py @@ -1,109 +1,23 @@ from __future__ import annotations -from collections import deque +import inspect from types import SimpleNamespace from typing import Any import pytest from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing_step import ( - ControlTimeQueueReevaluationContext, - CoreExecutionControlApplyContext, - CorePolicyAdmissionContext, -) -from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( - BookLevel, - BookPayload, - CancelOrderIntent, ControlTimeEvent, - FillEvent, - MarketEvent, NewOrderIntent, - OrderExecutionFeedbackEvent, - OrderSubmittedEvent, Price, Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.execution_control.types import ( - ControlSchedulingObligation, ) +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import GateDecision -from tradingchassis_core.strategies.base import Strategy import core_runtime.backtest.engine.strategy_runner as strategy_runner_module -from core_runtime.backtest.engine.event_stream_cursor import EventStreamCursor -from core_runtime.backtest.engine.hft_engine import HftEngineConfig -from core_runtime.backtest.engine.strategy_runner import ( - MAX_TIMEOUT_NS, - HftStrategyRunner, -) - - -class _NoopStrategy(Strategy): - def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, event, engine_cfg, constraints) - return [] - - def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, engine_cfg, constraints) - return [] - - def on_risk_decision(self, decision: Any) -> None: - _ = decision - - -class _NoopExecution: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [] - - -class _RecorderWrapper: - recorder: Any - - def __init__(self) -> None: - self.recorder = SimpleNamespace(record=lambda _hbt: None) - - -class _StubVenue: - def __init__( - self, - *, - rc_sequence: list[int], - ts_sequence: list[int], - depth: object | None = None, - state_values: object | None = None, - orders: object | None = None, - ) -> None: - self._rc = list(rc_sequence) - self._ts = list(ts_sequence) - self._depth = depth - self._state_values = state_values - self._orders = orders - self._current_ts = 0 - self.wait_calls: list[tuple[int, bool]] = [] - - def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: - self.wait_calls.append((timeout_ns, include_order_resp)) - self._current_ts = self._ts.pop(0) - return self._rc.pop(0) - - def current_timestamp_ns(self) -> int: - return self._current_ts - - def read_market_snapshot(self) -> object: - return self._depth - - def read_orders_snapshot(self) -> tuple[object, object]: - return self._state_values, self._orders - - def record(self, recorder: Any) -> None: - recorder.recorder.record(self) +from core_runtime.backtest.engine.strategy_runner import HftStrategyRunner def _core_cfg() -> CoreConfiguration: @@ -123,3948 +37,504 @@ def _core_cfg() -> CoreConfiguration: ) -def _engine_cfg() -> HftEngineConfig: - return HftEngineConfig( - initial_snapshot=None, - data_files=[], - instrument="BTC_USDC-PERPETUAL", - tick_size=0.1, - lot_size=0.01, - contract_size=1.0, - maker_fee_rate=0.0, - taker_fee_rate=0.0, - entry_latency_ns=0, - response_latency_ns=0, - use_risk_adverse_queue_model=False, - partial_fill_venue=False, - max_steps=1, - last_trades_capacity=1, - max_price_tick_levels=1, - roi_lb=0, - roi_ub=1, - stats_npz_path="/tmp/stats.npz", - event_bus_path="/tmp/events.jsonl", - ) - - def _risk_cfg() -> RiskConfig: return RiskConfig( - scope="test", - notional_limits={"currency": "USDC", "max_gross_notional": 1.0}, - ) - - -def _risk_cfg_with_rate_limits( - *, - max_orders_per_second: float, - max_cancels_per_second: float, -) -> RiskConfig: - return RiskConfig( - scope="test", - notional_limits={"currency": "USDC", "max_gross_notional": 1.0}, + scope="runtime-test", + notional_limits={ + "currency": "USDC", + "max_gross_notional": 10_000.0, + "max_single_order_notional": 1_000.0, + }, order_rate_limits={ - "max_orders_per_second": max_orders_per_second, - "max_cancels_per_second": max_cancels_per_second, + "max_orders_per_second": 10.0, + "max_cancels_per_second": 10.0, }, ) -def _market_event(ts_ns: int) -> MarketEvent: - return MarketEvent( - ts_ns_exch=ts_ns, - ts_ns_local=ts_ns, - instrument="BTC_USDC-PERPETUAL", - event_type="book", - book=BookPayload( - book_type="snapshot", - bids=[ - BookLevel( - price=Price(currency="UNKNOWN", value=100.0), - quantity=Quantity(value=1.0, unit="contracts"), - ) - ], - asks=[ - BookLevel( - price=Price(currency="UNKNOWN", value=101.0), - quantity=Quantity(value=1.0, unit="contracts"), - ) - ], - depth=1, - ), - ) - - -def _depth_snapshot() -> object: +def _engine_cfg() -> Any: return SimpleNamespace( - roi_lb_tick=100, + instrument="BTC_USDC-PERPETUAL", + max_price_tick_levels=2, + event_bus_path="/tmp/runtime-events.jsonl", tick_size=0.1, - best_ask_tick=101, - best_bid_tick=100, - ask_depth=[1.0, 0.0], - bid_depth=[1.0, 0.0], - best_bid=100.0, - best_ask=101.0, - best_bid_qty=1.0, - best_ask_qty=1.0, ) -def _new_intent(ts_ns_local: int = 2) -> NewOrderIntent: +def _new_intent(*, ts_ns_local: int, client_order_id: str) -> NewOrderIntent: return NewOrderIntent( ts_ns_local=ts_ns_local, instrument="BTC_USDC-PERPETUAL", - client_order_id="cid-new-1", - intents_correlation_id="corr-new-1", - side="buy", + client_order_id=client_order_id, + intent_type="new", order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _replace_intent(ts_ns_local: int = 2) -> ReplaceOrderIntent: - return ReplaceOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC_USDC-PERPETUAL", - client_order_id="cid-existing-1", - intents_correlation_id="corr-replace-1", side="buy", - order_type="limit", - intended_qty=Quantity(value=2.0, unit="contracts"), - intended_price=Price(currency="USDC", value=101.0), - ) - - -def _cancel_intent(ts_ns_local: int = 2) -> CancelOrderIntent: - return CancelOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC_USDC-PERPETUAL", - client_order_id="cid-existing-1", - intents_correlation_id="corr-cancel-1", + intended_price=Price(currency="UNKNOWN", value=100.0), + intended_qty=Quantity(value=0.1, unit="contracts"), + time_in_force="GTC", ) -class _EmitIntentsStrategy(Strategy): - def __init__(self, intents: list[object]) -> None: - self._intents = intents +class _Strategy: + def __init__(self) -> None: + self.on_feed_calls = 0 + self.on_order_update_calls = 0 def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: _ = (state, event, engine_cfg, constraints) - return list(self._intents) + self.on_feed_calls += 1 + return [] def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: _ = (state, engine_cfg, constraints) + self.on_order_update_calls += 1 return [] - def on_risk_decision(self, decision: Any) -> None: - _ = decision - - -def _decision_for( - accepted_now: list[Any], - *, - next_send_ts_ns_local: int | None = None, - control_scheduling_obligations: tuple[ControlSchedulingObligation, ...] = (), -) -> GateDecision: - return GateDecision( - ts_ns_local=2, - accepted_now=accepted_now, - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=next_send_ts_ns_local, - control_scheduling_obligations=control_scheduling_obligations, - ) - - -def _obligation( - *, - due_ts_ns_local: int, - obligation_key: str, - reason: str = "rate_limit", -) -> ControlSchedulingObligation: - return ControlSchedulingObligation( - due_ts_ns_local=due_ts_ns_local, - reason=reason, - scope_key="instrument:BTC_USDC-PERPETUAL", - source="execution_control_rate_limit", - obligation_key=obligation_key, - ) - - -def _runner_for_scheduling_helpers() -> HftStrategyRunner: - runner = object.__new__(HftStrategyRunner) - runner._pending_control_scheduling_obligation = None - runner._next_send_ts_ns_local = None - runner._last_injected_control_deadline_ns = None - return runner - - -def test_select_effective_control_scheduling_obligation_collapses_deterministically() -> None: - decision = _decision_for( - [], - control_scheduling_obligations=( - _obligation(due_ts_ns_local=7, obligation_key="z-key"), - _obligation(due_ts_ns_local=5, obligation_key="z-key"), - _obligation(due_ts_ns_local=5, obligation_key="a-key"), - ), - ) - - selected = HftStrategyRunner._select_effective_control_scheduling_obligation(decision) - - assert selected is not None - assert selected.due_ts_ns_local == 5 - assert selected.obligation_key == "a-key" - -def test_apply_control_scheduling_decision_sets_pending_and_mirror() -> None: - runner = _runner_for_scheduling_helpers() - obligation = _obligation(due_ts_ns_local=5, obligation_key="k1") - decision = _decision_for([], control_scheduling_obligations=(obligation,)) - - runner._apply_control_scheduling_decision(decision) - - assert runner._pending_control_scheduling_obligation == obligation - assert runner._next_send_ts_ns_local == 5 - - -def test_apply_control_scheduling_decision_replaces_pending_same_due_different_key() -> None: - runner = _runner_for_scheduling_helpers() - obligation_a = _obligation(due_ts_ns_local=5, obligation_key="a-key") - obligation_b = _obligation(due_ts_ns_local=5, obligation_key="b-key") - - runner._apply_control_scheduling_decision( - _decision_for([], control_scheduling_obligations=(obligation_a,)) - ) - runner._apply_control_scheduling_decision( - _decision_for([], control_scheduling_obligations=(obligation_b,)) - ) - - assert runner._pending_control_scheduling_obligation == obligation_b - assert runner._next_send_ts_ns_local == 5 - - -def test_apply_control_scheduling_decision_clears_pending_when_no_obligation() -> None: - runner = _runner_for_scheduling_helpers() - obligation = _obligation(due_ts_ns_local=5, obligation_key="k1") - runner._apply_control_scheduling_decision( - _decision_for([], control_scheduling_obligations=(obligation,)) - ) - - runner._apply_control_scheduling_decision(_decision_for([])) +class _Execution: + def __init__(self) -> None: + self.applied: list[list[Any]] = [] - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None + def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: + self.applied.append(list(intents)) + return [] -def test_apply_control_scheduling_decision_scalar_fallback_without_structured_obligation() -> None: - runner = _runner_for_scheduling_helpers() - decision = _decision_for([], next_send_ts_ns_local=12) +class _Recorder: + def __init__(self) -> None: + self.recorder = SimpleNamespace(record=lambda _hbt: None) - runner._apply_control_scheduling_decision(decision) - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local == 12 +class _Venue: + def __init__(self, *, rc_sequence: list[int], ts_sequence: list[int]) -> None: + self._rc = list(rc_sequence) + self._ts = list(ts_sequence) + self._now = 0 + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = (timeout_ns, include_order_resp) + self._now = self._ts.pop(0) + return self._rc.pop(0) -def test_wakeup_collapse_flag_defaults_to_false() -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - assert runner._enable_core_step_wakeup_collapse is False - - -def test_wakeup_collapse_flag_requires_market_core_step_flag() -> None: - with pytest.raises( - ValueError, - match=( - "enable_core_step_wakeup_collapse=True requires " - "enable_core_step_market_dispatch=True" - ), - ): - HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=False, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, + def current_timestamp_ns(self) -> int: + return self._now + + def read_market_snapshot(self) -> Any: + return SimpleNamespace( + roi_lb_tick=1000, + tick_size=0.1, + best_bid_tick=1005, + best_ask_tick=1006, + bid_depth=[1.0, 0.9, 0.8], + ask_depth=[1.1, 1.2, 1.3], ) - -def test_wakeup_collapse_flag_requires_control_core_step_flag() -> None: - with pytest.raises( - ValueError, - match=( - "enable_core_step_wakeup_collapse=True requires " - "enable_core_step_control_time_dispatch=True" - ), - ): - HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=False, - enable_core_step_wakeup_collapse=True, + def read_orders_snapshot(self) -> tuple[Any, Any]: + return ( + SimpleNamespace( + position=1.0, + balance=1000.0, + fee=1.5, + trading_volume=100.0, + trading_value=5000.0, + num_trades=3, + ), + SimpleNamespace(values=lambda: []), ) + def record(self, recorder: Any) -> None: + recorder.recorder.record(self) + -def test_wakeup_collapse_flag_accepts_when_both_core_step_flags_enabled() -> None: - runner = HftStrategyRunner( +def _runner(**kwargs: Any) -> HftStrategyRunner: + return HftStrategyRunner( engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), + strategy=_Strategy(), risk_cfg=_risk_cfg(), core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - assert runner._enable_core_step_wakeup_collapse is True - - -def test_process_market_event_routes_through_event_entry_with_core_configuration( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = object.__new__(HftStrategyRunner) - runner.strategy_state = object() - runner.strategy = _NoopStrategy() - runner.engine_cfg = _engine_cfg() - runner._core_cfg = _core_cfg() - runner._event_stream_cursor = EventStreamCursor() - - captured: list[tuple[int, object, object | None]] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = state - _ = core_decision_context - assert policy_admission_context is None - assert execution_control_apply_context is None - captured.append((entry.position.index, configuration, strategy_evaluator)) - assert control_time_queue_context is None - return CoreStepResult() - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - _spy_run_core_step, - ) - - runner._process_canonical_market_event(_market_event(1), constraints=SimpleNamespace()) - runner._process_canonical_market_event(_market_event(2), constraints=SimpleNamespace()) - - assert [idx for idx, _, _ in captured] == [0, 1] - assert captured[0][1] is runner._core_cfg - assert captured[1][1] is runner._core_cfg - assert captured[0][2] is not None - assert captured[1][2] is not None - assert runner._event_stream_cursor.next_index == 2 - - -def test_first_canonical_event_uses_processing_position_zero( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = object.__new__(HftStrategyRunner) - runner.strategy_state = object() - runner.strategy = _NoopStrategy() - runner.engine_cfg = _engine_cfg() - runner._core_cfg = _core_cfg() - runner._event_stream_cursor = EventStreamCursor() - - captured: list[int] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = state - _ = (control_time_queue_context, core_decision_context, strategy_evaluator) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert configuration is runner._core_cfg - captured.append(entry.position.index) - return CoreStepResult() - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - _spy_run_core_step, + **kwargs, ) - runner._process_canonical_market_event(_market_event(1), constraints=SimpleNamespace()) - assert captured == [0] - assert runner._event_stream_cursor.next_index == 1 +def test_market_path_dispatches_only_from_core_step_result(monkeypatch: Any) -> None: + runner = _runner() + execution = _Execution() + venue = _Venue(rc_sequence=[0, 2, 1], ts_sequence=[1, 2, 3]) + recorder = _Recorder() + captured_kwargs: list[dict[str, Any]] = [] -def test_market_branch_calls_canonical_boundary_not_update_market( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.strategy_state, - "update_market", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("update_market must not be called")), - ) - monkeypatch.setattr( - runner.strategy_state, - "apply_fill_event", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("apply_fill_event must not be called")), - ) + def _stub_run_core_step(state: Any, entry: Any, **kwargs: Any) -> CoreStepResult: + _ = (state, entry) + captured_kwargs.append(kwargs) + return CoreStepResult( + generated_intents=(_new_intent(ts_ns_local=2, client_order_id="generated"),), + dispatchable_intents=(_new_intent(ts_ns_local=2, client_order_id="dispatch"),), + ) - captured: list[tuple[int, object, str]] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = state - _ = (control_time_queue_context, core_decision_context) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert strategy_evaluator is not None - captured.append((entry.position.index, configuration, type(entry.event).__name__)) - return CoreStepResult() + monkeypatch.setattr(strategy_runner_module, "run_core_step", _stub_run_core_step) - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - _spy_run_core_step, - ) + def _fail_decide_intents(**_: Any) -> Any: + raise AssertionError("runtime must not call risk.decide_intents") - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + monkeypatch.setattr(runner.risk, "decide_intents", _fail_decide_intents, raising=False) - assert captured == [(0, runner._core_cfg, "MarketEvent")] + runner.run(venue, execution, recorder) + assert len(execution.applied) == 1 + assert [it.client_order_id for it in execution.applied[0]] == ["dispatch"] + assert "policy_admission_context" in captured_kwargs[0] + assert "execution_control_apply_context" in captured_kwargs[0] -def test_wait_next_bootstrap_uses_include_order_resp_false_then_true_in_loop() -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), +def test_control_time_path_uses_core_step_dispatchable_intents(monkeypatch: Any) -> None: + runner = _runner() + execution = _Execution() + venue = _Venue(rc_sequence=[0, 0, 1], ts_sequence=[1, 2, 3]) + recorder = _Recorder() + runner._pending_control_scheduling_obligation = ControlSchedulingObligation( + due_ts_ns_local=2, + reason="rate_limit", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="test", ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + runner._next_send_ts_ns_local = 2 - assert len(venue.wait_calls) >= 2 - first_timeout_ns, first_include_order_resp = venue.wait_calls[0] - assert first_timeout_ns == MAX_TIMEOUT_NS - assert first_include_order_resp is False - assert all(include_order_resp is True for _, include_order_resp in venue.wait_calls[1:]) - - -def test_market_mapping_from_depth_snapshot_is_deterministic_golden( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) + captured: list[tuple[Any, dict[str, Any]]] = [] - captured_market_events: list[MarketEvent] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - _ = (control_time_queue_context, core_decision_context, strategy_evaluator) - assert policy_admission_context is None - assert execution_control_apply_context is None - if isinstance(entry.event, MarketEvent): - captured_market_events.append(entry.event) + def _stub_run_core_step(state: Any, entry: Any, **kwargs: Any) -> CoreStepResult: + _ = state + captured.append((entry.event, kwargs)) + if isinstance(entry.event, ControlTimeEvent): + return CoreStepResult( + dispatchable_intents=(_new_intent(ts_ns_local=2, client_order_id="ctl"),) + ) return CoreStepResult() - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - _spy_run_core_step, - ) + monkeypatch.setattr(strategy_runner_module, "run_core_step", _stub_run_core_step) + runner.run(venue, execution, recorder) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2_000_000_000, 2_000_000_001], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(captured_market_events) == 1 - market_event = captured_market_events[0] - assert market_event.instrument == "BTC_USDC-PERPETUAL" - assert market_event.ts_ns_local == 2_000_000_000 - assert market_event.ts_ns_exch == 2_000_000_000 - assert market_event.book is not None - assert market_event.book.bids[0].price.value == 10.0 - assert market_event.book.asks[0].price.value == 10.100000000000001 - assert market_event.book.bids[0].quantity.value == 1.0 - assert market_event.book.asks[0].quantity.value == 0.0 - - -def test_market_branch_strategy_evaluator_preserves_legacy_on_feed_arguments( - monkeypatch: pytest.MonkeyPatch, -) -> None: - generated_intent = _new_intent(ts_ns_local=2_000_000_000) - constraints_obj = SimpleNamespace(tag="constraints") - on_feed_calls: list[tuple[object, object, object, object]] = [] - risk_calls: list[list[object]] = [] - - class _SpyStrategy(Strategy): - def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - on_feed_calls.append((state, event, engine_cfg, constraints)) - return [generated_intent] - - def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, engine_cfg, constraints) - return [] - - def on_risk_decision(self, decision: Any) -> None: - _ = decision - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_SpyStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), + assert [it.client_order_id for it in execution.applied[0]] == ["ctl"] + assert runner._pending_control_scheduling_obligation is None + control_events = [event for event, _ in captured if isinstance(event, ControlTimeEvent)] + assert len(control_events) == 1 + control_kwargs = [kwargs for event, kwargs in captured if isinstance(event, ControlTimeEvent)][0] + assert "control_time_queue_context" not in control_kwargs + + +def test_mixed_wakeup_path_uses_new_wakeup_evaluator_api(monkeypatch: Any) -> None: + runner = _runner(enable_core_step_wakeup_collapse=True) + execution = _Execution() + venue = _Venue(rc_sequence=[0, 2, 1], ts_sequence=[1, 2, 3]) + recorder = _Recorder() + runner._pending_control_scheduling_obligation = ControlSchedulingObligation( + due_ts_ns_local=2, + reason="rate_limit", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="test", ) - monkeypatch.setattr(runner.risk, "build_constraints", lambda _ts: constraints_obj) - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = core_decision_context - assert control_time_queue_context is None - assert policy_admission_context is None - assert execution_control_apply_context is None - assert strategy_evaluator is not None - evaluated = strategy_evaluator.evaluate( - SimpleNamespace( - state=state, - event=entry.event, - position=entry.position, - configuration=configuration, - ) - ) - return CoreStepResult(generated_intents=tuple(evaluated)) - - def _spy_decide_intents(**kwargs: object) -> GateDecision: - risk_calls.append(list(kwargs["raw_intents"])) - return _decision_for([]) + runner._next_send_ts_ns_local = 2 - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) + observed: dict[str, Any] = {} - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2_000_000_000, 2_000_000_001], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(on_feed_calls) == 1 - state_arg, event_arg, engine_cfg_arg, constraints_arg = on_feed_calls[0] - assert state_arg is runner.strategy_state - assert isinstance(event_arg, MarketEvent) - assert engine_cfg_arg is runner.engine_cfg - assert constraints_arg is constraints_obj - assert len(risk_calls) == 1 - assert risk_calls[0] == [generated_intent] - - -def test_market_core_step_mode_calls_run_core_step_with_policy_and_apply_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg_with_rate_limits( - max_orders_per_second=7.0, - max_cancels_per_second=3.0, - ), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) + def _stub_run_core_wakeup_step(state: Any, entries: Any, **kwargs: Any) -> CoreStepResult: + observed["entry_count"] = len(entries) + observed["kwargs"] = kwargs + evaluator = kwargs["wakeup_strategy_evaluator"] + if evaluator is not None: + evaluator.evaluate(SimpleNamespace(state=state, entries=entries)) + return CoreStepResult() - captured: list[tuple[object, object, object, object]] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = core_decision_context - assert control_time_queue_context is None - assert strategy_evaluator is not None - captured.append( - ( - state, - configuration, - policy_admission_context, - execution_control_apply_context, - ) - ) - return CoreStepResult(generated_intents=(_new_intent(),)) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) + monkeypatch.setattr(strategy_runner_module, "run_core_wakeup_step", _stub_run_core_wakeup_step) + runner.run(venue, execution, recorder) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2_000_000_000, 2_000_000_001], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(captured) == 1 - state, configuration, policy_ctx, apply_ctx = captured[0] - assert state is runner.strategy_state - assert configuration is runner._core_cfg - assert isinstance(policy_ctx, CorePolicyAdmissionContext) - assert policy_ctx.policy_evaluator is runner.risk - assert policy_ctx.now_ts_ns_local == 2_000_000_000 - assert isinstance(apply_ctx, CoreExecutionControlApplyContext) - assert apply_ctx.execution_control is runner.risk.execution_control - assert apply_ctx.now_ts_ns_local == 2_000_000_000 - assert apply_ctx.max_orders_per_sec == 7.0 - assert apply_ctx.max_cancels_per_sec == 3.0 - assert apply_ctx.activate_dispatchable_outputs is True - - -def test_market_core_step_mode_dispatches_core_step_dispatchable_intents_only( - monkeypatch: pytest.MonkeyPatch, -) -> None: - generated_intent = _new_intent(ts_ns_local=2) - dispatchable_intent = _new_intent(ts_ns_local=2).model_copy( - update={"client_order_id": "cid-dispatchable-only"} - ) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([generated_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) + assert observed["entry_count"] == 2 # market + injected control-time + assert "wakeup_strategy_evaluator" in observed["kwargs"] + assert "strategy_event_filter" not in observed["kwargs"] + assert observed["kwargs"]["queued_instrument"] == "BTC_USDC-PERPETUAL" + assert runner.strategy.on_feed_calls == 1 - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = ( - state, - entry, - configuration, - control_time_queue_context, - policy_admission_context, - execution_control_apply_context, - core_decision_context, - strategy_evaluator, - ) - return CoreStepResult( - generated_intents=(generated_intent,), - dispatchable_intents=(dispatchable_intent,), - ) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) +def test_rc3_feedback_event_uses_account_level_shape(monkeypatch: Any) -> None: + runner = _runner() + execution = _Execution() + venue = _Venue(rc_sequence=[0, 3, 1], ts_sequence=[1, 2, 3]) + recorder = _Recorder() - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) + seen_event_payloads: list[dict[str, Any]] = [] - assert execution.batches == [[dispatchable_intent.client_order_id]] + def _stub_run_core_step(state: Any, entry: Any, **kwargs: Any) -> CoreStepResult: + _ = (state, kwargs) + if hasattr(entry.event, "ts_ns_local_feedback"): + seen_event_payloads.append(entry.event.model_dump()) + return CoreStepResult() + monkeypatch.setattr(strategy_runner_module, "run_core_step", _stub_run_core_step) + runner.run(venue, execution, recorder) -def test_market_core_step_mode_applies_obligation_and_clears_when_none( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) - seeded = _obligation(due_ts_ns_local=9_999_999_999, obligation_key="seeded") - runner._pending_control_scheduling_obligation = seeded - runner._next_send_ts_ns_local = seeded.due_ts_ns_local - obligation = _obligation(due_ts_ns_local=25, obligation_key="core-step-obligation") - - results = [ - CoreStepResult(control_scheduling_obligation=obligation), - CoreStepResult(control_scheduling_obligation=None), - ] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = ( - state, - entry, - configuration, - control_time_queue_context, - policy_admission_context, - execution_control_apply_context, - core_decision_context, - strategy_evaluator, - ) - return results.pop(0) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) + assert len(seen_event_payloads) == 1 + assert "order_snapshots" not in seen_event_payloads[0] - venue = _StubVenue( - rc_sequence=[0, 2, 2, 1], - ts_sequence=[1, 2, 3, 4], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None +def test_order_submitted_event_emitted_before_mark_intent_sent(monkeypatch: Any) -> None: + runner = _runner() + execution = _Execution() + call_order: list[str] = [] + def _spy_process_submitted(intent: Any, *, ts_ns_local_dispatch: int) -> None: + _ = (intent, ts_ns_local_dispatch) + call_order.append("submitted") -def test_market_core_step_mode_preserves_order_submitted_before_mark_sent( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) - ordering: list[str] = [] - submitted_events: list[OrderSubmittedEvent] = [] - marks: list[tuple[str, str, str]] = [] - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) + def _spy_mark_sent(instrument: str, client_order_id: str, intent_type: str) -> None: + _ = (instrument, client_order_id, intent_type) + call_order.append("mark_sent") - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - ordering.append("submitted") - submitted_events.append(entry.event) - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - ordering.append("mark") - marks.append((instrument, client_order_id, intent_type)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - monkeypatch.setattr( - runner.strategy, - "on_risk_decision", - lambda _decision: (_ for _ in ()).throw( - AssertionError("market core-step mode must not synthesize GateDecision callbacks") - ), - ) + monkeypatch.setattr(runner, "_process_canonical_order_submitted_event", _spy_process_submitted) + monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_sent) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1_111, 5_000_000_000, 5_000_000_001], - depth=_depth_snapshot(), + runner._dispatch_accepted_intents( + [_new_intent(ts_ns_local=2, client_order_id="new-1")], + execution, + sim_now_ns=2, ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(submitted_events) == 1 - assert ordering == ["submitted", "mark"] - assert marks == [ - (dispatchable_new.instrument, dispatchable_new.client_order_id, "new") - ] - assert runner._last_core_step_execution_errors == [] + assert call_order == ["submitted"] -def test_market_core_step_mode_failed_new_dispatch_emits_no_order_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) - submitted_event_count = 0 - marked_count = 0 - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_event_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_event_count += 1 +def test_successful_new_dispatch_does_not_leave_stale_inflight() -> None: + runner = _runner() + execution = _Execution() - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - nonlocal marked_count - _ = (instrument, client_order_id, intent_type) - marked_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - monkeypatch.setattr( - runner.strategy, - "on_risk_decision", - lambda _decision: (_ for _ in ()).throw( - AssertionError("market core-step mode must not synthesize GateDecision callbacks") - ), + runner._dispatch_accepted_intents( + [_new_intent(ts_ns_local=2, client_order_id="new-1")], + execution, + sim_now_ns=2, ) - class _ExecutionFailNew: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [(dispatchable_new, "EXCHANGE_REJECT")] + assert runner.strategy_state.has_inflight("BTC_USDC-PERPETUAL", "new-1") is False - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[10, 20, 30], - depth=_depth_snapshot(), - ) - runner.run( - venue=venue, - execution=_ExecutionFailNew(), - recorder=_RecorderWrapper(), - ) - assert submitted_event_count == 0 - assert marked_count == 0 - assert len(runner._last_core_step_execution_errors) == 1 - failed_intent, failure_reason = runner._last_core_step_execution_errors[0] - assert failed_intent.client_order_id == dispatchable_new.client_order_id - assert failure_reason == "EXCHANGE_REJECT" +def test_runner_source_has_no_removed_compat_api_usage() -> None: + source = inspect.getsource(strategy_runner_module) + assert "decide_intents" not in source + assert "compat_gate_decision" not in source + assert "ControlTimeQueueReevaluationContext" not in source + assert "strategy_event_filter" not in source -def test_market_core_step_mode_replace_cancel_emit_no_order_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - replace_intent = _replace_intent() - cancel_intent = _cancel_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) - submitted_event_count = 0 - marks: list[tuple[str, str, str]] = [] - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(replace_intent, cancel_intent), - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("market core-step mode must not call runtime risk gate") - ), - ) +def test_stale_control_obligation_is_cleared_to_avoid_zero_timeout_spin() -> None: + runner = _runner() + execution = _Execution() + recorder = _Recorder() - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_event_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_event_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr( - runner.strategy_state, - "mark_intent_sent", - lambda i, c, t: marks.append((i, c, t)), + class _TimeoutSensitiveVenue: + def __init__(self) -> None: + self._now = 0 + self._call_count = 0 + + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = include_order_resp + self._call_count += 1 + if self._call_count == 1: + self._now = 1 + return 0 + if timeout_ns == 0: + if self._call_count > 20: + raise AssertionError("runner is spinning on zero-timeout wait_next") + return 0 + self._now = 2 + return 1 + + def current_timestamp_ns(self) -> int: + return self._now + + def read_market_snapshot(self) -> Any: + raise AssertionError("market snapshot should not be needed") + + def read_orders_snapshot(self) -> tuple[Any, Any]: + raise AssertionError("orders snapshot should not be needed") + + def record(self, recorder: Any) -> None: + recorder.recorder.record(self) + + venue = _TimeoutSensitiveVenue() + runner._pending_control_scheduling_obligation = ControlSchedulingObligation( + due_ts_ns_local=1, + reason="rate_limit", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="test", ) + runner._next_send_ts_ns_local = 1 + runner._last_injected_control_deadline_ns = 1 - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[100, 200, 300], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert submitted_event_count == 0 - assert marks == [ - ( - replace_intent.instrument, - replace_intent.client_order_id, - "replace", - ), - ( - cancel_intent.instrument, - cancel_intent.client_order_id, - "cancel", - ), - ] - assert runner._last_core_step_execution_errors == [] - - -def test_market_core_step_mode_failure_does_not_commit_or_dispatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) + runner.run(venue, execution, recorder) - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: (_ for _ in ()).throw( - RuntimeError("boom-market-core-step") - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("risk gate must not run after market core-step failure") - ), - ) + assert runner._pending_control_scheduling_obligation is None + assert runner._next_send_ts_ns_local is None - class _ExecutionMustNotRun: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - raise AssertionError("dispatch must not run after market core-step failure") - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), +def test_debug_max_iterations_guard_raises_with_loop_state(monkeypatch: Any) -> None: + runner = _runner() + execution = _Execution() + recorder = _Recorder() + runner._pending_control_scheduling_obligation = ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="test", ) - with pytest.raises(RuntimeError, match="boom-market-core-step"): - runner.run(venue=venue, execution=_ExecutionMustNotRun(), recorder=_RecorderWrapper()) - - assert runner._event_stream_cursor.next_index == 0 + runner._next_send_ts_ns_local = 1_000_000_000_000 + class _NeverEndingVenue: + def __init__(self) -> None: + self._now = 1 -def test_order_update_path_remains_legacy_when_market_core_step_mode_enabled() -> None: - order_update_intent = _new_intent(ts_ns_local=2) - risk_calls: list[list[object]] = [] + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = (timeout_ns, include_order_resp) + return 0 - class _OrderUpdateStrategy(Strategy): - def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, event, engine_cfg, constraints) - return [] + def current_timestamp_ns(self) -> int: + return self._now - def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, engine_cfg, constraints) - return [order_update_intent] + def read_market_snapshot(self) -> Any: + raise AssertionError("market snapshot should not be needed") - def on_risk_decision(self, decision: Any) -> None: - _ = decision + def read_orders_snapshot(self) -> tuple[Any, Any]: + raise AssertionError("orders snapshot should not be needed") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_OrderUpdateStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - ) + def record(self, recorder: Any) -> None: + recorder.recorder.record(self) - def _spy_decide_intents(**kwargs: object) -> GateDecision: - risk_calls.append(list(kwargs["raw_intents"])) - return _decision_for([]) - - runner.risk.decide_intents = _spy_decide_intents # type: ignore[method-assign] - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + monkeypatch.setenv("TRADINGCHASSIS_DEBUG_MAX_ITERATIONS", "5") - assert len(risk_calls) == 1 - assert risk_calls[0] == [order_update_intent] + with pytest.raises(RuntimeError, match="TRADINGCHASSIS_DEBUG_MAX_ITERATIONS exceeded"): + runner.run(_NeverEndingVenue(), execution, recorder) -def test_order_update_core_step_mode_does_not_execute_legacy_rc3_state_mutation_branch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Guardrail: rc3 core-step flag must not call legacy update_account/ingest_order_snapshots directly.""" - dispatchable = _cancel_intent(ts_ns_local=2) - legacy_calls: list[str] = [] +def test_runner_terminates_on_no_work_no_progress_loop() -> None: + runner = _runner() + execution = _Execution() + recorder = _Recorder() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) + class _NoProgressVenue: + def __init__(self) -> None: + self._now = 100 + self.wait_call_count = 0 + self.record_call_count = 0 - def _legacy_update_account(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - legacy_calls.append("update_account") + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = (timeout_ns, include_order_resp) + self.wait_call_count += 1 + if self.wait_call_count == 1: + self._now = 100 + return 0 + if self.wait_call_count > 3: + raise AssertionError("runner should have terminated no-work/no-progress loop") + return 0 - def _legacy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - legacy_calls.append("ingest_order_snapshots") + def current_timestamp_ns(self) -> int: + return self._now - monkeypatch.setattr(runner.strategy_state, "update_account", _legacy_update_account) - monkeypatch.setattr( - runner.strategy_state, - "ingest_order_snapshots", - _legacy_ingest_order_snapshots, - ) + def read_market_snapshot(self) -> Any: + raise AssertionError("market snapshot should not be needed") - def _spy_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: - # Important: this stub does not reduce the event, so any legacy-branch - # update_account/ingest_order_snapshots calls would be runtime-direct. - return CoreStepResult(dispatchable_intents=(dispatchable,)) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) + def read_orders_snapshot(self) -> tuple[Any, Any]: + raise AssertionError("orders snapshot should not be needed") - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + def record(self, _recorder: Any) -> bool: + self.record_call_count += 1 + return False - assert legacy_calls == [] + venue = _NoProgressVenue() + runner.run(venue, execution, recorder) + assert venue.wait_call_count == 2 # init wait + one loop iteration + assert venue.record_call_count == 0 -def test_order_update_core_step_mode_does_not_execute_legacy_finalize_gate_decision_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Guardrail: rc3 core-step flag must not reach _finalize_decision_effects via raw_intents gate.""" - dispatchable = _cancel_intent(ts_ns_local=2) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - monkeypatch.setattr( - runner, - "_finalize_decision_effects", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call legacy _finalize_decision_effects") - ), - ) - monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *a, **k: CoreStepResult(dispatchable_intents=(dispatchable,))) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) +def test_runner_terminates_on_no_work_with_non_event_rc() -> None: + runner = _runner() + execution = _Execution() + recorder = _Recorder() - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, + class _Rc14Venue: + def __init__(self) -> None: + self._now = 100 + self.wait_call_count = 0 + self.record_call_count = 0 + + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = (timeout_ns, include_order_resp) + self.wait_call_count += 1 + if self.wait_call_count == 1: + return 0 + if self.wait_call_count > 3: + raise AssertionError("runner should terminate on non-event rc with no work") + return 14 + + def current_timestamp_ns(self) -> int: + return self._now + + def read_market_snapshot(self) -> Any: + raise AssertionError("market snapshot should not be needed") + + def read_orders_snapshot(self) -> tuple[Any, Any]: + raise AssertionError("orders snapshot should not be needed") + + def record(self, _recorder: Any) -> bool: + self.record_call_count += 1 + return False + + venue = _Rc14Venue() + runner.run(venue, execution, recorder) + + assert venue.wait_call_count == 2 + assert venue.record_call_count == 0 + + +def test_runner_does_not_terminate_early_when_pending_work_exists() -> None: + runner = _runner() + execution = _Execution() + recorder = _Recorder() + runner._pending_control_scheduling_obligation = ControlSchedulingObligation( + due_ts_ns_local=200, + reason="rate_limit", + scope_key="instrument:BTC_USDC-PERPETUAL", + source="test", ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) + runner._next_send_ts_ns_local = 200 - -def test_order_update_core_step_mode_does_not_call_strategy_on_order_update_directly( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Guardrail: rc3 core-step flag must not call Strategy.on_order_update from legacy branch.""" - dispatchable = _cancel_intent(ts_ns_local=2) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - - # If the legacy rc3 else-branch executes, it will call strategy.on_order_update directly. - monkeypatch.setattr( - runner.strategy, - "on_order_update", - lambda *a, **k: (_ for _ in ()).throw( - AssertionError("legacy rc3 branch must not call strategy.on_order_update when flag is on") - ), - ) - - # Stub run_core_step so evaluator is not invoked (it would call on_order_update by design). - monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *a, **k: CoreStepResult(dispatchable_intents=(dispatchable,))) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - -def test_order_update_legacy_flag_off_still_calls_state_and_strategy_and_gate( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Regression: when flag is off, rc3 retains legacy state mutation + strategy hook + risk gate.""" - generated = _new_intent(ts_ns_local=2) - seen: list[str] = [] - - class _LegacySpyStrategy(Strategy): - def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, event, engine_cfg, constraints) - return [] - - def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, engine_cfg, constraints) - seen.append("strategy.on_order_update") - return [generated] - - def on_risk_decision(self, decision: Any) -> None: - _ = decision - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_LegacySpyStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=False, - ) - - def _spy_update_account(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - seen.append("state.update_account") - - def _spy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - seen.append("state.ingest_order_snapshots") - - monkeypatch.setattr(runner.strategy_state, "update_account", _spy_update_account) - monkeypatch.setattr(runner.strategy_state, "ingest_order_snapshots", _spy_ingest_order_snapshots) - - def _spy_decide_intents(**kwargs: object) -> GateDecision: - _ = kwargs - seen.append("risk.decide_intents") - return _decision_for([]) - - runner.risk.decide_intents = _spy_decide_intents # type: ignore[method-assign] - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert "state.update_account" in seen - assert "state.ingest_order_snapshots" in seen - assert "strategy.on_order_update" in seen - assert "risk.decide_intents" in seen - - -def test_order_update_core_step_mode_routes_rc3_through_canonical_event_and_dispatchables( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable = _cancel_intent(ts_ns_local=2) - captured_positions: list[int] = [] - strategy_order_update_calls = {"count": 0} - execution_batches: list[list[str]] = [] - - class _OrderUpdateSpyStrategy(Strategy): - def on_feed(self, state: Any, event: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, event, engine_cfg, constraints) - return [] - - def on_order_update(self, state: Any, engine_cfg: Any, constraints: Any) -> list[Any]: - _ = (state, engine_cfg, constraints) - strategy_order_update_calls["count"] += 1 - return [] - - def on_risk_decision(self, decision: Any) -> None: - _ = decision - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_OrderUpdateSpyStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = state - _ = core_decision_context - assert isinstance(entry.event, OrderExecutionFeedbackEvent) - captured_positions.append(entry.position.index) - assert configuration is runner._core_cfg - assert control_time_queue_context is None - assert isinstance(policy_admission_context, CorePolicyAdmissionContext) - assert isinstance(execution_control_apply_context, CoreExecutionControlApplyContext) - assert strategy_evaluator is not None - evaluated = strategy_evaluator.evaluate( - SimpleNamespace( - state=runner.strategy_state, - event=entry.event, - position=entry.position, - configuration=configuration, - ) - ) - assert evaluated == () - return CoreStepResult( - generated_intents=tuple(evaluated), - dispatchable_intents=(dispatchable,), - ) - - class _ExecutionCapture: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - execution_batches.append([it.client_order_id for it in intents]) - return [] - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={ - "101": SimpleNamespace( - order_id="101", - order_type=0, - side=1, - time_in_force=0, - status=1, - req=0, - price=100.0, - qty=1.0, - exec_price=100.25, - exec_qty=0.25, - leaves_qty=0.75, - exch_timestamp=2, - local_timestamp=2, - ) - }, - ) - runner.run(venue=venue, execution=_ExecutionCapture(), recorder=_RecorderWrapper()) - - assert captured_positions == [0] - assert strategy_order_update_calls["count"] == 1 - assert execution_batches == [[dispatchable.client_order_id]] - - -def test_order_update_core_step_mode_preserves_order_submitted_before_mark_sent( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent() - ordering: list[str] = [] - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - ordering.append("submitted") - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - _ = (instrument, client_order_id, intent_type) - ordering.append("mark") - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[11, 12, 13], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert ordering == ["submitted", "mark"] - - -def test_order_update_core_step_mode_failed_new_dispatch_emits_no_order_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent() - submitted_count = {"count": 0} - marked_count = {"count": 0} - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_count["count"] += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - _ = (instrument, client_order_id, intent_type) - marked_count["count"] += 1 - - class _ExecutionFailNew: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [(dispatchable_new, "EXCHANGE_REJECT")] - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[21, 22, 23], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run( - venue=venue, - execution=_ExecutionFailNew(), - recorder=_RecorderWrapper(), - ) - - assert submitted_count["count"] == 0 - assert marked_count["count"] == 0 - assert len(runner._last_core_step_execution_errors) == 1 - - -def test_order_update_core_step_mode_applies_and_clears_control_scheduling_obligation( - monkeypatch: pytest.MonkeyPatch, -) -> None: - obligation = _obligation(due_ts_ns_local=9, obligation_key="rc3") - results = iter( - ( - CoreStepResult(control_scheduling_obligation=obligation), - CoreStepResult(control_scheduling_obligation=None), - ) - ) - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_order_feedback_dispatch=True, - ) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", lambda *args, **kwargs: next(results)) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("rc3 core-step mode must not call runtime risk gate") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 3, 3, 1], - ts_sequence=[1, 5, 6, 7], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - - -def test_market_run_core_step_failure_does_not_commit_or_reach_risk_dispatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - - def _fail_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - raise RuntimeError("boom-market-core-step") - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("risk gate must not run after market core-step failure") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), - ) - with pytest.raises(RuntimeError, match="boom-market-core-step"): - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._event_stream_cursor.next_index == 0 - - -def test_missing_core_cfg_fails_before_market_mutation() -> None: - runner = object.__new__(HftStrategyRunner) - runner.strategy_state = StrategyState(event_bus=EventBus(sinks=[])) - runner.strategy = _NoopStrategy() - runner.engine_cfg = _engine_cfg() - runner._core_cfg = None - runner._event_stream_cursor = EventStreamCursor() - - with pytest.raises(ValueError, match="CoreConfiguration is required"): - runner._process_canonical_market_event( - _market_event(42), - constraints=SimpleNamespace(), - ) - - assert runner.strategy_state.market == {} - assert runner.strategy_state._last_processing_position_index is None - assert runner._event_stream_cursor.next_index == 0 - - -def test_invalid_core_cfg_type_fails_before_market_mutation() -> None: - runner = object.__new__(HftStrategyRunner) - runner.strategy_state = StrategyState(event_bus=EventBus(sinks=[])) - runner.strategy = _NoopStrategy() - runner.engine_cfg = _engine_cfg() - runner._core_cfg = object() - runner._event_stream_cursor = EventStreamCursor() - - with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - runner._process_canonical_market_event( - _market_event(42), - constraints=SimpleNamespace(), - ) - - assert runner.strategy_state.market == {} - assert runner.strategy_state._last_processing_position_index is None - assert runner._event_stream_cursor.next_index == 0 - - -def test_order_snapshot_branch_keeps_compatibility_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.strategy_state, - "apply_fill_event", - lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("apply_fill_event must not be called")), - ) - - calls = {"update_account": 0, "ingest_order_snapshots": 0} - - def _spy_update_account(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["update_account"] += 1 - - def _spy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["ingest_order_snapshots"] += 1 - - monkeypatch.setattr(runner.strategy_state, "update_account", _spy_update_account) - monkeypatch.setattr( - runner.strategy_state, - "ingest_order_snapshots", - _spy_ingest_order_snapshots, - ) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert calls["update_account"] == 1 - assert calls["ingest_order_snapshots"] == 1 - assert runner._event_stream_cursor.next_index == 0 - - -def test_snapshot_only_rc3_does_not_consume_canonical_cursor_position( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - - calls = {"update_account": 0, "ingest_order_snapshots": 0, "canonical": 0} - - def _spy_update_account(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["update_account"] += 1 - - def _spy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["ingest_order_snapshots"] += 1 - - def _spy_process_event_entry(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["canonical"] += 1 - - monkeypatch.setattr(runner.strategy_state, "update_account", _spy_update_account) - monkeypatch.setattr( - runner.strategy_state, - "ingest_order_snapshots", - _spy_ingest_order_snapshots, - ) - monkeypatch.setattr( - strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, - ) - - venue = _StubVenue( - rc_sequence=[0, 3, 1], - ts_sequence=[1, 2, 3], - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert calls == { - "update_account": 1, - "ingest_order_snapshots": 1, - "canonical": 0, - } - assert runner._event_stream_cursor.next_index == 0 - - -def test_rc2_rc3_paths_never_emit_fill_event_through_process_event_entry( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - - calls = {"update_account": 0, "ingest_order_snapshots": 0} - emitted_fill_events = 0 - - def _spy_update_account(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["update_account"] += 1 - - def _spy_ingest_order_snapshots(*args: object, **kwargs: object) -> None: - _ = (args, kwargs) - calls["ingest_order_snapshots"] += 1 - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal emitted_fill_events - _ = (state, configuration) - if isinstance(entry.event, FillEvent): - emitted_fill_events += 1 - - monkeypatch.setattr(runner.strategy_state, "update_account", _spy_update_account) - monkeypatch.setattr( - runner.strategy_state, - "ingest_order_snapshots", - _spy_ingest_order_snapshots, - ) - monkeypatch.setattr( - strategy_runner_module, - "process_event_entry", - _spy_process_event_entry, - ) - - venue = _StubVenue( - rc_sequence=[0, 2, 3, 1], - ts_sequence=[1, 2, 3, 4], - depth=_depth_snapshot(), - state_values=SimpleNamespace( - position=0.0, - balance=1000.0, - fee=0.0, - trading_volume=0.0, - trading_value=0.0, - num_trades=0, - ), - orders={}, - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert emitted_fill_events == 0 - assert calls["update_account"] == 1 - assert calls["ingest_order_snapshots"] == 1 - - -def test_successful_new_dispatch_processes_order_submitted_before_mark_sent( - monkeypatch: pytest.MonkeyPatch, -) -> None: - new_intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([new_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr(runner.strategy_state, "apply_fill_event", lambda *args, **kwargs: None) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for([new_intent]), - ) - - ordering: list[str] = [] - submitted_events: list[OrderSubmittedEvent] = [] - marks: list[tuple[str, str, str]] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - ordering.append("submitted") - submitted_events.append(entry.event) - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - ordering.append("mark") - marks.append((instrument, client_order_id, intent_type)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1_111, 5_000_000_000, 5_000_000_001], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(submitted_events) == 1 - event = submitted_events[0] - assert event.instrument == new_intent.instrument - assert event.client_order_id == new_intent.client_order_id - assert event.side == new_intent.side - assert event.order_type == new_intent.order_type - assert event.intended_price == new_intent.intended_price - assert event.intended_qty == new_intent.intended_qty - assert event.time_in_force == new_intent.time_in_force - assert event.intent_correlation_id == new_intent.intents_correlation_id - assert event.dispatch_attempt_id is None - assert event.runtime_correlation is None - assert event.ts_ns_local_dispatch == 5_000_000_000 - assert ordering == ["submitted", "mark"] - assert marks == [(new_intent.instrument, new_intent.client_order_id, "new")] - - -def test_failed_new_dispatch_processes_no_order_submitted_event( - monkeypatch: pytest.MonkeyPatch, -) -> None: - new_intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([new_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for([new_intent]), - ) - - submitted_event_count = 0 - marked_count = 0 - captured_decisions: list[GateDecision] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_event_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_event_count += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - nonlocal marked_count - _ = (instrument, client_order_id, intent_type) - marked_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - monkeypatch.setattr( - runner.strategy, - "on_risk_decision", - lambda decision: captured_decisions.append(decision), - ) - - class _ExecutionFailNew: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [(new_intent, "EXCHANGE_REJECT")] - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[10, 20, 30], - depth=_depth_snapshot(), - ) - runner.run( - venue=venue, - execution=_ExecutionFailNew(), - recorder=_RecorderWrapper(), - ) - - assert submitted_event_count == 0 - assert marked_count == 0 - assert len(captured_decisions) == 1 - assert len(captured_decisions[0].execution_rejected) == 1 - assert captured_decisions[0].execution_rejected[0].intent.client_order_id == new_intent.client_order_id - - -def test_successful_replace_cancel_dispatch_processes_no_order_submitted_event( - monkeypatch: pytest.MonkeyPatch, -) -> None: - replace_intent = _replace_intent() - cancel_intent = _cancel_intent() - accepted_now = [replace_intent, cancel_intent] - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy(accepted_now), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for(accepted_now), - ) - - submitted_event_count = 0 - marks: list[tuple[str, str, str]] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_event_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_event_count += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - marks.append((instrument, client_order_id, intent_type)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[100, 200, 300], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert submitted_event_count == 0 - assert marks == [ - ( - replace_intent.instrument, - replace_intent.client_order_id, - "replace", - ), - ( - cancel_intent.instrument, - cancel_intent.client_order_id, - "cancel", - ), - ] - - -def test_global_canonical_counter_shared_between_market_and_order_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - new_intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([new_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for([new_intent]), - ) - - positions: list[tuple[int, str]] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - event_name = type(entry.event).__name__ - positions.append((entry.position.index, event_name)) - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration, core_decision_context, strategy_evaluator) - assert control_time_queue_context is None - assert policy_admission_context is None - assert execution_control_apply_context is None - positions.append((entry.position.index, type(entry.event).__name__)) - return CoreStepResult(generated_intents=(new_intent,)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[7, 9_999_999_999, 10_000_000_000], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert positions == [ - (0, "MarketEvent"), - (1, "OrderSubmittedEvent"), - ] - assert runner._event_stream_cursor.next_index == 2 - - -def test_canonical_counter_increments_only_after_successful_canonical_processing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = object.__new__(HftStrategyRunner) - runner.strategy_state = object() - runner.strategy = _NoopStrategy() - runner.engine_cfg = _engine_cfg() - runner._core_cfg = _core_cfg() - runner._event_stream_cursor = EventStreamCursor() - - def _fail(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - raise RuntimeError("boom") - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail) - with pytest.raises(RuntimeError, match="boom"): - runner._process_canonical_market_event( - _market_event(1), - constraints=SimpleNamespace(), - ) - assert runner._event_stream_cursor.next_index == 0 - - called = {"count": 0} - - def _ok(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - called["count"] += 1 - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _ok) - runner._process_canonical_market_event( - _market_event(2), - constraints=SimpleNamespace(), - ) - assert called["count"] == 1 - assert runner._event_stream_cursor.next_index == 1 - - -def test_control_time_event_injected_when_scheduled_deadline_is_realized( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - control_events: list[ControlTimeEvent] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - _ = core_decision_context - assert policy_admission_context is None - assert execution_control_apply_context is None - if isinstance(entry.event, MarketEvent): - assert control_time_queue_context is None - assert strategy_evaluator is not None - return CoreStepResult(generated_intents=(_new_intent(),)) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - control_events.append(entry.event) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(control_events) == 1 - event = control_events[0] - assert event.ts_ns_local_control == 10 - assert event.reason == "scheduled_control_recheck" - assert event.due_ts_ns_local == 5 - assert event.realized_ts_ns_local == 10 - assert event.obligation_reason == "rate_limit" - assert event.obligation_due_ts_ns_local == 5 - assert event.runtime_correlation is None - - -def test_control_time_realization_routes_through_run_core_step_with_expected_arguments( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - captured_calls: list[tuple[object, object, object, object | None]] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - captured_calls.append((state, entry, configuration, control_time_queue_context)) - assert policy_admission_context is None - assert execution_control_apply_context is None - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(captured_calls) == 1 - state, entry, configuration, control_time_queue_context = captured_calls[0] - assert state is runner.strategy_state - assert configuration is runner._core_cfg - assert isinstance(entry.event, ControlTimeEvent) - assert entry.position.index == 0 - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - assert control_time_queue_context.risk_engine is runner.risk - assert control_time_queue_context.instrument == runner.engine_cfg.instrument - assert control_time_queue_context.now_ts_ns_local == 10 - assert runner._event_stream_cursor.next_index == 1 - - -def test_control_time_event_uses_structured_obligation_fields_when_available( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([_new_intent()]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - obligation = ControlSchedulingObligation( - due_ts_ns_local=5, - reason="custom_backpressure_reason", - scope_key="instrument:BTC_USDC-PERPETUAL", - source="execution_control_rate_limit", - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for( - [], - next_send_ts_ns_local=5, - control_scheduling_obligations=(obligation,), - ), - ) - - control_events: list[ControlTimeEvent] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - _ = core_decision_context - assert policy_admission_context is None - assert execution_control_apply_context is None - if isinstance(entry.event, MarketEvent): - assert control_time_queue_context is None - assert strategy_evaluator is not None - return CoreStepResult(generated_intents=(_new_intent(),)) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - control_events.append(entry.event) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 2, 0, 1], - ts_sequence=[1, 2, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(control_events) == 1 - event = control_events[0] - assert event.reason == "scheduled_control_recheck" - assert event.due_ts_ns_local == 5 - assert event.realized_ts_ns_local == 10 - assert event.obligation_reason == "custom_backpressure_reason" - assert event.obligation_due_ts_ns_local == 5 - - -def test_control_time_event_falls_back_when_structured_obligation_missing( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - runner._pending_control_scheduling_obligation = None - - control_events: list[ControlTimeEvent] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - control_events.append(entry.event) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(control_events) == 1 - event = control_events[0] - assert event.obligation_reason == "rate_limit" - assert event.obligation_due_ts_ns_local == 5 - - -def test_no_control_time_event_when_no_deadline_scheduled( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - control_count = 0 - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal control_count - _ = (state, configuration) - if isinstance(entry.event, ControlTimeEvent): - control_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2, 3], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert control_count == 0 - - -def test_no_control_time_event_when_deadline_not_yet_realized( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 50 - control_count = 0 - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal control_count - _ = (state, configuration) - if isinstance(entry.event, ControlTimeEvent): - control_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 20], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert control_count == 0 - - -def test_control_time_deadline_injection_is_not_periodic_for_same_deadline( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - control_count = 0 - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - nonlocal control_count - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - control_count += 1 - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 0, 0, 1], - ts_sequence=[1, 10, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert control_count == 1 - - -def test_control_time_event_processed_through_core_step_context_without_runtime_pop( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - assert entry.position.index == 0 - return CoreStepResult() - - monkeypatch.setattr( - runner.strategy_state, - "pop_queued_intents", - lambda _: (_ for _ in ()).throw( - AssertionError("runtime must not pop queued intents for control-time re-evaluation") - ), - ) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("runtime must not run risk gate for control-time queue directly") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._event_stream_cursor.next_index == 1 - - -def test_control_time_processing_failure_does_not_mark_deadline_or_dispatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - obligation = _obligation(due_ts_ns_local=5, obligation_key="k-failure") - runner._pending_control_scheduling_obligation = obligation - runner._next_send_ts_ns_local = obligation.due_ts_ns_local - - def _fail_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - raise RuntimeError("boom") - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _fail_run_core_step) - monkeypatch.setattr( - runner.strategy_state, - "pop_queued_intents", - lambda _: (_ for _ in ()).throw( - AssertionError("runtime queue pop must not run on control-time failure") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - - with pytest.raises(RuntimeError, match="boom"): - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._last_injected_control_deadline_ns is None - assert runner._pending_control_scheduling_obligation == obligation - assert runner._next_send_ts_ns_local == 5 - assert runner._event_stream_cursor.next_index == 0 - - -def test_market_and_order_submitted_paths_remain_on_process_event_entry_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - new_intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([new_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for([new_intent]), - ) - - process_event_names: list[str] = [] - run_core_step_names: list[str] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - process_event_names.append(type(entry.event).__name__) - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration, core_decision_context, strategy_evaluator) - assert control_time_queue_context is None - assert policy_admission_context is None - assert execution_control_apply_context is None - run_core_step_names.append(type(entry.event).__name__) - return CoreStepResult(generated_intents=(new_intent,)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert run_core_step_names == ["MarketEvent"] - assert process_event_names == ["OrderSubmittedEvent"] - - -def test_control_time_success_consumes_pending_obligation_after_core_step( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - obligation = _obligation(due_ts_ns_local=5, obligation_key="k-success") - runner._pending_control_scheduling_obligation = obligation - runner._next_send_ts_ns_local = obligation.due_ts_ns_local - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.strategy_state, - "pop_queued_intents", - lambda _: (_ for _ in ()).throw( - AssertionError("runtime must not directly pop queued intents") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - - -def test_realized_old_deadline_does_not_runtime_pop_without_new_canonical_injection( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - control_count = 0 - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - nonlocal control_count - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - if isinstance(entry.event, ControlTimeEvent): - control_count += 1 - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.strategy_state, - "pop_queued_intents", - lambda _: (_ for _ in ()).throw( - AssertionError("runtime must not pop control-time queue directly") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 0, 1], - ts_sequence=[1, 10, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert control_count == 1 - assert runner._event_stream_cursor.next_index == 1 - - -def test_global_canonical_counter_shared_with_control_time_market_and_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - new_intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([new_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: _decision_for([new_intent]), - ) - - positions: list[tuple[int, str]] = [] - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - positions.append((entry.position.index, type(entry.event).__name__)) - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - _ = (core_decision_context, strategy_evaluator) - assert policy_admission_context is None - assert execution_control_apply_context is None - positions.append((entry.position.index, type(entry.event).__name__)) - if isinstance(entry.event, MarketEvent): - assert control_time_queue_context is None - return CoreStepResult(generated_intents=(new_intent,)) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert positions == [ - (0, "MarketEvent"), - (1, "ControlTimeEvent"), - (2, "OrderSubmittedEvent"), - ] - assert runner._event_stream_cursor.next_index == 3 - - -def test_control_time_core_step_result_dispatches_via_existing_execution_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - control_intent = _new_intent(ts_ns_local=10) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - obligation = _obligation(due_ts_ns_local=25, obligation_key="control-obligation") - control_decision = _decision_for( - [control_intent], - next_send_ts_ns_local=25, - control_scheduling_obligations=(obligation,), - ) - - callbacks: list[GateDecision] = [] - mark_calls: list[tuple[str, str, str]] = [] - submitted_events: list[OrderSubmittedEvent] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - ) -> CoreStepResult: - _ = (state, configuration) - assert policy_admission_context is None - assert execution_control_apply_context is None - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - return CoreStepResult( - dispatchable_intents=(control_intent,), - compat_gate_decision=control_decision, - control_scheduling_obligation=obligation, - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_events.append(entry.event) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda d: callbacks.append(d)) - monkeypatch.setattr( - runner.strategy_state, - "mark_intent_sent", - lambda i, c, t: mark_calls.append((i, c, t)), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("raw path risk gate should not run in this control-only wakeup") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(submitted_events) == 1 - assert submitted_events[0].client_order_id == control_intent.client_order_id - assert mark_calls == [(control_intent.instrument, control_intent.client_order_id, "new")] - assert callbacks == [control_decision] - assert runner._pending_control_scheduling_obligation == obligation - assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local - - -def test_same_wakeup_strategy_and_control_time_intents_are_processed_in_two_decisions( - monkeypatch: pytest.MonkeyPatch, -) -> None: - strategy_intent = _new_intent(ts_ns_local=10) - control_intent = _new_intent(ts_ns_local=10) - control_intent = control_intent.model_copy(update={"client_order_id": "cid-control-1"}) - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([strategy_intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner._next_send_ts_ns_local = 5 - - control_decision = _decision_for([control_intent]) - strategy_decision = _decision_for([strategy_intent]) - - callback_order: list[str] = [] - - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = (state, entry, configuration) - _ = (core_decision_context, strategy_evaluator) - assert policy_admission_context is None - assert execution_control_apply_context is None - if isinstance(entry.event, MarketEvent): - assert control_time_queue_context is None - return CoreStepResult(generated_intents=(strategy_intent,)) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - return CoreStepResult( - dispatchable_intents=(control_intent,), - compat_gate_decision=control_decision, - ) - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: strategy_decision, - ) - monkeypatch.setattr( - runner.strategy, - "on_risk_decision", - lambda decision: callback_order.append( - "control" - if decision is control_decision - else "strategy" - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert execution.batches == [ - [control_intent.client_order_id], - [strategy_intent.client_order_id], - ] - assert callback_order == ["control", "strategy"] - - -@pytest.mark.parametrize( - ("market_flag", "control_flag", "expected_dispatch_batches", "expected_raw_risk_calls"), - [ - ( - False, - False, - [["cid-control-compat"], ["cid-strategy-gated"]], - [["cid-strategy-generated"]], - ), - ( - True, - False, - [["cid-control-compat"], ["cid-market-core-step"]], - [], - ), - ( - False, - True, - [["cid-control-core-step"], ["cid-strategy-gated"]], - [["cid-strategy-generated"]], - ), - ( - True, - True, - [["cid-control-core-step"], ["cid-market-core-step"]], - [], - ), - ], - ids=( - "market_off_control_off", - "market_on_control_off", - "market_off_control_on", - "market_on_control_on", - ), -) -def test_mixed_wakeup_matrix_characterization_keeps_split_dispatch_paths( - monkeypatch: pytest.MonkeyPatch, - market_flag: bool, - control_flag: bool, - expected_dispatch_batches: list[list[str]], - expected_raw_risk_calls: list[list[str]], -) -> None: - strategy_generated = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-strategy-generated"} - ) - strategy_gated = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-strategy-gated"} - ) - market_dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-market-core-step"} - ) - control_compat = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-control-compat"} - ) - control_dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-control-core-step"} - ) - control_obligation = _obligation(due_ts_ns_local=25, obligation_key="control") - - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([strategy_generated]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=market_flag, - enable_core_step_control_time_dispatch=control_flag, - ) - runner._next_send_ts_ns_local = 5 - seeded_core_step_errors = [(_new_intent(), "stale")] - runner._last_core_step_execution_errors = list(seeded_core_step_errors) - - control_compat_decision = _decision_for( - [control_compat], - control_scheduling_obligations=(control_obligation,), - ) - strategy_decision = _decision_for([strategy_gated]) - - run_core_step_calls: list[dict[str, object]] = [] - callback_decisions: list[GateDecision] = [] - raw_risk_calls: list[list[str]] = [] - ordering: list[str] = [] - submitted_positions: list[tuple[int, str]] = [] - - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = state - _ = core_decision_context - assert configuration is runner._core_cfg - run_core_step_calls.append( - { - "event": type(entry.event).__name__, - "position": entry.position.index, - "strategy_evaluator": strategy_evaluator, - "control_time_queue_context": control_time_queue_context, - "policy_admission_context": policy_admission_context, - "execution_control_apply_context": execution_control_apply_context, - } - ) - if isinstance(entry.event, MarketEvent): - assert strategy_evaluator is not None - if market_flag: - assert isinstance(policy_admission_context, CorePolicyAdmissionContext) - assert isinstance( - execution_control_apply_context, - CoreExecutionControlApplyContext, - ) - else: - assert policy_admission_context is None - assert execution_control_apply_context is None - assert control_time_queue_context is None - return CoreStepResult( - generated_intents=(strategy_generated,), - dispatchable_intents=( - (market_dispatchable,) if market_flag else () - ), - ) - assert isinstance(entry.event, ControlTimeEvent) - assert strategy_evaluator is None - if control_flag: - assert control_time_queue_context is None - assert isinstance(policy_admission_context, CorePolicyAdmissionContext) - assert isinstance( - execution_control_apply_context, - CoreExecutionControlApplyContext, - ) - return CoreStepResult( - dispatchable_intents=(control_dispatchable,), - compat_gate_decision=control_compat_decision, - control_scheduling_obligation=control_obligation, - ) - assert isinstance(control_time_queue_context, ControlTimeQueueReevaluationContext) - assert policy_admission_context is None - assert execution_control_apply_context is None - return CoreStepResult( - dispatchable_intents=(control_dispatchable,), - compat_gate_decision=control_compat_decision, - control_scheduling_obligation=control_obligation, - ) - - def _spy_decide_intents(**kwargs: object) -> GateDecision: - raw_intents = kwargs["raw_intents"] - raw_risk_calls.append([intent.client_order_id for intent in raw_intents]) - return strategy_decision - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - ordering.append(f"submitted:{entry.event.client_order_id}") - submitted_positions.append((entry.position.index, entry.event.client_order_id)) - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - _ = (instrument, intent_type) - ordering.append(f"mark:{client_order_id}") - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr(runner.risk, "decide_intents", _spy_decide_intents) - monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda decision: callback_decisions.append(decision)) - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 2, 0, 1], - ts_sequence=[1, 10, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert [call["event"] for call in run_core_step_calls] == ["MarketEvent", "ControlTimeEvent"] - assert [call["position"] for call in run_core_step_calls] == [0, 1] - assert runner._last_injected_control_deadline_ns == 5 - assert runner._event_stream_cursor.next_index == 4 - - # Dispatch remains split across market/control wakeups in all four flag combinations. - assert execution.batches == expected_dispatch_batches - assert raw_risk_calls == expected_raw_risk_calls - - if control_flag: - assert control_compat_decision not in callback_decisions - else: - assert control_compat_decision in callback_decisions - if market_flag: - assert strategy_decision not in callback_decisions - else: - assert strategy_decision in callback_decisions - - submitted_ids = [client_order_id for _, client_order_id in submitted_positions] - expected_submitted_ids = [batch[0] for batch in expected_dispatch_batches] - assert submitted_ids == expected_submitted_ids - assert [position for position, _ in submitted_positions] == [2, 3] - assert ordering == [ - f"submitted:{expected_submitted_ids[0]}", - f"mark:{expected_submitted_ids[0]}", - f"submitted:{expected_submitted_ids[1]}", - f"mark:{expected_submitted_ids[1]}", - ] - - # _last_core_step_execution_errors is runtime-owned observability for the latest - # CoreStep dispatch batch only. Legacy/compat paths must not mutate this field. - if market_flag or control_flag: - assert runner._last_core_step_execution_errors == [] - else: - assert runner._last_core_step_execution_errors == seeded_core_step_errors - - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - - -def test_control_time_core_step_mode_calls_run_core_step_with_policy_and_apply_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg_with_rate_limits( - max_orders_per_second=5.0, - max_cancels_per_second=2.0, - ), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - runner._next_send_ts_ns_local = 5 - captured: list[tuple[object, object, object, object]] = [] - - def _spy_run_core_step( - state: object, - entry: object, - *, - configuration: object | None = None, - control_time_queue_context: object | None = None, - policy_admission_context: object | None = None, - execution_control_apply_context: object | None = None, - core_decision_context: object | None = None, - strategy_evaluator: object | None = None, - ) -> CoreStepResult: - _ = core_decision_context - assert control_time_queue_context is None - assert strategy_evaluator is None - captured.append( - ( - state, - configuration, - policy_admission_context, - execution_control_apply_context, - ) - ) - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("control-time core-step mode must not call runtime risk gate") - ), - ) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert len(captured) == 1 - state, configuration, policy_ctx, apply_ctx = captured[0] - assert state is runner.strategy_state - assert configuration is runner._core_cfg - assert isinstance(policy_ctx, CorePolicyAdmissionContext) - assert policy_ctx.policy_evaluator is runner.risk - assert policy_ctx.now_ts_ns_local == 10 - assert isinstance(apply_ctx, CoreExecutionControlApplyContext) - assert apply_ctx.execution_control is runner.risk.execution_control - assert apply_ctx.now_ts_ns_local == 10 - assert apply_ctx.max_orders_per_sec == 5.0 - assert apply_ctx.max_cancels_per_sec == 2.0 - assert apply_ctx.activate_dispatchable_outputs is True - - -def test_control_time_core_step_mode_dispatches_from_dispatchables_and_ignores_compat_gate( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable = _new_intent(ts_ns_local=10) - obligation = _obligation(due_ts_ns_local=25, obligation_key="control-core-step") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - runner._next_send_ts_ns_local = 5 - callbacks: list[GateDecision] = [] - - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - compat_decision = _decision_for([]) - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable,), - control_scheduling_obligation=obligation, - compat_gate_decision=compat_decision, - ), - ) - monkeypatch.setattr(runner.strategy, "on_risk_decision", lambda d: callbacks.append(d)) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("control-time core-step mode must not call runtime risk gate") - ), - ) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert execution.batches == [[dispatchable.client_order_id]] - assert callbacks == [] - assert runner._pending_control_scheduling_obligation == obligation - assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local - - -def test_control_time_core_step_mode_none_obligation_clears_pending( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - seeded = _obligation(due_ts_ns_local=5, obligation_key="seeded") - runner._pending_control_scheduling_obligation = seeded - runner._next_send_ts_ns_local = seeded.due_ts_ns_local - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(), - control_scheduling_obligation=None, - ), - ) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - - -def test_control_time_core_step_mode_failure_preserves_pending_cursor_and_deadline_marker( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - obligation = _obligation(due_ts_ns_local=5, obligation_key="k-failure-core-step") - runner._pending_control_scheduling_obligation = obligation - runner._next_send_ts_ns_local = obligation.due_ts_ns_local - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: (_ for _ in ()).throw( - RuntimeError("boom-control-time-core-step") - ), - ) - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - with pytest.raises(RuntimeError, match="boom-control-time-core-step"): - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._last_injected_control_deadline_ns is None - assert runner._pending_control_scheduling_obligation == obligation - assert runner._next_send_ts_ns_local == obligation.due_ts_ns_local - assert runner._event_stream_cursor.next_index == 0 - - -def test_control_time_core_step_mode_same_deadline_injected_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - runner._next_send_ts_ns_local = 5 - calls = {"count": 0} - - def _spy_run_core_step(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - calls["count"] += 1 - return CoreStepResult() - - monkeypatch.setattr(strategy_runner_module, "run_core_step", _spy_run_core_step) - venue = _StubVenue( - rc_sequence=[0, 0, 0, 1], - ts_sequence=[1, 10, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert calls["count"] == 1 - - -def test_control_time_core_step_mode_failed_dispatch_records_execution_errors( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent(ts_ns_local=10) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_control_time_dispatch=True, - ) - runner._next_send_ts_ns_local = 5 - submitted_event_count = 0 - marked_count = 0 - - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_event_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_event_count += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - nonlocal marked_count - _ = (instrument, client_order_id, intent_type) - marked_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - class _ExecutionFailNew: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [(dispatchable_new, "EXCHANGE_REJECT")] - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run( - venue=venue, - execution=_ExecutionFailNew(), - recorder=_RecorderWrapper(), - ) - - assert submitted_event_count == 0 - assert marked_count == 0 - assert len(runner._last_core_step_execution_errors) == 1 - failed_intent, failure_reason = runner._last_core_step_execution_errors[0] - assert failed_intent.client_order_id == dispatchable_new.client_order_id - assert failure_reason == "EXCHANGE_REJECT" - - -def test_wakeup_collapse_mixed_wakeup_uses_single_core_wakeup_call_and_single_dispatch( - monkeypatch: pytest.MonkeyPatch, -) -> None: - market_dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-market-collapse"} - ) - control_dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-control-collapse"} - ) - prior_pending = _obligation(due_ts_ns_local=5, obligation_key="pending") - next_obligation = _obligation(due_ts_ns_local=25, obligation_key="next") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([_new_intent(ts_ns_local=10)]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - runner._pending_control_scheduling_obligation = prior_pending - runner._next_send_ts_ns_local = prior_pending.due_ts_ns_local - - ordering: list[str] = [] - submitted_positions: list[tuple[int, str]] = [] - wakeup_calls: list[dict[str, object]] = [] - - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_wakeup_step( - state: object, - entries: tuple[object, ...], - **kwargs: object, - ) -> CoreStepResult: - _ = state - strategy_event_filter = kwargs["strategy_event_filter"] - wakeup_calls.append( - { - "entries": tuple(type(entry.event).__name__ for entry in entries), - "positions": tuple(entry.position.index for entry in entries), - "strategy_event_filter_results": tuple( - strategy_event_filter(entry.event) for entry in entries - ), - "strategy_evaluator": kwargs["strategy_evaluator"], - "snapshot_instrument": kwargs["snapshot_instrument"], - "policy_admission_context": kwargs["policy_admission_context"], - "execution_control_apply_context": kwargs["execution_control_apply_context"], - "has_control_time_queue_context": "control_time_queue_context" in kwargs, - "has_core_decision_context": "core_decision_context" in kwargs, - } - ) - return CoreStepResult( - dispatchable_intents=(market_dispatchable, control_dispatchable), - control_scheduling_obligation=next_obligation, - compat_gate_decision=_decision_for([]), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - ordering.append(f"submitted:{entry.event.client_order_id}") - submitted_positions.append((entry.position.index, entry.event.client_order_id)) - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - _ = (instrument, intent_type) - ordering.append(f"mark:{client_order_id}") - - monkeypatch.setattr( - strategy_runner_module, - "run_core_wakeup_step", - _spy_run_core_wakeup_step, - ) - monkeypatch.setattr( - strategy_runner_module, - "run_core_step", - lambda *args, **kwargs: (_ for _ in ()).throw( - AssertionError("collapse mode must not call run_core_step for market/control path") - ), - ) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("collapse mode must not call runtime risk gate for market/control work") - ), - ) - monkeypatch.setattr( - runner.strategy, - "on_risk_decision", - lambda *_: (_ for _ in ()).throw( - AssertionError("collapse mode must not synthesize GateDecision callbacks") - ), - ) - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - - venue = _StubVenue( - rc_sequence=[0, 2, 0, 1], - ts_sequence=[1, 10, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert len(wakeup_calls) == 1 - wakeup_call = wakeup_calls[0] - assert wakeup_call["entries"] == ("MarketEvent", "ControlTimeEvent") - assert wakeup_call["positions"] == (0, 1) - assert wakeup_call["strategy_event_filter_results"] == (True, False) - assert wakeup_call["strategy_evaluator"] is not None - assert wakeup_call["snapshot_instrument"] == "BTC_USDC-PERPETUAL" - assert isinstance(wakeup_call["policy_admission_context"], CorePolicyAdmissionContext) - assert isinstance( - wakeup_call["execution_control_apply_context"], - CoreExecutionControlApplyContext, - ) - assert wakeup_call["execution_control_apply_context"].activate_dispatchable_outputs is True - assert wakeup_call["has_control_time_queue_context"] is False - assert wakeup_call["has_core_decision_context"] is False - assert execution.batches == [[ - market_dispatchable.client_order_id, - control_dispatchable.client_order_id, - ]] - assert [position for position, _ in submitted_positions] == [2, 3] - assert ordering == [ - f"submitted:{market_dispatchable.client_order_id}", - f"mark:{market_dispatchable.client_order_id}", - f"submitted:{control_dispatchable.client_order_id}", - f"mark:{control_dispatchable.client_order_id}", - ] - assert runner._last_core_step_execution_errors == [] - assert runner._pending_control_scheduling_obligation == next_obligation - assert runner._next_send_ts_ns_local == next_obligation.due_ts_ns_local - assert runner._last_injected_control_deadline_ns == 5 - assert runner._event_stream_cursor.next_index == 4 - - -def test_wakeup_collapse_market_only_path_dispatches_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-market-only-collapse"} - ) - seeded = _obligation(due_ts_ns_local=99, obligation_key="seeded-market-only") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - runner._pending_control_scheduling_obligation = seeded - runner._next_send_ts_ns_local = seeded.due_ts_ns_local - calls: list[tuple[str, ...]] = [] - - class _ExecutionCapture: - def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_wakeup_step( - state: object, - entries: tuple[object, ...], - **kwargs: object, - ) -> CoreStepResult: - _ = (state, kwargs) - calls.append(tuple(type(entry.event).__name__ for entry in entries)) - return CoreStepResult( - dispatchable_intents=(dispatchable,), - control_scheduling_obligation=None, - ) - - monkeypatch.setattr(strategy_runner_module, "run_core_wakeup_step", _spy_run_core_wakeup_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("collapse market-only path must not call runtime risk gate") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert calls == [("MarketEvent",)] - assert execution.batches == [[dispatchable.client_order_id]] - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - - -def test_wakeup_collapse_control_only_path_dispatches_once_without_strategy_eval( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-control-only-collapse"} - ) - pending = _obligation(due_ts_ns_local=5, obligation_key="pending-control-only") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - runner._pending_control_scheduling_obligation = pending - runner._next_send_ts_ns_local = pending.due_ts_ns_local - wakeup_calls: list[dict[str, object]] = [] - - class _ExecutionCapture: + class _PendingAwareVenue: def __init__(self) -> None: - self.batches: list[list[str]] = [] - - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - self.batches.append([it.client_order_id for it in intents]) - return [] - - execution = _ExecutionCapture() - - def _spy_run_core_wakeup_step( - state: object, - entries: tuple[object, ...], - **kwargs: object, - ) -> CoreStepResult: - _ = state - wakeup_calls.append( - { - "entries": tuple(type(entry.event).__name__ for entry in entries), - "strategy_evaluator": kwargs["strategy_evaluator"], - } - ) - return CoreStepResult( - dispatchable_intents=(dispatchable,), - control_scheduling_obligation=None, - ) - - monkeypatch.setattr(strategy_runner_module, "run_core_wakeup_step", _spy_run_core_wakeup_step) - monkeypatch.setattr( - runner.risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("collapse control-only path must not call runtime risk gate") - ), - ) - - venue = _StubVenue( - rc_sequence=[0, 0, 1], - ts_sequence=[1, 10, 11], - ) - runner.run(venue=venue, execution=execution, recorder=_RecorderWrapper()) - - assert len(wakeup_calls) == 1 - wakeup_call = wakeup_calls[0] - assert wakeup_call["entries"] == ("ControlTimeEvent",) - assert wakeup_call["strategy_evaluator"] is None - assert execution.batches == [[dispatchable.client_order_id]] - assert runner._pending_control_scheduling_obligation is None - assert runner._next_send_ts_ns_local is None - assert runner._last_injected_control_deadline_ns == 5 - - -def test_wakeup_collapse_failure_before_result_preserves_pending_and_cursor( - monkeypatch: pytest.MonkeyPatch, -) -> None: - pending = _obligation(due_ts_ns_local=5, obligation_key="pending-failure") - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - runner._pending_control_scheduling_obligation = pending - runner._next_send_ts_ns_local = pending.due_ts_ns_local - submitted_count = 0 - mark_count = 0 - - monkeypatch.setattr( - strategy_runner_module, - "run_core_wakeup_step", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom-collapse")), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_count += 1 - - def _spy_mark(*args: object, **kwargs: object) -> None: - nonlocal mark_count - _ = (args, kwargs) - mark_count += 1 - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark) - - class _ExecutionMustNotRun: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - raise AssertionError("dispatch must not run after collapse wakeup failure") - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - with pytest.raises(RuntimeError, match="boom-collapse"): - runner.run(venue=venue, execution=_ExecutionMustNotRun(), recorder=_RecorderWrapper()) - - assert submitted_count == 0 - assert mark_count == 0 - assert runner._pending_control_scheduling_obligation == pending - assert runner._next_send_ts_ns_local == pending.due_ts_ns_local - assert runner._last_injected_control_deadline_ns is None - assert runner._event_stream_cursor.next_index == 0 - - -def test_wakeup_collapse_same_deadline_injected_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - runner._next_send_ts_ns_local = 5 - calls = {"count": 0} - - def _spy_run_core_wakeup_step(*args: object, **kwargs: object) -> CoreStepResult: - _ = (args, kwargs) - calls["count"] += 1 - return CoreStepResult() - - monkeypatch.setattr( - strategy_runner_module, - "run_core_wakeup_step", - _spy_run_core_wakeup_step, - ) - venue = _StubVenue( - rc_sequence=[0, 0, 0, 1], - ts_sequence=[1, 10, 10, 11], - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert calls["count"] == 1 - - -def test_wakeup_collapse_failed_new_dispatch_records_errors_without_submitted_or_mark( - monkeypatch: pytest.MonkeyPatch, -) -> None: - dispatchable_new = _new_intent(ts_ns_local=10).model_copy( - update={"client_order_id": "cid-collapse-failed-new"} - ) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - submitted_count = 0 - marked_count = 0 - - monkeypatch.setattr( - strategy_runner_module, - "run_core_wakeup_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(dispatchable_new,), - ), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_count += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - nonlocal marked_count - _ = (instrument, client_order_id, intent_type) - marked_count += 1 - - class _ExecutionFailNew: - def apply_intents(self, intents: list[Any]) -> list[tuple[Any, str]]: - _ = intents - return [(dispatchable_new, "EXCHANGE_REJECT")] - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_ExecutionFailNew(), recorder=_RecorderWrapper()) - - assert submitted_count == 0 - assert marked_count == 0 - assert len(runner._last_core_step_execution_errors) == 1 - failed_intent, failure_reason = runner._last_core_step_execution_errors[0] - assert failed_intent.client_order_id == dispatchable_new.client_order_id - assert failure_reason == "EXCHANGE_REJECT" - - -def test_wakeup_collapse_replace_cancel_emit_no_order_submitted( - monkeypatch: pytest.MonkeyPatch, -) -> None: - replace_intent = _replace_intent(ts_ns_local=10) - cancel_intent = _cancel_intent(ts_ns_local=10) - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_NoopStrategy(), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - enable_core_step_market_dispatch=True, - enable_core_step_control_time_dispatch=True, - enable_core_step_wakeup_collapse=True, - ) - submitted_count = 0 - marks: list[tuple[str, str, str]] = [] - - monkeypatch.setattr( - strategy_runner_module, - "run_core_wakeup_step", - lambda *args, **kwargs: CoreStepResult( - dispatchable_intents=(replace_intent, cancel_intent), - ), - ) - - def _spy_process_event_entry(state: object, entry: object, *, configuration: object) -> None: - nonlocal submitted_count - _ = (state, configuration) - if isinstance(entry.event, OrderSubmittedEvent): - submitted_count += 1 - - def _spy_mark_intent_sent(instrument: str, client_order_id: str, intent_type: str) -> None: - marks.append((instrument, client_order_id, intent_type)) - - monkeypatch.setattr(strategy_runner_module, "process_event_entry", _spy_process_event_entry) - monkeypatch.setattr(runner.strategy_state, "mark_intent_sent", _spy_mark_intent_sent) - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 10, 11], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert submitted_count == 0 - assert marks == [ - (replace_intent.instrument, replace_intent.client_order_id, "replace"), - (cancel_intent.instrument, cancel_intent.client_order_id, "cancel"), - ] - assert runner._last_core_step_execution_errors == [] - - -def test_fallback_second_boundary_wakeup_behavior_unchanged( - monkeypatch: pytest.MonkeyPatch, -) -> None: - intent = _new_intent() - runner = HftStrategyRunner( - engine_cfg=_engine_cfg(), - strategy=_EmitIntentsStrategy([intent]), - risk_cfg=_risk_cfg(), - core_cfg=_core_cfg(), - ) - runner.strategy_state.queued_intents.setdefault(runner.engine_cfg.instrument, deque()) - runner.strategy_state.queued_intents[runner.engine_cfg.instrument].append( - SimpleNamespace(intent=intent) - ) - - monkeypatch.setattr(runner.risk, "decide_intents", lambda **_: _decision_for([])) - - venue = _StubVenue( - rc_sequence=[0, 2, 1], - ts_sequence=[1, 2_000_000_000, 2_000_000_001], - depth=_depth_snapshot(), - ) - runner.run(venue=venue, execution=_NoopExecution(), recorder=_RecorderWrapper()) - - assert runner._next_send_ts_ns_local == 3_000_000_000 + self._now = 100 + self.wait_call_count = 0 + + def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: + _ = include_order_resp + self.wait_call_count += 1 + if self.wait_call_count == 1: + self._now = 100 + return 0 + if self.wait_call_count == 2: + assert timeout_ns > 0 + self._now = 100 + return 0 + self._now = 101 + return 1 + + def current_timestamp_ns(self) -> int: + return self._now + + def read_market_snapshot(self) -> Any: + raise AssertionError("market snapshot should not be needed") + + def read_orders_snapshot(self) -> tuple[Any, Any]: + raise AssertionError("orders snapshot should not be needed") + + def record(self, _recorder: Any) -> bool: + return False + + venue = _PendingAwareVenue() + runner.run(venue, execution, recorder) + + assert venue.wait_call_count == 3 From 10615de843825e528cb840717862ba0aaa53c562 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 16 May 2026 09:04:39 +0000 Subject: [PATCH 18/18] chore: fix SHA --- requirements-dev.txt | 2 +- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements-dev.txt b/requirements-dev.txt index 64f0e7c..5523d8a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -331,7 +331,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@9798fe189265d4421aadb92b58090cf9599db851 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b50834e41facc19bfc638cd5b053dddf90640c0c # via -r _git_deps.in typing-extensions==4.15.0 # via diff --git a/requirements.txt b/requirements.txt index a259cb2..ca07101 100644 --- a/requirements.txt +++ b/requirements.txt @@ -307,7 +307,7 @@ tornado==6.5.4 # via bokeh tqdm==4.67.3 # via panel -tradingchassis-core @ git+https://github.com/TradingChassis/core.git@9798fe189265d4421aadb92b58090cf9599db851 +tradingchassis-core @ git+https://github.com/TradingChassis/core.git@b50834e41facc19bfc638cd5b053dddf90640c0c # via -r _git_deps.in typing-extensions==4.15.0 # via