From 922d47e2835145efbd70e6be5f1199b1520f2430 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 13:04:16 +0000 Subject: [PATCH 01/53] feat: expose structured control scheduling obligations on gate decisions --- .../models/test_event_taxonomy_boundary.py | 21 +++++++++++++++++++ ..._scheduling_obligation_characterization.py | 16 ++++++++++++-- .../execution_control/execution_control.py | 8 +++++-- .../core/execution_control/types.py | 21 ++++++++++++++++++- tradingchassis_core/core/risk/risk_engine.py | 13 ++++++++++-- 5 files changed, 72 insertions(+), 7 deletions(-) diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index ac622f5..6e812d8 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -161,3 +161,24 @@ def test_process_canonical_event_rejects_derived_fill_event_guard() -> None: else: raise AssertionError("Expected process_canonical_event to reject DerivedFillEvent") + +def test_process_canonical_event_rejects_control_scheduling_obligation_guard() -> None: + """Canonical processing boundary rejects non-canonical control obligations.""" + + state = StrategyState(event_bus=NullEventBus()) + non_canonical_helper = ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + + try: + process_canonical_event(state, non_canonical_helper) + except TypeError as exc: + assert "Unsupported non-canonical event type" in str(exc) + else: + raise AssertionError( + "Expected process_canonical_event to reject ControlSchedulingObligation" + ) + diff --git a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py index e886c86..d754fd2 100644 --- a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py +++ b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py @@ -90,6 +90,10 @@ def test_rate_limited_mixed_intents_keep_minimum_next_send_timestamp() -> None: assert decision.rejected == [] assert len(decision.queued) == 2 assert decision.next_send_ts_ns_local == 500_000_000 + assert len(decision.control_scheduling_obligations) == 2 + assert min( + o.due_ts_ns_local for o in decision.control_scheduling_obligations + ) == 500_000_000 def test_rate_limit_routing_sets_internal_obligation_reason_characterization() -> None: @@ -118,6 +122,14 @@ def test_rate_limit_routing_sets_internal_obligation_reason_characterization() - assert result.accept_now is False assert result.stage_to_queue is True assert result.scheduling_obligation is not None - assert result.scheduling_obligation.ts_ns_local == 1_000_000_000 - assert result.scheduling_obligation.reason == "rate_limit" + obligation = result.scheduling_obligation + assert obligation.due_ts_ns_local == 1_000_000_000 + assert obligation.ts_ns_local == 1_000_000_000 + assert obligation.reason == "rate_limit" + assert obligation.scope_key == "instrument:BTC-USDC-PERP" + assert obligation.source == "execution_control_rate_limit" + assert ( + obligation.obligation_key + == "execution_control_rate_limit|instrument:BTC-USDC-PERP|rate_limit|1000000000" + ) diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index 66f47e4..dc9cdb3 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -101,8 +101,10 @@ def route_after_policy_rate_limit( accept_now=False, stage_to_queue=True, scheduling_obligation=ControlSchedulingObligation( - ts_ns_local=wake_ts, + due_ts_ns_local=wake_ts, reason="rate_limit", + scope_key=f"instrument:{it.instrument}", + source="execution_control_rate_limit", ), ) return _RateRoutingResult( @@ -120,8 +122,10 @@ def route_after_policy_rate_limit( accept_now=False, stage_to_queue=True, scheduling_obligation=ControlSchedulingObligation( - ts_ns_local=wake_ts, + due_ts_ns_local=wake_ts, reason="rate_limit", + scope_key=f"instrument:{it.instrument}", + source="execution_control_rate_limit", ), ) diff --git a/tradingchassis_core/core/execution_control/types.py b/tradingchassis_core/core/execution_control/types.py index 7d15a32..ae44282 100644 --- a/tradingchassis_core/core/execution_control/types.py +++ b/tradingchassis_core/core/execution_control/types.py @@ -16,6 +16,25 @@ class ControlSchedulingObligation: This is a derived control signal (not an Event) and does not mutate State. """ - ts_ns_local: int + due_ts_ns_local: int reason: str + scope_key: str + source: str + obligation_key: str = "" + + def __post_init__(self) -> None: + if self.obligation_key: + return + object.__setattr__( + self, + "obligation_key", + ( + f"{self.source}|{self.scope_key}|{self.reason}|{self.due_ts_ns_local}" + ), + ) + + @property + def ts_ns_local(self) -> int: + """Compatibility alias for pre-16F callers/tests.""" + return self.due_ts_ns_local diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 20d0d17..1ef1678 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -10,6 +10,9 @@ from tradingchassis_core.core.domain.types import OrderIntent, RiskConstraints from tradingchassis_core.core.events.events import RiskDecisionEvent from tradingchassis_core.core.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ( + ControlSchedulingObligation, +) from tradingchassis_core.core.ports.venue_policy import VenuePolicy from tradingchassis_core.core.risk.risk_policy import RiskPolicy @@ -59,6 +62,7 @@ class GateDecision: # Populated by the runner after outbound execution. execution_rejected: list[RejectedIntent] next_send_ts_ns_local: int | None + control_scheduling_obligations: tuple[ControlSchedulingObligation, ...] = () class RiskEngine: @@ -223,6 +227,7 @@ def decide_intents( # Intents that ended up queued due to rate limits or due to queue-only handling. queued: list[OrderIntent] = [] next_send_ts: int | None = None + control_scheduling_obligations: list[ControlSchedulingObligation] = [] # counters for RiskDecisionEvent reject_counts: dict[str, int] = {} @@ -250,6 +255,7 @@ def _count_reject(reason: str) -> None: handled_in_queue=[], execution_rejected=[], next_send_ts_ns_local=None, + control_scheduling_obligations=(), ) # emit summary @@ -289,6 +295,7 @@ def _count_reject(reason: str) -> None: handled_in_queue=[], execution_rejected=[], next_send_ts_ns_local=None, + control_scheduling_obligations=(), ) self._event_bus.emit( @@ -397,10 +404,11 @@ def _count_reject(reason: str) -> None: to_queue_by_instr[it.instrument].append(it) obligation = rate_result.scheduling_obligation if obligation is not None: + control_scheduling_obligations.append(obligation) next_send_ts = ( - obligation.ts_ns_local + obligation.due_ts_ns_local if next_send_ts is None - else min(next_send_ts, obligation.ts_ns_local) + else min(next_send_ts, obligation.due_ts_ns_local) ) continue @@ -427,6 +435,7 @@ def _count_reject(reason: str) -> None: handled_in_queue=handled_in_queue, execution_rejected=[], next_send_ts_ns_local=next_send_ts, + control_scheduling_obligations=tuple(control_scheduling_obligations), ) self._event_bus.emit( From 6c1fe9aa85ceba7afd2d01d937a2b3c51fd08de4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 20:23:45 +0000 Subject: [PATCH 02/53] feat(core): add CoreStepResult model --- .../models/test_core_step_result_contract.py | 109 ++++++++++++++++++ tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 5 + .../core/domain/step_result.py | 32 +++++ 4 files changed, 148 insertions(+) create mode 100644 tests/semantics/models/test_core_step_result_contract.py create mode 100644 tradingchassis_core/core/domain/step_result.py diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py new file mode 100644 index 0000000..04afa32 --- /dev/null +++ b/tests/semantics/models/test_core_step_result_contract.py @@ -0,0 +1,109 @@ +"""Semantics tests for the CoreStepResult contract model.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_result import CoreStepResult +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_default_result_is_empty_and_none_compat() -> None: + result = CoreStepResult() + + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + assert result.compat_gate_decision is None + + +def test_result_is_immutable() -> None: + result = CoreStepResult() + + with pytest.raises(FrozenInstanceError): + result.compat_gate_decision = None + + +def test_dispatchable_intents_normalize_to_tuple() -> None: + intent_one = _new_intent(client_order_id="new-1") + intent_two = _new_intent(client_order_id="new-2") + + result = CoreStepResult(dispatchable_intents=[intent_one, intent_two]) + + assert isinstance(result.dispatchable_intents, tuple) + assert result.dispatchable_intents == (intent_one, intent_two) + + +def test_can_carry_optional_control_scheduling_obligation() -> None: + obligation = ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + + result = CoreStepResult(control_scheduling_obligation=obligation) + + assert result.control_scheduling_obligation is obligation + + +def test_can_carry_optional_compat_gate_decision() -> None: + accepted_intent = _new_intent(client_order_id="accepted-now") + compat_decision = GateDecision( + ts_ns_local=123, + accepted_now=[accepted_intent], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + result = CoreStepResult(compat_gate_decision=compat_decision) + + assert result.compat_gate_decision is compat_decision + + +def test_core_step_result_is_non_canonical_and_not_classified() -> None: + assert is_canonical_stream_candidate_type(CoreStepResult) is False + assert canonical_category_for_type(CoreStepResult) is None + + +def test_canonical_processing_boundary_rejects_core_step_result() -> None: + state = StrategyState(event_bus=NullEventBus()) + + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, CoreStepResult()) + + +def test_public_root_export_identity_when_root_exported() -> None: + assert hasattr(tc, "CoreStepResult") + assert tc.CoreStepResult is CoreStepResult diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index e4b0063..a37764e 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -35,6 +35,7 @@ # Domain Types (used by strategies) # ---------------------------------------------------------------------- from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( MarketEvent, NewOrderIntent, @@ -88,6 +89,7 @@ "EventStreamEntry", "process_event_entry", "fold_event_stream_entries", + "CoreStepResult", # Version "__version__", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index e69de29..6cd1e3b 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -0,0 +1,5 @@ +"""Public exports for core domain value objects.""" + +from tradingchassis_core.core.domain.step_result import CoreStepResult + +__all__ = ["CoreStepResult"] diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py new file mode 100644 index 0000000..5bf9a2e --- /dev/null +++ b/tradingchassis_core/core/domain/step_result.py @@ -0,0 +1,32 @@ +"""Core step result contract model. + +This value object is the future return contract for a higher-level Core step. +It carries deterministic runtime-facing effects and optional compatibility +payloads without changing canonical reducer semantics. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +@dataclass(frozen=True, slots=True) +class CoreStepResult: + """Immutable result object for the future Core processing step API.""" + + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + compat_gate_decision: GateDecision | None = None + + def __post_init__(self) -> None: + # Normalize sequence-like inputs to a tuple to keep deterministic value semantics. + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) From aa91f9273f1a739de1ada5f3283025c1c21f58d7 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 20:36:29 +0000 Subject: [PATCH 03/53] feat(core): add run_core_step skeleton --- .../models/test_core_step_api_contract.py | 298 ++++++++++++++++++ tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 3 +- .../core/domain/processing_step.py | 31 ++ 4 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 tests/semantics/models/test_core_step_api_contract.py create mode 100644 tradingchassis_core/core/domain/processing_step.py diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py new file mode 100644 index 0000000..c6b597d --- /dev/null +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -0,0 +1,298 @@ +"""Semantics tests for the transitional Core step API skeleton.""" + +from __future__ import annotations + +import copy + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain import run_core_step as domain_run_core_step +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +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.processing_step import run_core_step +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_result import CoreStepResult +from tradingchassis_core.core.domain.types import ( + FillEvent, + MarketEvent, + NewOrderIntent, + OrderStateEvent, + Price, + Quantity, +) +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: + return MarketEvent( + ts_ns_local=ts_ns_local, + ts_ns_exch=ts_ns_exch, + instrument=instrument, + event_type="book", + book={ + "book_type": "snapshot", + "bids": [ + { + "price": {"currency": "USDC", "value": 100.0}, + "quantity": {"unit": "contracts", "value": 2.0}, + } + ], + "asks": [ + { + "price": {"currency": "USDC", "value": 101.0}, + "quantity": {"unit": "contracts", "value": 3.0}, + } + ], + "depth": 1, + }, + trade=None, + ) + + +def _fill_event( + *, + instrument: str, + client_order_id: str, + ts_ns_local: int, + ts_ns_exch: int, + cum_filled_qty: float = 0.25, +) -> FillEvent: + return FillEvent( + ts_ns_local=ts_ns_local, + ts_ns_exch=ts_ns_exch, + instrument=instrument, + client_order_id=client_order_id, + side="buy", + intended_price=Price(currency="USDC", value=100.0), + filled_price=Price(currency="USDC", value=100.5), + intended_qty=Quantity(unit="contracts", value=1.0), + cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), + remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), + time_in_force="GTC", + liquidity_flag="maker", + fee=None, + ) + + +def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: + return OrderStateEvent( + ts_ns_local=300, + ts_ns_exch=290, + instrument=instrument, + client_order_id=client_order_id, + order_type="limit", + state_type="accepted", + side="buy", + intended_price=Price(currency="USDC", value=100.0), + filled_price=None, + intended_qty=Quantity(unit="contracts", value=1.0), + cum_filled_qty=None, + remaining_qty=None, + time_in_force="GTC", + reason=None, + raw={"req": 0, "source": "snapshot"}, + ) + + +def _market_configuration(*, instrument: str = "BTC-USDC-PERP") -> tc.CoreConfiguration: + return tc.CoreConfiguration( + version="v1", + payload={ + "market": { + "instruments": { + instrument: { + "tick_size": 0.1, + "lot_size": 0.01, + "contract_size": 1.0, + } + } + } + }, + ) + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: + return { + "market": copy.deepcopy(state.market), + "fills": copy.deepcopy(state.fills), + "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), + "last_processing_position_index": state._last_processing_position_index, + } + + +def test_run_core_step_public_exports_identity() -> None: + assert domain_run_core_step is run_core_step + assert hasattr(tc, "run_core_step") + assert tc.run_core_step is run_core_step + + +def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: + baseline_state = StrategyState(event_bus=NullEventBus()) + skeleton_state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=5), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-1", + ts_ns_local=200, + ts_ns_exch=180, + ), + ) + + process_event_entry(baseline_state, entry) + result = run_core_step(skeleton_state, entry) + + assert isinstance(result, CoreStepResult) + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + assert result.compat_gate_decision is None + assert _state_subset_snapshot(skeleton_state) == _state_subset_snapshot(baseline_state) + + +def test_run_core_step_propagates_non_canonical_rejection() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=1), + event=_order_state_event( + instrument="BTC-USDC-PERP", + client_order_id="order-compat-1", + ), + ) + + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + run_core_step(state, entry) + + +def test_run_core_step_propagates_non_monotonic_position_and_preserves_state() -> None: + state = StrategyState(event_bus=NullEventBus()) + first = EventStreamEntry( + position=ProcessingPosition(index=10), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-1", + ts_ns_local=100, + ts_ns_exch=90, + ), + ) + second = EventStreamEntry( + position=ProcessingPosition(index=11), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-1", + ts_ns_local=101, + ts_ns_exch=91, + cum_filled_qty=0.5, + ), + ) + repeated = EventStreamEntry( + position=ProcessingPosition(index=11), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-1", + ts_ns_local=102, + ts_ns_exch=92, + cum_filled_qty=0.75, + ), + ) + + run_core_step(state, first) + run_core_step(state, second) + before = _state_subset_snapshot(state) + + with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): + run_core_step(state, repeated) + + assert _state_subset_snapshot(state) == before + + +def test_run_core_step_positioned_market_requires_configuration() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=0), + event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), + ) + + with pytest.raises( + ValueError, + match="CoreConfiguration is required for positioned canonical MarketEvent processing", + ): + run_core_step(state, entry, configuration=None) + + +def test_run_core_step_passes_configuration_through_to_market_processing() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=0), + event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), + ) + + result = run_core_step(state, entry, configuration=_market_configuration()) + + market = state.market["BTC-USDC-PERP"] + assert isinstance(result, CoreStepResult) + assert state._last_processing_position_index == 0 + assert market.best_bid == 100.0 + assert market.best_ask == 101.0 + + +def test_run_core_step_boundary_remains_non_canonical_for_compatibility_artifacts() -> None: + assert is_canonical_stream_candidate_type(CoreStepResult) is False + assert canonical_category_for_type(CoreStepResult) is None + assert is_canonical_stream_candidate_type(ControlSchedulingObligation) is False + assert canonical_category_for_type(ControlSchedulingObligation) is None + assert is_canonical_stream_candidate_type(GateDecision) is False + assert canonical_category_for_type(GateDecision) is None + + state = StrategyState(event_bus=NullEventBus()) + entries = ( + EventStreamEntry(position=ProcessingPosition(index=1), event=CoreStepResult()), + EventStreamEntry( + position=ProcessingPosition(index=2), + event=ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ), + ), + EventStreamEntry( + position=ProcessingPosition(index=3), + event=GateDecision( + ts_ns_local=123, + accepted_now=[_new_intent(client_order_id="accepted-now")], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ), + ), + ) + + for entry in entries: + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + run_core_step(state, entry) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index a37764e..afa9a15 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -17,6 +17,7 @@ EventStreamEntry, ProcessingPosition, ) +from tradingchassis_core.core.domain.processing_step import run_core_step # ---------------------------------------------------------------------- # Backtest Engine API @@ -88,6 +89,7 @@ "ProcessingPosition", "EventStreamEntry", "process_event_entry", + "run_core_step", "fold_event_stream_entries", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 6cd1e3b..69347bf 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -1,5 +1,6 @@ """Public exports for core domain value objects.""" +from tradingchassis_core.core.domain.processing_step import run_core_step from tradingchassis_core.core.domain.step_result import CoreStepResult -__all__ = ["CoreStepResult"] +__all__ = ["CoreStepResult", "run_core_step"] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py new file mode 100644 index 0000000..318dfea --- /dev/null +++ b/tradingchassis_core/core/domain/processing_step.py @@ -0,0 +1,31 @@ +"""Higher-level Core step API skeleton. + +This module defines a transitional deterministic step entrypoint above the +canonical reducer boundary. In this phase, it delegates to process_event_entry +and returns an empty CoreStepResult contract value. +""" + +from __future__ import annotations + +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 + + +def run_core_step( + state: StrategyState, + entry: EventStreamEntry, + *, + configuration: CoreConfiguration | None = None, +) -> CoreStepResult: + """Run one transitional Core step. + + Behavior in this phase: + - delegates event processing to the canonical boundary via process_event_entry; + - propagates reducer/boundary exceptions unchanged; + - returns an empty CoreStepResult for future deterministic effects. + """ + process_event_entry(state, entry, configuration=configuration) + return CoreStepResult() From 852babf6cfc87e0a935d8edd488581ba83ff0ed1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 21:34:53 +0000 Subject: [PATCH 04/53] feat(core): reevaluate control-time queues in core step --- .../models/test_core_step_api_contract.py | 190 +++++++++++++++++- tradingchassis_core/__init__.py | 6 +- tradingchassis_core/core/domain/__init__.py | 7 +- .../core/domain/processing_step.py | 56 +++++- 4 files changed, 254 insertions(+), 5 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index c6b597d..f9209bb 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -7,6 +7,7 @@ import pytest import tradingchassis_core as tc +import tradingchassis_core.core.domain.processing_step as processing_step_module from tradingchassis_core.core.domain import run_core_step as domain_run_core_step from tradingchassis_core.core.domain.event_model import ( canonical_category_for_type, @@ -14,10 +15,14 @@ ) 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.processing_step import run_core_step +from tradingchassis_core.core.domain.processing_step import ( + ControlTimeQueueReevaluationContext, + run_core_step, +) from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, NewOrderIntent, @@ -118,6 +123,22 @@ def _market_configuration(*, instrument: str = "BTC-USDC-PERP") -> tc.CoreConfig ) +def _control_time_event( + *, + due_ts_ns_local: int, + realized_ts_ns_local: int, +) -> ControlTimeEvent: + return ControlTimeEvent( + ts_ns_local_control=realized_ts_ns_local, + reason="scheduled_control_recheck", + due_ts_ns_local=due_ts_ns_local, + realized_ts_ns_local=realized_ts_ns_local, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=due_ts_ns_local, + runtime_correlation=None, + ) + + def _new_intent(*, client_order_id: str) -> NewOrderIntent: return NewOrderIntent( ts_ns_local=1, @@ -296,3 +317,170 @@ def test_run_core_step_boundary_remains_non_canonical_for_compatibility_artifact for entry in entries: with pytest.raises(TypeError, match="Unsupported non-canonical event type"): run_core_step(state, entry) + + +def test_run_core_step_control_time_with_context_processes_canonical_then_queue_and_risk() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-1") + state.merge_intents_into_queue(instrument, [queued_intent]) + + calls: list[str] = [] + popped_raw_intents: list[list[NewOrderIntent]] = [] + + original_pop = state.pop_queued_intents + + def _spy_pop_queued_intents(target_instrument: str) -> list[NewOrderIntent]: + assert target_instrument == instrument + # Canonical processing runs first and advances the positioned cursor. + assert state._last_processing_position_index == 7 + calls.append("pop") + return original_pop(target_instrument) # type: ignore[return-value] + + state.pop_queued_intents = _spy_pop_queued_intents # type: ignore[method-assign] + + accepted_now = _new_intent(client_order_id="accepted-now") + obligation_a = ControlSchedulingObligation( + due_ts_ns_local=42, + reason="rate_limit", + scope_key=f"instrument:{instrument}", + source="execution_control_rate_limit", + obligation_key="z-key", + ) + obligation_b = ControlSchedulingObligation( + due_ts_ns_local=42, + reason="rate_limit", + scope_key=f"instrument:{instrument}", + source="execution_control_rate_limit", + obligation_key="a-key", + ) + obligation_c = ControlSchedulingObligation( + due_ts_ns_local=17, + reason="rate_limit", + scope_key=f"instrument:{instrument}", + source="execution_control_rate_limit", + obligation_key="x-key", + ) + + class _RiskSpy: + def decide_intents( + self, + *, + raw_intents: list[NewOrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> GateDecision: + assert state is not None + assert now_ts_ns_local == 1_000 + calls.append("risk") + popped_raw_intents.append(list(raw_intents)) + return GateDecision( + ts_ns_local=now_ts_ns_local, + accepted_now=[accepted_now], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=17, + control_scheduling_obligations=(obligation_a, obligation_b, obligation_c), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=7), + event=_control_time_event(due_ts_ns_local=999, realized_ts_ns_local=1_000), + ) + result = run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + instrument=instrument, + now_ts_ns_local=1_000, + ), + ) + + assert calls == ["pop", "risk"] + assert len(popped_raw_intents) == 1 + assert [it.client_order_id for it in popped_raw_intents[0]] == [queued_intent.client_order_id] + assert tuple(it.client_order_id for it in result.dispatchable_intents) == ("accepted-now",) + assert result.compat_gate_decision is not None + assert result.control_scheduling_obligation is not None + assert result.control_scheduling_obligation.due_ts_ns_local == 17 + assert result.control_scheduling_obligation.obligation_key == "x-key" + + +def test_run_core_step_non_control_time_ignores_control_time_context() -> None: + state = StrategyState(event_bus=NullEventBus()) + + class _RiskMustNotRun: + def decide_intents(self, **_: object) -> GateDecision: + raise AssertionError("risk must not run for non-control events") + + def _pop_must_not_run(_: str) -> list[NewOrderIntent]: + raise AssertionError("queue pop must not run for non-control events") + + state.pop_queued_intents = _pop_must_not_run # type: ignore[method-assign] + + entry = EventStreamEntry( + position=ProcessingPosition(index=5), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-no-control", + ts_ns_local=5, + ts_ns_exch=4, + ), + ) + result = run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] + instrument="BTC-USDC-PERP", + now_ts_ns_local=5, + ), + ) + + assert result == CoreStepResult() + + +def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"pop": 0, "risk": 0} + + def _pop_spy(_: str) -> list[NewOrderIntent]: + calls["pop"] += 1 + return [] + + state.pop_queued_intents = _pop_spy # type: ignore[method-assign] + + class _RiskSpy: + def decide_intents(self, **_: object) -> GateDecision: + calls["risk"] += 1 + raise AssertionError("risk should not run when boundary fails first") + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + + entry = EventStreamEntry( + position=ProcessingPosition(index=9), + event=_control_time_event(due_ts_ns_local=9, realized_ts_ns_local=9), + ) + + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + instrument="BTC-USDC-PERP", + now_ts_ns_local=9, + ), + ) + + assert calls == {"pop": 0, "risk": 0} diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index afa9a15..7c3479a 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -17,7 +17,10 @@ EventStreamEntry, ProcessingPosition, ) -from tradingchassis_core.core.domain.processing_step import run_core_step +from tradingchassis_core.core.domain.processing_step import ( + ControlTimeQueueReevaluationContext, + run_core_step, +) # ---------------------------------------------------------------------- # Backtest Engine API @@ -90,6 +93,7 @@ "EventStreamEntry", "process_event_entry", "run_core_step", + "ControlTimeQueueReevaluationContext", "fold_event_stream_entries", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 69347bf..0a76051 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -1,6 +1,9 @@ """Public exports for core domain value objects.""" -from tradingchassis_core.core.domain.processing_step import run_core_step +from tradingchassis_core.core.domain.processing_step import ( + ControlTimeQueueReevaluationContext, + run_core_step, +) from tradingchassis_core.core.domain.step_result import CoreStepResult -__all__ = ["CoreStepResult", "run_core_step"] +__all__ = ["CoreStepResult", "ControlTimeQueueReevaluationContext", "run_core_step"] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 318dfea..4813cee 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -7,11 +7,43 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import TYPE_CHECKING + 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 ControlTimeEvent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + +if TYPE_CHECKING: + from tradingchassis_core.core.risk.risk_engine import GateDecision, RiskEngine + + +@dataclass(frozen=True, slots=True) +class ControlTimeQueueReevaluationContext: + """Deterministic context for control-time queue re-evaluation in Core.""" + + risk_engine: RiskEngine + instrument: str + now_ts_ns_local: int + + +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 run_core_step( @@ -19,6 +51,7 @@ def run_core_step( entry: EventStreamEntry, *, configuration: CoreConfiguration | None = None, + control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, ) -> CoreStepResult: """Run one transitional Core step. @@ -28,4 +61,25 @@ def run_core_step( - returns an empty CoreStepResult for future deterministic effects. """ process_event_entry(state, entry, configuration=configuration) - return CoreStepResult() + + if not isinstance(entry.event, ControlTimeEvent): + return CoreStepResult() + + if control_time_queue_context is None: + return CoreStepResult() + + popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) + if not popped_intents: + return CoreStepResult() + + decision = control_time_queue_context.risk_engine.decide_intents( + raw_intents=popped_intents, + state=state, + now_ts_ns_local=control_time_queue_context.now_ts_ns_local, + ) + selected_obligation = _select_effective_control_scheduling_obligation(decision) + return CoreStepResult( + dispatchable_intents=tuple(decision.accepted_now), + control_scheduling_obligation=selected_obligation, + compat_gate_decision=decision, + ) From ad4b84d7ec3521568979a1c918177087b9438a6e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 22:37:14 +0000 Subject: [PATCH 05/53] feat(core): scaffold strategy evaluation in core step --- .../models/test_core_step_api_contract.py | 162 ++++++++++++++++++ .../core/domain/processing_step.py | 40 ++++- 2 files changed, 199 insertions(+), 3 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index f9209bb..207416d 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -17,6 +17,7 @@ from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, + CoreStepStrategyContext, run_core_step, ) from tradingchassis_core.core.domain.state import StrategyState @@ -191,6 +192,26 @@ def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: assert _state_subset_snapshot(skeleton_state) == _state_subset_snapshot(baseline_state) +def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() -> None: + baseline_state = StrategyState(event_bus=NullEventBus()) + no_strategy_state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=6), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-omitted-evaluator", + ts_ns_local=300, + ts_ns_exch=280, + ), + ) + + process_event_entry(baseline_state, entry) + result = run_core_step(no_strategy_state, entry) + + assert result == CoreStepResult() + assert _state_subset_snapshot(no_strategy_state) == _state_subset_snapshot(baseline_state) + + def test_run_core_step_propagates_non_canonical_rejection() -> None: state = StrategyState(event_bus=NullEventBus()) entry = EventStreamEntry( @@ -277,6 +298,43 @@ def test_run_core_step_passes_configuration_through_to_market_processing() -> No assert market.best_ask == 101.0 +def test_run_core_step_calls_strategy_evaluator_once_with_post_reducer_context() -> None: + state = StrategyState(event_bus=NullEventBus()) + configuration = _market_configuration(instrument="BTC-USDC-PERP") + entry = EventStreamEntry( + position=ProcessingPosition(index=12), + event=_book_market_event( + instrument="BTC-USDC-PERP", + ts_ns_local=1_200, + ts_ns_exch=1_100, + ), + ) + generated_intent = _new_intent(client_order_id="generated-not-dispatchable-yet") + captured_contexts: list[CoreStepStrategyContext] = [] + + class _EvaluatorSpy: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + captured_contexts.append(context) + return [generated_intent] + + result = run_core_step( + state, + entry, + configuration=configuration, + strategy_evaluator=_EvaluatorSpy(), + ) + + assert len(captured_contexts) == 1 + context = captured_contexts[0] + assert context.event is entry.event + assert context.position == entry.position + assert context.configuration is configuration + assert context.state is state + assert context.state._last_processing_position_index == 12 + assert context.state.market["BTC-USDC-PERP"].best_bid == 100.0 + assert result == CoreStepResult() + + def test_run_core_step_boundary_remains_non_canonical_for_compatibility_artifacts() -> None: assert is_canonical_stream_candidate_type(CoreStepResult) is False assert canonical_category_for_type(CoreStepResult) is None @@ -445,6 +503,110 @@ def _pop_must_not_run(_: str) -> list[NewOrderIntent]: assert result == CoreStepResult() +def test_run_core_step_does_not_call_strategy_evaluator_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + called = {"evaluate": 0} + + class _EvaluatorSpy: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + _ = context + called["evaluate"] += 1 + return [] + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + + entry = EventStreamEntry( + position=ProcessingPosition(index=10), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-failure-evaluator", + ts_ns_local=10, + ts_ns_exch=9, + ), + ) + + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step(state, entry, strategy_evaluator=_EvaluatorSpy()) + + assert called == {"evaluate": 0} + + +def test_run_core_step_with_strategy_and_control_time_context_orders_calls_deterministically() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-with-strategy") + state.merge_intents_into_queue(instrument, [queued_intent]) + + calls: list[str] = [] + + class _EvaluatorSpy: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 20 + calls.append("evaluate") + return [_new_intent(client_order_id="generated-scaffold-only")] + + original_pop = state.pop_queued_intents + + def _spy_pop_queued_intents(target_instrument: str) -> list[NewOrderIntent]: + assert target_instrument == instrument + calls.append("pop") + return original_pop(target_instrument) # type: ignore[return-value] + + state.pop_queued_intents = _spy_pop_queued_intents # type: ignore[method-assign] + + accepted_now = _new_intent(client_order_id="accepted-control-time") + + class _RiskSpy: + def decide_intents( + self, + *, + raw_intents: list[NewOrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> GateDecision: + assert state is not None + assert now_ts_ns_local == 2_000 + assert [it.client_order_id for it in raw_intents] == [queued_intent.client_order_id] + calls.append("risk") + return GateDecision( + ts_ns_local=now_ts_ns_local, + accepted_now=[accepted_now], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=20), + event=_control_time_event(due_ts_ns_local=2_000, realized_ts_ns_local=2_000), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorSpy(), + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + instrument=instrument, + now_ts_ns_local=2_000, + ), + ) + + assert calls == ["evaluate", "pop", "risk"] + assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( + "accepted-control-time", + ) + + def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 4813cee..3949a24 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -8,20 +8,41 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, Sequence 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.processing_order import EventStreamEntry, ProcessingPosition from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult -from tradingchassis_core.core.domain.types import ControlTimeEvent +from tradingchassis_core.core.domain.types import ControlTimeEvent, OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: from tradingchassis_core.core.risk.risk_engine import GateDecision, RiskEngine +@dataclass(frozen=True, slots=True) +class CoreStepStrategyContext: + """Deterministic strategy-evaluation context for one Core step. + + ``state`` is currently passed by reference for compatibility. Strategy + evaluators must treat it as read-only by contract in this scaffold slice. + """ + + state: StrategyState + event: object + position: ProcessingPosition + configuration: CoreConfiguration | None = None + + +class CoreStepStrategyEvaluator(Protocol): + """Core-owned strategy evaluation protocol for unified step semantics.""" + + def evaluate(self, context: CoreStepStrategyContext) -> Sequence[OrderIntent]: + """Evaluate strategy once for the provided step context.""" + + @dataclass(frozen=True, slots=True) class ControlTimeQueueReevaluationContext: """Deterministic context for control-time queue re-evaluation in Core.""" @@ -52,6 +73,7 @@ def run_core_step( *, configuration: CoreConfiguration | None = None, control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, + strategy_evaluator: CoreStepStrategyEvaluator | None = None, ) -> CoreStepResult: """Run one transitional Core step. @@ -62,6 +84,18 @@ def run_core_step( """ process_event_entry(state, entry, configuration=configuration) + if strategy_evaluator is not None: + strategy_context = CoreStepStrategyContext( + state=state, + event=entry.event, + position=entry.position, + configuration=configuration, + ) + # Scaffold-only evaluation entry: + # - exactly one strategy call per successful Core step when provided + # - returned intents are intentionally not integrated yet + _ = tuple(strategy_evaluator.evaluate(strategy_context)) + if not isinstance(entry.event, ControlTimeEvent): return CoreStepResult() From 46b49187700927d25e2e50a82592ea24cdeec6b5 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 22:46:28 +0000 Subject: [PATCH 06/53] feat(core): capture generated intents in CoreStepResult --- .../models/test_core_step_api_contract.py | 53 ++++++++++++++++- .../models/test_core_step_result_contract.py | 58 ++++++++++++++++++- .../core/domain/processing_step.py | 13 ++--- .../core/domain/step_result.py | 7 +++ 4 files changed, 121 insertions(+), 10 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 207416d..44b4999 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -186,6 +186,7 @@ def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: result = run_core_step(skeleton_state, entry) assert isinstance(result, CoreStepResult) + assert result.generated_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None assert result.compat_gate_decision is None @@ -208,6 +209,7 @@ def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() process_event_entry(baseline_state, entry) result = run_core_step(no_strategy_state, entry) + assert result.generated_intents == () assert result == CoreStepResult() assert _state_subset_snapshot(no_strategy_state) == _state_subset_snapshot(baseline_state) @@ -332,7 +334,9 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: assert context.state is state assert context.state._last_processing_position_index == 12 assert context.state.market["BTC-USDC-PERP"].best_bid == 100.0 - assert result == CoreStepResult() + assert result.generated_intents == (generated_intent,) + assert result.dispatchable_intents == () + assert result.compat_gate_decision is None def test_run_core_step_boundary_remains_non_canonical_for_compatibility_artifacts() -> None: @@ -548,7 +552,7 @@ class _EvaluatorSpy: def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: assert context.state._last_processing_position_index == 20 calls.append("evaluate") - return [_new_intent(client_order_id="generated-scaffold-only")] + return [_new_intent(client_order_id="generated-captured")] original_pop = state.pop_queued_intents @@ -602,11 +606,56 @@ def decide_intents( ) assert calls == ["evaluate", "pop", "risk"] + assert tuple(it.client_order_id for it in result.generated_intents) == ( + "generated-captured", + ) assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( "accepted-control-time", ) +def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + state.merge_intents_into_queue(instrument, [_new_intent(client_order_id="queued-before-failure")]) + calls = {"pop": 0, "risk": 0} + + def _pop_spy(_: str) -> list[NewOrderIntent]: + calls["pop"] += 1 + return [] + + state.pop_queued_intents = _pop_spy # type: ignore[method-assign] + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 30 + raise RuntimeError("strategy evaluator failed") + + class _RiskSpy: + def decide_intents(self, **_: object) -> GateDecision: + calls["risk"] += 1 + raise AssertionError("risk should not run after strategy evaluator failure") + + entry = EventStreamEntry( + position=ProcessingPosition(index=30), + event=_control_time_event(due_ts_ns_local=30, realized_ts_ns_local=30), + ) + + with pytest.raises(RuntimeError, match="strategy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorBoom(), + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + instrument=instrument, + now_ts_ns_local=30, + ), + ) + + assert calls == {"pop": 0, "risk": 0} + + def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py index 04afa32..763b366 100644 --- a/tests/semantics/models/test_core_step_result_contract.py +++ b/tests/semantics/models/test_core_step_result_contract.py @@ -14,7 +14,13 @@ from tradingchassis_core.core.domain.processing import process_canonical_event from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, + NewOrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation from tradingchassis_core.core.risk.risk_engine import GateDecision @@ -37,6 +43,7 @@ def _new_intent(*, client_order_id: str) -> NewOrderIntent: def test_default_result_is_empty_and_none_compat() -> None: result = CoreStepResult() + assert result.generated_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None assert result.compat_gate_decision is None @@ -59,6 +66,55 @@ def test_dispatchable_intents_normalize_to_tuple() -> None: assert result.dispatchable_intents == (intent_one, intent_two) +def test_generated_intents_normalize_to_tuple() -> None: + intent_one = _new_intent(client_order_id="generated-1") + intent_two = _new_intent(client_order_id="generated-2") + + result = CoreStepResult(generated_intents=[intent_one, intent_two]) + + assert isinstance(result.generated_intents, tuple) + assert result.generated_intents == (intent_one, intent_two) + + +def test_generated_intents_are_distinct_from_dispatchable_intents() -> None: + generated = _new_intent(client_order_id="generated-only") + dispatchable = _new_intent(client_order_id="dispatchable-only") + + result = CoreStepResult( + generated_intents=[generated], + dispatchable_intents=[dispatchable], + ) + + assert result.generated_intents == (generated,) + assert result.dispatchable_intents == (dispatchable,) + + +def test_generated_intents_accept_new_replace_cancel_intents() -> None: + new_intent = _new_intent(client_order_id="new-intent") + replace_intent = ReplaceOrderIntent( + ts_ns_local=2, + instrument="BTC-USDC-PERP", + client_order_id="replace-intent", + intents_correlation_id="corr-replace", + side="buy", + order_type="limit", + intended_qty=Quantity(value=2.0, unit="contracts"), + intended_price=Price(currency="USDC", value=101.0), + ) + cancel_intent = CancelOrderIntent( + ts_ns_local=3, + instrument="BTC-USDC-PERP", + client_order_id="cancel-intent", + intents_correlation_id="corr-cancel", + ) + + result = CoreStepResult( + generated_intents=[new_intent, replace_intent, cancel_intent], + ) + + assert result.generated_intents == (new_intent, replace_intent, cancel_intent) + + def test_can_carry_optional_control_scheduling_obligation() -> None: obligation = ControlSchedulingObligation( due_ts_ns_local=1_000_000_000, diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 3949a24..3f19386 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -84,6 +84,7 @@ def run_core_step( """ process_event_entry(state, entry, configuration=configuration) + generated_intents: tuple[OrderIntent, ...] = () if strategy_evaluator is not None: strategy_context = CoreStepStrategyContext( state=state, @@ -91,20 +92,17 @@ def run_core_step( position=entry.position, configuration=configuration, ) - # Scaffold-only evaluation entry: - # - exactly one strategy call per successful Core step when provided - # - returned intents are intentionally not integrated yet - _ = tuple(strategy_evaluator.evaluate(strategy_context)) + generated_intents = tuple(strategy_evaluator.evaluate(strategy_context)) if not isinstance(entry.event, ControlTimeEvent): - return CoreStepResult() + return CoreStepResult(generated_intents=generated_intents) if control_time_queue_context is None: - return CoreStepResult() + return CoreStepResult(generated_intents=generated_intents) popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) if not popped_intents: - return CoreStepResult() + return CoreStepResult(generated_intents=generated_intents) decision = control_time_queue_context.risk_engine.decide_intents( raw_intents=popped_intents, @@ -113,6 +111,7 @@ def run_core_step( ) selected_obligation = _select_effective_control_scheduling_obligation(decision) return CoreStepResult( + generated_intents=generated_intents, dispatchable_intents=tuple(decision.accepted_now), control_scheduling_obligation=selected_obligation, compat_gate_decision=decision, diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index 5bf9a2e..91ff6fb 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -18,11 +18,18 @@ class CoreStepResult: """Immutable result object for the future Core processing step API.""" + generated_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation: ControlSchedulingObligation | None = None compat_gate_decision: GateDecision | None = None def __post_init__(self) -> None: + if not isinstance(self.generated_intents, tuple): + object.__setattr__( + self, + "generated_intents", + tuple(self.generated_intents), + ) # Normalize sequence-like inputs to a tuple to keep deterministic value semantics. if not isinstance(self.dispatchable_intents, tuple): object.__setattr__( From 258c9144961a399f6529375c1016a7f2c598eba6 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 6 May 2026 23:04:44 +0000 Subject: [PATCH 07/53] feat(core): scaffold candidate intent combination --- docs/core-responsibility-model.md | 137 +++++++++++++++++ ...t_candidate_intent_combination_contract.py | 142 ++++++++++++++++++ .../models/test_core_step_api_contract.py | 87 ++++++++++- .../models/test_core_step_result_contract.py | 22 +++ .../core/domain/intent_combination.py | 75 +++++++++ .../core/domain/processing_step.py | 40 ++++- tradingchassis_core/core/domain/state.py | 13 ++ .../core/domain/step_result.py | 7 + 8 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 docs/core-responsibility-model.md create mode 100644 tests/semantics/models/test_candidate_intent_combination_contract.py create mode 100644 tradingchassis_core/core/domain/intent_combination.py diff --git a/docs/core-responsibility-model.md b/docs/core-responsibility-model.md new file mode 100644 index 0000000..b872b72 --- /dev/null +++ b/docs/core-responsibility-model.md @@ -0,0 +1,137 @@ +# Core / Runtime Responsibility Model + +## Core concepts + +- **Events**: inputs into the Core. +- **ProcessingPosition**: deterministic processing order. +- **State**: derived state. +- **Configuration**: explicit parameters. +- **Strategy**: produces intents. +- **Intents**: Core-internal intent to act. +- **Risk**: decides what is allowed. +- **Execution Control / Queue Processing**: controls when and how intents may be sent. +- **CoreStepResult**: deterministic outputs. +- **Runtime**: IO, venue integration, time, submission, and feedback. +- **Orders**: outside the Core; they begin in the submission/venue world. +- **ControlSchedulingObligation**: non-canonical signal meaning “Runtime, please re-enter later.” +- **ControlTimeEvent**: canonical re-entry into the Core. + +--- + +## Core definition + +The Core is a deterministic trading decision engine. + +It processes: + +```text +EventStreamEntry + State + CoreConfiguration + Strategy +```` + +and produces: + +```text +new State + CoreStepResult +``` + +In short: + +```text +Core = deterministic trading engine +Runtime = orchestration + venue IO +``` + +--- + +## Requirements for real backtest/live parity + +### 1. Core Step as the single trading decision procedure + +Eventually, the Runtime must not execute Strategy in one place and Core in another. + +There should only be one trading decision entrypoint, for example: + +```text +Core.run_step(...) +``` + +All trading-domain logic should run inside that step. + +--- + +### 2. No hidden input sources + +The Core must not secretly read from: + +* current time +* random sources +* network +* venue state +* environment +* global mutable state + +Everything must be passed explicitly as one of: + +* Event +* State +* Configuration +* Strategy + +or as another explicit deterministic dependency. + +--- + +### 3. Strategy must be deterministic + +This is important: even if the Core is deterministic, a Strategy can break determinism if it uses random values, wall-clock time, network calls, or other hidden external inputs. + +Long term, the Strategy rule should be clear: + +```text +Strategy = pure-ish function of State + Configuration/context +``` + +Or at minimum: + +```text +All external inputs must be explicit in the Event Stream or Configuration. +``` + +--- + +### 4. Runtime may execute, but not decide + +The Runtime may: + +* send orders +* wait +* poll +* map venue data +* assign processing positions +* inject events + +Long term, the Runtime must not: + +* make risk decisions +* perform domain queue popping +* evaluate strategy +* apply rate-limit policy +* interpret business scheduling + +This is exactly the boundary we are currently moving toward. + +--- + +### 5. CoreStepResult must remain venue-neutral + +The Core must not output a “Binance order” or an “hftbacktest order.” + +The Core outputs: + +```text +dispatchable_intents +``` + +The Runtime or Venue Adapter turns those intents into real orders. + +This is the Intents-vs-Orders separation. diff --git a/tests/semantics/models/test_candidate_intent_combination_contract.py b/tests/semantics/models/test_candidate_intent_combination_contract.py new file mode 100644 index 0000000..5a64a87 --- /dev/null +++ b/tests/semantics/models/test_candidate_intent_combination_contract.py @@ -0,0 +1,142 @@ +"""Semantics tests for Core-step candidate intent combination helper.""" + +from __future__ import annotations + +from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents +from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, + NewOrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) + + +def _new(*, client_order_id: str, ts: int = 1) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=ts, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-new", + side="buy", + 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(*, client_order_id: str, ts: int = 1, px: float = 101.0) -> ReplaceOrderIntent: + return ReplaceOrderIntent( + ts_ns_local=ts, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-replace", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=px), + ) + + +def _cancel(*, client_order_id: str, ts: int = 1) -> CancelOrderIntent: + return CancelOrderIntent( + ts_ns_local=ts, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-cancel", + ) + + +def test_combine_candidate_intents_empty_inputs_returns_empty() -> None: + result = combine_candidate_intents(generated_intents=(), queued_intents=()) + assert result == () + + +def test_combine_candidate_intents_generated_only() -> None: + generated = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) + result = combine_candidate_intents(generated_intents=generated, queued_intents=()) + assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") + + +def test_combine_candidate_intents_queued_only() -> None: + queued = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) + result = combine_candidate_intents(generated_intents=(), queued_intents=queued) + assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") + + +def test_combine_candidate_intents_keeps_different_keys() -> None: + queued = (_new(client_order_id="order-a"),) + generated = (_replace(client_order_id="order-b"),) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert tuple((it.intent_type, it.client_order_id) for it in result) == ( + ("replace", "order-b"), + ("new", "order-a"), + ) + + +def test_generated_cancel_dominates_queued_replace_or_new_same_key() -> None: + key = "same-key" + queued = (_new(client_order_id=key), _replace(client_order_id=key)) + generated = (_cancel(client_order_id=key),) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert len(result) == 1 + assert result[0].intent_type == "cancel" + + +def test_generated_replace_dominates_queued_new_same_key() -> None: + key = "same-key" + queued = (_new(client_order_id=key),) + generated = (_replace(client_order_id=key),) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert len(result) == 1 + assert result[0].intent_type == "replace" + + +def test_queued_cancel_dominates_generated_replace_same_key() -> None: + key = "same-key" + queued = (_cancel(client_order_id=key),) + generated = (_replace(client_order_id=key),) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert len(result) == 1 + assert result[0].intent_type == "cancel" + + +def test_same_type_conflict_latest_wins_by_merge_order() -> None: + key = "same-key" + queued = (_replace(client_order_id=key, px=101.0),) + generated = (_replace(client_order_id=key, px=102.0),) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert len(result) == 1 + assert result[0].intent_type == "replace" + assert result[0].intended_price.value == 102.0 + + +def test_output_order_is_priority_then_merge_order_then_key() -> None: + queued = ( + _new(client_order_id="n-queued"), + _replace(client_order_id="r-queued"), + _cancel(client_order_id="c-queued"), + ) + generated = ( + _cancel(client_order_id="c-generated"), + _replace(client_order_id="r-generated"), + _new(client_order_id="n-generated"), + ) + result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert tuple((it.intent_type, it.client_order_id) for it in result) == ( + ("cancel", "c-queued"), + ("cancel", "c-generated"), + ("replace", "r-queued"), + ("replace", "r-generated"), + ("new", "n-queued"), + ("new", "n-generated"), + ) + + +def test_inputs_are_not_mutated() -> None: + queued = [_new(client_order_id="q1")] + generated = [_cancel(client_order_id="g1")] + _ = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + assert len(queued) == 1 + assert len(generated) == 1 diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 44b4999..e58991e 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -23,6 +23,7 @@ from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, ControlTimeEvent, FillEvent, MarketEvent, @@ -187,6 +188,7 @@ def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: assert isinstance(result, CoreStepResult) assert result.generated_intents == () + assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None assert result.compat_gate_decision is None @@ -210,6 +212,7 @@ def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() result = run_core_step(no_strategy_state, entry) assert result.generated_intents == () + assert result.candidate_intents == () assert result == CoreStepResult() assert _state_subset_snapshot(no_strategy_state) == _state_subset_snapshot(baseline_state) @@ -335,6 +338,7 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: assert context.state._last_processing_position_index == 12 assert context.state.market["BTC-USDC-PERP"].best_bid == 100.0 assert result.generated_intents == (generated_intent,) + assert result.candidate_intents == (generated_intent,) assert result.dispatchable_intents == () assert result.compat_gate_decision is None @@ -467,6 +471,7 @@ def decide_intents( assert len(popped_raw_intents) == 1 assert [it.client_order_id for it in popped_raw_intents[0]] == [queued_intent.client_order_id] assert tuple(it.client_order_id for it in result.dispatchable_intents) == ("accepted-now",) + assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-1",) assert result.compat_gate_decision is not None assert result.control_scheduling_obligation is not None assert result.control_scheduling_obligation.due_ts_ns_local == 17 @@ -507,11 +512,71 @@ def _pop_must_not_run(_: str) -> list[NewOrderIntent]: assert result == CoreStepResult() +def test_run_core_step_includes_queued_snapshot_in_candidate_intents_without_mutation() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-candidate") + state.merge_intents_into_queue(instrument, [queued_intent]) + + entry = EventStreamEntry( + position=ProcessingPosition(index=21), + event=_fill_event( + instrument=instrument, + client_order_id="fill-with-queued-candidate", + ts_ns_local=21, + ts_ns_exch=20, + ), + ) + result = run_core_step(state, entry) + + assert tuple(it.client_order_id for it in result.generated_intents) == () + assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-candidate",) + assert result.dispatchable_intents == () + assert state.has_queued_intent(instrument, "queued-candidate") + + +def test_run_core_step_candidate_intents_apply_generated_vs_queued_dominance_without_queue_mutation() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + key = "same-key" + queued_new = _new_intent(client_order_id=key) + state.merge_intents_into_queue(instrument, [queued_new]) + + generated_cancel = CancelOrderIntent( + ts_ns_local=22, + instrument=instrument, + client_order_id=key, + intents_correlation_id="corr-cancel", + ) + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[CancelOrderIntent]: + assert context.state._last_processing_position_index == 22 + return [generated_cancel] + + entry = EventStreamEntry( + position=ProcessingPosition(index=22), + event=_fill_event( + instrument=instrument, + client_order_id="fill-generated-vs-queued", + ts_ns_local=22, + ts_ns_exch=21, + ), + ) + result = run_core_step(state, entry, strategy_evaluator=_Evaluator()) + + assert tuple(it.intent_type for it in result.generated_intents) == ("cancel",) + assert tuple(it.intent_type for it in result.candidate_intents) == ("cancel",) + assert result.dispatchable_intents == () + assert state.has_queued_intent(instrument, key) + + def test_run_core_step_does_not_call_strategy_evaluator_when_process_event_entry_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: state = StrategyState(event_bus=NullEventBus()) called = {"evaluate": 0} + combine_called = {"value": 0} class _EvaluatorSpy: def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: @@ -523,6 +588,11 @@ def _boom(*_: object, **__: object) -> None: raise RuntimeError("process boundary failed") monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + monkeypatch.setattr( + processing_step_module, + "combine_candidate_intents", + lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), + ) entry = EventStreamEntry( position=ProcessingPosition(index=10), @@ -538,6 +608,7 @@ def _boom(*_: object, **__: object) -> None: run_core_step(state, entry, strategy_evaluator=_EvaluatorSpy()) assert called == {"evaluate": 0} + assert combine_called == {"value": 0} def test_run_core_step_with_strategy_and_control_time_context_orders_calls_deterministically() -> None: @@ -609,16 +680,23 @@ def decide_intents( assert tuple(it.client_order_id for it in result.generated_intents) == ( "generated-captured", ) + assert tuple(it.client_order_id for it in result.candidate_intents) == ( + "queued-with-strategy", + "generated-captured", + ) assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( "accepted-control-time", ) -def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path() -> None: +def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path( + monkeypatch: pytest.MonkeyPatch, +) -> None: state = StrategyState(event_bus=NullEventBus()) instrument = "BTC-USDC-PERP" state.merge_intents_into_queue(instrument, [_new_intent(client_order_id="queued-before-failure")]) calls = {"pop": 0, "risk": 0} + combine_called = {"value": 0} def _pop_spy(_: str) -> list[NewOrderIntent]: calls["pop"] += 1 @@ -636,6 +714,12 @@ def decide_intents(self, **_: object) -> GateDecision: calls["risk"] += 1 raise AssertionError("risk should not run after strategy evaluator failure") + monkeypatch.setattr( + processing_step_module, + "combine_candidate_intents", + lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), + ) + entry = EventStreamEntry( position=ProcessingPosition(index=30), event=_control_time_event(due_ts_ns_local=30, realized_ts_ns_local=30), @@ -654,6 +738,7 @@ def decide_intents(self, **_: object) -> GateDecision: ) assert calls == {"pop": 0, "risk": 0} + assert combine_called == {"value": 0} def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py index 763b366..024fa65 100644 --- a/tests/semantics/models/test_core_step_result_contract.py +++ b/tests/semantics/models/test_core_step_result_contract.py @@ -44,6 +44,7 @@ def test_default_result_is_empty_and_none_compat() -> None: result = CoreStepResult() assert result.generated_intents == () + assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None assert result.compat_gate_decision is None @@ -78,14 +79,17 @@ def test_generated_intents_normalize_to_tuple() -> None: def test_generated_intents_are_distinct_from_dispatchable_intents() -> None: generated = _new_intent(client_order_id="generated-only") + candidate = _new_intent(client_order_id="candidate-only") dispatchable = _new_intent(client_order_id="dispatchable-only") result = CoreStepResult( generated_intents=[generated], + candidate_intents=[candidate], dispatchable_intents=[dispatchable], ) assert result.generated_intents == (generated,) + assert result.candidate_intents == (candidate,) assert result.dispatchable_intents == (dispatchable,) @@ -115,6 +119,24 @@ def test_generated_intents_accept_new_replace_cancel_intents() -> None: assert result.generated_intents == (new_intent, replace_intent, cancel_intent) +def test_candidate_intents_normalize_to_tuple() -> None: + intent_one = _new_intent(client_order_id="candidate-1") + intent_two = _new_intent(client_order_id="candidate-2") + + result = CoreStepResult(candidate_intents=[intent_one, intent_two]) + + assert isinstance(result.candidate_intents, tuple) + assert result.candidate_intents == (intent_one, intent_two) + + +def test_candidate_intents_are_not_dispatchable_by_default() -> None: + candidate = _new_intent(client_order_id="candidate-only") + result = CoreStepResult(candidate_intents=[candidate]) + + assert result.candidate_intents == (candidate,) + assert result.dispatchable_intents == () + + def test_can_carry_optional_control_scheduling_obligation() -> None: obligation = ControlSchedulingObligation( due_ts_ns_local=1_000_000_000, diff --git a/tradingchassis_core/core/domain/intent_combination.py b/tradingchassis_core/core/domain/intent_combination.py new file mode 100644 index 0000000..581e588 --- /dev/null +++ b/tradingchassis_core/core/domain/intent_combination.py @@ -0,0 +1,75 @@ +"""Pure helper for Core-step candidate intent combination.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from tradingchassis_core.core.domain.types import OrderIntent + + +def _logical_key(intent: OrderIntent) -> str: + return f"order:{intent.client_order_id}" + + +def _intent_priority(intent: OrderIntent) -> int: + if intent.intent_type == "cancel": + return 0 + if intent.intent_type == "replace": + return 1 + if intent.intent_type == "new": + return 2 + return 9 + + +def _dominance_rank(intent: OrderIntent) -> int: + if intent.intent_type == "cancel": + return 3 + if intent.intent_type == "replace": + return 2 + if intent.intent_type == "new": + return 1 + return 0 + + +def combine_candidate_intents( + *, + generated_intents: Sequence[OrderIntent], + queued_intents: Sequence[OrderIntent], +) -> tuple[OrderIntent, ...]: + """Combine queued + generated intents into a deterministic effective set. + + This helper is pure and does not mutate StrategyState. + Merge order is deterministic: queued first, then generated. + """ + + merged = [*queued_intents, *generated_intents] + # key -> (intent, merge_index) + effective_by_key: dict[str, tuple[OrderIntent, int]] = {} + + for merge_index, intent in enumerate(merged): + key = _logical_key(intent) + existing = effective_by_key.get(key) + if existing is None: + effective_by_key[key] = (intent, merge_index) + continue + + existing_intent, _ = existing + incoming_rank = _dominance_rank(intent) + existing_rank = _dominance_rank(existing_intent) + if incoming_rank > existing_rank: + effective_by_key[key] = (intent, merge_index) + continue + if incoming_rank < existing_rank: + continue + + # Same-type conflict: latest in deterministic merge order wins. + effective_by_key[key] = (intent, merge_index) + + ordered = sorted( + ( + (intent, merge_index, key) + for key, (intent, merge_index) in effective_by_key.items() + ), + key=lambda item: (_intent_priority(item[0]), item[1], item[2]), + ) + return tuple(intent for intent, _, _ in ordered) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 3f19386..a7ea9ab 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Protocol, Sequence from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents 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 @@ -67,6 +68,19 @@ def _select_effective_control_scheduling_obligation( ) +def _resolve_candidate_instrument( + *, + entry: EventStreamEntry, + control_time_queue_context: ControlTimeQueueReevaluationContext | None, +) -> str | None: + event_instrument = getattr(entry.event, "instrument", None) + if isinstance(event_instrument, str): + return event_instrument + if control_time_queue_context is not None: + return control_time_queue_context.instrument + return None + + def run_core_step( state: StrategyState, entry: EventStreamEntry, @@ -94,15 +108,34 @@ def run_core_step( ) generated_intents = tuple(strategy_evaluator.evaluate(strategy_context)) + snapshot_instrument = _resolve_candidate_instrument( + entry=entry, + control_time_queue_context=control_time_queue_context, + ) + queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) + candidate_intents = combine_candidate_intents( + generated_intents=generated_intents, + queued_intents=queued_snapshot, + ) + if not isinstance(entry.event, ControlTimeEvent): - return CoreStepResult(generated_intents=generated_intents) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intents=candidate_intents, + ) if control_time_queue_context is None: - return CoreStepResult(generated_intents=generated_intents) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intents=candidate_intents, + ) popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) if not popped_intents: - return CoreStepResult(generated_intents=generated_intents) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intents=candidate_intents, + ) decision = control_time_queue_context.risk_engine.decide_intents( raw_intents=popped_intents, @@ -112,6 +145,7 @@ def run_core_step( selected_obligation = _select_effective_control_scheduling_obligation(decision) return CoreStepResult( generated_intents=generated_intents, + candidate_intents=candidate_intents, dispatchable_intents=tuple(decision.accepted_now), control_scheduling_obligation=selected_obligation, compat_gate_decision=decision, diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 40de89d..41ba8e4 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1082,6 +1082,19 @@ def has_queued_intent(self, instrument: str, client_order_id: str) -> bool: key = f"order:{client_order_id}" return any(qi.logical_key == key for qi in q) + def queued_intents_snapshot(self, instrument: str | None = None) -> tuple[OrderIntent, ...]: + """Return queued intents in deterministic stored order without mutation.""" + if instrument is not None: + q = self.queued_intents.get(instrument) + if q is None: + return () + return tuple(qi.intent for qi in q) + + snapshots: list[OrderIntent] = [] + for instrument_key in sorted(self.queued_intents): + snapshots.extend(qi.intent for qi in self.queued_intents[instrument_key]) + return tuple(snapshots) + def pop_queued_intents_for_order(self, instrument: str, client_order_id: str) -> list[QueuedIntent]: """Remove and return all queued intents for the given order id.""" q = self.queued_intents.get(instrument) diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index 91ff6fb..9564f2c 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -19,6 +19,7 @@ class CoreStepResult: """Immutable result object for the future Core processing step API.""" generated_intents: tuple[OrderIntent, ...] = () + candidate_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation: ControlSchedulingObligation | None = None compat_gate_decision: GateDecision | None = None @@ -30,6 +31,12 @@ def __post_init__(self) -> None: "generated_intents", tuple(self.generated_intents), ) + if not isinstance(self.candidate_intents, tuple): + object.__setattr__( + self, + "candidate_intents", + tuple(self.candidate_intents), + ) # Normalize sequence-like inputs to a tuple to keep deterministic value semantics. if not isinstance(self.dispatchable_intents, tuple): object.__setattr__( From a4634655e897def3eae8e11ec02b01631e1b747b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 10:34:45 +0000 Subject: [PATCH 08/53] feat(core): add CoreStepDecision scaffold mapping for control-time results --- .../models/test_core_step_api_contract.py | 93 +++++++++++++++- .../test_core_step_decision_contract.py | 103 ++++++++++++++++++ .../models/test_core_step_result_contract.py | 25 +++++ tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 8 +- .../core/domain/processing_step.py | 20 ++++ .../core/domain/step_decision.py | 45 ++++++++ .../core/domain/step_result.py | 2 + 8 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 tests/semantics/models/test_core_step_decision_contract.py create mode 100644 tradingchassis_core/core/domain/step_decision.py diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index e58991e..9b4d477 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -21,6 +21,7 @@ run_core_step, ) from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( CancelOrderIntent, @@ -34,7 +35,7 @@ ) from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision +from tradingchassis_core.core.risk.risk_engine import GateDecision, RejectedIntent def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: @@ -191,6 +192,7 @@ def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None + assert result.core_step_decision is None assert result.compat_gate_decision is None assert _state_subset_snapshot(skeleton_state) == _state_subset_snapshot(baseline_state) @@ -213,6 +215,7 @@ def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() assert result.generated_intents == () assert result.candidate_intents == () + assert result.core_step_decision is None assert result == CoreStepResult() assert _state_subset_snapshot(no_strategy_state) == _state_subset_snapshot(baseline_state) @@ -340,6 +343,7 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: assert result.generated_intents == (generated_intent,) assert result.candidate_intents == (generated_intent,) assert result.dispatchable_intents == () + assert result.core_step_decision is None assert result.compat_gate_decision is None @@ -472,10 +476,23 @@ def decide_intents( assert [it.client_order_id for it in popped_raw_intents[0]] == [queued_intent.client_order_id] assert tuple(it.client_order_id for it in result.dispatchable_intents) == ("accepted-now",) assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-1",) + assert isinstance(result.core_step_decision, CoreStepDecision) + assert tuple( + it.client_order_id for it in result.core_step_decision.dispatchable_intents + ) == ("accepted-now",) + assert result.core_step_decision.control_scheduling_obligation is not None + assert result.core_step_decision.control_scheduling_obligation.due_ts_ns_local == 17 + assert result.core_step_decision.queued_effective_intents == () + assert result.core_step_decision.policy_rejected_intents == () + assert result.core_step_decision.execution_handled_intents == () assert result.compat_gate_decision is not None assert result.control_scheduling_obligation is not None assert result.control_scheduling_obligation.due_ts_ns_local == 17 assert result.control_scheduling_obligation.obligation_key == "x-key" + assert ( + result.core_step_decision.control_scheduling_obligation + == result.control_scheduling_obligation + ) def test_run_core_step_non_control_time_ignores_control_time_context() -> None: @@ -510,6 +527,76 @@ def _pop_must_not_run(_: str) -> list[NewOrderIntent]: ) assert result == CoreStepResult() + assert result.core_step_decision is None + + +def test_run_core_step_control_time_maps_compat_fields_to_core_step_decision() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-pop-source") + state.merge_intents_into_queue(instrument, [queued_intent]) + + accepted_now = _new_intent(client_order_id="accepted-now-mapped") + queued_effective = _new_intent(client_order_id="queued-effective") + rejected_intent = _new_intent(client_order_id="rejected-policy") + handled_intent = CancelOrderIntent( + ts_ns_local=33, + instrument=instrument, + client_order_id="handled-in-queue", + intents_correlation_id="corr-handled", + ) + + class _RiskSpy: + def decide_intents( + self, + *, + raw_intents: list[NewOrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> GateDecision: + assert state is not None + assert now_ts_ns_local == 33 + assert [it.client_order_id for it in raw_intents] == [queued_intent.client_order_id] + return GateDecision( + ts_ns_local=now_ts_ns_local, + accepted_now=[accepted_now], + queued=[queued_effective], + rejected=[RejectedIntent(intent=rejected_intent, reason="policy_reject")], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[handled_intent], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=33), + event=_control_time_event(due_ts_ns_local=33, realized_ts_ns_local=33), + ) + result = run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + instrument=instrument, + now_ts_ns_local=33, + ), + ) + + assert result.core_step_decision is not None + assert tuple( + it.client_order_id for it in result.core_step_decision.dispatchable_intents + ) == ("accepted-now-mapped",) + assert tuple( + it.client_order_id for it in result.core_step_decision.queued_effective_intents + ) == ("queued-effective",) + assert tuple( + it.client_order_id for it in result.core_step_decision.policy_rejected_intents + ) == ("rejected-policy",) + assert tuple( + it.client_order_id for it in result.core_step_decision.execution_handled_intents + ) == ("handled-in-queue",) def test_run_core_step_includes_queued_snapshot_in_candidate_intents_without_mutation() -> None: @@ -687,6 +774,10 @@ def decide_intents( assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( "accepted-control-time", ) + assert isinstance(result.core_step_decision, CoreStepDecision) + assert tuple( + it.client_order_id for it in result.core_step_decision.dispatchable_intents + ) == ("accepted-control-time",) def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path( diff --git a/tests/semantics/models/test_core_step_decision_contract.py b/tests/semantics/models/test_core_step_decision_contract.py new file mode 100644 index 0000000..e9a68d8 --- /dev/null +++ b/tests/semantics/models/test_core_step_decision_contract.py @@ -0,0 +1,103 @@ +"""Semantics tests for the CoreStepDecision scaffold contract model.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_default_decision_is_empty_and_none_obligation() -> None: + decision = CoreStepDecision() + + assert decision.policy_rejected_intents == () + assert decision.queued_effective_intents == () + assert decision.dispatchable_intents == () + assert decision.execution_handled_intents == () + assert decision.control_scheduling_obligation is None + + +def test_core_step_decision_tuple_fields_normalize() -> None: + rejected = _new_intent(client_order_id="rejected") + queued = _new_intent(client_order_id="queued") + dispatchable = _new_intent(client_order_id="dispatchable") + handled = _new_intent(client_order_id="handled") + + decision = CoreStepDecision( + policy_rejected_intents=[rejected], + queued_effective_intents=[queued], + dispatchable_intents=[dispatchable], + execution_handled_intents=[handled], + ) + + assert decision.policy_rejected_intents == (rejected,) + assert decision.queued_effective_intents == (queued,) + assert decision.dispatchable_intents == (dispatchable,) + assert decision.execution_handled_intents == (handled,) + + +def test_core_step_decision_is_immutable() -> None: + decision = CoreStepDecision() + + with pytest.raises(FrozenInstanceError): + decision.control_scheduling_obligation = None + + +def test_core_step_decision_is_non_canonical_and_not_classified() -> None: + assert is_canonical_stream_candidate_type(CoreStepDecision) is False + assert canonical_category_for_type(CoreStepDecision) is None + + +def test_canonical_processing_boundary_rejects_core_step_decision() -> None: + state = StrategyState(event_bus=NullEventBus()) + + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, CoreStepDecision()) + + +def test_core_step_decision_can_carry_dispatchable_and_obligation() -> None: + dispatchable = _new_intent(client_order_id="dispatchable") + obligation = ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + decision = CoreStepDecision( + dispatchable_intents=[dispatchable], + control_scheduling_obligation=obligation, + ) + + assert decision.dispatchable_intents == (dispatchable,) + assert decision.control_scheduling_obligation is obligation + + +def test_public_root_export_identity_when_root_exported() -> None: + assert hasattr(tc, "CoreStepDecision") + assert tc.CoreStepDecision is CoreStepDecision diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py index 024fa65..adbb176 100644 --- a/tests/semantics/models/test_core_step_result_contract.py +++ b/tests/semantics/models/test_core_step_result_contract.py @@ -13,6 +13,7 @@ ) from tradingchassis_core.core.domain.processing import process_canonical_event from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( CancelOrderIntent, @@ -47,6 +48,7 @@ def test_default_result_is_empty_and_none_compat() -> None: assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None + assert result.core_step_decision is None assert result.compat_gate_decision is None @@ -170,6 +172,29 @@ def test_can_carry_optional_compat_gate_decision() -> None: assert result.compat_gate_decision is compat_decision +def test_can_carry_optional_core_step_decision() -> None: + dispatchable = _new_intent(client_order_id="dispatchable") + decision = CoreStepDecision(dispatchable_intents=[dispatchable]) + + result = CoreStepResult(core_step_decision=decision) + + assert result.core_step_decision is decision + + +def test_core_step_result_dispatchable_intents_are_independent_from_core_step_decision() -> None: + top_level_dispatchable = _new_intent(client_order_id="top-level") + decision_dispatchable = _new_intent(client_order_id="decision") + decision = CoreStepDecision(dispatchable_intents=[decision_dispatchable]) + result = CoreStepResult( + dispatchable_intents=[top_level_dispatchable], + core_step_decision=decision, + ) + + assert result.dispatchable_intents == (top_level_dispatchable,) + assert result.core_step_decision is decision + assert result.core_step_decision.dispatchable_intents == (decision_dispatchable,) + + def test_core_step_result_is_non_canonical_and_not_classified() -> None: assert is_canonical_stream_candidate_type(CoreStepResult) is False assert canonical_category_for_type(CoreStepResult) is None diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 7c3479a..e5dc849 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -39,6 +39,7 @@ # Domain Types (used by strategies) # ---------------------------------------------------------------------- from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( MarketEvent, @@ -94,6 +95,7 @@ "process_event_entry", "run_core_step", "ControlTimeQueueReevaluationContext", + "CoreStepDecision", "fold_event_stream_entries", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 0a76051..87a8fcd 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -4,6 +4,12 @@ ControlTimeQueueReevaluationContext, run_core_step, ) +from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult -__all__ = ["CoreStepResult", "ControlTimeQueueReevaluationContext", "run_core_step"] +__all__ = [ + "CoreStepDecision", + "CoreStepResult", + "ControlTimeQueueReevaluationContext", + "run_core_step", +] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index a7ea9ab..5a5fd23 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -15,6 +15,7 @@ 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_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ControlTimeEvent, OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation @@ -81,6 +82,20 @@ def _resolve_candidate_instrument( return None +def _map_compat_gate_decision_to_core_step_decision( + *, + decision: GateDecision, + control_scheduling_obligation: ControlSchedulingObligation | None, +) -> CoreStepDecision: + return CoreStepDecision( + policy_rejected_intents=tuple(rejected.intent for rejected in decision.rejected), + queued_effective_intents=tuple(decision.queued), + dispatchable_intents=tuple(decision.accepted_now), + execution_handled_intents=tuple(decision.handled_in_queue), + control_scheduling_obligation=control_scheduling_obligation, + ) + + def run_core_step( state: StrategyState, entry: EventStreamEntry, @@ -143,10 +158,15 @@ def run_core_step( now_ts_ns_local=control_time_queue_context.now_ts_ns_local, ) selected_obligation = _select_effective_control_scheduling_obligation(decision) + core_step_decision = _map_compat_gate_decision_to_core_step_decision( + decision=decision, + control_scheduling_obligation=selected_obligation, + ) return CoreStepResult( generated_intents=generated_intents, candidate_intents=candidate_intents, dispatchable_intents=tuple(decision.accepted_now), control_scheduling_obligation=selected_obligation, + core_step_decision=core_step_decision, compat_gate_decision=decision, ) diff --git a/tradingchassis_core/core/domain/step_decision.py b/tradingchassis_core/core/domain/step_decision.py new file mode 100644 index 0000000..5aa5860 --- /dev/null +++ b/tradingchassis_core/core/domain/step_decision.py @@ -0,0 +1,45 @@ +"""Core-owned non-canonical decision scaffold for run_core_step results.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class CoreStepDecision: + """Immutable scaffold decision model for integrated Core-step semantics.""" + + policy_rejected_intents: tuple[OrderIntent, ...] = () + queued_effective_intents: tuple[OrderIntent, ...] = () + dispatchable_intents: tuple[OrderIntent, ...] = () + execution_handled_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + + def __post_init__(self) -> None: + if not isinstance(self.policy_rejected_intents, tuple): + object.__setattr__( + self, + "policy_rejected_intents", + tuple(self.policy_rejected_intents), + ) + if not isinstance(self.queued_effective_intents, tuple): + object.__setattr__( + self, + "queued_effective_intents", + tuple(self.queued_effective_intents), + ) + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) + if not isinstance(self.execution_handled_intents, tuple): + object.__setattr__( + self, + "execution_handled_intents", + tuple(self.execution_handled_intents), + ) diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index 9564f2c..ff84995 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -9,6 +9,7 @@ from dataclasses import dataclass +from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation from tradingchassis_core.core.risk.risk_engine import GateDecision @@ -22,6 +23,7 @@ class CoreStepResult: candidate_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation: ControlSchedulingObligation | None = None + core_step_decision: CoreStepDecision | None = None compat_gate_decision: GateDecision | None = None def __post_init__(self) -> None: From c388eb63906550884e8bc03a4b4f330a901b42e2 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 10:41:01 +0000 Subject: [PATCH 09/53] feat(core): scaffold policy risk decision boundary --- .../models/test_core_step_api_contract.py | 21 ++++ .../test_core_step_decision_contract.py | 14 +++ .../test_policy_risk_decision_contract.py | 98 +++++++++++++++++++ tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 2 + .../core/domain/policy_risk_decision.py | 48 +++++++++ .../core/domain/processing_step.py | 4 + .../core/domain/step_decision.py | 2 + 8 files changed, 191 insertions(+) create mode 100644 tests/semantics/models/test_policy_risk_decision_contract.py create mode 100644 tradingchassis_core/core/domain/policy_risk_decision.py diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 9b4d477..239beab 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -13,6 +13,7 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision 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.processing_step import ( @@ -482,6 +483,12 @@ def decide_intents( ) == ("accepted-now",) assert result.core_step_decision.control_scheduling_obligation is not None assert result.core_step_decision.control_scheduling_obligation.due_ts_ns_local == 17 + assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.accepted_intents + ) == ("accepted-now",) + assert result.core_step_decision.policy_risk_decision.rejected_intents == () assert result.core_step_decision.queued_effective_intents == () assert result.core_step_decision.policy_rejected_intents == () assert result.core_step_decision.execution_handled_intents == () @@ -597,6 +604,15 @@ def decide_intents( assert tuple( it.client_order_id for it in result.core_step_decision.execution_handled_intents ) == ("handled-in-queue",) + assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.accepted_intents + ) == ("accepted-now-mapped",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.rejected_intents + ) == ("rejected-policy",) def test_run_core_step_includes_queued_snapshot_in_candidate_intents_without_mutation() -> None: @@ -778,6 +794,11 @@ def decide_intents( assert tuple( it.client_order_id for it in result.core_step_decision.dispatchable_intents ) == ("accepted-control-time",) + assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.accepted_intents + ) == ("accepted-control-time",) def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path( diff --git a/tests/semantics/models/test_core_step_decision_contract.py b/tests/semantics/models/test_core_step_decision_contract.py index e9a68d8..e5b0fd8 100644 --- a/tests/semantics/models/test_core_step_decision_contract.py +++ b/tests/semantics/models/test_core_step_decision_contract.py @@ -11,6 +11,7 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing import process_canonical_event from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_decision import CoreStepDecision @@ -37,6 +38,7 @@ def test_default_decision_is_empty_and_none_obligation() -> None: decision = CoreStepDecision() assert decision.policy_rejected_intents == () + assert decision.policy_risk_decision is None assert decision.queued_effective_intents == () assert decision.dispatchable_intents == () assert decision.execution_handled_intents == () @@ -98,6 +100,18 @@ def test_core_step_decision_can_carry_dispatchable_and_obligation() -> None: assert decision.control_scheduling_obligation is obligation +def test_core_step_decision_can_carry_policy_risk_decision() -> None: + accepted = _new_intent(client_order_id="accepted") + rejected = _new_intent(client_order_id="rejected") + policy = PolicyRiskDecision( + accepted_intents=[accepted], + rejected_intents=[rejected], + ) + decision = CoreStepDecision(policy_risk_decision=policy) + + assert decision.policy_risk_decision is policy + + def test_public_root_export_identity_when_root_exported() -> None: assert hasattr(tc, "CoreStepDecision") assert tc.CoreStepDecision is CoreStepDecision diff --git a/tests/semantics/models/test_policy_risk_decision_contract.py b/tests/semantics/models/test_policy_risk_decision_contract.py new file mode 100644 index 0000000..a04f861 --- /dev/null +++ b/tests/semantics/models/test_policy_risk_decision_contract.py @@ -0,0 +1,98 @@ +"""Semantics tests for the PolicyRiskDecision scaffold contract model.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyRiskDecision, + map_compat_gate_decision_to_policy_risk_decision, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.risk.risk_engine import GateDecision, RejectedIntent + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_default_policy_risk_decision_is_empty() -> None: + decision = PolicyRiskDecision() + assert decision.accepted_intents == () + assert decision.rejected_intents == () + + +def test_policy_risk_decision_tuple_fields_normalize() -> None: + accepted = _new_intent(client_order_id="accepted") + rejected = _new_intent(client_order_id="rejected") + decision = PolicyRiskDecision( + accepted_intents=[accepted], + rejected_intents=[rejected], + ) + assert decision.accepted_intents == (accepted,) + assert decision.rejected_intents == (rejected,) + + +def test_policy_risk_decision_is_immutable() -> None: + decision = PolicyRiskDecision() + with pytest.raises(FrozenInstanceError): + decision.accepted_intents = () + + +def test_policy_risk_decision_is_non_canonical_and_not_classified() -> None: + assert is_canonical_stream_candidate_type(PolicyRiskDecision) is False + assert canonical_category_for_type(PolicyRiskDecision) is None + + +def test_canonical_processing_boundary_rejects_policy_risk_decision() -> None: + state = StrategyState(event_bus=NullEventBus()) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, PolicyRiskDecision()) + + +def test_map_compat_gate_decision_to_policy_risk_decision_projection() -> None: + accepted_now = _new_intent(client_order_id="accepted-now") + queued = _new_intent(client_order_id="queued") + rejected = _new_intent(client_order_id="rejected") + decision = GateDecision( + ts_ns_local=123, + accepted_now=[accepted_now], + queued=[queued], + rejected=[RejectedIntent(intent=rejected, reason="policy_reject")], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + policy = map_compat_gate_decision_to_policy_risk_decision(decision) + + assert policy.accepted_intents == (accepted_now,) + assert policy.rejected_intents == (rejected,) + + +def test_public_root_export_identity_when_root_exported() -> None: + assert hasattr(tc, "PolicyRiskDecision") + assert tc.PolicyRiskDecision is PolicyRiskDecision diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index e5dc849..0278bb1 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -9,6 +9,7 @@ from importlib.metadata import PackageNotFoundError, version from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing import ( fold_event_stream_entries, process_event_entry, @@ -95,6 +96,7 @@ "process_event_entry", "run_core_step", "ControlTimeQueueReevaluationContext", + "PolicyRiskDecision", "CoreStepDecision", "fold_event_stream_entries", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 87a8fcd..d70ea55 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -1,5 +1,6 @@ """Public exports for core domain value objects.""" +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, run_core_step, @@ -8,6 +9,7 @@ from tradingchassis_core.core.domain.step_result import CoreStepResult __all__ = [ + "PolicyRiskDecision", "CoreStepDecision", "CoreStepResult", "ControlTimeQueueReevaluationContext", diff --git a/tradingchassis_core/core/domain/policy_risk_decision.py b/tradingchassis_core/core/domain/policy_risk_decision.py new file mode 100644 index 0000000..705d51c --- /dev/null +++ b/tradingchassis_core/core/domain/policy_risk_decision.py @@ -0,0 +1,48 @@ +"""Core-owned policy-risk decision scaffold and compatibility projection helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +@dataclass(frozen=True, slots=True) +class PolicyRiskDecision: + """Immutable non-canonical policy admissibility projection.""" + + accepted_intents: tuple[OrderIntent, ...] = () + rejected_intents: tuple[OrderIntent, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.accepted_intents, tuple): + object.__setattr__( + self, + "accepted_intents", + tuple(self.accepted_intents), + ) + if not isinstance(self.rejected_intents, tuple): + object.__setattr__( + self, + "rejected_intents", + tuple(self.rejected_intents), + ) + + +def map_compat_gate_decision_to_policy_risk_decision( + decision: GateDecision, +) -> PolicyRiskDecision: + """Project compatibility GateDecision into policy-only scaffold fields. + + Notes: + - ``accepted_intents`` currently maps from ``accepted_now`` because the + compatibility gate does not expose a strict pre-execution-control + policy-accepted set. + - ``rejected_intents`` maps from the explicit rejected intent records. + """ + + return PolicyRiskDecision( + accepted_intents=tuple(decision.accepted_now), + rejected_intents=tuple(rejected.intent for rejected in decision.rejected), + ) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 5a5fd23..06942cd 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -12,6 +12,9 @@ from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents +from tradingchassis_core.core.domain.policy_risk_decision import ( + map_compat_gate_decision_to_policy_risk_decision, +) 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 @@ -89,6 +92,7 @@ def _map_compat_gate_decision_to_core_step_decision( ) -> CoreStepDecision: return CoreStepDecision( policy_rejected_intents=tuple(rejected.intent for rejected in decision.rejected), + policy_risk_decision=map_compat_gate_decision_to_policy_risk_decision(decision), queued_effective_intents=tuple(decision.queued), dispatchable_intents=tuple(decision.accepted_now), execution_handled_intents=tuple(decision.handled_in_queue), diff --git a/tradingchassis_core/core/domain/step_decision.py b/tradingchassis_core/core/domain/step_decision.py index 5aa5860..30ff376 100644 --- a/tradingchassis_core/core/domain/step_decision.py +++ b/tradingchassis_core/core/domain/step_decision.py @@ -4,6 +4,7 @@ from dataclasses import dataclass +from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation @@ -13,6 +14,7 @@ class CoreStepDecision: """Immutable scaffold decision model for integrated Core-step semantics.""" policy_rejected_intents: tuple[OrderIntent, ...] = () + policy_risk_decision: PolicyRiskDecision | None = None queued_effective_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () execution_handled_intents: tuple[OrderIntent, ...] = () From 1a8986762e7e20c80c65d2d2e483cb51b96abc61 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 10:48:13 +0000 Subject: [PATCH 10/53] feat(core): scaffold execution control decision boundary --- .../models/test_core_step_api_contract.py | 39 ++++++ .../test_core_step_decision_contract.py | 14 ++ ...est_execution_control_decision_contract.py | 126 ++++++++++++++++++ tradingchassis_core/__init__.py | 4 + tradingchassis_core/core/domain/__init__.py | 2 + .../core/domain/execution_control_decision.py | 54 ++++++++ .../core/domain/processing_step.py | 7 + .../core/domain/step_decision.py | 4 + 8 files changed, 250 insertions(+) create mode 100644 tests/semantics/models/test_execution_control_decision_contract.py create mode 100644 tradingchassis_core/core/domain/execution_control_decision.py diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 239beab..b4d54ba 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -13,6 +13,9 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing import process_event_entry from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition @@ -483,6 +486,18 @@ def decide_intents( ) == ("accepted-now",) assert result.core_step_decision.control_scheduling_obligation is not None assert result.core_step_decision.control_scheduling_obligation.due_ts_ns_local == 17 + assert isinstance( + result.core_step_decision.execution_control_decision, + ExecutionControlDecision, + ) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("accepted-now",) + assert ( + result.core_step_decision.execution_control_decision.control_scheduling_obligation + == result.control_scheduling_obligation + ) assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) assert tuple( it.client_order_id @@ -604,6 +619,22 @@ def decide_intents( assert tuple( it.client_order_id for it in result.core_step_decision.execution_handled_intents ) == ("handled-in-queue",) + assert isinstance( + result.core_step_decision.execution_control_decision, + ExecutionControlDecision, + ) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.queued_effective_intents + ) == ("queued-effective",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("accepted-now-mapped",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.execution_handled_intents + ) == ("handled-in-queue",) assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) assert tuple( it.client_order_id @@ -794,6 +825,14 @@ def decide_intents( assert tuple( it.client_order_id for it in result.core_step_decision.dispatchable_intents ) == ("accepted-control-time",) + assert isinstance( + result.core_step_decision.execution_control_decision, + ExecutionControlDecision, + ) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("accepted-control-time",) assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) assert tuple( it.client_order_id diff --git a/tests/semantics/models/test_core_step_decision_contract.py b/tests/semantics/models/test_core_step_decision_contract.py index e5b0fd8..21eef41 100644 --- a/tests/semantics/models/test_core_step_decision_contract.py +++ b/tests/semantics/models/test_core_step_decision_contract.py @@ -11,6 +11,9 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing import process_canonical_event from tradingchassis_core.core.domain.state import StrategyState @@ -39,6 +42,7 @@ def test_default_decision_is_empty_and_none_obligation() -> None: assert decision.policy_rejected_intents == () assert decision.policy_risk_decision is None + assert decision.execution_control_decision is None assert decision.queued_effective_intents == () assert decision.dispatchable_intents == () assert decision.execution_handled_intents == () @@ -112,6 +116,16 @@ def test_core_step_decision_can_carry_policy_risk_decision() -> None: assert decision.policy_risk_decision is policy +def test_core_step_decision_can_carry_execution_control_decision() -> None: + dispatchable = _new_intent(client_order_id="dispatchable") + execution_control = ExecutionControlDecision( + dispatchable_intents=[dispatchable], + ) + decision = CoreStepDecision(execution_control_decision=execution_control) + + assert decision.execution_control_decision is execution_control + + def test_public_root_export_identity_when_root_exported() -> None: assert hasattr(tc, "CoreStepDecision") assert tc.CoreStepDecision is CoreStepDecision diff --git a/tests/semantics/models/test_execution_control_decision_contract.py b/tests/semantics/models/test_execution_control_decision_contract.py new file mode 100644 index 0000000..77677b3 --- /dev/null +++ b/tests/semantics/models/test_execution_control_decision_contract.py @@ -0,0 +1,126 @@ +"""Semantics tests for the ExecutionControlDecision scaffold contract model.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, + map_compat_gate_decision_to_execution_control_decision, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_default_execution_control_decision_is_empty() -> None: + decision = ExecutionControlDecision() + assert decision.queued_effective_intents == () + assert decision.dispatchable_intents == () + assert decision.execution_handled_intents == () + assert decision.control_scheduling_obligation is None + + +def test_execution_control_decision_tuple_fields_normalize() -> None: + queued = _new_intent(client_order_id="queued") + dispatchable = _new_intent(client_order_id="dispatchable") + handled = _new_intent(client_order_id="handled") + decision = ExecutionControlDecision( + queued_effective_intents=[queued], + dispatchable_intents=[dispatchable], + execution_handled_intents=[handled], + ) + assert decision.queued_effective_intents == (queued,) + assert decision.dispatchable_intents == (dispatchable,) + assert decision.execution_handled_intents == (handled,) + + +def test_execution_control_decision_is_immutable() -> None: + decision = ExecutionControlDecision() + with pytest.raises(FrozenInstanceError): + decision.dispatchable_intents = () + + +def test_execution_control_decision_is_non_canonical_and_not_classified() -> None: + assert is_canonical_stream_candidate_type(ExecutionControlDecision) is False + assert canonical_category_for_type(ExecutionControlDecision) is None + + +def test_canonical_processing_boundary_rejects_execution_control_decision() -> None: + state = StrategyState(event_bus=NullEventBus()) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, ExecutionControlDecision()) + + +def test_execution_control_decision_can_carry_obligation() -> None: + obligation = ControlSchedulingObligation( + due_ts_ns_local=1_000_000_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + decision = ExecutionControlDecision(control_scheduling_obligation=obligation) + assert decision.control_scheduling_obligation is obligation + + +def test_map_compat_gate_decision_to_execution_control_decision_projection() -> None: + dispatchable = _new_intent(client_order_id="dispatchable") + queued = _new_intent(client_order_id="queued") + handled = _new_intent(client_order_id="handled") + obligation = ControlSchedulingObligation( + due_ts_ns_local=123, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + gate = GateDecision( + ts_ns_local=123, + accepted_now=[dispatchable], + queued=[queued], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[handled], + execution_rejected=[], + next_send_ts_ns_local=123, + control_scheduling_obligations=(obligation,), + ) + + decision = map_compat_gate_decision_to_execution_control_decision( + gate, + control_scheduling_obligation=obligation, + ) + + assert decision.queued_effective_intents == (queued,) + assert decision.dispatchable_intents == (dispatchable,) + assert decision.execution_handled_intents == (handled,) + assert decision.control_scheduling_obligation is obligation + + +def test_public_root_export_identity_when_root_exported() -> None: + assert hasattr(tc, "ExecutionControlDecision") + assert tc.ExecutionControlDecision is ExecutionControlDecision diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 0278bb1..867a33d 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -9,6 +9,9 @@ from importlib.metadata import PackageNotFoundError, version from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing import ( fold_event_stream_entries, @@ -96,6 +99,7 @@ "process_event_entry", "run_core_step", "ControlTimeQueueReevaluationContext", + "ExecutionControlDecision", "PolicyRiskDecision", "CoreStepDecision", "fold_event_stream_entries", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index d70ea55..6fc8351 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -1,5 +1,6 @@ """Public exports for core domain value objects.""" +from tradingchassis_core.core.domain.execution_control_decision import ExecutionControlDecision from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, @@ -9,6 +10,7 @@ from tradingchassis_core.core.domain.step_result import CoreStepResult __all__ = [ + "ExecutionControlDecision", "PolicyRiskDecision", "CoreStepDecision", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/execution_control_decision.py b/tradingchassis_core/core/domain/execution_control_decision.py new file mode 100644 index 0000000..1dcf8f8 --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_decision.py @@ -0,0 +1,54 @@ +"""Core-owned execution-control decision scaffold and compatibility projection helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import GateDecision + + +@dataclass(frozen=True, slots=True) +class ExecutionControlDecision: + """Immutable non-canonical execution-control outcome projection.""" + + queued_effective_intents: tuple[OrderIntent, ...] = () + dispatchable_intents: tuple[OrderIntent, ...] = () + execution_handled_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + + def __post_init__(self) -> None: + if not isinstance(self.queued_effective_intents, tuple): + object.__setattr__( + self, + "queued_effective_intents", + tuple(self.queued_effective_intents), + ) + if not isinstance(self.dispatchable_intents, tuple): + object.__setattr__( + self, + "dispatchable_intents", + tuple(self.dispatchable_intents), + ) + if not isinstance(self.execution_handled_intents, tuple): + object.__setattr__( + self, + "execution_handled_intents", + tuple(self.execution_handled_intents), + ) + + +def map_compat_gate_decision_to_execution_control_decision( + decision: GateDecision, + *, + control_scheduling_obligation: ControlSchedulingObligation | None = None, +) -> ExecutionControlDecision: + """Project compatibility GateDecision into execution-control scaffold fields.""" + + return ExecutionControlDecision( + queued_effective_intents=tuple(decision.queued), + dispatchable_intents=tuple(decision.accepted_now), + execution_handled_intents=tuple(decision.handled_in_queue), + control_scheduling_obligation=control_scheduling_obligation, + ) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 06942cd..0455487 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -11,6 +11,9 @@ from typing import TYPE_CHECKING, Protocol, Sequence from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_decision import ( + map_compat_gate_decision_to_execution_control_decision, +) from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents from tradingchassis_core.core.domain.policy_risk_decision import ( map_compat_gate_decision_to_policy_risk_decision, @@ -93,6 +96,10 @@ def _map_compat_gate_decision_to_core_step_decision( return CoreStepDecision( policy_rejected_intents=tuple(rejected.intent for rejected in decision.rejected), policy_risk_decision=map_compat_gate_decision_to_policy_risk_decision(decision), + execution_control_decision=map_compat_gate_decision_to_execution_control_decision( + decision, + control_scheduling_obligation=control_scheduling_obligation, + ), queued_effective_intents=tuple(decision.queued), dispatchable_intents=tuple(decision.accepted_now), execution_handled_intents=tuple(decision.handled_in_queue), diff --git a/tradingchassis_core/core/domain/step_decision.py b/tradingchassis_core/core/domain/step_decision.py index 30ff376..e06fdec 100644 --- a/tradingchassis_core/core/domain/step_decision.py +++ b/tradingchassis_core/core/domain/step_decision.py @@ -4,6 +4,9 @@ from dataclasses import dataclass +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation @@ -15,6 +18,7 @@ class CoreStepDecision: policy_rejected_intents: tuple[OrderIntent, ...] = () policy_risk_decision: PolicyRiskDecision | None = None + execution_control_decision: ExecutionControlDecision | None = None queued_effective_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () execution_handled_intents: tuple[OrderIntent, ...] = () From e57e3305753efbb4233b3aeb7c98d592a11ee7e1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 11:07:25 +0000 Subject: [PATCH 11/53] feat(core): capture candidate decision context --- .../models/test_core_step_api_contract.py | 524 +++++++++++++++++- tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 2 + .../core/domain/processing_step.py | 95 +++- 4 files changed, 597 insertions(+), 26 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index b4d54ba..d950980 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -21,6 +21,7 @@ from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, + CoreDecisionContext, CoreStepStrategyContext, run_core_step, ) @@ -33,13 +34,16 @@ FillEvent, MarketEvent, NewOrderIntent, + NotionalLimits, + OrderRateLimits, OrderStateEvent, Price, Quantity, ) from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision, RejectedIntent +from tradingchassis_core.core.risk.risk_config import RiskConfig +from tradingchassis_core.core.risk.risk_engine import GateDecision, RejectedIntent, RiskEngine def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: @@ -552,6 +556,237 @@ def _pop_must_not_run(_: str) -> list[NewOrderIntent]: assert result.core_step_decision is None +def test_run_core_step_non_control_candidate_context_disabled_keeps_scaffold_behavior() -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="generated-disabled-context") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 40 + return [generated_intent] + + class _RiskMustNotRun: + def decide_intents(self, **_: object) -> GateDecision: + raise AssertionError("risk must not run when candidate decision context is disabled") + + entry = EventStreamEntry( + position=ProcessingPosition(index=40), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-disabled-context", + ts_ns_local=40, + ts_ns_exch=39, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + core_decision_context=CoreDecisionContext( + risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] + now_ts_ns_local=40, + enable_candidate_intent_decision=False, + capture_only=True, + ), + ) + + assert result.generated_intents == (generated_intent,) + assert result.candidate_intents == (generated_intent,) + assert result.core_step_decision is None + assert result.compat_gate_decision is None + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + + +def test_run_core_step_non_control_candidate_context_enabled_capture_only_maps_decision() -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="generated-candidate-risk") + accepted_now = _new_intent(client_order_id="accepted-now-candidate") + queued_effective = _new_intent(client_order_id="queued-effective-candidate") + rejected_intent = _new_intent(client_order_id="rejected-candidate") + handled_intent = CancelOrderIntent( + ts_ns_local=41, + instrument="BTC-USDC-PERP", + client_order_id="handled-candidate", + intents_correlation_id="corr-handled-candidate", + ) + obligation = ControlSchedulingObligation( + due_ts_ns_local=77, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + captured_raw_intents: list[list[NewOrderIntent]] = [] + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 41 + return [generated_intent] + + class _RiskSpy: + def decide_intents( + self, + *, + raw_intents: list[NewOrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> GateDecision: + assert state is not None + assert now_ts_ns_local == 41 + captured_raw_intents.append(list(raw_intents)) + return GateDecision( + ts_ns_local=now_ts_ns_local, + accepted_now=[accepted_now], + queued=[queued_effective], + rejected=[RejectedIntent(intent=rejected_intent, reason="policy_reject")], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[handled_intent], + execution_rejected=[], + next_send_ts_ns_local=77, + control_scheduling_obligations=(obligation,), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=41), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-candidate-risk", + ts_ns_local=41, + ts_ns_exch=40, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + core_decision_context=CoreDecisionContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + now_ts_ns_local=41, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + + assert len(captured_raw_intents) == 1 + assert tuple(it.client_order_id for it in captured_raw_intents[0]) == ( + "generated-candidate-risk", + ) + assert result.generated_intents == (generated_intent,) + assert result.candidate_intents == (generated_intent,) + assert result.compat_gate_decision is not None + assert result.core_step_decision is not None + assert tuple( + it.client_order_id for it in result.core_step_decision.dispatchable_intents + ) == ("accepted-now-candidate",) + assert tuple( + it.client_order_id for it in result.core_step_decision.queued_effective_intents + ) == ("queued-effective-candidate",) + assert tuple( + it.client_order_id for it in result.core_step_decision.policy_rejected_intents + ) == ("rejected-candidate",) + assert tuple( + it.client_order_id for it in result.core_step_decision.execution_handled_intents + ) == ("handled-candidate",) + assert result.core_step_decision.policy_risk_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.accepted_intents + ) == ("accepted-now-candidate",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.rejected_intents + ) == ("rejected-candidate",) + assert result.core_step_decision.execution_control_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("accepted-now-candidate",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.queued_effective_intents + ) == ("queued-effective-candidate",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.execution_handled_intents + ) == ("handled-candidate",) + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + + +def test_run_core_step_non_control_candidate_context_enabled_empty_candidates_skips_risk() -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"risk": 0} + + class _RiskSpy: + def decide_intents(self, **_: object) -> GateDecision: + calls["risk"] += 1 + raise AssertionError("risk must not run when candidate intents are empty") + + entry = EventStreamEntry( + position=ProcessingPosition(index=42), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-empty-candidates", + ts_ns_local=42, + ts_ns_exch=41, + ), + ) + result = run_core_step( + state, + entry, + core_decision_context=CoreDecisionContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + now_ts_ns_local=42, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + + assert calls == {"risk": 0} + assert result.generated_intents == () + assert result.candidate_intents == () + assert result.core_step_decision is None + assert result.compat_gate_decision is None + assert result.dispatchable_intents == () + + +def test_run_core_step_non_control_candidate_context_capture_only_false_not_supported() -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="generated-capture-false") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 43 + return [generated_intent] + + class _RiskMustNotRun: + def decide_intents(self, **_: object) -> GateDecision: + raise AssertionError("risk must not run when capture_only=False is unsupported") + + entry = EventStreamEntry( + position=ProcessingPosition(index=43), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-capture-false", + ts_ns_local=43, + ts_ns_exch=42, + ), + ) + with pytest.raises(NotImplementedError, match="capture_only=False is not supported yet"): + run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + core_decision_context=CoreDecisionContext( + risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] + now_ts_ns_local=43, + enable_candidate_intent_decision=True, + capture_only=False, + ), + ) + + def test_run_core_step_control_time_maps_compat_fields_to_core_step_decision() -> None: state = StrategyState(event_bus=NullEventBus()) instrument = "BTC-USDC-PERP" @@ -745,6 +980,56 @@ def _boom(*_: object, **__: object) -> None: assert combine_called == {"value": 0} +def test_run_core_step_candidate_decision_context_not_reached_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"risk": 0} + + class _RiskSpy: + def decide_intents(self, **_: object) -> GateDecision: + calls["risk"] += 1 + return GateDecision( + ts_ns_local=99, + accepted_now=[], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + + entry = EventStreamEntry( + position=ProcessingPosition(index=44), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-process-fail-context", + ts_ns_local=44, + ts_ns_exch=43, + ), + ) + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step( + state, + entry, + core_decision_context=CoreDecisionContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + now_ts_ns_local=44, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + assert calls == {"risk": 0} + + def test_run_core_step_with_strategy_and_control_time_context_orders_calls_deterministically() -> None: state = StrategyState(event_bus=NullEventBus()) instrument = "BTC-USDC-PERP" @@ -892,6 +1177,243 @@ def decide_intents(self, **_: object) -> GateDecision: assert combine_called == {"value": 0} +def test_run_core_step_candidate_decision_context_not_reached_when_strategy_fails() -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"risk": 0} + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 45 + raise RuntimeError("strategy evaluator failed") + + class _RiskSpy: + def decide_intents(self, **_: object) -> GateDecision: + calls["risk"] += 1 + return GateDecision( + ts_ns_local=45, + accepted_now=[], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=45), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-evaluator-fail-context", + ts_ns_local=45, + ts_ns_exch=44, + ), + ) + with pytest.raises(RuntimeError, match="strategy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorBoom(), + core_decision_context=CoreDecisionContext( + risk_engine=_RiskSpy(), # type: ignore[arg-type] + now_ts_ns_local=45, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + assert calls == {"risk": 0} + + +def test_run_core_step_candidate_decision_context_propagates_risk_failure() -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="generated-risk-failure") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 46 + return [generated_intent] + + class _RiskBoom: + def decide_intents(self, **_: object) -> GateDecision: + raise RuntimeError("risk engine failed in candidate capture path") + + entry = EventStreamEntry( + position=ProcessingPosition(index=46), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-risk-failure-context", + ts_ns_local=46, + ts_ns_exch=45, + ), + ) + with pytest.raises(RuntimeError, match="risk engine failed in candidate capture path"): + run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + core_decision_context=CoreDecisionContext( + risk_engine=_RiskBoom(), # type: ignore[arg-type] + now_ts_ns_local=46, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + + +def test_run_core_step_candidate_decision_context_side_effects_are_opt_in_characterization() -> None: + instrument = "BTC-USDC-PERP" + client_order_id = "opt-in-side-effect-order" + risk_cfg = RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1e18, + max_single_order_notional=1e18, + ), + order_rate_limits=OrderRateLimits(max_orders_per_second=0), + ) + + baseline_state = StrategyState(event_bus=NullEventBus()) + enabled_state = StrategyState(event_bus=NullEventBus()) + enabled_risk = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index in (47, 48) + return [ + NewOrderIntent( + ts_ns_local=context.position.index, + instrument=instrument, + client_order_id=client_order_id, + intents_correlation_id="corr-opt-in", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + baseline_entry = EventStreamEntry( + position=ProcessingPosition(index=47), + event=_fill_event( + instrument=instrument, + client_order_id="fill-side-effect-baseline", + ts_ns_local=47, + ts_ns_exch=46, + ), + ) + enabled_entry = EventStreamEntry( + position=ProcessingPosition(index=48), + event=_fill_event( + instrument=instrument, + client_order_id="fill-side-effect-enabled", + ts_ns_local=48, + ts_ns_exch=47, + ), + ) + + baseline_result = run_core_step( + baseline_state, + baseline_entry, + strategy_evaluator=_Evaluator(), + ) + enabled_result = run_core_step( + enabled_state, + enabled_entry, + strategy_evaluator=_Evaluator(), + core_decision_context=CoreDecisionContext( + risk_engine=enabled_risk, + now_ts_ns_local=48, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + assert baseline_result.core_step_decision is None + assert baseline_result.compat_gate_decision is None + assert baseline_result.dispatchable_intents == () + assert not baseline_state.has_queued_intent(instrument, client_order_id) + + assert enabled_result.core_step_decision is not None + assert enabled_result.compat_gate_decision is not None + assert enabled_result.dispatchable_intents == () + assert enabled_state.has_queued_intent(instrument, client_order_id) + + +def test_run_core_step_control_time_with_both_contexts_preserves_existing_control_time_path() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-control-both-contexts") + state.merge_intents_into_queue(instrument, [queued_intent]) + + calls = {"control_risk": 0, "candidate_risk": 0} + accepted_now = _new_intent(client_order_id="accepted-control-both-contexts") + + class _ControlRiskSpy: + def decide_intents( + self, + *, + raw_intents: list[NewOrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> GateDecision: + calls["control_risk"] += 1 + assert state is not None + assert now_ts_ns_local == 49 + assert [it.client_order_id for it in raw_intents] == ( + [queued_intent.client_order_id] + ) + return GateDecision( + ts_ns_local=now_ts_ns_local, + accepted_now=[accepted_now], + queued=[], + rejected=[], + replaced_in_queue=[], + dropped_in_queue=[], + handled_in_queue=[], + execution_rejected=[], + next_send_ts_ns_local=None, + control_scheduling_obligations=(), + ) + + class _CandidateRiskMustNotRun: + def decide_intents(self, **_: object) -> GateDecision: + calls["candidate_risk"] += 1 + raise AssertionError( + "candidate decision path must not run on control-time compatibility path" + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=49), + event=_control_time_event(due_ts_ns_local=49, realized_ts_ns_local=49), + ) + result = run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_ControlRiskSpy(), # type: ignore[arg-type] + instrument=instrument, + now_ts_ns_local=49, + ), + core_decision_context=CoreDecisionContext( + risk_engine=_CandidateRiskMustNotRun(), # type: ignore[arg-type] + now_ts_ns_local=49, + enable_candidate_intent_decision=True, + capture_only=True, + ), + ) + + assert calls == {"control_risk": 1, "candidate_risk": 0} + assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( + "accepted-control-both-contexts", + ) + assert result.compat_gate_decision is not None + assert result.core_step_decision is not None + + def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 867a33d..e3c42eb 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -23,6 +23,7 @@ ) from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, + CoreDecisionContext, run_core_step, ) @@ -98,6 +99,7 @@ "EventStreamEntry", "process_event_entry", "run_core_step", + "CoreDecisionContext", "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", "PolicyRiskDecision", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 6fc8351..691edaa 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -4,6 +4,7 @@ from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, + CoreDecisionContext, run_core_step, ) from tradingchassis_core.core.domain.step_decision import CoreStepDecision @@ -14,6 +15,7 @@ "PolicyRiskDecision", "CoreStepDecision", "CoreStepResult", + "CoreDecisionContext", "ControlTimeQueueReevaluationContext", "run_core_step", ] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 0455487..f90afbe 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -60,6 +60,22 @@ class ControlTimeQueueReevaluationContext: now_ts_ns_local: int +@dataclass(frozen=True, slots=True) +class CoreDecisionContext: + """Optional deterministic context for candidate-intent decision capture. + + Notes: + - ``capture_only`` controls only result projection behavior. + - The compatibility RiskEngine path may still mutate queue/rate state. + """ + + risk_engine: RiskEngine + now_ts_ns_local: int + instrument: str | None = None + enable_candidate_intent_decision: bool = False + capture_only: bool = True + + def _select_effective_control_scheduling_obligation( decision: GateDecision, ) -> ControlSchedulingObligation | None: @@ -113,14 +129,16 @@ def run_core_step( *, configuration: CoreConfiguration | None = None, control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, + core_decision_context: CoreDecisionContext | None = None, strategy_evaluator: CoreStepStrategyEvaluator | None = None, ) -> CoreStepResult: """Run one transitional Core step. Behavior in this phase: - delegates event processing to the canonical boundary via process_event_entry; - - propagates reducer/boundary exceptions unchanged; - - returns an empty CoreStepResult for future deterministic effects. + - computes generated/candidate intents deterministically; + - optionally captures compatibility decision projections via core_decision_context; + - preserves the existing control-time queue reevaluation compatibility path. """ process_event_entry(state, entry, configuration=configuration) @@ -144,40 +162,67 @@ def run_core_step( queued_intents=queued_snapshot, ) - if not isinstance(entry.event, ControlTimeEvent): + # Preserve the existing ControlTimeEvent compatibility path behavior. + if isinstance(entry.event, ControlTimeEvent) and control_time_queue_context is not None: + popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) + if not popped_intents: + return CoreStepResult( + generated_intents=generated_intents, + candidate_intents=candidate_intents, + ) + + decision = control_time_queue_context.risk_engine.decide_intents( + raw_intents=popped_intents, + state=state, + now_ts_ns_local=control_time_queue_context.now_ts_ns_local, + ) + selected_obligation = _select_effective_control_scheduling_obligation(decision) + core_step_decision = _map_compat_gate_decision_to_core_step_decision( + decision=decision, + control_scheduling_obligation=selected_obligation, + ) return CoreStepResult( generated_intents=generated_intents, candidate_intents=candidate_intents, + dispatchable_intents=tuple(decision.accepted_now), + control_scheduling_obligation=selected_obligation, + core_step_decision=core_step_decision, + compat_gate_decision=decision, ) - if control_time_queue_context is None: + if not isinstance(entry.event, ControlTimeEvent): + if ( + core_decision_context is not None + and core_decision_context.enable_candidate_intent_decision + and candidate_intents + ): + if not core_decision_context.capture_only: + raise NotImplementedError( + "core_decision_context capture_only=False is not supported yet" + ) + decision = core_decision_context.risk_engine.decide_intents( + raw_intents=list(candidate_intents), + state=state, + now_ts_ns_local=core_decision_context.now_ts_ns_local, + ) + selected_obligation = _select_effective_control_scheduling_obligation(decision) + core_step_decision = _map_compat_gate_decision_to_core_step_decision( + decision=decision, + control_scheduling_obligation=selected_obligation, + ) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intents=candidate_intents, + core_step_decision=core_step_decision, + compat_gate_decision=decision, + ) return CoreStepResult( generated_intents=generated_intents, candidate_intents=candidate_intents, ) - popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) - if not popped_intents: + if control_time_queue_context is None: return CoreStepResult( generated_intents=generated_intents, candidate_intents=candidate_intents, ) - - decision = control_time_queue_context.risk_engine.decide_intents( - raw_intents=popped_intents, - state=state, - now_ts_ns_local=control_time_queue_context.now_ts_ns_local, - ) - selected_obligation = _select_effective_control_scheduling_obligation(decision) - core_step_decision = _map_compat_gate_decision_to_core_step_decision( - decision=decision, - control_scheduling_obligation=selected_obligation, - ) - return CoreStepResult( - generated_intents=generated_intents, - candidate_intents=candidate_intents, - dispatchable_intents=tuple(decision.accepted_now), - control_scheduling_obligation=selected_obligation, - core_step_decision=core_step_decision, - compat_gate_decision=decision, - ) From fae326226b0e4c073d0afbce4208582d58489214 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 12:06:10 +0000 Subject: [PATCH 12/53] feat(core): add candidate intent origin records to core step results --- ...t_candidate_intent_combination_contract.py | 126 +++++++++++++++++- .../models/test_core_step_api_contract.py | 44 +++++- .../models/test_core_step_result_contract.py | 98 ++++++++++++++ tradingchassis_core/__init__.py | 6 + tradingchassis_core/core/domain/__init__.py | 6 + .../core/domain/candidate_intent.py | 26 ++++ .../core/domain/intent_combination.py | 61 ++++++--- .../core/domain/processing_step.py | 12 +- .../core/domain/step_result.py | 14 ++ 9 files changed, 370 insertions(+), 23 deletions(-) create mode 100644 tradingchassis_core/core/domain/candidate_intent.py diff --git a/tests/semantics/models/test_candidate_intent_combination_contract.py b/tests/semantics/models/test_candidate_intent_combination_contract.py index 5a64a87..436ab9d 100644 --- a/tests/semantics/models/test_candidate_intent_combination_contract.py +++ b/tests/semantics/models/test_candidate_intent_combination_contract.py @@ -2,7 +2,11 @@ from __future__ import annotations -from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin +from tradingchassis_core.core.domain.intent_combination import ( + combine_candidate_intent_records, + combine_candidate_intents, +) from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, @@ -53,18 +57,47 @@ def test_combine_candidate_intents_empty_inputs_returns_empty() -> None: assert result == () +def test_combine_candidate_intent_records_empty_inputs_returns_empty() -> None: + result = combine_candidate_intent_records(generated_intents=(), queued_intents=()) + assert result == () + + def test_combine_candidate_intents_generated_only() -> None: generated = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) result = combine_candidate_intents(generated_intents=generated, queued_intents=()) assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") +def test_combine_candidate_intent_records_generated_only_origin_is_generated() -> None: + generated = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) + result = combine_candidate_intent_records(generated_intents=generated, queued_intents=()) + + assert tuple(record.intent.client_order_id for record in result) == ("c1", "r1", "n1") + assert tuple(record.origin for record in result) == ( + CandidateIntentOrigin.GENERATED, + CandidateIntentOrigin.GENERATED, + CandidateIntentOrigin.GENERATED, + ) + + def test_combine_candidate_intents_queued_only() -> None: queued = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) result = combine_candidate_intents(generated_intents=(), queued_intents=queued) assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") +def test_combine_candidate_intent_records_queued_only_origin_is_queued() -> None: + queued = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) + result = combine_candidate_intent_records(generated_intents=(), queued_intents=queued) + + assert tuple(record.intent.client_order_id for record in result) == ("c1", "r1", "n1") + assert tuple(record.origin for record in result) == ( + CandidateIntentOrigin.QUEUED, + CandidateIntentOrigin.QUEUED, + CandidateIntentOrigin.QUEUED, + ) + + def test_combine_candidate_intents_keeps_different_keys() -> None: queued = (_new(client_order_id="order-a"),) generated = (_replace(client_order_id="order-b"),) @@ -75,6 +108,17 @@ def test_combine_candidate_intents_keeps_different_keys() -> None: ) +def test_combine_candidate_intent_records_keeps_different_keys_with_origins() -> None: + queued = (_new(client_order_id="order-a"),) + generated = (_replace(client_order_id="order-b"),) + result = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert tuple((r.intent.intent_type, r.intent.client_order_id, r.origin) for r in result) == ( + ("replace", "order-b", CandidateIntentOrigin.GENERATED), + ("new", "order-a", CandidateIntentOrigin.QUEUED), + ) + + def test_generated_cancel_dominates_queued_replace_or_new_same_key() -> None: key = "same-key" queued = (_new(client_order_id=key), _replace(client_order_id=key)) @@ -84,6 +128,17 @@ def test_generated_cancel_dominates_queued_replace_or_new_same_key() -> None: assert result[0].intent_type == "cancel" +def test_generated_cancel_dominates_queued_replace_or_new_same_key_origin_generated() -> None: + key = "same-key" + queued = (_new(client_order_id=key), _replace(client_order_id=key)) + generated = (_cancel(client_order_id=key),) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert len(records) == 1 + assert records[0].intent.intent_type == "cancel" + assert records[0].origin == CandidateIntentOrigin.GENERATED + + def test_generated_replace_dominates_queued_new_same_key() -> None: key = "same-key" queued = (_new(client_order_id=key),) @@ -93,6 +148,17 @@ def test_generated_replace_dominates_queued_new_same_key() -> None: assert result[0].intent_type == "replace" +def test_generated_replace_dominates_queued_new_same_key_origin_generated() -> None: + key = "same-key" + queued = (_new(client_order_id=key),) + generated = (_replace(client_order_id=key),) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert len(records) == 1 + assert records[0].intent.intent_type == "replace" + assert records[0].origin == CandidateIntentOrigin.GENERATED + + def test_queued_cancel_dominates_generated_replace_same_key() -> None: key = "same-key" queued = (_cancel(client_order_id=key),) @@ -102,6 +168,17 @@ def test_queued_cancel_dominates_generated_replace_same_key() -> None: assert result[0].intent_type == "cancel" +def test_queued_cancel_dominates_generated_replace_same_key_origin_queued() -> None: + key = "same-key" + queued = (_cancel(client_order_id=key),) + generated = (_replace(client_order_id=key),) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert len(records) == 1 + assert records[0].intent.intent_type == "cancel" + assert records[0].origin == CandidateIntentOrigin.QUEUED + + def test_same_type_conflict_latest_wins_by_merge_order() -> None: key = "same-key" queued = (_replace(client_order_id=key, px=101.0),) @@ -112,6 +189,18 @@ def test_same_type_conflict_latest_wins_by_merge_order() -> None: assert result[0].intended_price.value == 102.0 +def test_same_type_conflict_latest_wins_origin_follows_winner() -> None: + key = "same-key" + queued = (_replace(client_order_id=key, px=101.0),) + generated = (_replace(client_order_id=key, px=102.0),) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert len(records) == 1 + assert records[0].intent.intent_type == "replace" + assert records[0].intent.intended_price.value == 102.0 + assert records[0].origin == CandidateIntentOrigin.GENERATED + + def test_output_order_is_priority_then_merge_order_then_key() -> None: queued = ( _new(client_order_id="n-queued"), @@ -134,9 +223,44 @@ def test_output_order_is_priority_then_merge_order_then_key() -> None: ) +def test_record_output_order_is_priority_then_merge_order_then_key() -> None: + queued = ( + _new(client_order_id="n-queued"), + _replace(client_order_id="r-queued"), + _cancel(client_order_id="c-queued"), + ) + generated = ( + _cancel(client_order_id="c-generated"), + _replace(client_order_id="r-generated"), + _new(client_order_id="n-generated"), + ) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert tuple((record.intent.intent_type, record.intent.client_order_id) for record in records) == ( + ("cancel", "c-queued"), + ("cancel", "c-generated"), + ("replace", "r-queued"), + ("replace", "r-generated"), + ("new", "n-queued"), + ("new", "n-generated"), + ) + assert tuple(record.merge_index for record in records) == (2, 3, 1, 4, 0, 5) + + +def test_combine_candidate_intents_matches_intent_view_of_record_helper() -> None: + queued = (_new(client_order_id="q-new"), _cancel(client_order_id="q-cancel")) + generated = (_replace(client_order_id="g-replace"), _new(client_order_id="g-new")) + + compat = combine_candidate_intents(generated_intents=generated, queued_intents=queued) + records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) + + assert compat == tuple(record.intent for record in records) + + def test_inputs_are_not_mutated() -> None: queued = [_new(client_order_id="q1")] generated = [_cancel(client_order_id="g1")] + _ = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) _ = combine_candidate_intents(generated_intents=generated, queued_intents=queued) assert len(queued) == 1 assert len(generated) == 1 diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index d950980..c1f63ac 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -9,6 +9,7 @@ import tradingchassis_core as tc import tradingchassis_core.core.domain.processing_step as processing_step_module from tradingchassis_core.core.domain import run_core_step as domain_run_core_step +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin from tradingchassis_core.core.domain.event_model import ( canonical_category_for_type, is_canonical_stream_candidate_type, @@ -197,6 +198,7 @@ def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: assert isinstance(result, CoreStepResult) assert result.generated_intents == () + assert result.candidate_intent_records == () assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None @@ -222,6 +224,7 @@ def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() result = run_core_step(no_strategy_state, entry) assert result.generated_intents == () + assert result.candidate_intent_records == () assert result.candidate_intents == () assert result.core_step_decision is None assert result == CoreStepResult() @@ -349,6 +352,10 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: assert context.state._last_processing_position_index == 12 assert context.state.market["BTC-USDC-PERP"].best_bid == 100.0 assert result.generated_intents == (generated_intent,) + assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.GENERATED, + ) assert result.candidate_intents == (generated_intent,) assert result.dispatchable_intents == () assert result.core_step_decision is None @@ -483,6 +490,12 @@ def decide_intents( assert len(popped_raw_intents) == 1 assert [it.client_order_id for it in popped_raw_intents[0]] == [queued_intent.client_order_id] assert tuple(it.client_order_id for it in result.dispatchable_intents) == ("accepted-now",) + assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( + "queued-1", + ) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.QUEUED, + ) assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-1",) assert isinstance(result.core_step_decision, CoreStepDecision) assert tuple( @@ -591,6 +604,10 @@ def decide_intents(self, **_: object) -> GateDecision: ) assert result.generated_intents == (generated_intent,) + assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.GENERATED, + ) assert result.candidate_intents == (generated_intent,) assert result.core_step_decision is None assert result.compat_gate_decision is None @@ -673,6 +690,10 @@ def decide_intents( "generated-candidate-risk", ) assert result.generated_intents == (generated_intent,) + assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.GENERATED, + ) assert result.candidate_intents == (generated_intent,) assert result.compat_gate_decision is not None assert result.core_step_decision is not None @@ -745,6 +766,7 @@ def decide_intents(self, **_: object) -> GateDecision: assert calls == {"risk": 0} assert result.generated_intents == () + assert result.candidate_intent_records == () assert result.candidate_intents == () assert result.core_step_decision is None assert result.compat_gate_decision is None @@ -899,6 +921,12 @@ def test_run_core_step_includes_queued_snapshot_in_candidate_intents_without_mut result = run_core_step(state, entry) assert tuple(it.client_order_id for it in result.generated_intents) == () + assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( + "queued-candidate", + ) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.QUEUED, + ) assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-candidate",) assert result.dispatchable_intents == () assert state.has_queued_intent(instrument, "queued-candidate") @@ -935,6 +963,10 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[CancelOrderIntent]: result = run_core_step(state, entry, strategy_evaluator=_Evaluator()) assert tuple(it.intent_type for it in result.generated_intents) == ("cancel",) + assert tuple(record.intent.intent_type for record in result.candidate_intent_records) == ("cancel",) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.GENERATED, + ) assert tuple(it.intent_type for it in result.candidate_intents) == ("cancel",) assert result.dispatchable_intents == () assert state.has_queued_intent(instrument, key) @@ -959,7 +991,7 @@ def _boom(*_: object, **__: object) -> None: monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) monkeypatch.setattr( processing_step_module, - "combine_candidate_intents", + "combine_candidate_intent_records", lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), ) @@ -1099,6 +1131,14 @@ def decide_intents( assert tuple(it.client_order_id for it in result.generated_intents) == ( "generated-captured", ) + assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( + "queued-with-strategy", + "generated-captured", + ) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.QUEUED, + CandidateIntentOrigin.GENERATED, + ) assert tuple(it.client_order_id for it in result.candidate_intents) == ( "queued-with-strategy", "generated-captured", @@ -1152,7 +1192,7 @@ def decide_intents(self, **_: object) -> GateDecision: monkeypatch.setattr( processing_step_module, - "combine_candidate_intents", + "combine_candidate_intent_records", lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), ) diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py index adbb176..611afd1 100644 --- a/tests/semantics/models/test_core_step_result_contract.py +++ b/tests/semantics/models/test_core_step_result_contract.py @@ -7,6 +7,10 @@ import pytest import tradingchassis_core as tc +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.event_model import ( canonical_category_for_type, is_canonical_stream_candidate_type, @@ -45,6 +49,7 @@ def test_default_result_is_empty_and_none_compat() -> None: result = CoreStepResult() assert result.generated_intents == () + assert result.candidate_intent_records == () assert result.candidate_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None @@ -95,6 +100,62 @@ def test_generated_intents_are_distinct_from_dispatchable_intents() -> None: assert result.dispatchable_intents == (dispatchable,) +def test_candidate_intent_records_normalize_to_tuple() -> None: + intent = _new_intent(client_order_id="record-candidate") + record = CandidateIntentRecord( + intent=intent, + origin=CandidateIntentOrigin.GENERATED, + logical_key=f"order:{intent.client_order_id}", + merge_index=0, + priority=2, + ) + result = CoreStepResult(candidate_intent_records=[record]) + + assert isinstance(result.candidate_intent_records, tuple) + assert result.candidate_intent_records == (record,) + + +def test_candidate_intents_follow_candidate_intent_records_when_present() -> None: + record_intent = _new_intent(client_order_id="record-wins-intent-view") + stale_view_intent = _new_intent(client_order_id="stale-candidate-view") + record = CandidateIntentRecord( + intent=record_intent, + origin=CandidateIntentOrigin.QUEUED, + logical_key=f"order:{record_intent.client_order_id}", + merge_index=4, + priority=2, + ) + result = CoreStepResult( + candidate_intent_records=[record], + candidate_intents=[stale_view_intent], + ) + + assert result.candidate_intent_records == (record,) + assert result.candidate_intents == (record_intent,) + + +def test_candidate_intent_records_are_distinct_from_dispatchable_intents() -> None: + candidate_intent = _new_intent(client_order_id="candidate-record-only") + dispatchable_intent = _new_intent(client_order_id="dispatchable-only") + record = CandidateIntentRecord( + intent=candidate_intent, + origin=CandidateIntentOrigin.GENERATED, + logical_key=f"order:{candidate_intent.client_order_id}", + merge_index=1, + priority=2, + ) + result = CoreStepResult( + candidate_intent_records=[record], + dispatchable_intents=[dispatchable_intent], + ) + + assert tuple(r.intent.client_order_id for r in result.candidate_intent_records) == ( + "candidate-record-only", + ) + assert tuple(i.client_order_id for i in result.candidate_intents) == ("candidate-record-only",) + assert tuple(i.client_order_id for i in result.dispatchable_intents) == ("dispatchable-only",) + + def test_generated_intents_accept_new_replace_cancel_intents() -> None: new_intent = _new_intent(client_order_id="new-intent") replace_intent = ReplaceOrderIntent( @@ -139,6 +200,25 @@ def test_candidate_intents_are_not_dispatchable_by_default() -> None: assert result.dispatchable_intents == () +def test_candidate_intent_origin_values_are_stable() -> None: + assert CandidateIntentOrigin.GENERATED.value == "generated" + assert CandidateIntentOrigin.QUEUED.value == "queued" + + +def test_candidate_intent_record_is_immutable() -> None: + candidate_intent = _new_intent(client_order_id="immutable-candidate-record") + record = CandidateIntentRecord( + intent=candidate_intent, + origin=CandidateIntentOrigin.GENERATED, + logical_key=f"order:{candidate_intent.client_order_id}", + merge_index=0, + priority=2, + ) + + with pytest.raises(FrozenInstanceError): + record.merge_index = 7 + + def test_can_carry_optional_control_scheduling_obligation() -> None: obligation = ControlSchedulingObligation( due_ts_ns_local=1_000_000_000, @@ -207,6 +287,24 @@ def test_canonical_processing_boundary_rejects_core_step_result() -> None: process_canonical_event(state, CoreStepResult()) +def test_candidate_intent_record_is_non_canonical_and_rejected_if_misrouted() -> None: + candidate_intent = _new_intent(client_order_id="candidate-record-boundary") + candidate_record = CandidateIntentRecord( + intent=candidate_intent, + origin=CandidateIntentOrigin.GENERATED, + logical_key=f"order:{candidate_intent.client_order_id}", + merge_index=0, + priority=2, + ) + state = StrategyState(event_bus=NullEventBus()) + + assert is_canonical_stream_candidate_type(CandidateIntentRecord) is False + assert canonical_category_for_type(CandidateIntentRecord) is None + + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, candidate_record) + + def test_public_root_export_identity_when_root_exported() -> None: assert hasattr(tc, "CoreStepResult") assert tc.CoreStepResult is CoreStepResult diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index e3c42eb..75b06f6 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -8,6 +8,10 @@ from importlib.metadata import PackageNotFoundError, version +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.execution_control_decision import ( ExecutionControlDecision, @@ -95,6 +99,8 @@ "EngineContext", "GateDecision", "CoreConfiguration", + "CandidateIntentOrigin", + "CandidateIntentRecord", "ProcessingPosition", "EventStreamEntry", "process_event_entry", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 691edaa..9dc85d4 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -1,5 +1,9 @@ """Public exports for core domain value objects.""" +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.execution_control_decision import ExecutionControlDecision from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision from tradingchassis_core.core.domain.processing_step import ( @@ -11,6 +15,8 @@ from tradingchassis_core.core.domain.step_result import CoreStepResult __all__ = [ + "CandidateIntentOrigin", + "CandidateIntentRecord", "ExecutionControlDecision", "PolicyRiskDecision", "CoreStepDecision", diff --git a/tradingchassis_core/core/domain/candidate_intent.py b/tradingchassis_core/core/domain/candidate_intent.py new file mode 100644 index 0000000..2fba7c8 --- /dev/null +++ b/tradingchassis_core/core/domain/candidate_intent.py @@ -0,0 +1,26 @@ +"""Core-owned non-canonical candidate intent provenance models.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from tradingchassis_core.core.domain.types import OrderIntent + + +class CandidateIntentOrigin(str, Enum): + """Origin marker for candidate intents in one Core step.""" + + GENERATED = "generated" + QUEUED = "queued" + + +@dataclass(frozen=True, slots=True) +class CandidateIntentRecord: + """Non-canonical Core-step candidate record with explicit provenance.""" + + intent: OrderIntent + origin: CandidateIntentOrigin + logical_key: str + merge_index: int + priority: int diff --git a/tradingchassis_core/core/domain/intent_combination.py b/tradingchassis_core/core/domain/intent_combination.py index 581e588..5f575f7 100644 --- a/tradingchassis_core/core/domain/intent_combination.py +++ b/tradingchassis_core/core/domain/intent_combination.py @@ -4,6 +4,10 @@ from collections.abc import Sequence +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.types import OrderIntent @@ -36,40 +40,61 @@ def combine_candidate_intents( generated_intents: Sequence[OrderIntent], queued_intents: Sequence[OrderIntent], ) -> tuple[OrderIntent, ...]: + """Compatibility helper returning only effective intent values. + + Prefer ``combine_candidate_intent_records`` when origin/provenance is needed. + """ + records = combine_candidate_intent_records( + generated_intents=generated_intents, + queued_intents=queued_intents, + ) + return tuple(record.intent for record in records) + + +def combine_candidate_intent_records( + *, + generated_intents: Sequence[OrderIntent], + queued_intents: Sequence[OrderIntent], +) -> tuple[CandidateIntentRecord, ...]: """Combine queued + generated intents into a deterministic effective set. This helper is pure and does not mutate StrategyState. Merge order is deterministic: queued first, then generated. """ - - merged = [*queued_intents, *generated_intents] - # key -> (intent, merge_index) - effective_by_key: dict[str, tuple[OrderIntent, int]] = {} - - for merge_index, intent in enumerate(merged): + merged: list[tuple[OrderIntent, CandidateIntentOrigin]] = [ + *((intent, CandidateIntentOrigin.QUEUED) for intent in queued_intents), + *((intent, CandidateIntentOrigin.GENERATED) for intent in generated_intents), + ] + # key -> winning record + effective_by_key: dict[str, CandidateIntentRecord] = {} + + for merge_index, (intent, origin) in enumerate(merged): key = _logical_key(intent) + incoming = CandidateIntentRecord( + intent=intent, + origin=origin, + logical_key=key, + merge_index=merge_index, + priority=_intent_priority(intent), + ) existing = effective_by_key.get(key) if existing is None: - effective_by_key[key] = (intent, merge_index) + effective_by_key[key] = incoming continue - existing_intent, _ = existing - incoming_rank = _dominance_rank(intent) - existing_rank = _dominance_rank(existing_intent) + incoming_rank = _dominance_rank(incoming.intent) + existing_rank = _dominance_rank(existing.intent) if incoming_rank > existing_rank: - effective_by_key[key] = (intent, merge_index) + effective_by_key[key] = incoming continue if incoming_rank < existing_rank: continue # Same-type conflict: latest in deterministic merge order wins. - effective_by_key[key] = (intent, merge_index) + effective_by_key[key] = incoming ordered = sorted( - ( - (intent, merge_index, key) - for key, (intent, merge_index) in effective_by_key.items() - ), - key=lambda item: (_intent_priority(item[0]), item[1], item[2]), + effective_by_key.values(), + key=lambda item: (item.priority, item.merge_index, item.logical_key), ) - return tuple(intent for intent, _, _ in ordered) + return tuple(ordered) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index f90afbe..5de8c5b 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -14,7 +14,9 @@ from tradingchassis_core.core.domain.execution_control_decision import ( map_compat_gate_decision_to_execution_control_decision, ) -from tradingchassis_core.core.domain.intent_combination import combine_candidate_intents +from tradingchassis_core.core.domain.intent_combination import ( + combine_candidate_intent_records, +) from tradingchassis_core.core.domain.policy_risk_decision import ( map_compat_gate_decision_to_policy_risk_decision, ) @@ -157,10 +159,11 @@ def run_core_step( control_time_queue_context=control_time_queue_context, ) queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) - candidate_intents = combine_candidate_intents( + candidate_intent_records = combine_candidate_intent_records( generated_intents=generated_intents, queued_intents=queued_snapshot, ) + candidate_intents = tuple(record.intent for record in candidate_intent_records) # Preserve the existing ControlTimeEvent compatibility path behavior. if isinstance(entry.event, ControlTimeEvent) and control_time_queue_context is not None: @@ -168,6 +171,7 @@ def run_core_step( if not popped_intents: return CoreStepResult( generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, ) @@ -183,6 +187,7 @@ def run_core_step( ) return CoreStepResult( generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, dispatchable_intents=tuple(decision.accepted_now), control_scheduling_obligation=selected_obligation, @@ -212,17 +217,20 @@ def run_core_step( ) return CoreStepResult( generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, core_step_decision=core_step_decision, compat_gate_decision=decision, ) return CoreStepResult( generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, ) if control_time_queue_context is None: return CoreStepResult( generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, ) diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index ff84995..b254aca 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -9,6 +9,7 @@ from dataclasses import dataclass +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentRecord from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation @@ -20,6 +21,7 @@ class CoreStepResult: """Immutable result object for the future Core processing step API.""" generated_intents: tuple[OrderIntent, ...] = () + candidate_intent_records: tuple[CandidateIntentRecord, ...] = () candidate_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation: ControlSchedulingObligation | None = None @@ -33,12 +35,24 @@ def __post_init__(self) -> None: "generated_intents", tuple(self.generated_intents), ) + if not isinstance(self.candidate_intent_records, tuple): + object.__setattr__( + self, + "candidate_intent_records", + tuple(self.candidate_intent_records), + ) if not isinstance(self.candidate_intents, tuple): object.__setattr__( self, "candidate_intents", tuple(self.candidate_intents), ) + if self.candidate_intent_records: + object.__setattr__( + self, + "candidate_intents", + tuple(record.intent for record in self.candidate_intent_records), + ) # Normalize sequence-like inputs to a tuple to keep deterministic value semantics. if not isinstance(self.dispatchable_intents, tuple): object.__setattr__( From 5c8c70688de0f8062451b6be83770756195a9de6 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 12:26:19 +0000 Subject: [PATCH 13/53] feat(core): add policy admission for generated candidate intents --- .../models/test_core_step_api_contract.py | 251 ++++++++++++++++++ .../test_policy_risk_decision_contract.py | 178 +++++++++++++ tradingchassis_core/__init__.py | 10 +- tradingchassis_core/core/domain/__init__.py | 10 +- .../core/domain/policy_risk_decision.py | 121 ++++++++- .../core/domain/processing_step.py | 39 +++ tradingchassis_core/core/risk/risk_engine.py | 82 ++++++ 7 files changed, 687 insertions(+), 4 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index c1f63ac..942bbf4 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -23,6 +23,7 @@ from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CorePolicyAdmissionContext, CoreStepStrategyContext, run_core_step, ) @@ -36,11 +37,14 @@ MarketEvent, NewOrderIntent, NotionalLimits, + OrderIntent, OrderRateLimits, OrderStateEvent, Price, Quantity, ) +from tradingchassis_core.core.events.event_bus import EventBus +from tradingchassis_core.core.events.events import RiskDecisionEvent from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation from tradingchassis_core.core.risk.risk_config import RiskConfig @@ -1493,3 +1497,250 @@ def _boom(*_: object, **__: object) -> None: ) assert calls == {"pop": 0, "risk": 0} + + +def test_run_core_step_policy_admission_context_populates_policy_decision_only() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-passthrough") + state.merge_intents_into_queue(instrument, [queued_intent]) + + generated_new = _new_intent(client_order_id="generated-new-rejected") + generated_cancel = CancelOrderIntent( + ts_ns_local=50, + instrument=instrument, + client_order_id="generated-cancel-accepted", + intents_correlation_id="corr-generated-cancel", + ) + risk_cfg = RiskConfig( + scope="test", + trading_enabled=False, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1e18, + max_single_order_notional=1e18, + ), + ) + risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: + assert context.state._last_processing_position_index == 50 + return [generated_new, generated_cancel] + + entry = EventStreamEntry( + position=ProcessingPosition(index=50), + event=_fill_event( + instrument=instrument, + client_order_id="fill-policy-context", + ts_ns_local=50, + ts_ns_exch=49, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=risk_engine, + now_ts_ns_local=50, + ), + ) + + assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( + "generated-cancel-accepted", + "queued-passthrough", + "generated-new-rejected", + ) + assert result.core_step_decision is not None + assert result.core_step_decision.execution_control_decision is None + assert tuple(it.client_order_id for it in result.core_step_decision.policy_rejected_intents) == ( + "generated-new-rejected", + ) + assert result.core_step_decision.policy_risk_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.accepted_intents + ) == ("generated-cancel-accepted",) + assert tuple( + it.client_order_id + for it in result.core_step_decision.policy_risk_decision.rejected_intents + ) == ("generated-new-rejected",) + assert result.core_step_decision.dispatchable_intents == () + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + assert result.compat_gate_decision is None + + +def test_run_core_step_policy_admission_context_queued_only_skips_policy_evaluation() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + state.merge_intents_into_queue( + instrument, + [_new_intent(client_order_id="queued-only-record")], + ) + calls = {"evaluate": 0} + + class _Evaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + calls["evaluate"] += 1 + return True, None + + entry = EventStreamEntry( + position=ProcessingPosition(index=51), + event=_fill_event( + instrument=instrument, + client_order_id="fill-queued-only", + ts_ns_local=51, + ts_ns_exch=50, + ), + ) + result = run_core_step( + state, + entry, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_Evaluator(), # type: ignore[arg-type] + now_ts_ns_local=51, + ), + ) + + assert calls == {"evaluate": 0} + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.QUEUED, + ) + assert result.core_step_decision is not None + assert result.core_step_decision.policy_risk_decision == PolicyRiskDecision() + assert result.core_step_decision.policy_rejected_intents == () + assert result.dispatchable_intents == () + + +def test_run_core_step_policy_admission_context_not_reached_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"evaluate": 0} + + class _Evaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + calls["evaluate"] += 1 + return True, None + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + + entry = EventStreamEntry( + position=ProcessingPosition(index=52), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-process-fail", + ts_ns_local=52, + ts_ns_exch=51, + ), + ) + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step( + state, + entry, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_Evaluator(), # type: ignore[arg-type] + now_ts_ns_local=52, + ), + ) + assert calls == {"evaluate": 0} + + +def test_run_core_step_policy_admission_context_not_reached_when_strategy_fails() -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"evaluate": 0} + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 53 + raise RuntimeError("strategy evaluator failed") + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + calls["evaluate"] += 1 + return True, None + + entry = EventStreamEntry( + position=ProcessingPosition(index=53), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-strategy-fail", + ts_ns_local=53, + ts_ns_exch=52, + ), + ) + with pytest.raises(RuntimeError, match="strategy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorBoom(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=53, + ), + ) + assert calls == {"evaluate": 0} + + +def test_run_core_step_policy_admission_context_is_side_effect_safe_characterization() -> None: + class _CaptureSink: + def __init__(self) -> None: + self.events: list[object] = [] + + def on_event(self, event: object) -> None: + self.events.append(event) + + sink = _CaptureSink() + event_bus = EventBus(sinks=[sink]) + state = StrategyState(event_bus=event_bus) + risk_cfg = RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1e18, + max_single_order_notional=1e18, + ), + order_rate_limits=OrderRateLimits(max_orders_per_second=0), + ) + risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=event_bus) + + generated_intent = _new_intent(client_order_id="side-effect-safe-generated") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: + assert context.state._last_processing_position_index == 54 + return [generated_intent] + + before_rate_state = copy.deepcopy(risk_engine._execution_control._rate_state) + before_queue = state.queued_intents_snapshot("BTC-USDC-PERP") + + entry = EventStreamEntry( + position=ProcessingPosition(index=54), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-side-effect-safe", + ts_ns_local=54, + ts_ns_exch=53, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=risk_engine, + now_ts_ns_local=54, + ), + ) + + assert state.queued_intents_snapshot("BTC-USDC-PERP") == before_queue + assert risk_engine._execution_control._rate_state == before_rate_state + assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) + assert result.dispatchable_intents == () + assert result.compat_gate_decision is None diff --git a/tests/semantics/models/test_policy_risk_decision_contract.py b/tests/semantics/models/test_policy_risk_decision_contract.py index a04f861..b5c4c61 100644 --- a/tests/semantics/models/test_policy_risk_decision_contract.py +++ b/tests/semantics/models/test_policy_risk_decision_contract.py @@ -7,12 +7,19 @@ import pytest import tradingchassis_core as tc +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.event_model import ( canonical_category_for_type, is_canonical_stream_candidate_type, ) from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyRejectedCandidate, PolicyRiskDecision, + apply_policy_to_candidate_records, map_compat_gate_decision_to_policy_risk_decision, ) from tradingchassis_core.core.domain.processing import process_canonical_event @@ -96,3 +103,174 @@ def test_map_compat_gate_decision_to_policy_risk_decision_projection() -> None: def test_public_root_export_identity_when_root_exported() -> None: assert hasattr(tc, "PolicyRiskDecision") assert tc.PolicyRiskDecision is PolicyRiskDecision + + +def test_policy_rejected_candidate_is_immutable() -> None: + record = CandidateIntentRecord( + intent=_new_intent(client_order_id="generated-rejected"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:generated-rejected", + merge_index=0, + priority=2, + ) + rejected = PolicyRejectedCandidate(record=record, reason="policy_reject") + with pytest.raises(FrozenInstanceError): + rejected.reason = "changed" + + +def test_policy_admission_result_defaults_are_empty() -> None: + result = PolicyAdmissionResult() + assert result.accepted_generated == () + assert result.rejected_generated == () + assert result.passthrough_queued == () + assert result.policy_risk_decision == PolicyRiskDecision() + + +def test_policy_admission_result_tuple_normalization() -> None: + generated_record = CandidateIntentRecord( + intent=_new_intent(client_order_id="generated-accepted"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:generated-accepted", + merge_index=1, + priority=2, + ) + queued_record = CandidateIntentRecord( + intent=_new_intent(client_order_id="queued-passthrough"), + origin=CandidateIntentOrigin.QUEUED, + logical_key="order:queued-passthrough", + merge_index=2, + priority=2, + ) + rejected = PolicyRejectedCandidate( + record=generated_record, + reason="policy_reject", + ) + result = PolicyAdmissionResult( + accepted_generated=[generated_record], + rejected_generated=[rejected], + passthrough_queued=[queued_record], + ) + assert result.accepted_generated == (generated_record,) + assert result.rejected_generated == (rejected,) + assert result.passthrough_queued == (queued_record,) + + +def test_policy_admission_result_and_related_models_are_non_canonical() -> None: + assert is_canonical_stream_candidate_type(PolicyRejectedCandidate) is False + assert canonical_category_for_type(PolicyRejectedCandidate) is None + assert is_canonical_stream_candidate_type(PolicyAdmissionResult) is False + assert canonical_category_for_type(PolicyAdmissionResult) is None + + +def test_canonical_processing_boundary_rejects_policy_admission_models() -> None: + state = StrategyState(event_bus=NullEventBus()) + record = CandidateIntentRecord( + intent=_new_intent(client_order_id="boundary-record"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:boundary-record", + merge_index=0, + priority=2, + ) + rejected = PolicyRejectedCandidate(record=record, reason="policy_reject") + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, rejected) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, PolicyAdmissionResult()) + + +def test_apply_policy_to_candidate_records_partitions_generated_and_queued() -> None: + accepted_generated = CandidateIntentRecord( + intent=_new_intent(client_order_id="accepted-generated"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:accepted-generated", + merge_index=0, + priority=2, + ) + rejected_generated = CandidateIntentRecord( + intent=_new_intent(client_order_id="rejected-generated"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:rejected-generated", + merge_index=1, + priority=2, + ) + passthrough_queued = CandidateIntentRecord( + intent=_new_intent(client_order_id="queued-record"), + origin=CandidateIntentOrigin.QUEUED, + logical_key="order:queued-record", + merge_index=2, + priority=2, + ) + state = StrategyState(event_bus=NullEventBus()) + calls: list[str] = [] + + class _Evaluator: + def evaluate_policy_intent( + self, + *, + intent: NewOrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = state + assert now_ts_ns_local == 123 + calls.append(intent.client_order_id) + if intent.client_order_id == "rejected-generated": + return False, "policy_reject" + return True, None + + result = apply_policy_to_candidate_records( + ( + accepted_generated, + rejected_generated, + passthrough_queued, + ), + state=state, + now_ts_ns_local=123, + policy_evaluator=_Evaluator(), + ) + + assert calls == ["accepted-generated", "rejected-generated"] + assert result.accepted_generated == (accepted_generated,) + assert result.passthrough_queued == (passthrough_queued,) + assert len(result.rejected_generated) == 1 + assert result.rejected_generated[0].record == rejected_generated + assert result.rejected_generated[0].reason == "policy_reject" + assert tuple(it.client_order_id for it in result.policy_risk_decision.accepted_intents) == ( + "accepted-generated", + ) + assert tuple(it.client_order_id for it in result.policy_risk_decision.rejected_intents) == ( + "rejected-generated", + ) + + +def test_apply_policy_to_candidate_records_does_not_call_decide_intents() -> None: + record = CandidateIntentRecord( + intent=_new_intent(client_order_id="generated-only"), + origin=CandidateIntentOrigin.GENERATED, + logical_key="order:generated-only", + merge_index=0, + priority=2, + ) + + class _Evaluator: + def evaluate_policy_intent( + self, + *, + intent: NewOrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + def decide_intents(self, **_: object) -> GateDecision: + raise AssertionError("decide_intents must not be called by policy helper") + + result = apply_policy_to_candidate_records( + (record,), + state=StrategyState(event_bus=NullEventBus()), + now_ts_ns_local=1, + policy_evaluator=_Evaluator(), # type: ignore[arg-type] + ) + + assert result.accepted_generated == (record,) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 75b06f6..c5ce9fc 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -16,7 +16,11 @@ from tradingchassis_core.core.domain.execution_control_decision import ( ExecutionControlDecision, ) -from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyRejectedCandidate, + PolicyRiskDecision, +) from tradingchassis_core.core.domain.processing import ( fold_event_stream_entries, process_event_entry, @@ -28,6 +32,7 @@ from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CorePolicyAdmissionContext, run_core_step, ) @@ -106,9 +111,12 @@ "process_event_entry", "run_core_step", "CoreDecisionContext", + "CorePolicyAdmissionContext", "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", "PolicyRiskDecision", + "PolicyRejectedCandidate", + "PolicyAdmissionResult", "CoreStepDecision", "fold_event_stream_entries", "CoreStepResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 9dc85d4..d14d898 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -5,10 +5,15 @@ CandidateIntentRecord, ) from tradingchassis_core.core.domain.execution_control_decision import ExecutionControlDecision -from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision +from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, + PolicyRejectedCandidate, + PolicyRiskDecision, +) from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CorePolicyAdmissionContext, run_core_step, ) from tradingchassis_core.core.domain.step_decision import CoreStepDecision @@ -19,9 +24,12 @@ "CandidateIntentRecord", "ExecutionControlDecision", "PolicyRiskDecision", + "PolicyRejectedCandidate", + "PolicyAdmissionResult", "CoreStepDecision", "CoreStepResult", "CoreDecisionContext", + "CorePolicyAdmissionContext", "ControlTimeQueueReevaluationContext", "run_core_step", ] diff --git a/tradingchassis_core/core/domain/policy_risk_decision.py b/tradingchassis_core/core/domain/policy_risk_decision.py index 705d51c..afdd29d 100644 --- a/tradingchassis_core/core/domain/policy_risk_decision.py +++ b/tradingchassis_core/core/domain/policy_risk_decision.py @@ -1,12 +1,33 @@ -"""Core-owned policy-risk decision scaffold and compatibility projection helpers.""" +"""Core-owned policy-risk decision scaffold and policy admission helpers.""" from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Protocol, Sequence +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.risk.risk_engine import GateDecision +if TYPE_CHECKING: + from tradingchassis_core.core.domain.state import StrategyState + + +class PolicyIntentEvaluator(Protocol): + """Side-effect-safe policy evaluator contract for one candidate intent.""" + + def evaluate_policy_intent( + self, + *, + intent: OrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + """Return (accepted, reason_if_rejected).""" + @dataclass(frozen=True, slots=True) class PolicyRiskDecision: @@ -30,6 +51,102 @@ def __post_init__(self) -> None: ) +@dataclass(frozen=True, slots=True) +class PolicyRejectedCandidate: + """Generated-origin candidate denied by policy with preserved reason.""" + + record: CandidateIntentRecord + reason: str + + +@dataclass(frozen=True, slots=True) +class PolicyAdmissionResult: + """Result of side-effect-safe policy admission over candidate records.""" + + accepted_generated: tuple[CandidateIntentRecord, ...] = () + rejected_generated: tuple[PolicyRejectedCandidate, ...] = () + passthrough_queued: tuple[CandidateIntentRecord, ...] = () + policy_risk_decision: PolicyRiskDecision = field(default_factory=PolicyRiskDecision) + + def __post_init__(self) -> None: + if not isinstance(self.accepted_generated, tuple): + object.__setattr__( + self, + "accepted_generated", + tuple(self.accepted_generated), + ) + if not isinstance(self.rejected_generated, tuple): + object.__setattr__( + self, + "rejected_generated", + tuple(self.rejected_generated), + ) + if not isinstance(self.passthrough_queued, tuple): + object.__setattr__( + self, + "passthrough_queued", + tuple(self.passthrough_queued), + ) + + +def apply_policy_to_candidate_records( + candidate_records: Sequence[CandidateIntentRecord], + *, + state: StrategyState, + now_ts_ns_local: int, + policy_evaluator: PolicyIntentEvaluator, +) -> PolicyAdmissionResult: + """Apply policy admission to generated-origin candidates only. + + Side-effect contract: + - does not mutate candidate records; + - does not mutate queue/rate/inflight state by itself; + - does not emit events by itself. + """ + + accepted_generated: list[CandidateIntentRecord] = [] + rejected_generated: list[PolicyRejectedCandidate] = [] + passthrough_queued: list[CandidateIntentRecord] = [] + + accepted_intents: list[OrderIntent] = [] + rejected_intents: list[OrderIntent] = [] + + for record in candidate_records: + if record.origin == CandidateIntentOrigin.QUEUED: + passthrough_queued.append(record) + continue + if record.origin != CandidateIntentOrigin.GENERATED: + raise ValueError(f"Unsupported CandidateIntentOrigin: {record.origin!r}") + + accepted, reason = policy_evaluator.evaluate_policy_intent( + intent=record.intent, + state=state, + now_ts_ns_local=now_ts_ns_local, + ) + if accepted: + accepted_generated.append(record) + accepted_intents.append(record.intent) + continue + + rejected_generated.append( + PolicyRejectedCandidate( + record=record, + reason=reason or "policy_rejected", + ) + ) + rejected_intents.append(record.intent) + + return PolicyAdmissionResult( + accepted_generated=tuple(accepted_generated), + rejected_generated=tuple(rejected_generated), + passthrough_queued=tuple(passthrough_queued), + policy_risk_decision=PolicyRiskDecision( + accepted_intents=tuple(accepted_intents), + rejected_intents=tuple(rejected_intents), + ), + ) + + def map_compat_gate_decision_to_policy_risk_decision( decision: GateDecision, ) -> PolicyRiskDecision: diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 5de8c5b..03d808d 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -18,6 +18,8 @@ combine_candidate_intent_records, ) from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyIntentEvaluator, + apply_policy_to_candidate_records, map_compat_gate_decision_to_policy_risk_decision, ) from tradingchassis_core.core.domain.processing import process_event_entry @@ -78,6 +80,14 @@ class CoreDecisionContext: capture_only: bool = True +@dataclass(frozen=True, slots=True) +class CorePolicyAdmissionContext: + """Optional side-effect-safe policy admission capture context.""" + + policy_evaluator: PolicyIntentEvaluator + now_ts_ns_local: int + + def _select_effective_control_scheduling_obligation( decision: GateDecision, ) -> ControlSchedulingObligation | None: @@ -131,6 +141,7 @@ def run_core_step( *, configuration: CoreConfiguration | None = None, control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, core_decision_context: CoreDecisionContext | None = None, strategy_evaluator: CoreStepStrategyEvaluator | None = None, ) -> CoreStepResult: @@ -196,6 +207,34 @@ def run_core_step( ) if not isinstance(entry.event, ControlTimeEvent): + if ( + policy_admission_context is not None + and core_decision_context is not None + and core_decision_context.enable_candidate_intent_decision + ): + raise ValueError( + "policy_admission_context cannot be combined with " + "core_decision_context.enable_candidate_intent_decision=True" + ) + if policy_admission_context is not None: + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + core_step_decision = CoreStepDecision( + policy_rejected_intents=tuple( + rejected.record.intent for rejected in policy_result.rejected_generated + ), + policy_risk_decision=policy_result.policy_risk_decision, + ) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + core_step_decision=core_step_decision, + ) if ( core_decision_context is not None and core_decision_context.enable_candidate_intent_decision diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 1ef1678..4f99ade 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -200,6 +200,88 @@ def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: # Hard gate decision # --------------------------------------------------------------------- + def evaluate_policy_intent( + self, + *, + intent: OrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + """Evaluate one intent with policy-only checks and no side effects. + + Side-effect contract: + - does not call execution-control helpers; + - does not mutate queue/rate/inflight state; + - does not emit EventBus events. + """ + + raw_intents = [intent] + + triggered, policy_accepted, policy_rejected = self._risk_policy.trading_enabled_gate( + trading_enabled=self.risk_cfg.trading_enabled, + raw_intents=raw_intents, + ) + if triggered: + if policy_accepted: + return True, None + if policy_rejected: + return False, policy_rejected[0][1] + return False, RejectReason.TRADING_DISABLED + + triggered, policy_accepted, policy_rejected = self._risk_policy.max_loss_gate( + max_loss_cfg=self.risk_cfg.max_loss, + raw_intents=raw_intents, + state=state, + now_ts_ns_local=now_ts_ns_local, + ) + if triggered: + if policy_accepted: + return True, None + if policy_rejected: + return False, policy_rejected[0][1] + return False, RejectReason.MAX_LOSS_DRAWDOWN + + norm = self._risk_policy.normalize_intent(intent, state) + if norm.reject_reason is not None: + return False, norm.reject_reason + if norm.dropped: + return False, "dropped_by_policy" + if norm.normalized is None: + return False, RejectReason.INVALID_QTY + + normalized_intent = norm.normalized + + ok, reason = self._risk_policy.validate_intent(normalized_intent, state) + if not ok: + return False, reason + + pos_cfg = self.risk_cfg.position_limits + max_pos = None if (pos_cfg is None or pos_cfg.max_position is None) else pos_cfg.max_position + + notional_cfg = self.risk_cfg.notional_limits + max_gross_notional = notional_cfg.max_gross_notional + max_single_order_notional = notional_cfg.max_single_order_notional + + quote_cfg = self.risk_cfg.quote_limits + quote_book = None + if quote_cfg is not None: + quote_book = self._risk_policy.quote_book_global(state) + base_gross_notional = self._risk_policy.portfolio_gross_notional(state) + + ok, reason = self._risk_policy.hard_checks( + normalized_intent, + state, + max_pos=max_pos, + max_single_order_notional=max_single_order_notional, + max_gross_notional=max_gross_notional, + base_gross_notional=base_gross_notional, + quote_cfg=quote_cfg, + quote_book=quote_book, + ) + if not ok: + return False, reason + return True, None + # pylint: disable=too-many-locals,too-many-branches,too-many-statements def decide_intents( self, From a7aa03becb2c4a023920b6ef5e72c5dcf72e42bd Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 12:40:45 +0000 Subject: [PATCH 14/53] feat(core): add capture-only execution control planning scaffold --- .../models/test_core_step_api_contract.py | 123 ++++++++++++- .../test_execution_control_plan_contract.py | 168 ++++++++++++++++++ .../core/domain/execution_control_plan.py | 96 ++++++++++ .../core/domain/processing_step.py | 11 ++ 4 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 tests/semantics/models/test_execution_control_plan_contract.py create mode 100644 tradingchassis_core/core/domain/execution_control_plan.py diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 942bbf4..f74c816 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -1553,7 +1553,26 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: "generated-new-rejected", ) assert result.core_step_decision is not None - assert result.core_step_decision.execution_control_decision is None + assert result.core_step_decision.execution_control_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.queued_effective_intents + ) == ( + "generated-cancel-accepted", + "queued-passthrough", + ) + assert ( + result.core_step_decision.execution_control_decision.dispatchable_intents + == () + ) + assert ( + result.core_step_decision.execution_control_decision.execution_handled_intents + == () + ) + assert ( + result.core_step_decision.execution_control_decision.control_scheduling_obligation + is None + ) assert tuple(it.client_order_id for it in result.core_step_decision.policy_rejected_intents) == ( "generated-new-rejected", ) @@ -1566,6 +1585,10 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: it.client_order_id for it in result.core_step_decision.policy_risk_decision.rejected_intents ) == ("generated-new-rejected",) + assert result.core_step_decision.queued_effective_intents == () + assert result.core_step_decision.dispatchable_intents == () + assert result.core_step_decision.execution_handled_intents == () + assert result.core_step_decision.control_scheduling_obligation is None assert result.core_step_decision.dispatchable_intents == () assert result.dispatchable_intents == () assert result.control_scheduling_obligation is None @@ -1611,6 +1634,20 @@ def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: assert result.core_step_decision is not None assert result.core_step_decision.policy_risk_decision == PolicyRiskDecision() assert result.core_step_decision.policy_rejected_intents == () + assert result.core_step_decision.execution_control_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.queued_effective_intents + ) == ("queued-only-record",) + assert result.core_step_decision.execution_control_decision.dispatchable_intents == () + assert ( + result.core_step_decision.execution_control_decision.execution_handled_intents + == () + ) + assert ( + result.core_step_decision.execution_control_decision.control_scheduling_obligation + is None + ) assert result.dispatchable_intents == () @@ -1651,6 +1688,47 @@ def _boom(*_: object, **__: object) -> None: assert calls == {"evaluate": 0} +def test_run_core_step_policy_planner_not_reached_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"planner": 0} + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + def _planner_spy(*_: object, **__: object) -> object: + calls["planner"] += 1 + raise AssertionError("planner must not run when boundary fails") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + monkeypatch.setattr( + processing_step_module, + "plan_execution_control_candidates", + _planner_spy, + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=55), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-planner-process-fail", + ts_ns_local=55, + ts_ns_exch=54, + ), + ) + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step( + state, + entry, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=object(), # type: ignore[arg-type] + now_ts_ns_local=55, + ), + ) + assert calls == {"planner": 0} + + def test_run_core_step_policy_admission_context_not_reached_when_strategy_fails() -> None: state = StrategyState(event_bus=NullEventBus()) calls = {"evaluate": 0} @@ -1687,6 +1765,49 @@ def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: assert calls == {"evaluate": 0} +def test_run_core_step_policy_planner_not_reached_when_strategy_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"planner": 0} + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 56 + raise RuntimeError("strategy evaluator failed") + + def _planner_spy(*_: object, **__: object) -> object: + calls["planner"] += 1 + raise AssertionError("planner must not run when strategy evaluation fails") + + monkeypatch.setattr( + processing_step_module, + "plan_execution_control_candidates", + _planner_spy, + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=56), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-planner-strategy-fail", + ts_ns_local=56, + ts_ns_exch=55, + ), + ) + with pytest.raises(RuntimeError, match="strategy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorBoom(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=object(), # type: ignore[arg-type] + now_ts_ns_local=56, + ), + ) + assert calls == {"planner": 0} + + def test_run_core_step_policy_admission_context_is_side_effect_safe_characterization() -> None: class _CaptureSink: def __init__(self) -> None: diff --git a/tests/semantics/models/test_execution_control_plan_contract.py b/tests/semantics/models/test_execution_control_plan_contract.py new file mode 100644 index 0000000..e3e2def --- /dev/null +++ b/tests/semantics/models/test_execution_control_plan_contract.py @@ -0,0 +1,168 @@ +"""Semantics tests for execution-control candidate planning scaffolds.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.execution_control_plan import ( + ExecutionControlCandidateInput, + ExecutionControlPlan, + plan_execution_control_candidates, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def _record(*, client_order_id: str, origin: CandidateIntentOrigin, merge_index: int) -> CandidateIntentRecord: + return CandidateIntentRecord( + intent=_new_intent(client_order_id=client_order_id), + origin=origin, + logical_key=f"order:{client_order_id}", + merge_index=merge_index, + priority=2, + ) + + +def test_execution_control_candidate_input_defaults_empty() -> None: + planning_input = ExecutionControlCandidateInput() + assert planning_input.accepted_generated == () + assert planning_input.passthrough_queued == () + + +def test_execution_control_candidate_input_normalizes_tuples_and_is_immutable() -> None: + generated = _record( + client_order_id="generated-1", + origin=CandidateIntentOrigin.GENERATED, + merge_index=0, + ) + queued = _record( + client_order_id="queued-1", + origin=CandidateIntentOrigin.QUEUED, + merge_index=1, + ) + planning_input = ExecutionControlCandidateInput( + accepted_generated=[generated], + passthrough_queued=[queued], + ) + assert planning_input.accepted_generated == (generated,) + assert planning_input.passthrough_queued == (queued,) + with pytest.raises(FrozenInstanceError): + planning_input.accepted_generated = () + + +def test_execution_control_plan_defaults_empty() -> None: + plan = ExecutionControlPlan() + assert plan.active_records == () + assert plan.queued_effective_records == () + assert plan.dispatchable_records == () + assert plan.execution_handled_records == () + assert plan.execution_control_decision.queued_effective_intents == () + assert plan.execution_control_decision.dispatchable_intents == () + assert plan.execution_control_decision.execution_handled_intents == () + assert plan.execution_control_decision.control_scheduling_obligation is None + + +def test_execution_control_plan_is_non_canonical_and_rejected_by_canonical_boundary() -> None: + assert is_canonical_stream_candidate_type(ExecutionControlCandidateInput) is False + assert canonical_category_for_type(ExecutionControlCandidateInput) is None + assert is_canonical_stream_candidate_type(ExecutionControlPlan) is False + assert canonical_category_for_type(ExecutionControlPlan) is None + + state = StrategyState(event_bus=NullEventBus()) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, ExecutionControlCandidateInput()) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, ExecutionControlPlan()) + + +def test_plan_execution_control_candidates_preserves_origin_and_order_capture_only() -> None: + accepted_generated_a = _record( + client_order_id="generated-a", + origin=CandidateIntentOrigin.GENERATED, + merge_index=10, + ) + accepted_generated_b = _record( + client_order_id="generated-b", + origin=CandidateIntentOrigin.GENERATED, + merge_index=11, + ) + passthrough_queued = _record( + client_order_id="queued-a", + origin=CandidateIntentOrigin.QUEUED, + merge_index=3, + ) + + planning_input = ExecutionControlCandidateInput( + accepted_generated=[accepted_generated_a, accepted_generated_b], + passthrough_queued=[passthrough_queued], + ) + plan = plan_execution_control_candidates(planning_input) + + assert plan.active_records == ( + accepted_generated_a, + accepted_generated_b, + passthrough_queued, + ) + assert tuple(record.origin for record in plan.active_records) == ( + CandidateIntentOrigin.GENERATED, + CandidateIntentOrigin.GENERATED, + CandidateIntentOrigin.QUEUED, + ) + assert plan.queued_effective_records == plan.active_records + assert plan.dispatchable_records == () + assert plan.execution_handled_records == () + assert tuple( + intent.client_order_id + for intent in plan.execution_control_decision.queued_effective_intents + ) == ("generated-a", "generated-b", "queued-a") + assert plan.execution_control_decision.dispatchable_intents == () + assert plan.execution_control_decision.execution_handled_intents == () + assert plan.execution_control_decision.control_scheduling_obligation is None + + +def test_plan_execution_control_candidates_does_not_mutate_input() -> None: + accepted_generated = _record( + client_order_id="generated-immutable", + origin=CandidateIntentOrigin.GENERATED, + merge_index=1, + ) + passthrough_queued = _record( + client_order_id="queued-immutable", + origin=CandidateIntentOrigin.QUEUED, + merge_index=2, + ) + planning_input = ExecutionControlCandidateInput( + accepted_generated=(accepted_generated,), + passthrough_queued=(passthrough_queued,), + ) + + _ = plan_execution_control_candidates(planning_input) + + assert planning_input.accepted_generated == (accepted_generated,) + assert planning_input.passthrough_queued == (passthrough_queued,) diff --git a/tradingchassis_core/core/domain/execution_control_plan.py b/tradingchassis_core/core/domain/execution_control_plan.py new file mode 100644 index 0000000..40c1468 --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_plan.py @@ -0,0 +1,96 @@ +"""Pure, non-canonical execution-control candidate planning scaffolds.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentRecord +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) + + +@dataclass(frozen=True, slots=True) +class ExecutionControlCandidateInput: + """Policy-admitted candidate records for capture-only execution-control planning.""" + + accepted_generated: tuple[CandidateIntentRecord, ...] = () + passthrough_queued: tuple[CandidateIntentRecord, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.accepted_generated, tuple): + object.__setattr__( + self, + "accepted_generated", + tuple(self.accepted_generated), + ) + if not isinstance(self.passthrough_queued, tuple): + object.__setattr__( + self, + "passthrough_queued", + tuple(self.passthrough_queued), + ) + + +@dataclass(frozen=True, slots=True) +class ExecutionControlPlan: + """Capture-only execution-control candidate planning result.""" + + active_records: tuple[CandidateIntentRecord, ...] = () + queued_effective_records: tuple[CandidateIntentRecord, ...] = () + dispatchable_records: tuple[CandidateIntentRecord, ...] = () + execution_handled_records: tuple[CandidateIntentRecord, ...] = () + execution_control_decision: ExecutionControlDecision = field( + default_factory=ExecutionControlDecision + ) + + def __post_init__(self) -> None: + if not isinstance(self.active_records, tuple): + object.__setattr__(self, "active_records", tuple(self.active_records)) + if not isinstance(self.queued_effective_records, tuple): + object.__setattr__( + self, + "queued_effective_records", + tuple(self.queued_effective_records), + ) + if not isinstance(self.dispatchable_records, tuple): + object.__setattr__( + self, + "dispatchable_records", + tuple(self.dispatchable_records), + ) + if not isinstance(self.execution_handled_records, tuple): + object.__setattr__( + self, + "execution_handled_records", + tuple(self.execution_handled_records), + ) + + +def plan_execution_control_candidates( + planning_input: ExecutionControlCandidateInput, +) -> ExecutionControlPlan: + """Build a deterministic, side-effect-free execution-control plan projection.""" + + active_records = ( + tuple(planning_input.accepted_generated) + + tuple(planning_input.passthrough_queued) + ) + queued_effective_records = active_records + dispatchable_records: tuple[CandidateIntentRecord, ...] = () + execution_handled_records: tuple[CandidateIntentRecord, ...] = () + + return ExecutionControlPlan( + active_records=active_records, + queued_effective_records=queued_effective_records, + dispatchable_records=dispatchable_records, + execution_handled_records=execution_handled_records, + execution_control_decision=ExecutionControlDecision( + queued_effective_intents=tuple( + record.intent for record in queued_effective_records + ), + dispatchable_intents=(), + execution_handled_intents=(), + control_scheduling_obligation=None, + ), + ) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 03d808d..33682bc 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -14,6 +14,10 @@ from tradingchassis_core.core.domain.execution_control_decision import ( map_compat_gate_decision_to_execution_control_decision, ) +from tradingchassis_core.core.domain.execution_control_plan import ( + ExecutionControlCandidateInput, + plan_execution_control_candidates, +) from tradingchassis_core.core.domain.intent_combination import ( combine_candidate_intent_records, ) @@ -223,11 +227,18 @@ def run_core_step( now_ts_ns_local=policy_admission_context.now_ts_ns_local, policy_evaluator=policy_admission_context.policy_evaluator, ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, + ) + ) core_step_decision = CoreStepDecision( policy_rejected_intents=tuple( rejected.record.intent for rejected in policy_result.rejected_generated ), policy_risk_decision=policy_result.policy_risk_decision, + execution_control_decision=execution_control_plan.execution_control_decision, ) return CoreStepResult( generated_intents=generated_intents, From 965bf2c029b214b666cc002ccd097feea11bc4cd Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 12:54:54 +0000 Subject: [PATCH 15/53] feat(core): add isolated execution control apply API --- .../test_execution_control_apply_contract.py | 146 ++++++++ .../test_execution_control_apply_isolated.py | 308 +++++++++++++++++ tradingchassis_core/__init__.py | 14 + tradingchassis_core/core/domain/__init__.py | 14 + .../core/domain/execution_control_apply.py | 322 ++++++++++++++++++ 5 files changed, 804 insertions(+) create mode 100644 tests/semantics/models/test_execution_control_apply_contract.py create mode 100644 tests/semantics/queue_semantics/test_execution_control_apply_isolated.py create mode 100644 tradingchassis_core/core/domain/execution_control_apply.py diff --git a/tests/semantics/models/test_execution_control_apply_contract.py b/tests/semantics/models/test_execution_control_apply_contract.py new file mode 100644 index 0000000..e5e12e3 --- /dev/null +++ b/tests/semantics/models/test_execution_control_apply_contract.py @@ -0,0 +1,146 @@ +"""Semantics tests for isolated execution-control apply API models.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + ExecutionControlApplyResult, + ExecutionControlBlockedRecord, + ExecutionControlDispatchableRecord, + ExecutionControlHandledRecord, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.processing import process_canonical_event +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +def _new_intent(*, client_order_id: str) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=1, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def _record(*, client_order_id: str) -> CandidateIntentRecord: + return CandidateIntentRecord( + intent=_new_intent(client_order_id=client_order_id), + origin=CandidateIntentOrigin.GENERATED, + logical_key=f"order:{client_order_id}", + merge_index=0, + priority=2, + ) + + +def test_execution_control_apply_context_is_immutable_reference_holder() -> None: + context = ExecutionControlApplyContext( + state=StrategyState(event_bus=NullEventBus()), + execution_control=ExecutionControl(), + now_ts_ns_local=1, + ) + with pytest.raises(FrozenInstanceError): + context.now_ts_ns_local = 2 + + +def test_execution_control_apply_record_models_are_immutable() -> None: + record = _record(client_order_id="cid-immutable") + + dispatchable = ExecutionControlDispatchableRecord(record=record) + blocked = ExecutionControlBlockedRecord(record=record, reason="rate_limit") + handled = ExecutionControlHandledRecord(record=record, reason="queue_local_handled") + + with pytest.raises(FrozenInstanceError): + dispatchable.record = record + with pytest.raises(FrozenInstanceError): + blocked.reason = "other" + with pytest.raises(FrozenInstanceError): + handled.reason = "other" + + +def test_execution_control_apply_result_defaults_and_tuple_normalization() -> None: + record = _record(client_order_id="cid-normalize") + obligation = ControlSchedulingObligation( + due_ts_ns_local=100, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + result = ExecutionControlApplyResult( + queued_effective_records=[record], + dispatchable_records=[ExecutionControlDispatchableRecord(record=record)], + execution_handled_records=[ + ExecutionControlHandledRecord(record=record, reason="queue_local_handled") + ], + blocked_records=[ + ExecutionControlBlockedRecord( + record=record, + reason="rate_limit", + scheduling_obligation=obligation, + ) + ], + execution_control_decision=ExecutionControlDecision(), + ) + + assert result.queued_effective_records == (record,) + assert len(result.dispatchable_records) == 1 + assert len(result.execution_handled_records) == 1 + assert len(result.blocked_records) == 1 + + +def test_execution_control_apply_models_are_non_canonical_and_boundary_rejects_them() -> None: + state = StrategyState(event_bus=NullEventBus()) + context = ExecutionControlApplyContext( + state=state, + execution_control=ExecutionControl(), + now_ts_ns_local=1, + ) + result = ExecutionControlApplyResult() + + assert is_canonical_stream_candidate_type(ExecutionControlApplyContext) is False + assert canonical_category_for_type(ExecutionControlApplyContext) is None + assert is_canonical_stream_candidate_type(ExecutionControlApplyResult) is False + assert canonical_category_for_type(ExecutionControlApplyResult) is None + assert is_canonical_stream_candidate_type(ExecutionControlDispatchableRecord) is False + assert canonical_category_for_type(ExecutionControlDispatchableRecord) is None + assert is_canonical_stream_candidate_type(ExecutionControlBlockedRecord) is False + assert canonical_category_for_type(ExecutionControlBlockedRecord) is None + assert is_canonical_stream_candidate_type(ExecutionControlHandledRecord) is False + assert canonical_category_for_type(ExecutionControlHandledRecord) is None + + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, context) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_canonical_event(state, result) + + +def test_execution_control_apply_public_root_exports_identity() -> None: + assert hasattr(tc, "ExecutionControlApplyContext") + assert hasattr(tc, "ExecutionControlApplyResult") + assert hasattr(tc, "ExecutionControlDispatchableRecord") + assert hasattr(tc, "ExecutionControlBlockedRecord") + assert hasattr(tc, "ExecutionControlHandledRecord") diff --git a/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py b/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py new file mode 100644 index 0000000..0048cf3 --- /dev/null +++ b/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py @@ -0,0 +1,308 @@ +"""Isolated mutable execution-control apply semantics tests.""" + +from __future__ import annotations + +import copy + +import pytest + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + apply_execution_control_plan, +) +from tradingchassis_core.core.domain.execution_control_plan import ExecutionControlPlan +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( + NewOrderIntent, + OrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) +from tradingchassis_core.core.events.event_bus import EventBus +from tradingchassis_core.core.events.events import RiskDecisionEvent +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.risk.risk_config import NotionalLimits, RiskConfig +from tradingchassis_core.core.risk.risk_engine import RiskEngine + + +def _new_intent( + *, + client_order_id: str, + ts_ns_local: int = 1, + px: float = 100.0, + qty: float = 1.0, +) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=ts_ns_local, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=Quantity(value=qty, unit="contracts"), + intended_price=Price(currency="USDC", value=px), + time_in_force="GTC", + ) + + +def _replace_intent( + *, + client_order_id: str, + ts_ns_local: int = 1, + px: float = 101.0, + qty: float = 2.0, +) -> ReplaceOrderIntent: + return ReplaceOrderIntent( + ts_ns_local=ts_ns_local, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id="corr-replace", + side="buy", + intended_price=Price(currency="USDC", value=px), + intended_qty=Quantity(value=qty, unit="contracts"), + ) + + +def _record( + intent: OrderIntent, + *, + origin: CandidateIntentOrigin, + merge_index: int, +) -> CandidateIntentRecord: + return CandidateIntentRecord( + intent=intent, + origin=origin, + logical_key=f"order:{intent.client_order_id}", + merge_index=merge_index, + priority=0 if intent.intent_type == "cancel" else 1 if intent.intent_type == "replace" else 2, + ) + + +def _plan(*records: CandidateIntentRecord) -> ExecutionControlPlan: + return ExecutionControlPlan(active_records=records) + + +def test_apply_execution_control_plan_empty_plan_has_no_side_effects() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + queue_before = state.queued_intents_snapshot() + rate_before = copy.deepcopy(execution_control._rate_state) + + result = apply_execution_control_plan( + _plan(), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert result.dispatchable_records == () + assert result.blocked_records == () + assert result.execution_handled_records == () + assert result.queued_effective_records == () + assert result.control_scheduling_obligation is None + assert state.queued_intents_snapshot() == queue_before + assert execution_control._rate_state == rate_before + + +def test_apply_execution_control_plan_generated_dispatchable_path() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + intent = _new_intent(client_order_id="generated-dispatchable") + record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert tuple(item.record.origin for item in result.dispatchable_records) == ( + CandidateIntentOrigin.GENERATED, + ) + assert tuple(item.record.intent.client_order_id for item in result.dispatchable_records) == ( + "generated-dispatchable", + ) + assert result.blocked_records == () + assert result.execution_handled_records == () + assert result.execution_control_decision.dispatchable_intents == (intent,) + assert not state.has_queued_intent(intent.instrument, intent.client_order_id) + + +def test_apply_execution_control_plan_generated_rate_blocked_path() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + intent = _new_intent(client_order_id="generated-rate-blocked") + record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + rate_before = copy.deepcopy(execution_control._rate_state) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + max_orders_per_sec=1, + ), + ) + + assert result.dispatchable_records == () + assert len(result.blocked_records) == 1 + assert result.blocked_records[0].record.origin == CandidateIntentOrigin.GENERATED + assert result.blocked_records[0].reason == "rate_limit" + assert result.blocked_records[0].scheduling_obligation is not None + assert result.control_scheduling_obligation is not None + assert result.execution_control_decision.control_scheduling_obligation is not None + assert tuple(it.client_order_id for it in result.execution_control_decision.queued_effective_intents) == ( + "generated-rate-blocked", + ) + assert state.has_queued_intent(intent.instrument, intent.client_order_id) + assert execution_control._rate_state != rate_before + assert execution_control._rate_state["order"]["last_ts"] == 1 + + +def test_apply_execution_control_plan_queued_dispatchable_removes_from_queue() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + queued_intent = _new_intent(client_order_id="queued-dispatchable") + state.merge_intents_into_queue(queued_intent.instrument, [queued_intent]) + record = _record( + queued_intent, + origin=CandidateIntentOrigin.QUEUED, + merge_index=0, + ) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert tuple(item.record.origin for item in result.dispatchable_records) == ( + CandidateIntentOrigin.QUEUED, + ) + assert tuple(item.record.intent.client_order_id for item in result.dispatchable_records) == ( + "queued-dispatchable", + ) + assert not state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) + + +def test_apply_execution_control_plan_queued_rate_blocked_keeps_resident() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + queued_intent = _new_intent(client_order_id="queued-rate-blocked") + state.merge_intents_into_queue(queued_intent.instrument, [queued_intent]) + record = _record( + queued_intent, + origin=CandidateIntentOrigin.QUEUED, + merge_index=0, + ) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + max_orders_per_sec=1, + ), + ) + + assert result.dispatchable_records == () + assert len(result.blocked_records) == 1 + assert result.blocked_records[0].record.origin == CandidateIntentOrigin.QUEUED + assert result.blocked_records[0].reason == "rate_limit" + assert result.blocked_records[0].scheduling_obligation is not None + assert result.control_scheduling_obligation is not None + assert state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) + + +def test_apply_execution_control_plan_replace_against_queued_new_is_handled_locally() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + queued_new = _new_intent(client_order_id="queued-replace-local") + state.merge_intents_into_queue(queued_new.instrument, [queued_new]) + generated_replace = _replace_intent(client_order_id="queued-replace-local", px=111.0, qty=3.0) + record = _record( + generated_replace, + origin=CandidateIntentOrigin.GENERATED, + merge_index=0, + ) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=2, + ), + ) + + assert result.dispatchable_records == () + assert tuple(item.reason for item in result.execution_handled_records) == ( + "queue_local_handled", + ) + updated_new = state.find_queued_new_intent( + queued_new.instrument, + queued_new.client_order_id, + ) + assert updated_new is not None + assert updated_new.intended_price.value == 111.0 + assert updated_new.intended_qty.value == 3.0 + + +def test_apply_execution_control_plan_side_effect_boundaries_do_not_use_risk_engine( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _CaptureSink: + def __init__(self) -> None: + self.events: list[object] = [] + + def on_event(self, event: object) -> None: + self.events.append(event) + + sink = _CaptureSink() + state = StrategyState(event_bus=EventBus(sinks=[sink])) + execution_control = ExecutionControl() + intent = _new_intent(client_order_id="side-effect-check") + record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + + def _boom(*args: object, **kwargs: object) -> object: + _ = (args, kwargs) + raise AssertionError("RiskEngine.decide_intents must not be called by apply") + + monkeypatch.setattr(RiskEngine, "decide_intents", _boom) + _ = RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1e18, + max_single_order_notional=1e18, + ), + ) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert len(result.dispatchable_records) == 1 + assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index c5ce9fc..b55ae58 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -13,6 +13,14 @@ CandidateIntentRecord, ) from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + ExecutionControlApplyResult, + ExecutionControlBlockedRecord, + ExecutionControlDispatchableRecord, + ExecutionControlHandledRecord, + apply_execution_control_plan, +) from tradingchassis_core.core.domain.execution_control_decision import ( ExecutionControlDecision, ) @@ -114,6 +122,12 @@ "CorePolicyAdmissionContext", "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", + "ExecutionControlApplyContext", + "ExecutionControlApplyResult", + "ExecutionControlBlockedRecord", + "ExecutionControlDispatchableRecord", + "ExecutionControlHandledRecord", + "apply_execution_control_plan", "PolicyRiskDecision", "PolicyRejectedCandidate", "PolicyAdmissionResult", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index d14d898..ee5828e 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -4,6 +4,14 @@ CandidateIntentOrigin, CandidateIntentRecord, ) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + ExecutionControlApplyResult, + ExecutionControlBlockedRecord, + ExecutionControlDispatchableRecord, + ExecutionControlHandledRecord, + apply_execution_control_plan, +) from tradingchassis_core.core.domain.execution_control_decision import ExecutionControlDecision from tradingchassis_core.core.domain.policy_risk_decision import ( PolicyAdmissionResult, @@ -23,6 +31,12 @@ "CandidateIntentOrigin", "CandidateIntentRecord", "ExecutionControlDecision", + "ExecutionControlApplyContext", + "ExecutionControlApplyResult", + "ExecutionControlBlockedRecord", + "ExecutionControlDispatchableRecord", + "ExecutionControlHandledRecord", + "apply_execution_control_plan", "PolicyRiskDecision", "PolicyRejectedCandidate", "PolicyAdmissionResult", diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py new file mode 100644 index 0000000..03462a8 --- /dev/null +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -0,0 +1,322 @@ +"""Mutable execution-control apply stage over a pure execution-control plan.""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field + +from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentRecord, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +from tradingchassis_core.core.domain.execution_control_plan import ( + ExecutionControlPlan, +) +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation + + +@dataclass(frozen=True, slots=True) +class ExecutionControlApplyContext: + """Mutable apply inputs for one deterministic apply operation. + + The context keeps references to mutable runtime objects (state and execution + control) while the dataclass itself stays immutable as a value container. + """ + + state: StrategyState + execution_control: ExecutionControl + now_ts_ns_local: int + max_orders_per_sec: float | None = None + max_cancels_per_sec: float | None = None + + +@dataclass(frozen=True, slots=True) +class ExecutionControlDispatchableRecord: + """Candidate record selected as dispatchable in this apply pass.""" + + record: CandidateIntentRecord + + +@dataclass(frozen=True, slots=True) +class ExecutionControlBlockedRecord: + """Candidate record blocked from immediate dispatch.""" + + record: CandidateIntentRecord + reason: str + scheduling_obligation: ControlSchedulingObligation | None = None + + +@dataclass(frozen=True, slots=True) +class ExecutionControlHandledRecord: + """Candidate record fully handled by queue-local semantics.""" + + record: CandidateIntentRecord + reason: str + + +@dataclass(frozen=True, slots=True) +class ExecutionControlApplyResult: + """Result of mutable execution-control apply over one plan snapshot.""" + + queued_effective_records: tuple[CandidateIntentRecord, ...] = () + dispatchable_records: tuple[ExecutionControlDispatchableRecord, ...] = () + execution_handled_records: tuple[ExecutionControlHandledRecord, ...] = () + blocked_records: tuple[ExecutionControlBlockedRecord, ...] = () + control_scheduling_obligation: ControlSchedulingObligation | None = None + execution_control_decision: ExecutionControlDecision = field( + default_factory=ExecutionControlDecision + ) + + def __post_init__(self) -> None: + if not isinstance(self.queued_effective_records, tuple): + object.__setattr__( + self, + "queued_effective_records", + tuple(self.queued_effective_records), + ) + if not isinstance(self.dispatchable_records, tuple): + object.__setattr__( + self, + "dispatchable_records", + tuple(self.dispatchable_records), + ) + if not isinstance(self.execution_handled_records, tuple): + object.__setattr__( + self, + "execution_handled_records", + tuple(self.execution_handled_records), + ) + if not isinstance(self.blocked_records, tuple): + object.__setattr__( + self, + "blocked_records", + tuple(self.blocked_records), + ) + + +def _float_equal(a: float, b: float) -> bool: + return abs(a - b) <= 1e-12 + + +def _select_effective_control_scheduling_obligation( + obligations: list[ControlSchedulingObligation], +) -> ControlSchedulingObligation | None: + if not obligations: + return None + return min( + obligations, + key=lambda obligation: ( + obligation.due_ts_ns_local, + obligation.obligation_key, + ), + ) + + +def _record_is_currently_queued( + state: StrategyState, + record: CandidateIntentRecord, +) -> bool: + queue = state.queued_intents.get(record.intent.instrument) + if queue is None: + return False + return any( + queued.intent == record.intent and queued.logical_key == record.logical_key + for queued in queue + ) + + +def apply_execution_control_plan( + plan: ExecutionControlPlan, + context: ExecutionControlApplyContext, +) -> ExecutionControlApplyResult: + """Apply mutable execution-control semantics over planned active records. + + This function mutates only StrategyState queue data and ExecutionControl + rate state. It does not call RiskEngine.decide_intents, does not perform + venue dispatch, and does not emit canonical events. + """ + + state = context.state + execution_control = context.execution_control + + dispatchable_records: list[ExecutionControlDispatchableRecord] = [] + execution_handled_records: list[ExecutionControlHandledRecord] = [] + blocked_records: list[ExecutionControlBlockedRecord] = [] + obligations: list[ControlSchedulingObligation] = [] + + to_queue_by_instr: defaultdict[str, list] = defaultdict(list) + replaced_in_queue: list[tuple] = [] + dropped_in_queue: list = [] + queued: list = [] + handled_in_queue: list = [] + + for record in plan.active_records: + intent = record.intent + instrument = intent.instrument + + if record.origin.value == "generated": + to_queue_before = len(to_queue_by_instr[instrument]) + handled_before = len(handled_in_queue) + continue_to_sendability, reject_reason = ( + execution_control.route_pre_submission_lifecycle_and_inflight( + intent, + state=state, + to_queue_by_instr=to_queue_by_instr, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + queued=queued, + handled_in_queue=handled_in_queue, + float_equal=_float_equal, + ) + ) + if not continue_to_sendability: + to_queue_after = len(to_queue_by_instr[instrument]) + handled_after = len(handled_in_queue) + if reject_reason is not None: + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason=reject_reason, + ) + ) + continue + if to_queue_after > to_queue_before: + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="inflight", + ) + ) + continue + if handled_after > handled_before: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="queue_local_handled", + ) + ) + continue + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="handled", + ) + ) + continue + + rate_result = execution_control.route_after_policy_rate_limit( + intent, + now_ts_ns_local=context.now_ts_ns_local, + max_orders_per_sec=context.max_orders_per_sec, + max_cancels_per_sec=context.max_cancels_per_sec, + ) + if rate_result.stage_to_queue: + to_queue_by_instr[instrument].append(intent) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="rate_limit", + scheduling_obligation=rate_result.scheduling_obligation, + ) + ) + if rate_result.scheduling_obligation is not None: + obligations.append(rate_result.scheduling_obligation) + continue + + dispatchable_records.append(ExecutionControlDispatchableRecord(record=record)) + continue + + detached = state.pop_queued_intents_for_order( + intent.instrument, + intent.client_order_id, + ) + detached_intents = [queued_item.intent for queued_item in detached] + if not detached_intents: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="queued_record_missing", + ) + ) + continue + + if intent.intent_type in ("new", "replace") and state.has_inflight( + intent.instrument, intent.client_order_id + ): + state.merge_intents_into_queue( + instrument=intent.instrument, + intents=detached_intents, + ) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="inflight", + ) + ) + continue + + rate_result = execution_control.route_after_policy_rate_limit( + intent, + now_ts_ns_local=context.now_ts_ns_local, + max_orders_per_sec=context.max_orders_per_sec, + max_cancels_per_sec=context.max_cancels_per_sec, + ) + if rate_result.stage_to_queue: + state.merge_intents_into_queue( + instrument=intent.instrument, + intents=detached_intents, + ) + blocked_records.append( + ExecutionControlBlockedRecord( + record=record, + reason="rate_limit", + scheduling_obligation=rate_result.scheduling_obligation, + ) + ) + if rate_result.scheduling_obligation is not None: + obligations.append(rate_result.scheduling_obligation) + continue + + dispatchable_records.append(ExecutionControlDispatchableRecord(record=record)) + + execution_control.merge_to_queue_per_instrument( + state=state, + to_queue_by_instr=to_queue_by_instr, + queued=queued, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + ) + + queued_effective_records = tuple( + record + for record in plan.active_records + if _record_is_currently_queued(state, record) + ) + control_scheduling_obligation = _select_effective_control_scheduling_obligation( + obligations + ) + decision = ExecutionControlDecision( + queued_effective_intents=tuple( + record.intent for record in queued_effective_records + ), + dispatchable_intents=tuple( + item.record.intent for item in dispatchable_records + ), + execution_handled_intents=tuple( + item.record.intent for item in execution_handled_records + ), + control_scheduling_obligation=control_scheduling_obligation, + ) + + return ExecutionControlApplyResult( + queued_effective_records=queued_effective_records, + dispatchable_records=tuple(dispatchable_records), + execution_handled_records=tuple(execution_handled_records), + blocked_records=tuple(blocked_records), + control_scheduling_obligation=control_scheduling_obligation, + execution_control_decision=decision, + ) From 654a2a9647b32d1155a48ef43fe11a68f4754ace Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:02:54 +0000 Subject: [PATCH 16/53] test(core): close execution control apply edge cases --- .../test_execution_control_apply_isolated.py | 143 ++++++++++++++++++ .../core/domain/execution_control_apply.py | 15 +- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py b/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py index 0048cf3..63db895 100644 --- a/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py +++ b/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py @@ -19,6 +19,7 @@ from tradingchassis_core.core.domain.types import ( NewOrderIntent, OrderIntent, + OrderStateEvent, Price, Quantity, ReplaceOrderIntent, @@ -306,3 +307,145 @@ def _boom(*args: object, **kwargs: object) -> object: assert len(result.dispatchable_records) == 1 assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) + + +def test_apply_execution_control_plan_stale_queued_origin_is_handled_without_crash() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + queued_intent = _new_intent(client_order_id="stale-queued") + record = _record( + queued_intent, + origin=CandidateIntentOrigin.QUEUED, + merge_index=0, + ) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert result.dispatchable_records == () + assert result.blocked_records == () + assert tuple(item.reason for item in result.execution_handled_records) == ( + "queued_record_missing", + ) + assert not state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) + + +def test_apply_execution_control_plan_duplicate_generated_candidates_do_not_double_dispatch() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + intent = _new_intent(client_order_id="dup-generated") + record_a = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + record_b = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=1) + + result = apply_execution_control_plan( + _plan(record_a, record_b), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + ), + ) + + assert tuple(it.client_order_id for it in result.execution_control_decision.dispatchable_intents) == ( + "dup-generated", + ) + assert len(result.dispatchable_records) == 1 + assert tuple(item.reason for item in result.execution_handled_records) == ( + "duplicate_candidate_record", + ) + + +def test_apply_execution_control_plan_inflight_blocks_before_rate_and_does_not_consume_tokens() -> None: + instrument = "BTC-USDC-PERP" + client_order_id = "inflight-order" + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + + state.apply_order_state_event( + OrderStateEvent( + ts_ns_exch=1, + ts_ns_local=1, + instrument=instrument, + client_order_id=client_order_id, + order_type="limit", + state_type="working", + side="buy", + intended_price=Price(currency="USDC", value=100.0), + filled_price=None, + intended_qty=Quantity(unit="contracts", value=1.0), + cum_filled_qty=None, + remaining_qty=None, + time_in_force="GTC", + reason=None, + raw={"req": 1, "source": "snapshot"}, + ) + ) + state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="replace") + + replace_intent = _replace_intent(client_order_id=client_order_id, px=101.0, qty=1.0) + record = _record(replace_intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + rate_before = copy.deepcopy(execution_control._rate_state) + + result = apply_execution_control_plan( + _plan(record), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=2, + max_orders_per_sec=10, + max_cancels_per_sec=10, + ), + ) + + assert result.dispatchable_records == () + assert len(result.blocked_records) == 1 + assert result.blocked_records[0].reason == "inflight" + assert execution_control._rate_state == rate_before + + +def test_apply_execution_control_plan_obligation_collapse_is_deterministic() -> None: + state = StrategyState(event_bus=NullEventBus()) + execution_control = ExecutionControl() + + a = _new_intent(client_order_id="o-a") + b = _new_intent(client_order_id="o-b") + b = b.model_copy(update={"instrument": "ETH-USDC-PERP"}) + + record_a = _record(a, origin=CandidateIntentOrigin.GENERATED, merge_index=0) + record_b = _record(b, origin=CandidateIntentOrigin.GENERATED, merge_index=1) + + _ = apply_execution_control_plan( + _plan(_record(_new_intent(client_order_id="warm"), origin=CandidateIntentOrigin.GENERATED, merge_index=99)), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + max_orders_per_sec=1, + ), + ) + + result = apply_execution_control_plan( + _plan(record_a, record_b), + ExecutionControlApplyContext( + state=state, + execution_control=execution_control, + now_ts_ns_local=1, + max_orders_per_sec=1, + ), + ) + + assert len(result.blocked_records) == 2 + collapsed = result.control_scheduling_obligation + assert collapsed is not None + expected = min( + (br.scheduling_obligation for br in result.blocked_records if br.scheduling_obligation is not None), + key=lambda o: (o.due_ts_ns_local, o.obligation_key), + ) + assert collapsed == expected + assert result.execution_control_decision.control_scheduling_obligation == collapsed diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py index 03462a8..d0bbcc0 100644 --- a/tradingchassis_core/core/domain/execution_control_apply.py +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from tradingchassis_core.core.domain.candidate_intent import ( + CandidateIntentOrigin, CandidateIntentRecord, ) from tradingchassis_core.core.domain.execution_control_decision import ( @@ -148,6 +149,8 @@ def apply_execution_control_plan( blocked_records: list[ExecutionControlBlockedRecord] = [] obligations: list[ControlSchedulingObligation] = [] + processed_keys: set[str] = set() + to_queue_by_instr: defaultdict[str, list] = defaultdict(list) replaced_in_queue: list[tuple] = [] dropped_in_queue: list = [] @@ -155,10 +158,20 @@ def apply_execution_control_plan( handled_in_queue: list = [] for record in plan.active_records: + if record.logical_key in processed_keys: + execution_handled_records.append( + ExecutionControlHandledRecord( + record=record, + reason="duplicate_candidate_record", + ) + ) + continue + processed_keys.add(record.logical_key) + intent = record.intent instrument = intent.instrument - if record.origin.value == "generated": + if record.origin == CandidateIntentOrigin.GENERATED: to_queue_before = len(to_queue_by_instr[instrument]) handled_before = len(handled_in_queue) continue_to_sendability, reject_reason = ( From fcb1fc0c274fb369691bacdd9fdd5595a4689cc4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:11:36 +0000 Subject: [PATCH 17/53] feat(core): integrate execution control apply in core step --- .../models/test_core_step_api_contract.py | 412 ++++++++++++++++++ tradingchassis_core/__init__.py | 2 + tradingchassis_core/core/domain/__init__.py | 2 + .../core/domain/processing_step.py | 76 +++- 4 files changed, 491 insertions(+), 1 deletion(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index f74c816..fd63dde 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -14,6 +14,10 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyResult, + ExecutionControlDispatchableRecord, +) from tradingchassis_core.core.domain.execution_control_decision import ( ExecutionControlDecision, ) @@ -23,6 +27,7 @@ from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CoreExecutionControlApplyContext, CorePolicyAdmissionContext, CoreStepStrategyContext, run_core_step, @@ -46,6 +51,7 @@ from tradingchassis_core.core.events.event_bus import EventBus from tradingchassis_core.core.events.events import RiskDecisionEvent from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl 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, RejectedIntent, RiskEngine @@ -182,6 +188,7 @@ def test_run_core_step_public_exports_identity() -> None: assert domain_run_core_step is run_core_step assert hasattr(tc, "run_core_step") assert tc.run_core_step is run_core_step + assert hasattr(tc, "CoreExecutionControlApplyContext") def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: @@ -1865,3 +1872,408 @@ def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) assert result.dispatchable_intents == () assert result.compat_gate_decision is None + + +def test_run_core_step_apply_context_requires_policy_admission_context() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=57), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-without-policy", + ts_ns_local=57, + ts_ns_exch=56, + ), + ) + + with pytest.raises( + ValueError, + match="execution_control_apply_context requires policy_admission_context", + ): + run_core_step( + state, + entry, + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=57, + ), + ) + + +def test_run_core_step_apply_context_rejects_control_time_event_path() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=58), + event=_control_time_event(due_ts_ns_local=58, realized_ts_ns_local=58), + ) + + class _ControlTimeRiskMustNotRun: + def decide_intents(self, **_: object) -> GateDecision: + raise AssertionError("control-time compatibility risk must not run") + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + with pytest.raises( + ValueError, + match="execution_control_apply_context is not supported for ControlTimeEvent", + ): + run_core_step( + state, + entry, + control_time_queue_context=ControlTimeQueueReevaluationContext( + risk_engine=_ControlTimeRiskMustNotRun(), # type: ignore[arg-type] + instrument="BTC-USDC-PERP", + now_ts_ns_local=58, + ), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=58, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=58, + ), + ) + + +def test_run_core_step_apply_integration_orders_policy_plan_apply_and_maps_result() -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-passthrough") + state.merge_intents_into_queue(instrument, [queued_intent]) + generated_new = _new_intent(client_order_id="generated-new-rejected") + generated_cancel = CancelOrderIntent( + ts_ns_local=59, + instrument=instrument, + client_order_id="generated-cancel-accepted", + intents_correlation_id="corr-generated-cancel-accepted", + ) + calls: list[str] = [] + observed_apply_active_ids: list[tuple[str, ...]] = [] + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: + assert context.state._last_processing_position_index == 59 + return [generated_new, generated_cancel] + + class _PolicyEvaluator: + def evaluate_policy_intent( + self, + *, + intent: OrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + assert state is not None + assert now_ts_ns_local == 59 + return intent.client_order_id == "generated-cancel-accepted", "policy_rejected" + + original_policy = processing_step_module.apply_policy_to_candidate_records + original_plan = processing_step_module.plan_execution_control_candidates + obligation = ControlSchedulingObligation( + due_ts_ns_local=88, + reason="rate_limit", + scope_key=f"instrument:{instrument}", + source="execution_control_rate_limit", + ) + + def _policy_spy(*args: object, **kwargs: object) -> object: + calls.append("policy") + return original_policy(*args, **kwargs) + + def _plan_spy(*args: object, **kwargs: object) -> object: + calls.append("plan") + return original_plan(*args, **kwargs) + + def _apply_spy(*args: object, **kwargs: object) -> ExecutionControlApplyResult: + calls.append("apply") + plan = args[0] + context = args[1] + assert context.state is state + observed_apply_active_ids.append( + tuple(record.intent.client_order_id for record in plan.active_records) + ) + dispatchable = ( + ExecutionControlDispatchableRecord(record=plan.active_records[0]), + ) + decision = ExecutionControlDecision( + queued_effective_intents=tuple(record.intent for record in plan.active_records), + dispatchable_intents=tuple(item.record.intent for item in dispatchable), + execution_handled_intents=(), + control_scheduling_obligation=obligation, + ) + return ExecutionControlApplyResult( + queued_effective_records=tuple(plan.active_records), + dispatchable_records=dispatchable, + execution_handled_records=(), + blocked_records=(), + control_scheduling_obligation=obligation, + execution_control_decision=decision, + ) + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + processing_step_module, + "apply_policy_to_candidate_records", + _policy_spy, + ) + monkeypatch.setattr( + processing_step_module, + "plan_execution_control_candidates", + _plan_spy, + ) + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_spy, + ) + try: + entry = EventStreamEntry( + position=ProcessingPosition(index=59), + event=_fill_event( + instrument=instrument, + client_order_id="fill-apply-ordering", + ts_ns_local=59, + ts_ns_exch=58, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=59, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=59, + activate_dispatchable_outputs=False, + ), + ) + finally: + monkeypatch.undo() + + assert calls == ["policy", "plan", "apply"] + assert observed_apply_active_ids == [ + ("generated-cancel-accepted", "queued-passthrough"), + ] + assert result.core_step_decision is not None + assert result.core_step_decision.execution_control_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("generated-cancel-accepted",) + assert result.core_step_decision.control_scheduling_obligation == obligation + assert result.control_scheduling_obligation == obligation + assert result.dispatchable_intents == () + assert result.compat_gate_decision is None + + +def test_run_core_step_apply_integration_can_activate_top_level_dispatchables() -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="generated-dispatchable-activated") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 60 + return [generated_intent] + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + entry = EventStreamEntry( + position=ProcessingPosition(index=60), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-dispatchable-activation", + ts_ns_local=60, + ts_ns_exch=59, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=60, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=60, + activate_dispatchable_outputs=True, + ), + ) + + assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( + "generated-dispatchable-activated", + ) + assert result.core_step_decision is not None + assert result.core_step_decision.execution_control_decision is not None + assert tuple( + it.client_order_id + for it in result.core_step_decision.execution_control_decision.dispatchable_intents + ) == ("generated-dispatchable-activated",) + assert result.compat_gate_decision is None + + +def test_run_core_step_apply_context_not_reached_when_process_event_entry_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"apply": 0} + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + def _boom(*_: object, **__: object) -> None: + raise RuntimeError("process boundary failed") + + def _apply_spy(*_: object, **__: object) -> object: + calls["apply"] += 1 + raise AssertionError("apply must not run when boundary fails") + + monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_spy, + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=61), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-process-fail", + ts_ns_local=61, + ts_ns_exch=60, + ), + ) + with pytest.raises(RuntimeError, match="process boundary failed"): + run_core_step( + state, + entry, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=61, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=61, + ), + ) + assert calls == {"apply": 0} + + +def test_run_core_step_apply_context_not_reached_when_strategy_fails() -> None: + state = StrategyState(event_bus=NullEventBus()) + calls = {"apply": 0} + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 62 + raise RuntimeError("strategy evaluator failed") + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + def _apply_spy(*_: object, **__: object) -> object: + calls["apply"] += 1 + raise AssertionError("apply must not run when strategy fails") + + monkeypatch = pytest.MonkeyPatch() + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_spy, + ) + try: + entry = EventStreamEntry( + position=ProcessingPosition(index=62), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-strategy-fail", + ts_ns_local=62, + ts_ns_exch=61, + ), + ) + with pytest.raises(RuntimeError, match="strategy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_EvaluatorBoom(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=62, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=62, + ), + ) + finally: + monkeypatch.undo() + + assert calls == {"apply": 0} + + +def test_run_core_step_apply_path_does_not_call_risk_decide_intents_or_emit_risk_events( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _CaptureSink: + def __init__(self) -> None: + self.events: list[object] = [] + + def on_event(self, event: object) -> None: + self.events.append(event) + + sink = _CaptureSink() + state = StrategyState(event_bus=EventBus(sinks=[sink])) + generated_intent = _new_intent(client_order_id="apply-no-risk-decide") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 63 + return [generated_intent] + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + def _boom(*_: object, **__: object) -> object: + raise AssertionError("RiskEngine.decide_intents must not run in apply path") + + monkeypatch.setattr(RiskEngine, "decide_intents", _boom) + + entry = EventStreamEntry( + position=ProcessingPosition(index=63), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-no-risk-decide", + ts_ns_local=63, + ts_ns_exch=62, + ), + ) + result = run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=63, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=63, + ), + ) + + assert result.core_step_decision is not None + assert result.compat_gate_decision is None + assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index b55ae58..859aacd 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -40,6 +40,7 @@ from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CoreExecutionControlApplyContext, CorePolicyAdmissionContext, run_core_step, ) @@ -119,6 +120,7 @@ "process_event_entry", "run_core_step", "CoreDecisionContext", + "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index ee5828e..24f933d 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -21,6 +21,7 @@ from tradingchassis_core.core.domain.processing_step import ( ControlTimeQueueReevaluationContext, CoreDecisionContext, + CoreExecutionControlApplyContext, CorePolicyAdmissionContext, run_core_step, ) @@ -43,6 +44,7 @@ "CoreStepDecision", "CoreStepResult", "CoreDecisionContext", + "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", "ControlTimeQueueReevaluationContext", "run_core_step", diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 33682bc..b711c03 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -11,6 +11,10 @@ from typing import TYPE_CHECKING, Protocol, Sequence from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyContext, + apply_execution_control_plan, +) from tradingchassis_core.core.domain.execution_control_decision import ( map_compat_gate_decision_to_execution_control_decision, ) @@ -35,6 +39,7 @@ from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: + from tradingchassis_core.core.execution_control.execution_control import ExecutionControl from tradingchassis_core.core.risk.risk_engine import GateDecision, RiskEngine @@ -92,6 +97,17 @@ class CorePolicyAdmissionContext: now_ts_ns_local: int +@dataclass(frozen=True, slots=True) +class CoreExecutionControlApplyContext: + """Optional mutable execution-control apply context for one Core step.""" + + execution_control: ExecutionControl + now_ts_ns_local: int + max_orders_per_sec: float | None = None + max_cancels_per_sec: float | None = None + activate_dispatchable_outputs: bool = False + + def _select_effective_control_scheduling_obligation( decision: GateDecision, ) -> ControlSchedulingObligation | None: @@ -146,6 +162,7 @@ def run_core_step( configuration: CoreConfiguration | None = None, control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, core_decision_context: CoreDecisionContext | None = None, strategy_evaluator: CoreStepStrategyEvaluator | None = None, ) -> CoreStepResult: @@ -157,6 +174,17 @@ def run_core_step( - optionally captures compatibility decision projections via core_decision_context; - preserves the existing control-time queue reevaluation compatibility path. """ + if execution_control_apply_context is not None and policy_admission_context is None: + raise ValueError( + "execution_control_apply_context requires policy_admission_context" + ) + if execution_control_apply_context is not None and isinstance( + entry.event, ControlTimeEvent + ): + raise ValueError( + "execution_control_apply_context is not supported for ControlTimeEvent" + ) + process_event_entry(state, entry, configuration=configuration) generated_intents: tuple[OrderIntent, ...] = () @@ -233,17 +261,63 @@ def run_core_step( passthrough_queued=policy_result.passthrough_queued, ) ) + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, + ), + ) core_step_decision = CoreStepDecision( policy_rejected_intents=tuple( rejected.record.intent for rejected in policy_result.rejected_generated ), policy_risk_decision=policy_result.policy_risk_decision, - execution_control_decision=execution_control_plan.execution_control_decision, + execution_control_decision=( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ), + queued_effective_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.queued_effective_intents + ), + dispatchable_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.dispatchable_intents + ), + execution_handled_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.execution_handled_intents + ), + control_scheduling_obligation=( + None + if apply_result is None + else apply_result.control_scheduling_obligation + ), ) + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if execution_control_apply_context.activate_dispatchable_outputs: + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records + ) return CoreStepResult( generated_intents=generated_intents, candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, core_step_decision=core_step_decision, ) if ( From c97ae2052087685c59776eff21d04b8fcc08276b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:15:43 +0000 Subject: [PATCH 18/53] test(core): close run_core_step apply integration semantics --- .../models/test_core_step_api_contract.py | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index fd63dde..38fc59a 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -2277,3 +2277,109 @@ def _boom(*_: object, **__: object) -> object: assert result.core_step_decision is not None assert result.compat_gate_decision is None assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) + + +def test_run_core_step_apply_path_policy_failure_short_circuits_plan_and_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="policy-boom-generated") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 64 + return [generated_intent] + + class _PolicyBoom: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + raise RuntimeError("policy evaluator failed") + + def _planner_must_not_run(*_: object, **__: object) -> object: + raise AssertionError("planner must not run when policy admission fails") + + def _apply_must_not_run(*_: object, **__: object) -> object: + raise AssertionError("apply must not run when policy admission fails") + + monkeypatch.setattr( + processing_step_module, + "plan_execution_control_candidates", + _planner_must_not_run, + ) + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_must_not_run, + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=64), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-policy-fail-short-circuit", + ts_ns_local=64, + ts_ns_exch=63, + ), + ) + with pytest.raises(RuntimeError, match="policy evaluator failed"): + run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyBoom(), # type: ignore[arg-type] + now_ts_ns_local=64, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=64, + ), + ) + + +def test_run_core_step_apply_path_apply_failure_propagates_without_partial_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + generated_intent = _new_intent(client_order_id="apply-boom-generated") + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + assert context.state._last_processing_position_index == 65 + return [generated_intent] + + class _PolicyOk: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + def _apply_boom(*_: object, **__: object) -> object: + raise RuntimeError("apply failed") + + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_boom, + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=65), + event=_fill_event( + instrument="BTC-USDC-PERP", + client_order_id="fill-apply-fail-propagates", + ts_ns_local=65, + ts_ns_exch=64, + ), + ) + with pytest.raises(RuntimeError, match="apply failed"): + run_core_step( + state, + entry, + strategy_evaluator=_Evaluator(), + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyOk(), # type: ignore[arg-type] + now_ts_ns_local=65, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=65, + ), + ) From a48430bd637a8fa967efb40ebdd5055412ec3152 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 13:30:29 +0000 Subject: [PATCH 19/53] feat(core): expose execution control from risk engine --- .../test_execution_control_accessor.py | 71 +++++++++++++++++++ tradingchassis_core/core/risk/risk_engine.py | 10 +++ 2 files changed, 81 insertions(+) create mode 100644 tests/semantics/gate_risk_invariants/test_execution_control_accessor.py diff --git a/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py b/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py new file mode 100644 index 0000000..953adb1 --- /dev/null +++ b/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py @@ -0,0 +1,71 @@ +"""Semantics tests for RiskEngine.execution_control accessor contract.""" + +from __future__ import annotations + +import copy + +import pytest + +from tradingchassis_core.core.domain.types import NotionalLimits +from tradingchassis_core.core.events.event_bus import EventBus +from tradingchassis_core.core.risk.risk_config import RiskConfig +from tradingchassis_core.core.risk.risk_engine import RiskEngine + + +class _CaptureSink: + def __init__(self) -> None: + self.events: list[object] = [] + + def on_event(self, event: object) -> None: + self.events.append(event) + + +def _risk_cfg() -> RiskConfig: + return RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1e18, + max_single_order_notional=1e18, + ), + ) + + +def test_execution_control_accessor_returns_owned_stateful_instance_without_side_effects( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sink = _CaptureSink() + risk = RiskEngine(risk_cfg=_risk_cfg(), event_bus=EventBus(sinks=[sink])) + + before_rate_state = copy.deepcopy(risk._execution_control._rate_state) + + monkeypatch.setattr( + risk, + "decide_intents", + lambda **_: (_ for _ in ()).throw( + AssertionError("execution_control accessor must not call decide_intents") + ), + ) + + first = risk.execution_control + second = risk.execution_control + + assert first is second + assert first is risk._execution_control + assert risk._execution_control._rate_state == before_rate_state + assert sink.events == [] + + +def test_execution_control_accessor_preserves_rate_state_continuity_across_calls() -> None: + risk = RiskEngine(risk_cfg=_risk_cfg(), event_bus=EventBus(sinks=[])) + ts_ns_local = 1_000_000_000 + + # Same timestamp, same bucket; the third consume must fail after two accepts. + allowed_1, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) + allowed_2, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) + allowed_3, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) + + assert allowed_1 is True + assert allowed_2 is True + assert allowed_3 is False diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 4f99ade..3750c21 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -177,6 +177,16 @@ def _float_equal(a: float, b: float) -> bool: """Best-effort float equality for normalized values.""" return abs(a - b) <= 1e-12 + @property + def execution_control(self) -> ExecutionControl: + """Expose the owned stateful execution-control instance. + + This accessor intentionally returns the existing instance to preserve + queue/inflight/rate continuity across compatibility and Core step paths. + It must not allocate a new ExecutionControl. + """ + return self._execution_control + # --------------------------------------------------------------------- # Soft constraints for strategy # --------------------------------------------------------------------- From abc4f908bf383608c33d04acaad2beb3d5ec9803 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 14:19:02 +0000 Subject: [PATCH 20/53] feat(runtime): add flagged ControlTime core-step dispatch path --- .../models/test_core_step_api_contract.py | 98 +++++++++- .../core/domain/processing_step.py | 170 +++++++++--------- 2 files changed, 184 insertions(+), 84 deletions(-) diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py index 38fc59a..830cf97 100644 --- a/tests/semantics/models/test_core_step_api_contract.py +++ b/tests/semantics/models/test_core_step_api_contract.py @@ -1900,7 +1900,7 @@ def test_run_core_step_apply_context_requires_policy_admission_context() -> None ) -def test_run_core_step_apply_context_rejects_control_time_event_path() -> None: +def test_run_core_step_control_time_rejects_mixed_compat_and_unified_contexts() -> None: state = StrategyState(event_bus=NullEventBus()) entry = EventStreamEntry( position=ProcessingPosition(index=58), @@ -1917,7 +1917,10 @@ def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: with pytest.raises( ValueError, - match="execution_control_apply_context is not supported for ControlTimeEvent", + match=( + "control_time_queue_context cannot be combined with " + "policy_admission_context or execution_control_apply_context" + ), ): run_core_step( state, @@ -1938,6 +1941,97 @@ def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: ) +def test_run_core_step_control_time_accepts_policy_and_apply_context_and_emits_dispatchables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + instrument = "BTC-USDC-PERP" + queued_intent = _new_intent(client_order_id="queued-control-unified") + state.merge_intents_into_queue(instrument, [queued_intent]) + obligation = ControlSchedulingObligation( + due_ts_ns_local=77, + reason="rate_limit", + scope_key=f"instrument:{instrument}", + source="execution_control_rate_limit", + ) + apply_calls = {"count": 0} + + class _PolicyEvaluator: + def evaluate_policy_intent( + self, + *, + intent: OrderIntent, + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + def _apply_spy( + plan: object, + context: object, + ) -> ExecutionControlApplyResult: + _ = context + apply_calls["count"] += 1 + active_records = plan.active_records # type: ignore[attr-defined] + dispatchable = ( + ExecutionControlDispatchableRecord(record=active_records[0]), + ) + decision = ExecutionControlDecision( + queued_effective_intents=tuple(record.intent for record in active_records), + dispatchable_intents=tuple(item.record.intent for item in dispatchable), + execution_handled_intents=(), + control_scheduling_obligation=obligation, + ) + return ExecutionControlApplyResult( + queued_effective_records=tuple(active_records), + dispatchable_records=dispatchable, + execution_handled_records=(), + blocked_records=(), + control_scheduling_obligation=obligation, + execution_control_decision=decision, + ) + + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + _apply_spy, + ) + monkeypatch.setattr( + state, + "pop_queued_intents", + lambda _: (_ for _ in ()).throw( + AssertionError("compatibility control-time queue path must not run") + ), + ) + + entry = EventStreamEntry( + position=ProcessingPosition(index=58), + event=_control_time_event(due_ts_ns_local=58, realized_ts_ns_local=58), + ) + result = run_core_step( + state, + entry, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=58, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=58, + activate_dispatchable_outputs=True, + ), + ) + + assert apply_calls["count"] == 1 + assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( + queued_intent.client_order_id, + ) + assert result.control_scheduling_obligation == obligation + assert result.compat_gate_decision is None + assert result.core_step_decision is not None + + def test_run_core_step_apply_integration_orders_policy_plan_apply_and_maps_result() -> None: state = StrategyState(event_bus=NullEventBus()) instrument = "BTC-USDC-PERP" diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index b711c03..ea7dc31 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -178,11 +178,17 @@ def run_core_step( raise ValueError( "execution_control_apply_context requires policy_admission_context" ) - if execution_control_apply_context is not None and isinstance( - entry.event, ControlTimeEvent + if ( + isinstance(entry.event, ControlTimeEvent) + and control_time_queue_context is not None + and ( + policy_admission_context is not None + or execution_control_apply_context is not None + ) ): raise ValueError( - "execution_control_apply_context is not supported for ControlTimeEvent" + "control_time_queue_context cannot be combined with " + "policy_admission_context or execution_control_apply_context" ) process_event_entry(state, entry, configuration=configuration) @@ -238,88 +244,88 @@ def run_core_step( compat_gate_decision=decision, ) - if not isinstance(entry.event, ControlTimeEvent): - if ( - policy_admission_context is not None - and core_decision_context is not None - and core_decision_context.enable_candidate_intent_decision - ): - raise ValueError( - "policy_admission_context cannot be combined with " - "core_decision_context.enable_candidate_intent_decision=True" - ) - if policy_admission_context is not None: - policy_result = apply_policy_to_candidate_records( - candidate_intent_records, - state=state, - now_ts_ns_local=policy_admission_context.now_ts_ns_local, - policy_evaluator=policy_admission_context.policy_evaluator, - ) - execution_control_plan = plan_execution_control_candidates( - ExecutionControlCandidateInput( - accepted_generated=policy_result.accepted_generated, - passthrough_queued=policy_result.passthrough_queued, - ) + if ( + policy_admission_context is not None + and core_decision_context is not None + and core_decision_context.enable_candidate_intent_decision + ): + raise ValueError( + "policy_admission_context cannot be combined with " + "core_decision_context.enable_candidate_intent_decision=True" + ) + if policy_admission_context is not None: + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, ) - apply_result = None - if execution_control_apply_context is not None: - apply_result = apply_execution_control_plan( - execution_control_plan, - ExecutionControlApplyContext( - state=state, - execution_control=execution_control_apply_context.execution_control, - now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, - max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, - max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, - ), - ) - core_step_decision = CoreStepDecision( - policy_rejected_intents=tuple( - rejected.record.intent for rejected in policy_result.rejected_generated - ), - policy_risk_decision=policy_result.policy_risk_decision, - execution_control_decision=( - execution_control_plan.execution_control_decision - if apply_result is None - else apply_result.execution_control_decision - ), - queued_effective_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.queued_effective_intents - ), - dispatchable_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.dispatchable_intents - ), - execution_handled_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.execution_handled_intents - ), - control_scheduling_obligation=( - None - if apply_result is None - else apply_result.control_scheduling_obligation + ) + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, ), ) - dispatchable_intents: tuple[OrderIntent, ...] = () - control_scheduling_obligation = None - if apply_result is not None: - control_scheduling_obligation = apply_result.control_scheduling_obligation - if execution_control_apply_context.activate_dispatchable_outputs: - dispatchable_intents = tuple( - record.record.intent for record in apply_result.dispatchable_records - ) - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - dispatchable_intents=dispatchable_intents, - control_scheduling_obligation=control_scheduling_obligation, - core_step_decision=core_step_decision, - ) + core_step_decision = CoreStepDecision( + policy_rejected_intents=tuple( + rejected.record.intent for rejected in policy_result.rejected_generated + ), + policy_risk_decision=policy_result.policy_risk_decision, + execution_control_decision=( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ), + queued_effective_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.queued_effective_intents + ), + dispatchable_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.dispatchable_intents + ), + execution_handled_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.execution_handled_intents + ), + control_scheduling_obligation=( + None + if apply_result is None + else apply_result.control_scheduling_obligation + ), + ) + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if execution_control_apply_context.activate_dispatchable_outputs: + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records + ) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, + core_step_decision=core_step_decision, + ) + if not isinstance(entry.event, ControlTimeEvent): if ( core_decision_context is not None and core_decision_context.enable_candidate_intent_decision From b9d27754339b0e2000a330a94d1bc25a4345b241 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 14:49:17 +0000 Subject: [PATCH 21/53] feat(core): add two-phase wakeup step scaffold --- .../models/test_core_wakeup_step_contract.py | 526 ++++++++++++++++++ tradingchassis_core/__init__.py | 8 + tradingchassis_core/core/domain/__init__.py | 8 + .../core/domain/processing_step.py | 180 +++++- 4 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 tests/semantics/models/test_core_wakeup_step_contract.py diff --git a/tests/semantics/models/test_core_wakeup_step_contract.py b/tests/semantics/models/test_core_wakeup_step_contract.py new file mode 100644 index 0000000..efd9420 --- /dev/null +++ b/tests/semantics/models/test_core_wakeup_step_contract.py @@ -0,0 +1,526 @@ +"""Semantics tests for Core two-phase wakeup scaffold APIs.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +import tradingchassis_core as tc +import tradingchassis_core.core.domain.processing_step as processing_step_module +from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin +from tradingchassis_core.core.domain.event_model import ( + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from tradingchassis_core.core.domain.execution_control_apply import ( + ExecutionControlApplyResult, + ExecutionControlDispatchableRecord, +) +from tradingchassis_core.core.domain.execution_control_decision import ( + ExecutionControlDecision, +) +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.processing_step import ( + CoreExecutionControlApplyContext, + CorePolicyAdmissionContext, + CoreStepStrategyContext, + CoreWakeupReductionResult, + run_core_wakeup_decision, + run_core_wakeup_reduction, + run_core_wakeup_step, +) +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, + ControlTimeEvent, + FillEvent, + NewOrderIntent, + Price, + Quantity, +) +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.risk.risk_engine import RiskEngine + + +def _fill_event(*, ts: int, client_order_id: str) -> FillEvent: + return FillEvent( + ts_ns_local=ts, + ts_ns_exch=max(1, ts - 1), + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + side="buy", + intended_price=Price(currency="USDC", value=100.0), + filled_price=Price(currency="USDC", value=100.5), + intended_qty=Quantity(unit="contracts", value=1.0), + cum_filled_qty=Quantity(unit="contracts", value=0.5), + remaining_qty=Quantity(unit="contracts", value=0.5), + time_in_force="GTC", + liquidity_flag="maker", + fee=None, + ) + + +def _control_event(*, ts: int) -> ControlTimeEvent: + return ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ) + + +def _new_intent(*, client_order_id: str, ts_ns_local: int = 1) -> NewOrderIntent: + return NewOrderIntent( + ts_ns_local=ts_ns_local, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id=f"corr-{client_order_id}", + side="buy", + order_type="limit", + intended_qty=Quantity(value=1.0, unit="contracts"), + intended_price=Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def _cancel_intent(*, client_order_id: str, ts_ns_local: int = 1) -> CancelOrderIntent: + return CancelOrderIntent( + ts_ns_local=ts_ns_local, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + intents_correlation_id=f"corr-cancel-{client_order_id}", + ) + + +def test_run_core_wakeup_exports_identity() -> None: + assert tc.run_core_wakeup_reduction is run_core_wakeup_reduction + assert tc.run_core_wakeup_decision is run_core_wakeup_decision + assert tc.run_core_wakeup_step is run_core_wakeup_step + assert tc.CoreWakeupReductionResult is CoreWakeupReductionResult + + +def test_run_core_wakeup_reduction_processes_entries_in_order() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry_a = EventStreamEntry(position=ProcessingPosition(index=5), event=_fill_event(ts=10, client_order_id="fill-a")) + entry_b = EventStreamEntry(position=ProcessingPosition(index=6), event=_control_event(ts=11)) + calls: list[tuple[str, int]] = [] + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + calls.append((type(context.event).__name__, context.position.index)) + return [_new_intent(client_order_id=f"gen-{context.position.index}", ts_ns_local=context.position.index)] + + reduction = run_core_wakeup_reduction( + state, + (entry_a, entry_b), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda event: isinstance(event, FillEvent), + ) + + assert state._last_processing_position_index == 6 + assert calls == [("FillEvent", 5)] + assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ("gen-5",) + assert reduction.entries == (entry_a, entry_b) + + +def test_run_core_wakeup_reduction_failure_short_circuits_and_skips_later_entries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + entry_a = EventStreamEntry(position=ProcessingPosition(index=1), event=_fill_event(ts=1, client_order_id="first")) + entry_b = EventStreamEntry(position=ProcessingPosition(index=2), event=_fill_event(ts=2, client_order_id="second")) + processed: list[int] = [] + evaluate_calls = {"count": 0} + original_process = processing_step_module.process_event_entry + + def _process_spy(state_obj: StrategyState, entry: EventStreamEntry, *, configuration: object | None = None) -> None: + _ = configuration + processed.append(entry.position.index) + if entry.position.index == 1: + raise RuntimeError("boom-reducer") + original_process(state_obj, entry) + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + _ = context + evaluate_calls["count"] += 1 + return [] + + monkeypatch.setattr(processing_step_module, "process_event_entry", _process_spy) + with pytest.raises(RuntimeError, match="boom-reducer"): + run_core_wakeup_reduction( + state, + (entry_a, entry_b), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda _: True, + ) + assert processed == [1] + assert evaluate_calls["count"] == 0 + assert state._last_processing_position_index is None + + +def test_run_core_wakeup_reduction_does_not_evaluate_control_event_without_explicit_filter() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry_fill = EventStreamEntry(position=ProcessingPosition(index=1), event=_fill_event(ts=1, client_order_id="fill")) + entry_control = EventStreamEntry(position=ProcessingPosition(index=2), event=_control_event(ts=2)) + seen: list[str] = [] + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + seen.append(type(context.event).__name__) + return [_new_intent(client_order_id=f"from-{type(context.event).__name__}")] + + reduction = run_core_wakeup_reduction( + state, + (entry_fill, entry_control), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda event: isinstance(event, FillEvent), + ) + assert seen == ["FillEvent"] + assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ( + "from-FillEvent", + ) + + +def test_run_core_wakeup_reduction_can_evaluate_control_event_when_filter_allows_it() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry_control = EventStreamEntry(position=ProcessingPosition(index=9), event=_control_event(ts=9)) + seen: list[str] = [] + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + seen.append(type(context.event).__name__) + return [_new_intent(client_order_id="from-control")] + + reduction = run_core_wakeup_reduction( + state, + (entry_control,), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda event: isinstance(event, ControlTimeEvent), + ) + assert seen == ["ControlTimeEvent"] + assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ( + "from-control", + ) + + +def test_run_core_wakeup_decision_combines_generated_and_post_reduction_queue_snapshot_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + queued = _new_intent(client_order_id="same-key") + state.merge_intents_into_queue("BTC-USDC-PERP", [queued]) + reduction = CoreWakeupReductionResult( + generated_intents=(_cancel_intent(client_order_id="same-key"),), + ) + combine_calls: list[tuple[tuple[str, ...], tuple[str, ...]]] = [] + original_combine = processing_step_module.combine_candidate_intent_records + + def _combine_spy(*, generated_intents: Any, queued_intents: Any) -> Any: + combine_calls.append( + ( + tuple(intent.client_order_id for intent in generated_intents), + tuple(intent.client_order_id for intent in queued_intents), + ) + ) + return original_combine( + generated_intents=generated_intents, + queued_intents=queued_intents, + ) + + monkeypatch.setattr(processing_step_module, "combine_candidate_intent_records", _combine_spy) + result = run_core_wakeup_decision(state, reduction, snapshot_instrument="BTC-USDC-PERP") + + assert combine_calls == [(("same-key",), ("same-key",))] + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.GENERATED, + ) + assert tuple(intent.intent_type for intent in result.candidate_intents) == ("cancel",) + + +def test_run_core_wakeup_decision_without_policy_context_returns_candidates_only() -> None: + state = StrategyState(event_bus=NullEventBus()) + queued = _new_intent(client_order_id="queued-only") + state.merge_intents_into_queue("BTC-USDC-PERP", [queued]) + before_queue = state.queued_intents_snapshot("BTC-USDC-PERP") + reduction = CoreWakeupReductionResult(generated_intents=(_new_intent(client_order_id="generated"),)) + + result = run_core_wakeup_decision(state, reduction, snapshot_instrument="BTC-USDC-PERP") + + assert tuple(record.origin for record in result.candidate_intent_records) == ( + CandidateIntentOrigin.QUEUED, + CandidateIntentOrigin.GENERATED, + ) + assert result.core_step_decision is None + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is None + assert state.queued_intents_snapshot("BTC-USDC-PERP") == before_queue + + +def test_run_core_wakeup_decision_policy_only_populates_policy_and_plan_without_apply() -> None: + state = StrategyState(event_bus=NullEventBus()) + state.merge_intents_into_queue("BTC-USDC-PERP", [_new_intent(client_order_id="queued")]) + reduction = CoreWakeupReductionResult( + generated_intents=( + _new_intent(client_order_id="generated-reject"), + _cancel_intent(client_order_id="generated-accept"), + ) + ) + + class _PolicyEvaluator: + def evaluate_policy_intent(self, **kwargs: object) -> tuple[bool, str | None]: + intent = kwargs["intent"] + if intent.intent_type == "cancel": + return True, None + return False, "policy_rejected" + + result = run_core_wakeup_decision( + state, + reduction, + snapshot_instrument="BTC-USDC-PERP", + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] + now_ts_ns_local=99, + ), + ) + + assert result.core_step_decision is not None + assert result.core_step_decision.policy_risk_decision is not None + assert tuple( + intent.client_order_id for intent in result.core_step_decision.policy_risk_decision.rejected_intents + ) == ("generated-reject",) + assert tuple( + intent.client_order_id for intent in result.core_step_decision.execution_control_decision.queued_effective_intents + ) == ("generated-accept", "queued") + assert result.dispatchable_intents == () + + +@pytest.mark.parametrize( + ("activate_outputs", "expected_dispatchables"), + [ + (False, ()), + (True, ("generated-apply",)), + ], +) +def test_run_core_wakeup_decision_policy_plus_apply_runs_once_and_maps_outputs( + monkeypatch: pytest.MonkeyPatch, + activate_outputs: bool, + expected_dispatchables: tuple[str, ...], +) -> None: + state = StrategyState(event_bus=NullEventBus()) + reduction = CoreWakeupReductionResult( + generated_intents=(_new_intent(client_order_id="generated-apply"),) + ) + obligation = ControlSchedulingObligation( + due_ts_ns_local=1_000, + reason="rate_limit", + scope_key="instrument:BTC-USDC-PERP", + source="execution_control_rate_limit", + ) + apply_calls = {"count": 0} + + class _PolicyOk: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + def _apply_spy(plan: object, context: object) -> ExecutionControlApplyResult: + _ = context + apply_calls["count"] += 1 + active_records = plan.active_records # type: ignore[attr-defined] + dispatchable_records = ( + ExecutionControlDispatchableRecord(record=active_records[0]), + ) + decision = ExecutionControlDecision( + queued_effective_intents=tuple(record.intent for record in active_records), + dispatchable_intents=tuple(item.record.intent for item in dispatchable_records), + execution_handled_intents=(), + control_scheduling_obligation=obligation, + ) + return ExecutionControlApplyResult( + queued_effective_records=tuple(active_records), + dispatchable_records=dispatchable_records, + execution_handled_records=(), + blocked_records=(), + control_scheduling_obligation=obligation, + execution_control_decision=decision, + ) + + monkeypatch.setattr(processing_step_module, "apply_execution_control_plan", _apply_spy) + monkeypatch.setattr( + RiskEngine, + "decide_intents", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("RiskEngine.decide_intents must not run in wakeup decision/apply") + ), + ) + result = run_core_wakeup_decision( + state, + reduction, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyOk(), # type: ignore[arg-type] + now_ts_ns_local=123, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=123, + activate_dispatchable_outputs=activate_outputs, + ), + ) + + assert apply_calls["count"] == 1 + assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == expected_dispatchables + assert result.control_scheduling_obligation == obligation + assert result.compat_gate_decision is None + + +def test_run_core_wakeup_decision_apply_requires_policy_context() -> None: + state = StrategyState(event_bus=NullEventBus()) + with pytest.raises( + ValueError, + match="execution_control_apply_context requires policy_admission_context", + ): + run_core_wakeup_decision( + state, + CoreWakeupReductionResult(), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=1, + ), + ) + + +def test_run_core_wakeup_failure_behavior_short_circuits() -> None: + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry(position=ProcessingPosition(index=10), event=_fill_event(ts=10, client_order_id="boom")) + + class _EvaluatorBoom: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + _ = context + raise RuntimeError("strategy failed") + + with pytest.raises(RuntimeError, match="strategy failed"): + run_core_wakeup_reduction( + state, + (entry,), + strategy_evaluator=_EvaluatorBoom(), + strategy_event_filter=lambda _: True, + ) + + +def test_run_core_wakeup_decision_policy_failure_short_circuits_apply( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + reduction = CoreWakeupReductionResult( + generated_intents=(_new_intent(client_order_id="generated-policy-fail"),) + ) + + class _PolicyBoom: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + raise RuntimeError("policy failed") + + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("apply must not run after policy failure") + ), + ) + with pytest.raises(RuntimeError, match="policy failed"): + run_core_wakeup_decision( + state, + reduction, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyBoom(), # type: ignore[arg-type] + now_ts_ns_local=1, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=1, + ), + ) + + +def test_run_core_wakeup_decision_apply_failure_propagates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + state = StrategyState(event_bus=NullEventBus()) + reduction = CoreWakeupReductionResult( + generated_intents=(_new_intent(client_order_id="generated-apply-fail"),) + ) + + class _PolicyOk: + def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: + return True, None + + monkeypatch.setattr( + processing_step_module, + "apply_execution_control_plan", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("apply failed")), + ) + with pytest.raises(RuntimeError, match="apply failed"): + run_core_wakeup_decision( + state, + reduction, + policy_admission_context=CorePolicyAdmissionContext( + policy_evaluator=_PolicyOk(), # type: ignore[arg-type] + now_ts_ns_local=2, + ), + execution_control_apply_context=CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=2, + ), + ) + + +def test_run_core_wakeup_step_wrapper_matches_manual_two_phase() -> None: + state_manual = StrategyState(event_bus=NullEventBus()) + state_wrapper = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry(position=ProcessingPosition(index=5), event=_fill_event(ts=5, client_order_id="fill")) + + class _Evaluator: + def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: + _ = context + return [_new_intent(client_order_id="generated-manual")] + + reduction = run_core_wakeup_reduction( + state_manual, + (entry,), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda _: True, + ) + manual_result = run_core_wakeup_decision( + state_manual, + reduction, + snapshot_instrument="BTC-USDC-PERP", + ) + wrapper_result = run_core_wakeup_step( + state_wrapper, + (entry,), + strategy_evaluator=_Evaluator(), + strategy_event_filter=lambda _: True, + snapshot_instrument="BTC-USDC-PERP", + ) + + assert wrapper_result == manual_result + assert state_wrapper._last_processing_position_index == state_manual._last_processing_position_index + + +def test_core_wakeup_reduction_result_remains_non_canonical_boundary_artifact() -> None: + assert is_canonical_stream_candidate_type(CoreWakeupReductionResult) is False + assert canonical_category_for_type(CoreWakeupReductionResult) is None + + state = StrategyState(event_bus=NullEventBus()) + entry = EventStreamEntry( + position=ProcessingPosition(index=1), + event=CoreWakeupReductionResult(), + ) + with pytest.raises(TypeError, match="Unsupported non-canonical event type"): + process_event_entry(state, entry) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 859aacd..52a23e3 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -42,7 +42,11 @@ CoreDecisionContext, CoreExecutionControlApplyContext, CorePolicyAdmissionContext, + CoreWakeupReductionResult, run_core_step, + run_core_wakeup_decision, + run_core_wakeup_reduction, + run_core_wakeup_step, ) # ---------------------------------------------------------------------- @@ -119,9 +123,13 @@ "EventStreamEntry", "process_event_entry", "run_core_step", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", "CoreDecisionContext", "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", + "CoreWakeupReductionResult", "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", "ExecutionControlApplyContext", diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 24f933d..8cfff6a 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -23,7 +23,11 @@ CoreDecisionContext, CoreExecutionControlApplyContext, CorePolicyAdmissionContext, + CoreWakeupReductionResult, run_core_step, + run_core_wakeup_decision, + run_core_wakeup_reduction, + run_core_wakeup_step, ) from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult @@ -46,6 +50,10 @@ "CoreDecisionContext", "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", + "CoreWakeupReductionResult", "ControlTimeQueueReevaluationContext", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", + "run_core_wakeup_step", "run_core_step", ] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index ea7dc31..3e5b0b8 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol, Sequence +from typing import TYPE_CHECKING, Callable, Protocol, Sequence from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.execution_control_apply import ( @@ -108,6 +108,20 @@ class CoreExecutionControlApplyContext: activate_dispatchable_outputs: bool = False +@dataclass(frozen=True, slots=True) +class CoreWakeupReductionResult: + """Non-canonical reduction-phase output for one runtime wakeup.""" + + entries: tuple[EventStreamEntry, ...] = () + generated_intents: tuple[OrderIntent, ...] = () + + def __post_init__(self) -> None: + if not isinstance(self.entries, tuple): + object.__setattr__(self, "entries", tuple(self.entries)) + if not isinstance(self.generated_intents, tuple): + object.__setattr__(self, "generated_intents", tuple(self.generated_intents)) + + def _select_effective_control_scheduling_obligation( decision: GateDecision, ) -> ControlSchedulingObligation | None: @@ -364,3 +378,167 @@ def run_core_step( candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, ) + + +def run_core_wakeup_reduction( + state: StrategyState, + entries: Sequence[EventStreamEntry], + *, + configuration: CoreConfiguration | None = None, + strategy_evaluator: CoreStepStrategyEvaluator | None = None, + strategy_event_filter: Callable[[object], bool] | None = None, +) -> CoreWakeupReductionResult: + """Reduce multiple canonical entries and collect wakeup-level generated intents. + + This reducer phase intentionally performs no policy, no execution-control plan, + and no execution-control apply. + """ + + entries_tuple = tuple(entries) + generated_intents: list[OrderIntent] = [] + for entry in entries_tuple: + process_event_entry(state, entry, configuration=configuration) + if strategy_evaluator is None: + continue + if strategy_event_filter is None or not strategy_event_filter(entry.event): + continue + strategy_context = CoreStepStrategyContext( + state=state, + event=entry.event, + position=entry.position, + configuration=configuration, + ) + generated_intents.extend(strategy_evaluator.evaluate(strategy_context)) + return CoreWakeupReductionResult( + entries=entries_tuple, + generated_intents=tuple(generated_intents), + ) + + +def run_core_wakeup_decision( + state: StrategyState, + reduction: CoreWakeupReductionResult, + *, + snapshot_instrument: str | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, +) -> CoreStepResult: + """Run one wakeup-level candidate/policy/execution-control decision phase.""" + + if execution_control_apply_context is not None and policy_admission_context is None: + raise ValueError( + "execution_control_apply_context requires policy_admission_context" + ) + + queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) + candidate_intent_records = combine_candidate_intent_records( + generated_intents=reduction.generated_intents, + queued_intents=queued_snapshot, + ) + candidate_intents = tuple(record.intent for record in candidate_intent_records) + + if policy_admission_context is None: + return CoreStepResult( + generated_intents=reduction.generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + ) + + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, + ) + ) + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, + ), + ) + core_step_decision = CoreStepDecision( + policy_rejected_intents=tuple( + rejected.record.intent for rejected in policy_result.rejected_generated + ), + policy_risk_decision=policy_result.policy_risk_decision, + execution_control_decision=( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ), + queued_effective_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.queued_effective_intents + ), + dispatchable_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.dispatchable_intents + ), + execution_handled_intents=( + () + if apply_result is None + else apply_result.execution_control_decision.execution_handled_intents + ), + control_scheduling_obligation=( + None if apply_result is None else apply_result.control_scheduling_obligation + ), + ) + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if execution_control_apply_context.activate_dispatchable_outputs: + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records + ) + return CoreStepResult( + generated_intents=reduction.generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, + core_step_decision=core_step_decision, + ) + + +def run_core_wakeup_step( + state: StrategyState, + entries: Sequence[EventStreamEntry], + *, + configuration: CoreConfiguration | None = None, + strategy_evaluator: CoreStepStrategyEvaluator | None = None, + strategy_event_filter: Callable[[object], bool] | None = None, + snapshot_instrument: str | None = None, + policy_admission_context: CorePolicyAdmissionContext | None = None, + execution_control_apply_context: CoreExecutionControlApplyContext | None = None, +) -> CoreStepResult: + """Convenience wrapper for reduction + wakeup-level decision/apply.""" + + reduction = run_core_wakeup_reduction( + state, + entries, + configuration=configuration, + strategy_evaluator=strategy_evaluator, + strategy_event_filter=strategy_event_filter, + ) + return run_core_wakeup_decision( + state, + reduction, + snapshot_instrument=snapshot_instrument, + policy_admission_context=policy_admission_context, + execution_control_apply_context=execution_control_apply_context, + ) From 43db205e30d18a244b46e6784166303bfb6cacfa Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 7 May 2026 16:08:26 +0000 Subject: [PATCH 22/53] chore(core): remove runtime-specific hft boundary leaks --- CHANGELOG.md | 2 +- README.md | 2 +- docs/core-responsibility-model.md | 2 +- ...event-stream-cursor-characterization-v1.md | 2 +- ...r-execution-feedback-source-contract-v1.md | 24 ++++++++-------- ...untime-to-coreconfiguration-contract-v1.md | 2 +- ...antic-core-upgrade-milestone-closure-v1.md | 4 +-- docs/venue-adapter-capability-model-v1.md | 10 +++---- pyproject.toml | 1 - ...arket_configuration_positioned_contract.py | 4 +-- tradingchassis_core/core/domain/state.py | 28 +++++++++---------- .../core/schemas/order_intent.schema.json | 2 +- 12 files changed, 41 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39c7683..d5112f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ Initial public release of the core. - Deterministic risk gating before execution #### Backtest Layer -- Integration with [hftbacktest](https://github.com/nkaz001/hftbacktest) +- Integration with an external backtest runtime - Strategy runner abstraction - Venue adapter interface - Deterministic event processing pipeline diff --git a/README.md b/README.md index 5d98c01..1b18fc7 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ python -m pytest -q core/tests Core Runtime (`core-runtime`) provides runtime execution around Core, including: -- local hftbacktest-backed execution entrypoints +- local backtest-runtime execution entrypoints - Argo/runtime orchestration entrypoints - runtime configuration and environment wiring - local output artifacts under `.runtime/local/results/` diff --git a/docs/core-responsibility-model.md b/docs/core-responsibility-model.md index b872b72..3a22c95 100644 --- a/docs/core-responsibility-model.md +++ b/docs/core-responsibility-model.md @@ -124,7 +124,7 @@ This is exactly the boundary we are currently moving toward. ### 5. CoreStepResult must remain venue-neutral -The Core must not output a “Binance order” or an “hftbacktest order.” +The Core must not output a venue-specific order shape from any external engine. The Core outputs: diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md index ceeec8a..dcbb451 100644 --- a/docs/event-stream-cursor-characterization-v1.md +++ b/docs/event-stream-cursor-characterization-v1.md @@ -135,7 +135,7 @@ Existing tests that already anchor current behavior: - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_canonical_counter_increments_only_after_successful_canonical_processing` - Compatibility `rc == 3` snapshot branch remains unchanged: - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_order_snapshot_branch_keeps_compatibility_path` - - `core-runtime/tests/runtime/test_hftbacktest_execution_feedback_probe.py::test_runner_contains_rc3_snapshot_branch` + - `core-runtime` runtime execution-feedback probe test for `rc == 3` snapshot branching - Configuration passed to `process_event_entry(...)`: - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_process_market_event_routes_through_event_entry_with_core_configuration` diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md index 9e022fd..c116b9a 100644 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ b/docs/runtime-adapter-execution-feedback-source-contract-v1.md @@ -605,7 +605,7 @@ decision and test-backed reconciliation rules. `RAEFSC-127` - Code interface addition. -`RAEFSC-128` - hftbacktest or other adapter implementation work. +`RAEFSC-128` - specific backtest runtime or other adapter implementation work. `RAEFSC-129` - Runtime canonical `FillEvent` ingress implementation. @@ -620,9 +620,9 @@ implementation. --- -## Appendix C: hftbacktest source feasibility and gap decision (Phase 4H) +## Appendix C: backtest runtime source feasibility and gap decision (Phase 4H) -This appendix records the hftbacktest-specific feasibility decision from Phase +This appendix records the backtest-runtime-specific feasibility decision from Phase 4G and documents the exact source/adapter gap required before any canonical `FillEvent` ingress planning. @@ -637,22 +637,22 @@ This appendix is docs-contract only: - it does not change reducers or event taxonomy; - it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. -`RAEFSC-134` - Appendix C scope is hftbacktest-specific feasibility and gap +`RAEFSC-134` - Appendix C scope is backtest-runtime-specific feasibility and gap documentation only; no implementation behavior changes are introduced. --- ### C.1 Decision snapshot -`RAEFSC-135` - Current hftbacktest/core-runtime integration feasibility remains +`RAEFSC-135` - Current backtest-runtime/core-runtime integration feasibility remains decision **C** for `ExecutionFeedbackRecordSource` eligibility. -`RAEFSC-136` - No currently exposed hftbacktest/core-runtime source satisfies +`RAEFSC-136` - No currently exposed backtest-runtime/core-runtime source satisfies the `ExecutionFeedbackRecordSource` contract end-to-end under Appendices A and B. `RAEFSC-137` - Canonical runtime `FillEvent` ingress remains deferred for the -hftbacktest integration in this phase. +backtest-runtime integration in this phase. --- @@ -697,7 +697,7 @@ for canonical order execution feedback: - it is market-trade data, not deterministic own-order execution-feedback records with canonical order correlation guarantees. -`RAEFSC-143` - Latent hftbacktest order-structure fields (including potential +`RAEFSC-143` - Latent backtest-runtime order-structure fields (including potential maker/taker-style flags) are classified as **insufficient unless surfaced through explicit authoritative execution-feedback records**: @@ -710,7 +710,7 @@ through explicit authoritative execution-feedback records**: ### C.3 Exact missing requirements -`RAEFSC-144` - Current hftbacktest/core-runtime integration lacks an explicit +`RAEFSC-144` - Current backtest-runtime/core-runtime integration lacks an explicit adapter-facing execution-feedback record channel matching Appendix A required shape and Appendix B drain semantics. @@ -743,7 +743,7 @@ dual-path operation. ### C.4 Minimum required extension boundary (future, non-implemented) -`RAEFSC-152` - Minimum required extension is a hftbacktest wrapper/adapter +`RAEFSC-152` - Minimum required extension is a backtest-runtime wrapper/adapter capability that provides authoritative `ExecutionFeedbackRecordSource` semantics at the venue-side adapter boundary. @@ -789,9 +789,9 @@ phase. --- -### C.6 Future implementation gate for hftbacktest scope +### C.6 Future implementation gate for backtest runtime scope -`RAEFSC-165` - Canonical `FillEvent` implementation planning for hftbacktest +`RAEFSC-165` - Canonical `FillEvent` implementation planning for backtest runtime scope may begin only after all C.3 missing requirements are satisfied under Appendix A/B contracts. diff --git a/docs/runtime-to-coreconfiguration-contract-v1.md b/docs/runtime-to-coreconfiguration-contract-v1.md index 6a5dc22..159c90e 100644 --- a/docs/runtime-to-coreconfiguration-contract-v1.md +++ b/docs/runtime-to-coreconfiguration-contract-v1.md @@ -54,7 +54,7 @@ before invoking core canonical processing/fold APIs. `RCC-07` — `core` must not read runtime JSON files directly. `RCC-08` — `core` must not depend on runtime/engine config classes (including -`HftEngineConfig`, live engine config types, or runtime config classes) at the +backtest engine config types, live engine config types, or runtime config classes) at the configuration boundary. `RCC-09` — A run config may contain multiple sections (for example `engine`, diff --git a/docs/semantic-core-upgrade-milestone-closure-v1.md b/docs/semantic-core-upgrade-milestone-closure-v1.md index 7ba3c1c..c3ac0a8 100644 --- a/docs/semantic-core-upgrade-milestone-closure-v1.md +++ b/docs/semantic-core-upgrade-milestone-closure-v1.md @@ -65,7 +65,7 @@ Implementation-facing contract references in `core/docs`: - `StrategyState` contains canonical reducer paths and compatibility reducer/projection paths concurrently. - Post-submission lifecycle progression after `Submitted` remains snapshot/compatibility-driven (`ingest_order_snapshots` / `OrderStateEvent` / `apply_order_state_event` / `DerivedFillEvent` projection). - `ControlTimeEvent` reducer behavior is currently no-op transition slice (no queue/rate/control reducer migration implied). -- hftbacktest capability support is partial in the model: market/submitted/control-time boundaries are wired; execution-feedback source capability remains unsatisfied. +- Backtest-runtime capability support is partial in the model: market/submitted/control-time boundaries are wired; execution-feedback source capability remains unsatisfied. ### Deferred in current implementation @@ -82,7 +82,7 @@ Implementation-facing contract references in `core/docs`: Current usability decision: -- Usable for current hftbacktest backtests: **Yes**. +- Usable for current backtest runtime integrations: **Yes**. - Usable as a transitional semantic milestone: **Yes**. - Usable as final full canonical Event Stream implementation: **No**. diff --git a/docs/venue-adapter-capability-model-v1.md b/docs/venue-adapter-capability-model-v1.md index ffabd5e..ee9c462 100644 --- a/docs/venue-adapter-capability-model-v1.md +++ b/docs/venue-adapter-capability-model-v1.md @@ -215,12 +215,12 @@ is gated and not enabled by this document. --- -## Current hftbacktest capability map (Phase 6C snapshot) +## Current backtest runtime capability map (Phase 6C snapshot) `VACM-37` - This table records current capability support classification for the -hftbacktest adapter/runtime integration without changing behavior. +current backtest runtime adapter integration without changing behavior. -| capability | current hftbacktest support | classification | current event/artifact path | notes / limitations | +| capability | current backtest runtime support | classification | current event/artifact path | notes / limitations | | --- | --- | --- | --- | --- | | market input capability | supported | canonical event capable | canonical `MarketEvent` positioned ingestion path | canonical market path active; ordering remains `ProcessingPosition` authority | | order submission result boundary capability | supported (entry boundary) | canonical event capable | successful `new` dispatch -> canonical `OrderSubmittedEvent` | failed `new` dispatch emits no `OrderSubmittedEvent`; replace/cancel do not create new entry event | @@ -229,7 +229,7 @@ hftbacktest adapter/runtime integration without changing behavior. | control-time realization capability | supported (current transition slice) | canonical event capable | realized deadline obligation -> canonical `ControlTimeEvent` injection | sparse/deadline-style realization only; no periodic tick model | | execution feedback capability | not supported as authoritative source | optional future capability (currently missing/ineligible) | no eligible `ExecutionFeedbackRecordSource` path in current integration | blocked by missing authoritative source channel, deterministic non-timestamp `source_sequence`, source-authoritative liquidity, and explicit canonical correlation gates | -`VACM-38` - Current hftbacktest execution-feedback feasibility remains blocked by +`VACM-38` - Current backtest runtime execution-feedback feasibility remains blocked by the missing authoritative `ExecutionFeedbackRecordSource` capability. `VACM-39` - Snapshot compatibility path remains active semantic authority for @@ -307,7 +307,7 @@ or compatibility boundaries in existing contracts. `VACM-56` - No adapter API methods/signatures are defined or implemented. -`VACM-57` - No hftbacktest-specific `core` semantics are introduced. +`VACM-57` - No runtime-specific `core` semantics are introduced. `VACM-58` - No runtime canonical `FillEvent` ingress implementation. diff --git a/pyproject.toml b/pyproject.toml index 4589da3..e0fa628 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,6 @@ dev = [ "import-linter>=1.11,<2", "ruff>=0.4,<1", "mypy>=1.9,<2", - "hftbacktest>=2,<3", "jsonschema>=4,<5", "matplotlib>=3,<4", "numpy>=2.0,<2.3", diff --git a/tests/semantics/models/test_market_configuration_positioned_contract.py b/tests/semantics/models/test_market_configuration_positioned_contract.py index 8e1b6ac..e7633dd 100644 --- a/tests/semantics/models/test_market_configuration_positioned_contract.py +++ b/tests/semantics/models/test_market_configuration_positioned_contract.py @@ -377,11 +377,11 @@ def test_positioned_market_contract_does_not_import_runtime_configuration_mappin forbidden_modules = ( "core_runtime", "trading_runtime", - "hft_engine_config", + "backtest_engine_config", "live_engine_config", ) forbidden_symbols = { - "HftEngineConfig", + "BacktestEngineConfig", "LiveEngineConfig", "RiskConfig", } diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 41ba8e4..a4b4f11 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -48,7 +48,7 @@ # Internal state models # # These models are intentionally NOT part of the JSON-schema "source of truth". -# They exist to hold runtime state derived from hftbacktest snapshots/events. +# They exist to hold runtime state derived from adapter snapshots/events. # --------------------------------------------------------------------------- TERMINAL_ORDER_STATES: set[str] = {"filled", "canceled", "expired", "rejected"} @@ -84,7 +84,7 @@ class OrderSnapshot: cum_filled_qty: float remaining_qty: float - # Best-effort request marker from hftbacktest snapshots. + # Best-effort request marker from runtime snapshots. # Convention: 0 indicates no in-flight request. req: int = 0 @@ -141,7 +141,7 @@ class MarketState: @dataclass(slots=True) class AccountState: - """Best-effort account values from hftbacktest state_values().""" + """Best-effort account values from runtime account snapshots.""" position: float = 0.0 balance: float = 0.0 @@ -224,7 +224,7 @@ def _advance_processing_position(self, position: ProcessingPosition) -> None: def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: str) -> None: """Record that an intent was sent to the execution layer. - This is used for best-effort inflight handling. hftbacktest provides snapshots + This is used for best-effort inflight handling. Backtest runtimes often provide snapshots (status/req) rather than explicit ACK events, so inflight is cleared heuristically as soon as subsequent snapshots indicate completion. @@ -816,7 +816,7 @@ def _advance_canonical_order_projection(self, event: OrderStateEvent) -> None: # NOTE: # Currently unused. - # hftbacktest does not emit explicit FillEvent deltas; fills are inferred + # Some backtest runtimes do not emit explicit FillEvent deltas; fills are inferred # indirectly from order state snapshots instead. # This method is reserved for event-driven backends or live trading venues # that provide fill-level events. @@ -860,9 +860,9 @@ def apply_fill_event(self, event: FillEvent, *, max_keep: int = 10_000) -> None: self._event_bus.emit(event) def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable[object]) -> None: - """Ingest hftbacktest order snapshots and reduce them into internal state. + """Ingest runtime order snapshots and reduce them into internal state. - hftbacktest provides *snapshots* (not deltas). We translate each snapshot into + Snapshot-driven runtimes provide *snapshots* (not deltas). We translate each snapshot into an OrderStateEvent (snapshot) and feed it into apply_order_state_event(). This is an adapter/materialization path for compatibility snapshot @@ -870,7 +870,7 @@ def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable """ def map_status(status: int, req: int, client_order_id: str) -> str: - """Best-effort mapping from hftbacktest (status, req) to schema state. + """Best-effort mapping from runtime snapshot (status, req) to schema state. Design: terminal status wins. If a request marker is present (req!=0), treat this as "in-flight". In that case, "pending_new" is used only @@ -900,7 +900,7 @@ def map_status(status: int, req: int, client_order_id: str) -> str: return "rejected" - # hftbacktest "values()" often returns a custom iterator with has_next/get + # Some runtimes expose custom iterators with has_next/get semantics. if hasattr(orders_snapshot_iter, "has_next") and hasattr(orders_snapshot_iter, "get"): it = orders_snapshot_iter @@ -911,25 +911,25 @@ def _next() -> object | None: o = _next() if o is None: break - self._ingest_one_hft_order(instrument, o, map_status) + self._ingest_one_snapshot_order(instrument, o, map_status) return # Otherwise assume a normal Python iterable for o in orders_snapshot_iter: - self._ingest_one_hft_order(instrument, o, map_status) + self._ingest_one_snapshot_order(instrument, o, map_status) - def _ingest_one_hft_order( + def _ingest_one_snapshot_order( self, instrument: str, o: object, map_status: Callable[[int, int, int], str], ) -> None: - """Translate a single hftbacktest order snapshot object into an OrderStateEvent.""" + """Translate a single runtime order snapshot object into an OrderStateEvent.""" # --- Map primitive enums to your schema enums --- order_type: str = "limit" if o.order_type == 0 else "market" - # hftbacktest typically uses BUY=1, SELL=-1 + # Snapshot adapters commonly use BUY=1, SELL=-1. side: str = "buy" if o.side == 1 else "sell" tif: int = o.time_in_force diff --git a/tradingchassis_core/core/schemas/order_intent.schema.json b/tradingchassis_core/core/schemas/order_intent.schema.json index 9b4522e..6fffd37 100644 --- a/tradingchassis_core/core/schemas/order_intent.schema.json +++ b/tradingchassis_core/core/schemas/order_intent.schema.json @@ -23,7 +23,7 @@ }, "client_order_id": { "type": "string", - "description": "Order identifier (maps to hftbacktest 'order_id'). Used for new/cancel/replace. Must be unique while an order with the same ID exists.", + "description": "Order identifier (maps to runtime adapter order IDs). Used for new/cancel/replace. Must be unique while an order with the same ID exists.", "minLength": 1 }, "intent_type": { From 9798fe189265d4421aadb92b58090cf9599db851 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 9 May 2026 21:37:25 +0000 Subject: [PATCH 23/53] core: add canonical order execution feedback event reduction --- .../test_canonical_processing_boundary.py | 76 ++++++++++ .../models/test_event_taxonomy_boundary.py | 7 + .../core/domain/event_model.py | 2 + tradingchassis_core/core/domain/processing.py | 11 ++ tradingchassis_core/core/domain/state.py | 140 ++++++++++++++---- tradingchassis_core/core/domain/types.py | 46 ++++++ 6 files changed, 252 insertions(+), 30 deletions(-) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index b7bda90..53a050c 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -15,6 +15,8 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderExecutionFeedbackEvent, + OrderExecutionFeedbackSnapshot, OrderStateEvent, OrderSubmittedEvent, Price, @@ -141,6 +143,42 @@ def _order_submitted_event( ) +def _order_execution_feedback_event( + *, + instrument: str, + ts_ns_local_feedback: int, + order_id: str = "101", +) -> OrderExecutionFeedbackEvent: + return OrderExecutionFeedbackEvent( + ts_ns_local_feedback=ts_ns_local_feedback, + instrument=instrument, + position=1.25, + balance=10_000.0, + fee=3.5, + trading_volume=20.0, + trading_value=2_050.0, + num_trades=7, + order_snapshots=( + OrderExecutionFeedbackSnapshot( + order_id=order_id, + 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, + ts_ns_exch=ts_ns_local_feedback - 1, + ts_ns_local=ts_ns_local_feedback, + ), + ), + runtime_correlation={"source": "unit-test"}, + ) + + def _control_time_event( *, ts_ns_local_control: int, @@ -264,6 +302,44 @@ def test_process_canonical_event_accepts_fill_event_with_processing_position() - assert state._last_processing_position_index == 12 +def test_process_canonical_event_accepts_order_execution_feedback_event() -> None: + state = StrategyState(event_bus=NullEventBus()) + event = _order_execution_feedback_event( + instrument="BTC-USDC-PERP", + ts_ns_local_feedback=250, + ) + + process_canonical_event(state, event) + + account = state.account["BTC-USDC-PERP"] + assert account.position == 1.25 + assert account.balance == 10_000.0 + assert account.fee == 3.5 + assert account.trading_volume == 20.0 + assert account.trading_value == 2_050.0 + assert account.num_trades == 7 + assert state.orders["BTC-USDC-PERP"]["101"].state_type == "working" + + +def test_process_canonical_event_accepts_order_execution_feedback_event_with_position() -> None: + state = StrategyState(event_bus=NullEventBus()) + event = _order_execution_feedback_event( + instrument="BTC-USDC-PERP", + ts_ns_local_feedback=260, + order_id="102", + ) + + process_canonical_event( + state, + event, + position=ProcessingPosition(index=13), + ) + + assert state._last_processing_position_index == 13 + assert state.account["BTC-USDC-PERP"].num_trades == 7 + assert state.orders["BTC-USDC-PERP"]["102"].state_type == "working" + + def test_process_canonical_event_accepts_order_submitted_event() -> None: state = StrategyState(event_bus=NullEventBus()) event = _order_submitted_event( diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index 6e812d8..69b798f 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -17,6 +17,7 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderExecutionFeedbackEvent, OrderStateEvent, OrderSubmittedEvent, ) @@ -53,6 +54,12 @@ def test_canonical_stream_candidate_classification_current_slice() -> None: assert is_canonical_stream_candidate_type(FillEvent) is True assert canonical_category_for_type(FillEvent) == CanonicalEventCategory.EXECUTION + assert is_canonical_stream_candidate_type(OrderExecutionFeedbackEvent) is True + assert ( + canonical_category_for_type(OrderExecutionFeedbackEvent) + == CanonicalEventCategory.EXECUTION + ) + assert is_canonical_stream_candidate_type(OrderSubmittedEvent) is True assert ( canonical_category_for_type(OrderSubmittedEvent) diff --git a/tradingchassis_core/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py index 126233c..60fc60a 100644 --- a/tradingchassis_core/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -16,6 +16,7 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderExecutionFeedbackEvent, OrderStateEvent, OrderSubmittedEvent, ) @@ -50,6 +51,7 @@ class CanonicalEventCategory(str, Enum): MarketEvent: CanonicalEventCategory.MARKET, OrderSubmittedEvent: CanonicalEventCategory.INTENT_RELATED, FillEvent: CanonicalEventCategory.EXECUTION, + OrderExecutionFeedbackEvent: CanonicalEventCategory.EXECUTION, ControlTimeEvent: CanonicalEventCategory.CONTROL, } diff --git a/tradingchassis_core/core/domain/processing.py b/tradingchassis_core/core/domain/processing.py index 85ac603..6e2a560 100644 --- a/tradingchassis_core/core/domain/processing.py +++ b/tradingchassis_core/core/domain/processing.py @@ -29,6 +29,7 @@ ControlTimeEvent, FillEvent, MarketEvent, + OrderExecutionFeedbackEvent, OrderSubmittedEvent, ) @@ -110,6 +111,7 @@ def process_canonical_event( - ``MarketEvent`` (category: ``market``) - ``OrderSubmittedEvent`` (category: ``intent_related``) - ``FillEvent`` (category: ``execution``) + - ``OrderExecutionFeedbackEvent`` (category: ``execution``) - ``ControlTimeEvent`` (category: ``control``) ``ProcessingPosition`` is accepted as Processing Order metadata at this @@ -179,6 +181,15 @@ def process_canonical_event( state.apply_fill_event(event) return + if ( + category == CanonicalEventCategory.EXECUTION + and isinstance(event, OrderExecutionFeedbackEvent) + ): + if position is not None: + state._advance_processing_position(position) + state.apply_order_execution_feedback_event(event) + return + if ( category == CanonicalEventCategory.INTENT_RELATED and isinstance(event, OrderSubmittedEvent) diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index a4b4f11..46a9d77 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -38,6 +38,8 @@ ControlTimeEvent, FillEvent, NewOrderIntent, + OrderExecutionFeedbackEvent, + OrderExecutionFeedbackSnapshot, OrderIntent, OrderSubmittedEvent, ) @@ -859,6 +861,58 @@ def apply_fill_event(self, event: FillEvent, *, max_keep: int = 10_000) -> None: self._event_bus.emit(event) + def apply_order_execution_feedback_event( + self, + event: OrderExecutionFeedbackEvent, + ) -> None: + """Reduce canonical order/account feedback into compatibility state reducers.""" + self.update_account( + instrument=event.instrument, + position=event.position, + balance=event.balance, + fee=event.fee, + trading_volume=event.trading_volume, + trading_value=event.trading_value, + num_trades=event.num_trades, + ) + self.ingest_normalized_order_snapshots( + event.instrument, + event.order_snapshots, + ) + + def _map_snapshot_status( + self, + *, + instrument: str, + status: int, + req: int, + client_order_id: str, + ) -> str: + if status == 3: + return "filled" + if status == 4: + return "canceled" + if status == 5: + return "expired" + + if req != 0: + inflight_bucket = self.inflight.get(instrument) + inflight_info = ( + None if inflight_bucket is None else inflight_bucket.get(client_order_id) + ) + if inflight_info is not None and inflight_info.action == "new": + return "pending_new" + return "accepted" + + if status == 0: + return "accepted" + if status == 1: + return "working" + if status == 2: + return "partially_filled" + + return "rejected" + def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable[object]) -> None: """Ingest runtime order snapshots and reduce them into internal state. @@ -870,35 +924,12 @@ def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable """ def map_status(status: int, req: int, client_order_id: str) -> str: - """Best-effort mapping from runtime snapshot (status, req) to schema state. - - Design: terminal status wins. If a request marker is present (req!=0), - treat this as "in-flight". In that case, "pending_new" is used only - for in-flight NEW actions; all other in-flight actions map to "accepted". - """ - - if status == 3: - return "filled" - if status == 4: - return "canceled" - if status == 5: - return "expired" - - if req != 0: - inflight_bucket = self.inflight.get(instrument) - inflight_info = None if inflight_bucket is None else inflight_bucket.get(client_order_id) - if inflight_info is not None and inflight_info.action == "new": - return "pending_new" - return "accepted" - - if status == 0: - return "accepted" - if status == 1: - return "working" - if status == 2: - return "partially_filled" - - return "rejected" + return self._map_snapshot_status( + instrument=instrument, + status=status, + req=req, + client_order_id=str(client_order_id), + ) # Some runtimes expose custom iterators with has_next/get semantics. if hasattr(orders_snapshot_iter, "has_next") and hasattr(orders_snapshot_iter, "get"): @@ -918,11 +949,60 @@ def _next() -> object | None: for o in orders_snapshot_iter: self._ingest_one_snapshot_order(instrument, o, map_status) + def ingest_normalized_order_snapshots( + self, + instrument: str, + snapshots: Iterable[OrderExecutionFeedbackSnapshot], + ) -> None: + """Ingest normalized snapshot rows carried by canonical feedback events.""" + + def map_status(status: int, req: int, client_order_id: str) -> str: + return self._map_snapshot_status( + instrument=instrument, + status=status, + req=req, + client_order_id=str(client_order_id), + ) + + for snapshot in snapshots: + class _SnapshotRow: + __slots__ = ( + "order_id", + "order_type", + "side", + "time_in_force", + "status", + "req", + "price", + "qty", + "exec_price", + "exec_qty", + "leaves_qty", + "exch_timestamp", + "local_timestamp", + ) + + row = _SnapshotRow() + row.order_id = snapshot.order_id + row.order_type = snapshot.order_type + row.side = snapshot.side + row.time_in_force = snapshot.time_in_force + row.status = snapshot.status + row.req = snapshot.req + row.price = snapshot.price + row.qty = snapshot.qty + row.exec_price = snapshot.exec_price + row.exec_qty = snapshot.exec_qty + row.leaves_qty = snapshot.leaves_qty + row.exch_timestamp = snapshot.ts_ns_exch + row.local_timestamp = snapshot.ts_ns_local + self._ingest_one_snapshot_order(instrument, row, map_status) + def _ingest_one_snapshot_order( self, instrument: str, o: object, - map_status: Callable[[int, int, int], str], + map_status: Callable[[int, int, str], str], ) -> None: """Translate a single runtime order snapshot object into an OrderStateEvent.""" diff --git a/tradingchassis_core/core/domain/types.py b/tradingchassis_core/core/domain/types.py index a368d3e..03d578d 100644 --- a/tradingchassis_core/core/domain/types.py +++ b/tradingchassis_core/core/domain/types.py @@ -6,6 +6,8 @@ Semantic notes for this refactor slice: - ``MarketEvent`` is a canonical Market Event candidate. - ``FillEvent`` is tracked as a canonical Execution Event candidate. +- ``OrderExecutionFeedbackEvent`` is a canonical execution-feedback event + candidate used for normalized rc3 runtime feedback migration. - ``OrderStateEvent`` remains a compatibility execution-feedback / snapshot-materialization record for now. @@ -336,6 +338,50 @@ class FillEvent(BaseModel): model_config = ConfigDict(extra="forbid") +# --------------------------------------------------------------------------- +# OrderExecutionFeedbackEvent model (normalized rc3 feedback event) +# --------------------------------------------------------------------------- + + +class OrderExecutionFeedbackSnapshot(BaseModel): + order_id: str = Field(..., min_length=1) + order_type: int + side: int + time_in_force: int + status: int + req: int + + price: float + qty: float = Field(..., ge=0) + exec_price: float + exec_qty: float = Field(..., ge=0) + leaves_qty: float = Field(..., ge=0) + + ts_ns_exch: int = Field(..., gt=0) + ts_ns_local: int = Field(..., gt=0) + + model_config = ConfigDict(extra="forbid") + + +class OrderExecutionFeedbackEvent(BaseModel): + ts_ns_local_feedback: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + + position: float + balance: float + fee: float + trading_volume: float + trading_value: float + num_trades: int + + order_snapshots: tuple[OrderExecutionFeedbackSnapshot, ...] = Field( + default_factory=tuple + ) + runtime_correlation: dict[str, str | int | float | bool | None] | None = None + + model_config = ConfigDict(extra="forbid") + + # --------------------------------------------------------------------------- # OrderSubmittedEvent model (dispatch-time submitted boundary event) # --------------------------------------------------------------------------- From c736c1fc15b575df1fabcc27f3210ac0129d388b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 21:07:14 +0000 Subject: [PATCH 24/53] docs(core): document frozen CoreStep MVP baseline --- docs/README.md | 86 +- docs/control-time-and-scheduling.md | 25 + docs/control-time-event-contract-v1.md | 213 ----- docs/core-responsibility-model.md | 137 --- docs/core-runtime-responsibility-model.md | 39 + docs/core-stable-contract-v1.md | 201 ----- docs/core-step-mvp-baseline.md | 55 ++ ...onfiguration-positioned-market-contract.md | 51 -- docs/event-model.md | 25 + ...event-stream-cursor-characterization-v1.md | 168 ---- docs/gate-decision-compatibility.md | 20 + docs/order-execution-feedback-event.md | 25 + docs/order-submitted-event-contract-v1.md | 228 ----- docs/order-submitted-event.md | 23 + docs/package-rename-stage-0-decision-v1.md | 182 ---- docs/post-mvp-roadmap.md | 16 + ...bmission-lifecycle-compatibility-map-v1.md | 144 ---- ...context-event-stream-cursor-contract-v1.md | 198 ----- docs/risk-vs-execution-control.md | 26 + ...r-execution-feedback-source-contract-v1.md | 811 ------------------ .../runtime-execution-feedback-contract-v1.md | 166 ---- ...untime-to-coreconfiguration-contract-v1.md | 207 ----- ...antic-core-upgrade-milestone-closure-v1.md | 109 --- docs/venue-adapter-capability-model-v1.md | 325 ------- 24 files changed, 274 insertions(+), 3206 deletions(-) create mode 100644 docs/control-time-and-scheduling.md delete mode 100644 docs/control-time-event-contract-v1.md delete mode 100644 docs/core-responsibility-model.md create mode 100644 docs/core-runtime-responsibility-model.md delete mode 100644 docs/core-stable-contract-v1.md create mode 100644 docs/core-step-mvp-baseline.md delete mode 100644 docs/coreconfiguration-positioned-market-contract.md create mode 100644 docs/event-model.md delete mode 100644 docs/event-stream-cursor-characterization-v1.md create mode 100644 docs/gate-decision-compatibility.md create mode 100644 docs/order-execution-feedback-event.md delete mode 100644 docs/order-submitted-event-contract-v1.md create mode 100644 docs/order-submitted-event.md delete mode 100644 docs/package-rename-stage-0-decision-v1.md create mode 100644 docs/post-mvp-roadmap.md delete mode 100644 docs/post-submission-lifecycle-compatibility-map-v1.md delete mode 100644 docs/processing-context-event-stream-cursor-contract-v1.md create mode 100644 docs/risk-vs-execution-control.md delete mode 100644 docs/runtime-adapter-execution-feedback-source-contract-v1.md delete mode 100644 docs/runtime-execution-feedback-contract-v1.md delete mode 100644 docs/runtime-to-coreconfiguration-contract-v1.md delete mode 100644 docs/semantic-core-upgrade-milestone-closure-v1.md delete mode 100644 docs/venue-adapter-capability-model-v1.md diff --git a/docs/README.md b/docs/README.md index c55ee60..4baba65 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,76 +1,30 @@ -# Core Docs Contract Index v1 +# Core Docs Baseline (MVP) -This directory contains implementation-facing contracts and snapshots for `core`. +This directory documents the accepted and frozen CoreStep MVP baseline for the +`core` repository and its runtime integration boundary. -The main `docs` repository remains the semantic source of truth for architecture -and terminology. Documents in `core/docs` must not contradict main docs -semantics. +Use these docs for current architecture decisions in this phase. -## Current documents +## Read first -- **[stable]** [Core Stable Contract v1](core-stable-contract-v1.md) - Stable snapshot of currently implemented and tested `core` v1 semantic - guarantees and boundaries. +- [CoreStep MVP Baseline](core-step-mvp-baseline.md) +- [Core/Runtime Responsibility Model](core-runtime-responsibility-model.md) +- [Event Model](event-model.md) -- **[boundary]** [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) - Boundary contract for runtime-owned mapping into `CoreConfiguration` - before calling `core` canonical processing APIs; mapping is implemented in - `core-runtime`, while this page defines boundary expectations. +## Topic guides -- **[boundary/deferred]** [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) - Boundary contract freezing eligibility requirements for future canonical - runtime execution feedback emission (including `FillEvent`), while preserving - current compatibility projection behavior. +- [Control Time and Scheduling](control-time-and-scheduling.md) +- [OrderSubmittedEvent](order-submitted-event.md) +- [OrderExecutionFeedbackEvent (rc3 MVP path)](order-execution-feedback-event.md) +- [Risk vs ExecutionControl](risk-vs-execution-control.md) +- [GateDecision Compatibility](gate-decision-compatibility.md) -- **[boundary/source-contract]** [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - Source-authority boundary contract defining eligibility, authority, ordering, - and no-double-counting requirements before canonical `FillEvent` ingress. +## Scope boundaries -- **[boundary/implemented-transition]** [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - Implemented-transition boundary contract for dispatch-time canonical - order-entry semantics and coexistence constraints around `Submitted` authority. +- [Post-MVP Roadmap](post-mvp-roadmap.md) -- **[boundary/implemented-transition]** [Control-Time Event Contract v1](control-time-event-contract-v1.md) - Implemented-transition boundary contract for canonical Control-Time Event - realization semantics and coexistence constraints with compatibility wakeups. +## Status notes -- **[boundary/compatibility-map]** [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) - Docs-only authority split map freezing canonical `Submitted` entry via - `OrderSubmittedEvent` and compatibility-governed post-submission lifecycle - progression until execution-feedback source gates are satisfied. - -- **[boundary/model]** [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) - Docs-only venue-agnostic capability model defining adapter/runtime source - capability categories and semantic authority classifications without API - implementation or runtime behavior changes. - -- **[boundary/deferred-abstraction]** [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) - Docs-only boundary contract defining ownership and responsibility split for - runtime-owned `EventStreamCursor` and deferred `ProcessingContext` - abstraction work, without introducing behavior changes in this slice. - -- **[boundary/characterization]** [EventStreamCursor Characterization Note v1](event-stream-cursor-characterization-v1.md) - Read-only characterization of current runtime `EventStreamCursor` behavior - and invariants, without introducing implementation or behavior change. - -- **[milestone/closure]** [Semantic Core Upgrade Milestone Closure v1](semantic-core-upgrade-milestone-closure-v1.md) - Docs-only closure snapshot of satisfied, transitional, and deferred semantic - implementation status and current usability statements for `core` and - `core-runtime`. - -- **[planning/naming]** [Package Rename Stage 0 Decision v1](package-rename-stage-0-decision-v1.md) - Stage 0 naming decision record defining final naming targets, compatibility - strategy, and first implementation slice for the package-rename track. - -- **[historical/dev-log]** [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - Historical closure contract for positioned canonical `MarketEvent` - configuration-path and validation behavior in `core`. - -## Deferred / not implemented here - -- Runtime mapping implementation details. -- Queue/rate reducer migration and full control-time authority migration. -- FillEvent runtime ingress and source authority rollout. -- Post-submission execution feedback canonicalization. -- `OrderStateEvent` canonicalization. -- Replay/storage/`ProcessingContext` and full runtime stream integration. +- Flags for migrated paths remain default `false`. +- GateDecision remains compatibility for legacy/default-off behavior. +- This MVP is not the final architecture. diff --git a/docs/control-time-and-scheduling.md b/docs/control-time-and-scheduling.md new file mode 100644 index 0000000..135cd78 --- /dev/null +++ b/docs/control-time-and-scheduling.md @@ -0,0 +1,25 @@ +# Control Time and Scheduling + +## What `ControlSchedulingObligation` is + +`ControlSchedulingObligation` is a non-canonical Core output that requests a +future runtime wakeup boundary. It is not an event-stream input by itself. + +## Why it is non-canonical + +- It is an internal handoff from Core to Runtime. +- It does not directly reduce Core state as an event. +- Canonical status begins only when Runtime realizes the obligation and injects + a `ControlTimeEvent`. + +## What `ControlTimeEvent` is + +`ControlTimeEvent` is the canonical control re-entry event that Runtime injects +when a due obligation is realized. + +## Current MVP behavior + +- Control-time CoreStep path is behind `enable_core_step_control_time_dispatch`. +- Mixed wakeup collapse behavior is behind `enable_core_step_wakeup_collapse`. +- Runtime owns pending obligations and due-time realization. +- No periodic tick model is implied by this MVP. diff --git a/docs/control-time-event-contract-v1.md b/docs/control-time-event-contract-v1.md deleted file mode 100644 index b3924ab..0000000 --- a/docs/control-time-event-contract-v1.md +++ /dev/null @@ -1,213 +0,0 @@ -# Control-Time Event Contract v1 - ---- - -## Purpose and scope - -This document defines an implementation-facing boundary contract snapshot for -the Control-Time Event transition boundary across `core` and runtime after -initial model/taxonomy/boundary/runtime injection slices. - -This is a docs-contract reconciliation slice only: - -- it does not change runtime wakeup behavior; -- it does not modify `ExecutionControl` behavior; -- it does not modify queue/rate/inflight behavior; -- it does not introduce periodic control ticks. - ---- - -## Semantic source of truth and precedence - -`CTEC-01` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream, Processing Order, Control Events, and -Control-Time Event behavior. - -`CTEC-02` - This document is a `core` implementation boundary contract snapshot -for future Control-Time Event canonicalization boundaries. It does not redefine -architecture semantics. - -`CTEC-03` - Existing `core` implementation snapshot semantics remain governed by -[Core Stable Contract v1](core-stable-contract-v1.md). This contract records -the current implemented transition slice and what remains deferred. - -Normative semantic sources: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/queue-processing.md` -- `docs/docs/20-concepts/invariants.md` - ---- - -## Classification - -`CTEC-04` - `ControlSchedulingObligation` remains a non-canonical runtime-facing -helper in this contract snapshot. - -`CTEC-05` - `GateDecision.next_send_ts_ns_local` remains a compatibility -scheduling surface in this contract snapshot. - -`CTEC-06` - A Control-Time Event is a canonical Control Event only once Runtime -realizes a previously derived control scheduling obligation and injects the -event into the Event Stream boundary. - -`CTEC-06a` - Current implemented transition slice includes: - -- `ControlTimeEvent` model in `core` domain types; -- taxonomy mapping as canonical `CONTROL` category candidate; -- canonical boundary acceptance via `process_event_entry` / `process_canonical_event`; -- `StrategyState.apply_control_time_event` as a no-op reducer for this slice. - -`CTEC-07` - `EventBus` remains non-canonical transport/integration -infrastructure and is not a canonical Event Stream record. - -`CTEC-08` - Queued intents, inflight markers, and rate state remain -derived/internal state and are not canonical Events. - ---- - -## Runtime realization trigger - -`CTEC-09` - A canonical Control-Time Event may be emitted only when Runtime -realizes a previously derived scheduling obligation/deadline. - -`CTEC-10` - Realization is sparse and deadline-style; it is not a periodic tick -model. - -`CTEC-11` - A Control-Time Event must not be emitted merely because wall-clock -or simulation time passes without a derived obligation boundary. - -`CTEC-12` - `ExecutionControl` does not emit canonical Control-Time Events -directly in this contract snapshot. - -`CTEC-12a` - In the current HFT runtime transition slice, injection occurs only -when a scheduled deadline is realized (`next_send_ts_ns_local` is present and -runtime local time has reached/passed that deadline), and injection is ordered -after queued-intent pop and before the gate path. - ---- - -## Relationship to ControlSchedulingObligation - -`CTEC-13` - Control scheduling obligations are derived by the current -core execution-control/risk path as non-canonical runtime-facing signals. - -`CTEC-14` - A Control scheduling obligation is not Event Stream input and -produces no canonical State Transition by itself. - -`CTEC-15` - A control scheduling obligation may request/suggest a future wakeup -or deadline (for example through compatibility scheduling surfaces). - -`CTEC-16` - Runtime owns future realization of the obligation into canonical -Control-Time Event stream input. - ---- - -## Minimal future Control-Time Event shape - -`CTEC-17` - ProcessingPosition authority remains carried by -`EventStreamEntry.position`, not embedded as an inline event payload field. - -`CTEC-18` - The future Control-Time Event payload should include at least: - -- `ts_ns_local_control` -- `reason` -- `due_ts_ns_local` or `realized_ts_ns_local` (when applicable) -- optional obligation/correlation metadata - -`CTEC-19` - Control-Time Event payload must not introduce market/order/fill -semantic fields. - -`CTEC-20` - Control-Time Event payload must not encode direct queue mutation -commands/payloads. - ---- - -## ProcessingPosition policy - -`CTEC-21` - Control-Time Event acceptance ordering must use the global canonical -ProcessingPosition sequence shared with other canonical candidates, including -`MarketEvent` and `OrderSubmittedEvent`. - -`CTEC-22` - Category-local canonical counters are not allowed. - -`CTEC-23` - Processing order authority must not be timestamp-derived. - ---- - -## Reducer and processing semantics boundary - -`CTEC-24` - Future Control-Time Event processing should allow deterministic -queue/rate/inflight derived processing to run at the canonical event boundary. - -`CTEC-25` - Current implemented reducer semantics are intentionally no-op for -ControlTimeEvent in this transition slice. - -`CTEC-26` - Queue Processing remains deterministic event processing, not -independent wall-clock mutation. - ---- - -## Coexistence with current compatibility behavior - -`CTEC-27` - `next_send_ts_ns_local` remains the current compatibility -scheduling/wakeup surface during transition. - -`CTEC-28` - Existing runtime timeout/wakeup behavior remains unchanged in this -contract snapshot. - -`CTEC-29` - Future implementation must avoid dual-authority ambiguity between -compatibility wakeup surfaces and canonical Control-Time Event stream authority. - -`CTEC-30` - `GateDecision` shape remains unchanged in this contract snapshot. - -`CTEC-30a` - Current runtime uses one global canonical ProcessingPosition -counter shared by `MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent`. - ---- - -## Explicitly prohibited behavior - -`CTEC-31` - Do not classify `ControlSchedulingObligation` as canonical Event. - -`CTEC-32` - Do not emit periodic control ticks. - -`CTEC-33` - Do not use Event Time as Processing Order authority. - -`CTEC-34` - Do not mutate queue/rate state outside canonical processing in -future strict-mode canonical behavior. - -`CTEC-35` - Do not use `EventBus` as canonical Event Stream. - ---- - -## Explicitly out of scope - -`CTEC-36` - Additional ControlTimeEvent model shape expansion beyond the current -implemented contract fields. - -`CTEC-37` - Further event taxonomy semantic changes beyond current -`ControlTimeEvent` canonical `CONTROL` mapping. - -`CTEC-38` - Runtime injection generalization beyond current scheduled-deadline -realization transition behavior. - -`CTEC-39` - Queue/rate reducer migration. - -`CTEC-40` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - -`CTEC-41` - `FillEvent` ingress implementation. - -`CTEC-42` - `OrderStateEvent` canonicalization. - ---- - -## Relationship to existing core contracts - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - diff --git a/docs/core-responsibility-model.md b/docs/core-responsibility-model.md deleted file mode 100644 index 3a22c95..0000000 --- a/docs/core-responsibility-model.md +++ /dev/null @@ -1,137 +0,0 @@ -# Core / Runtime Responsibility Model - -## Core concepts - -- **Events**: inputs into the Core. -- **ProcessingPosition**: deterministic processing order. -- **State**: derived state. -- **Configuration**: explicit parameters. -- **Strategy**: produces intents. -- **Intents**: Core-internal intent to act. -- **Risk**: decides what is allowed. -- **Execution Control / Queue Processing**: controls when and how intents may be sent. -- **CoreStepResult**: deterministic outputs. -- **Runtime**: IO, venue integration, time, submission, and feedback. -- **Orders**: outside the Core; they begin in the submission/venue world. -- **ControlSchedulingObligation**: non-canonical signal meaning “Runtime, please re-enter later.” -- **ControlTimeEvent**: canonical re-entry into the Core. - ---- - -## Core definition - -The Core is a deterministic trading decision engine. - -It processes: - -```text -EventStreamEntry + State + CoreConfiguration + Strategy -```` - -and produces: - -```text -new State + CoreStepResult -``` - -In short: - -```text -Core = deterministic trading engine -Runtime = orchestration + venue IO -``` - ---- - -## Requirements for real backtest/live parity - -### 1. Core Step as the single trading decision procedure - -Eventually, the Runtime must not execute Strategy in one place and Core in another. - -There should only be one trading decision entrypoint, for example: - -```text -Core.run_step(...) -``` - -All trading-domain logic should run inside that step. - ---- - -### 2. No hidden input sources - -The Core must not secretly read from: - -* current time -* random sources -* network -* venue state -* environment -* global mutable state - -Everything must be passed explicitly as one of: - -* Event -* State -* Configuration -* Strategy - -or as another explicit deterministic dependency. - ---- - -### 3. Strategy must be deterministic - -This is important: even if the Core is deterministic, a Strategy can break determinism if it uses random values, wall-clock time, network calls, or other hidden external inputs. - -Long term, the Strategy rule should be clear: - -```text -Strategy = pure-ish function of State + Configuration/context -``` - -Or at minimum: - -```text -All external inputs must be explicit in the Event Stream or Configuration. -``` - ---- - -### 4. Runtime may execute, but not decide - -The Runtime may: - -* send orders -* wait -* poll -* map venue data -* assign processing positions -* inject events - -Long term, the Runtime must not: - -* make risk decisions -* perform domain queue popping -* evaluate strategy -* apply rate-limit policy -* interpret business scheduling - -This is exactly the boundary we are currently moving toward. - ---- - -### 5. CoreStepResult must remain venue-neutral - -The Core must not output a venue-specific order shape from any external engine. - -The Core outputs: - -```text -dispatchable_intents -``` - -The Runtime or Venue Adapter turns those intents into real orders. - -This is the Intents-vs-Orders separation. diff --git a/docs/core-runtime-responsibility-model.md b/docs/core-runtime-responsibility-model.md new file mode 100644 index 0000000..d01ca66 --- /dev/null +++ b/docs/core-runtime-responsibility-model.md @@ -0,0 +1,39 @@ +# Core and Runtime Responsibility Model + +This is the target architecture model that current MVP paths are moving toward. + +## Runtime owns + +- Receiving raw venue/backtest/live inputs. +- Normalizing raw inputs into canonical Core events. +- Allocating `ProcessingPosition` and creating `EventStreamEntry`. +- Calling `CoreStep` / `CoreWakeupStep` APIs. +- Holding and realizing `ControlSchedulingObligation`. +- Injecting `ControlTimeEvent` when due. +- Dispatching only `CoreStepResult.dispatchable_intents` for migrated paths. +- Emitting `OrderSubmittedEvent` only after successful external `NEW` dispatch. +- Venue/runtime I/O and execution error observability. + +## Core owns + +- Consuming canonical events only. +- Deterministic state reduction. +- Strategy evaluation via the CoreStep evaluator path. +- Combining generated and queued intent candidates. +- Intent dominance/reconciliation. +- Policy-only risk decisions. +- Execution control decisions: + - queue behavior + - rate behavior + - inflight/sendability behavior + - scheduling obligation derivation +- Returning `CoreStepResult`. + +## Runtime must not own (migrated paths) + +- Productive strategy execution outside CoreStep. +- Productive runtime `risk.decide_intents` for migrated paths. +- Intent dominance decisions. +- Fachlich queue pop/merge business semantics. +- Venue-independent business semantics that belong to Core. +- GateDecision as final architecture. diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md deleted file mode 100644 index 78e4270..0000000 --- a/docs/core-stable-contract-v1.md +++ /dev/null @@ -1,201 +0,0 @@ -# Core Stable Contract v1 - ---- - -## Purpose and scope - -This page freezes the currently implemented and tested semantic kernel of `core` as a **stable implementation contract snapshot (v1)**. - -Repository boundary: - -- Semantic definitions (Event, Event Stream, Processing Order, Configuration, State, Intent, Order, etc.) live in the main `docs` repository and remain the semantic source of truth. -- This page is **implementation-facing** documentation for `core` and only claims what is currently implemented and tested in `core` v1. - -This page is intentionally narrow: - -- it documents what `core` v1 currently guarantees; -- it distinguishes implemented guarantees from deferred architecture concepts; -- it does not introduce new behavior. - -Historical provenance for the positioned market configuration closure is recorded in: - -- [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - ---- - -## Normative sources and precedence - -`CSC-01` — Terminology and architecture concepts in the main `docs` repository remain the semantic source of truth. - -`CSC-02` — This page defines the **implementation snapshot contract** for current `core` v1. If architecture/concept docs describe broader target semantics not yet implemented in `core`, this page controls claims about current `core` behavior. - -`CSC-03` — Dev logs remain historical decision trails and are not the stable contract surface. - ---- - -## Canonical boundary APIs (v1) - -`CSC-04` — `core` v1 currently guarantees a minimal canonical processing boundary through: - -- `process_canonical_event` -- `process_event_entry` -- `fold_event_stream_entries` - -`CSC-05` — These APIs define the currently stabilized canonical ingestion/fold surface in `core` v1. They are not a full Event Stream runtime, storage, or replay orchestration API. - ---- - -## Canonical event candidate set (v1) - -`CSC-06` — `core` v1 currently guarantees the canonical event candidate set: - -- `MarketEvent` (market category candidate) -- `OrderSubmittedEvent` (intent-related category candidate) -- `FillEvent` (execution category candidate) -- `ControlTimeEvent` (control category candidate) - -`CSC-07` — Current canonical runtime wiring in this snapshot processes positioned canonical `MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent` through the canonical boundary. `ControlTimeEvent` runtime injection is currently realized only for scheduled-deadline wakeup realization and remains a transition slice (no queue/rate/control reducer migration implied). `FillEvent` remains a canonical execution candidate in `core`, while runtime `FillEvent` ingress remains deferred per [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md). - ---- - -## Non-canonical artifacts (v1) - -`CSC-08` — `OrderStateEvent` remains compatibility-only and is non-canonical at the canonical boundary. - -`CSC-09` — `DerivedFillEvent` remains a compatibility projection artifact and is non-canonical. - -`CSC-10` — Telemetry/observability records remain non-canonical, including: - -- `RiskDecisionEvent` -- `DerivedPnLEvent` -- `ExposureDerivedEvent` -- `OrderStateTransitionEvent` - -`CSC-11` — `GateDecision` remains compatibility/non-canonical. - -`CSC-12` — `ControlSchedulingObligation` remains a non-canonical runtime-facing helper, not a canonical Event. - -`CSC-13` — `EventBus` remains transport/integration infrastructure, not a canonical Event Stream record. - ---- - -## ProcessingPosition and Processing Order guarantees - -`CSC-14` — `ProcessingPosition` is the explicit boundary metadata for positioned canonical processing in `core` v1. - -`CSC-15` — For positioned canonical processing, position indexes are strictly monotonic; repeated or regressing indexes fail. - -`CSC-16` — Within the `core` package canonical boundary, processing position cursor advancement is boundary-owned behavior and remains guarded against out-of-boundary mutation patterns by current `core` semantics coverage. This clause does not claim repo-wide enforcement outside `core`. - -`CSC-17` — Positioned boundary acceptance order follows `ProcessingPosition` monotonicity, not event timestamp ordering. - ---- - -## EventStreamEntry contract - -`CSC-18` — `EventStreamEntry` v1 contract shape is: - -- `position` -- `event` - -`CSC-19` — `EventStreamEntry` contains no `configuration` field. - -`CSC-20` — Configuration remains call-level processing input, not entry-level payload shape. - ---- - -## CoreConfiguration contract - -`CSC-21` — `CoreConfiguration` v1 currently guarantees: - -- explicit `version`; -- explicit `payload`; -- stable derived `fingerprint`. - -`CSC-22` — Equivalent semantic payloads and version yield stable identity/fingerprint behavior; identity remains stable against source-payload mutation after construction. - -`CSC-23` — Canonical processing entry/fold APIs accept configuration as explicit call-level input (`CoreConfiguration | None`) and reject non-`CoreConfiguration` objects. - ---- - -## Positioned MarketEvent metadata contract - -`CSC-24` — For positioned canonical `MarketEvent` processing, `core` v1 consumes instrument metadata from: - -- `payload.market.instruments..tick_size` -- `payload.market.instruments..lot_size` -- `payload.market.instruments..contract_size` - -`CSC-25` — Positioned canonical market processing is explicit-or-fail for missing/invalid required configuration path or values. - -`CSC-26` — Positioned canonical market path has no implicit defaults for these required fields. - ---- - -## Fold and minimal replay contract (v1) - -`CSC-27` — `fold_event_stream_entries` is a deterministic fold utility over caller-provided ordered `EventStreamEntry` values. - -`CSC-28` — `fold_event_stream_entries` in `core` v1 is not a full replay engine, not Event Stream storage, and not runtime orchestration. - ---- - -## Compatibility boundaries preserved - -`CSC-29` — Unpositioned canonical market compatibility path remains preserved. - -`CSC-30` — Direct `StrategyState.update_market(...)` compatibility path remains preserved. - -`CSC-31` — `FillEvent` behavior remains preserved (including existing idempotence/no-op characteristics). - -`CSC-32` — `OrderStateEvent` compatibility reducer path remains preserved and non-canonical at canonical boundary. - ---- - -## Explicitly out of scope for core stable contract v1 - -`CSC-33` — Runtime/backtest-to-`CoreConfiguration` mapping implementation. - -`CSC-34` — Full Control-Time authority migration, including queue/rate reducer migration and broader runtime realization generalization beyond the current transition slice. - -`CSC-35` — Introduction of new canonical event categories or canonicalization of currently non-canonical artifacts. - -`CSC-36` — Event Stream storage layer. - -`CSC-37` — Full replay engine/runtime integration. - -`CSC-38` — `ProcessingContext` introduction and broader replay/storage-oriented -`EventStreamCursor` extraction in `core` scope. (A runtime-only -`EventStreamCursor` ordering helper exists in `core-runtime` and is outside -this `core` stable-contract scope.) - ---- - -## Change rubric - -`CSC-39` — **Breaking change** (v1 contract): any change that alters guaranteed behavior or contract shape in `CSC-04` through `CSC-38` (including canonical/non-canonical classification shifts, positioned market config semantics changes, cursor monotonicity behavior changes, or compatibility boundary behavior changes). - -`CSC-40` — **Additive change** (v1-compatible): new capability that does not alter existing guarantees and does not reinterpret current clause semantics. - -`CSC-41` — **Docs-only clarification**: wording refinement that improves precision without changing contract meaning or introducing new semantics. - ---- - -## Traceability matrix to existing semantics tests - -| Clause(s) | Contract statement (summary) | Existing semantics test anchors | -| --------- | ---------------------------- | ------------------------------- | -| `CSC-04`, `CSC-05` | Canonical boundary API surface and minimal scope | `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py` | -| `CSC-06`, `CSC-07` | Canonical candidate set is MarketEvent + OrderSubmittedEvent + FillEvent + ControlTimeEvent; current runtime wiring path includes MarketEvent + OrderSubmittedEvent + ControlTimeEvent while FillEvent ingress remains deferred | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py` | -| `CSC-08` to `CSC-13` | Non-canonical classifications (compatibility/telemetry/control helper/transport) | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-14` to `CSC-17` | ProcessingPosition monotonic positioned boundary and cursor guarantees | `core/tests/semantics/models/test_canonical_processing_boundary.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py`, `core/tests/semantics/models/test_processing_position_cursor_ownership_guard.py` | -| `CSC-18` to `CSC-20` | EventStreamEntry shape and call-level configuration boundary | `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-21` to `CSC-23` | CoreConfiguration identity and call-level typing contract | `core/tests/semantics/models/test_core_configuration_contract.py`, `core/tests/semantics/models/test_fold_event_stream_entries_contract.py`, `core/tests/semantics/models/test_event_stream_entry_contract.py` | -| `CSC-24` to `CSC-26` | Positioned market metadata path and explicit-or-fail semantics | `core/tests/semantics/models/test_market_configuration_positioned_contract.py` | -| `CSC-27`, `CSC-28` | Deterministic fold minimal contract; not full replay/runtime/storage | `core/tests/semantics/models/test_fold_event_stream_entries_contract.py` | -| `CSC-29` to `CSC-32` | Compatibility boundaries preserved | `core/tests/semantics/models/test_market_configuration_positioned_contract.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py` | - -Notes: - -- This matrix maps stable contract clauses to existing semantics coverage; it does not claim architecture-complete implementation. -- Deferred architecture concepts remain governed by their concept/architecture docs and are out of scope for this v1 implementation snapshot. diff --git a/docs/core-step-mvp-baseline.md b/docs/core-step-mvp-baseline.md new file mode 100644 index 0000000..471c5a2 --- /dev/null +++ b/docs/core-step-mvp-baseline.md @@ -0,0 +1,55 @@ +# CoreStep MVP Baseline + +This document records the accepted and frozen CoreStep MVP baseline. + +## Accepted baseline (current behavior) + +- MarketEvent CoreStep path exists behind `enable_core_step_market_dispatch`. +- ControlTimeEvent CoreStep path exists behind + `enable_core_step_control_time_dispatch`. +- Mixed wakeup collapse exists behind `enable_core_step_wakeup_collapse`. +- rc `== 3` order/execution feedback CoreStep path exists behind + `enable_core_step_order_feedback_dispatch`. +- Runtime dispatches `CoreStepResult.dispatchable_intents` on migrated + flag-on paths. +- Runtime does not productively use runtime `risk.decide_intents` or + GateDecision for migrated flag-on paths. +- Runtime emits `OrderSubmittedEvent` only after successful external `NEW` + dispatch. +- `OrderSubmittedEvent` remains ordered before `mark_intent_sent`. +- `ControlSchedulingObligation` remains non-canonical Core output. +- Runtime owns pending `ControlSchedulingObligation` and injects + `ControlTimeEvent` when due. +- `GateDecision` remains temporary compatibility for legacy/default-off paths. +- All migrated flags remain default `false`. + +## Core API/model surface used by MVP + +Core result and decision models: + +- `CoreStepResult` +- `CoreStepDecision` +- `PolicyRiskDecision` +- `ExecutionControlDecision` +- `CandidateIntentOrigin` +- `CandidateIntentRecord` +- `CoreWakeupReductionResult` + +Core orchestration and helper APIs: + +- `run_core_step` +- `run_core_wakeup_reduction` +- `run_core_wakeup_decision` +- `run_core_wakeup_step` +- `apply_policy_to_candidate_records` +- `plan_execution_control_candidates` +- `apply_execution_control_plan` +- `combine_candidate_intent_records` + +## Important boundaries in this MVP + +- Runtime dispatches from `dispatchable_intents` for migrated flag-on paths. +- Runtime-owned risk/gate logic is compatibility-only for those migrated paths. +- No full order lifecycle model is part of this MVP. +- Legacy rc3 path remains available when + `enable_core_step_order_feedback_dispatch` is `false`. diff --git a/docs/coreconfiguration-positioned-market-contract.md b/docs/coreconfiguration-positioned-market-contract.md deleted file mode 100644 index 58dca5b..0000000 --- a/docs/coreconfiguration-positioned-market-contract.md +++ /dev/null @@ -1,51 +0,0 @@ -# CoreConfiguration to Positioned Market Contract - ---- - -## Context - -Introduced strict `CoreConfiguration` consumption for the positioned canonical `MarketEvent` reduction path in `core`. - -This note freezes that behavior as an explicit closure contract. - ---- - -## Contract (Core-facing) - -For **positioned canonical** `MarketEvent` processing in `core`: - -1. `core` consumes deterministic semantic configuration **only** through `CoreConfiguration`. -2. Required payload path: - - `CoreConfiguration.payload["market"]["instruments"][instrument]` - -3. Required instrument fields: - - `tick_size` - - `lot_size` - - `contract_size` -4. Semantics are **explicit-or-fail**: - - missing `CoreConfiguration` fails; - - missing `market`/`instruments`/`instrument` path fails; - - missing required fields fails; - - invalid values (`None`, `bool`, non-numeric, non-finite, non-positive) fail. -5. Positioned canonical path has **no implicit defaults** for these fields. - ---- - -## Boundary and Compatibility Guarantees - -1. Validation for positioned canonical `MarketEvent` happens before: - - `ProcessingPosition` cursor advancement, and - - `MarketState` mutation. -2. **Unpositioned** canonical `MarketEvent` compatibility path remains unchanged. -3. Direct `StrategyState.update_market(...)` compatibility path remains unchanged. -4. `FillEvent` behavior remains unchanged. -5. `OrderStateEvent` remains compatibility-only (non-canonical at canonical boundary). - ---- - -## Runtime Boundary - -1. This contract does **not** introduce runtime/backtest JSON mapping in `core`. -2. Mapping from runtime/backtest config to `CoreConfiguration` is a **runtime responsibility**. -3. No `core-runtime` behavior or interfaces are changed by this contract note. diff --git a/docs/event-model.md b/docs/event-model.md new file mode 100644 index 0000000..90e09d3 --- /dev/null +++ b/docs/event-model.md @@ -0,0 +1,25 @@ +# Event Model (Current MVP) + +## Canonical events today + +- `MarketEvent` is canonical. +- `ControlTimeEvent` is canonical only after Runtime realizes a due + `ControlSchedulingObligation` and injects the event. +- `OrderSubmittedEvent` is canonical and emitted by Runtime after successful + external `NEW` dispatch. +- `OrderExecutionFeedbackEvent` is canonical MVP ingress event for normalized + rc3 order/execution/account feedback. +- `FillEvent` is canonical in model terms, but is not used for snapshot-only rc3 + MVP ingress. + +## Compatibility/non-canonical today + +- `OrderStateEvent` remains compatibility/non-canonical. +- `ControlSchedulingObligation` is non-canonical output from Core. +- `GateDecision` is compatibility for legacy/default-off paths. + +## Processing and ordering notes + +- Runtime owns canonical event construction and injection timing. +- Canonical ingestion order is defined by runtime-assigned `ProcessingPosition`. +- Runtime dispatches `CoreStepResult.dispatchable_intents` for migrated paths. diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md deleted file mode 100644 index dcbb451..0000000 --- a/docs/event-stream-cursor-characterization-v1.md +++ /dev/null @@ -1,168 +0,0 @@ -# EventStreamCursor Characterization Note v1 - ---- - -## Purpose and scope - -This note characterizes the **current runtime EventStreamCursor behavior** used -for canonical `ProcessingPosition` assignment and records invariants for future -runtime extraction/refinement work. - -This is read-only characterization/planning documentation: - -- it does not introduce new `EventStreamCursor` behavior; -- it does not implement `ProcessingContext`; -- it does not change runtime behavior; -- it does not change reducers or event taxonomy; -- it does not implement canonical `FillEvent` ingress; -- it does not add adapter APIs; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not implement replay/storage/EventStream persistence. - -`ESCC-01` - Main `docs` remains the semantic source of truth for Event Stream and -Processing Order semantics. - -`ESCC-02` - This note is implementation-facing characterization only and does not -redefine existing contracts. - -`ESCC-03` - This note must remain consistent with: - -- [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) - ---- - -## Current runtime cursor behavior (characterized) - -Current behavior is implemented in -`core-runtime/core_runtime/backtest/engine/strategy_runner.py`. - -`ESCC-04` - Runtime runner owns an `EventStreamCursor` instance. - -`ESCC-05` - Cursor starts at index `0`. - -`ESCC-06` - `_process_canonical_event(...)` calls -`EventStreamCursor.attempt_position()` and constructs `EventStreamEntry` -with the returned `ProcessingPosition`. - -`ESCC-07` - Runner calls `process_event_entry(state, entry, configuration=core_cfg)` -for canonical boundary processing. - -`ESCC-08` - Cursor advances by exactly `+1` only after successful -`process_event_entry(...)` return via `commit_success(...)`. - -`ESCC-09` - If canonical boundary processing raises, cursor does not advance. - -`ESCC-10` - One global cursor sequence is shared by currently wired canonical -categories: - -- `MarketEvent` -- `OrderSubmittedEvent` -- `ControlTimeEvent` - -`ESCC-11` - Runtime canonical `FillEvent` ingress remains absent/deferred in the -current runner path. - -`ESCC-12` - Compatibility `rc == 3` snapshot branch -(`update_account` / `ingest_order_snapshots`) bypasses canonical -`EventStreamEntry` construction and does not define position-allocation authority. - -`ESCC-13` - Current ordering authority for canonical boundary acceptance remains -`ProcessingPosition`, not timestamp-derived ordering. - ---- - -## Invariants to preserve for extraction - -`ESCC-14` - First canonical event in a stream scope uses index `0`. - -`ESCC-15` - Position progression is monotone, global, and stepwise (`+1`) after -each successful canonical boundary processing call. - -`ESCC-16` - Failed canonical processing must not consume/advance position. - -`ESCC-17` - Cursor scope remains global across canonical categories; no -category-local counters. - -`ESCC-18` - Compatibility snapshot path (`rc == 3`) remains non-canonical and -does not allocate canonical positions in this phase. - -`ESCC-19` - `EventStreamEntry` remains minimal (`position`, `event`) and -config-free. - -`ESCC-20` - `CoreConfiguration` remains call-level processing input. - -`ESCC-21` - Core remains canonical boundary consumer/validator and is not runtime -position allocator. - ---- - -## Future EventStreamCursor extraction semantics (non-implemented) - -`ESCC-22` - Any future `EventStreamCursor` work remains runtime-owned and -ordering-only. - -`ESCC-23` - Recommended extraction model is reservation/commit semantics: - -- `attempt_position() -> position` -- `commit_success(position)` - -`ESCC-24` - Commit occurs only after successful `process_event_entry(...)` -completion. - -`ESCC-25` - No rollback-after-commit behavior is implied in this slice. - -`ESCC-26` - No reset/fork semantics within one canonical stream scope. - -`ESCC-27` - No category-local sequencing semantics. - -`ESCC-28` - No replay/storage/EventStream persistence semantics are implied by -cursor extraction characterization. - ---- - -## Characterization test anchors - -Existing tests that already anchor current behavior: - -- Shared global counter across canonical categories: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_global_canonical_counter_shared_between_market_and_order_submitted` - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_global_canonical_counter_shared_with_control_time_market_and_submitted` -- No advance on failed canonical processing: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_canonical_counter_increments_only_after_successful_canonical_processing` -- Compatibility `rc == 3` snapshot branch remains unchanged: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_order_snapshot_branch_keeps_compatibility_path` - - `core-runtime` runtime execution-feedback probe test for `rc == 3` snapshot branching -- Configuration passed to `process_event_entry(...)`: - - `core-runtime/tests/runtime/test_strategy_runner_canonical_market_adoption.py::test_process_market_event_routes_through_event_entry_with_core_configuration` - -Coverage notes / potential direct-test gaps: - -`ESCC-29` - Existing tests strongly imply first-position-at-zero behavior, but no -dedicated runner test is named solely for that invariant. - -`ESCC-30` - Existing compatibility snapshot branch tests assert path usage, but no -dedicated assertion currently checks that runner cursor remains unchanged during -`rc == 3` processing alone. - ---- - -## Out of scope - -`ESCC-31` - Additional `EventStreamCursor` feature expansion beyond the current -runtime ordering helper behavior characterized here. - -`ESCC-32` - `ProcessingContext` implementation. - -`ESCC-33` - Adapter interface/API design. - -`ESCC-34` - Runtime canonical `FillEvent` ingress. - -`ESCC-35` - Lifecycle migration away from compatibility snapshot authority. - -`ESCC-36` - Replay/storage/EventStream persistence implementation. - ---- diff --git a/docs/gate-decision-compatibility.md b/docs/gate-decision-compatibility.md new file mode 100644 index 0000000..bc16d81 --- /dev/null +++ b/docs/gate-decision-compatibility.md @@ -0,0 +1,20 @@ +# GateDecision Compatibility Status + +## Current role + +`GateDecision` is temporary compatibility for legacy/default-off paths. + +## What remains valid today + +- `CoreStepResult.compat_gate_decision` can exist as bridge data. +- Legacy/default-off paths may still rely on GateDecision behavior. + +## Migrated path rule + +For migrated flag-on paths, Runtime dispatches from +`CoreStepResult.dispatchable_intents`, not from `GateDecision.accepted_now`. + +## Architectural status + +- GateDecision is not the final architecture. +- GateDecision removal is post-MVP work. diff --git a/docs/order-execution-feedback-event.md b/docs/order-execution-feedback-event.md new file mode 100644 index 0000000..da4daa5 --- /dev/null +++ b/docs/order-execution-feedback-event.md @@ -0,0 +1,25 @@ +# OrderExecutionFeedbackEvent (rc3 MVP) + +## Scope + +This page describes the MVP rc3 feedback path, not a full lifecycle redesign. + +## Current MVP flow (flag on) + +- Runtime reads raw rc3 feedback/snapshot input as adapter input. +- Runtime normalizes that input into `OrderExecutionFeedbackEvent`. +- Runtime calls `run_core_step`. +- Core reduces the canonical feedback event and evaluates strategy through the + CoreStep evaluator bridge. +- Runtime dispatches `CoreStepResult.dispatchable_intents`. + +## Flag and compatibility behavior + +- Migrated path flag: + `enable_core_step_order_feedback_dispatch`. +- When flag is `false`, legacy rc3 path remains available. + +## Explicit non-claims + +- No full order lifecycle model is introduced by this MVP. +- Do not treat snapshot-only rc3 ingress as `FillEvent` ingress. diff --git a/docs/order-submitted-event-contract-v1.md b/docs/order-submitted-event-contract-v1.md deleted file mode 100644 index c136f8c..0000000 --- a/docs/order-submitted-event-contract-v1.md +++ /dev/null @@ -1,228 +0,0 @@ -# OrderSubmittedEvent / Dispatch Boundary Contract v1 - ---- - -## Purpose and scope - -This document defines an implementation-facing boundary contract snapshot for the -dispatch-time canonical order-entry record `OrderSubmittedEvent` after initial -runtime wiring. - -This is a docs-contract reconciliation slice only: - -- it does not change runtime behavior; -- it does not change snapshot compatibility reducers; -- it does not canonicalize `OrderStateEvent`; -- it does not introduce `FillEvent` ingress; -- it does not change `mark_intent_sent`, `RiskEngine`, or Execution Control behavior. - ---- - -## Semantic source of truth and precedence - -`OSEC-01` - Main `docs` remains the semantic source of truth for Event semantics, -Intent pipeline semantics, Order lifecycle semantics, Event Stream, and -Processing Order. - -`OSEC-02` - This document is a `core` implementation boundary contract snapshot -for the dispatch-time Submitted boundary. It does not redefine architecture -semantics. - -`OSEC-03` - Existing `core` implementation snapshot semantics remain governed by -[Core Stable Contract v1](core-stable-contract-v1.md). This contract records the -implemented Submitted-boundary slice and its transition constraints; it does not -claim full order/execution lifecycle canonicalization. - -Normative semantic sources: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/10-architecture/intent-pipeline.md` -- `docs/docs/20-concepts/intent-lifecycle.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` - ---- - -## Classification - -`OSEC-04` - `OrderSubmittedEvent` is classified as a canonical -**Intent-related Event**. - -`OSEC-05` - `OrderSubmittedEvent` is not an Execution Event in this contract. - -`OSEC-06` - Rationale: - -- Execution Events represent venue/simulated-venue execution feedback records. -- The Submitted boundary record captures a dispatch/submission pipeline outcome - from infrastructure processing. -- Therefore the semantic class is Intent-related Event, not Execution Event. - -`OSEC-07` - This classification is implemented in current `core` v1 candidate -taxonomy and canonical processing boundary behavior. - ---- - -## Creation trigger - -`OSEC-08` - `OrderSubmittedEvent` is created only after successful outbound -transmission/dispatch of a `new` intent. - -`OSEC-09` - In current runtime-oriented terms, the dispatch-success boundary is: - -1. intent was accepted for immediate send; -2. outbound `execution.apply_intents(...)` did not fail for the order key; -3. dispatch success boundary is reached for that outbound new-order send. - -`OSEC-10` - Failed venue/runtime submission creates no `OrderSubmittedEvent`. - -`OSEC-11` - Replace/cancel dispatches do not create a new -`OrderSubmittedEvent`. - ---- - -## Required field contract (v1, implemented boundary shape) - -`OSEC-12` - Required canonical boundary fields in this implemented slice: - -- `ts_ns_local_dispatch` -- `instrument` -- `client_order_id` -- `side` -- `order_type` -- `intended_price` -- `intended_qty` -- `time_in_force` - -Canonical ProcessingPosition authority is carried by `EventStreamEntry.position` -at canonical ingestion (`process_event_entry` / `process_canonical_event`), not -as an inline `OrderSubmittedEvent` model field in this slice. - -`OSEC-13` - Optional/correlation fields when available: - -- `intent_correlation_id` -- `dispatch_attempt_id` (if introduced in a future runtime boundary) -- venue/runtime correlation metadata - -`OSEC-14` - Optional/correlation fields are not canonical identity authority in -this contract. - ---- - -## Identity and correlation contract - -`OSEC-15` - Canonical order key for this v1 boundary is -`(instrument, client_order_id)`. - -`OSEC-16` - `client_order_id` is the stable dispatch/order correlation key in -this slice. - -`OSEC-17` - Venue/runtime IDs remain correlation metadata only for this slice. - -`OSEC-18` - Replace/cancel intents target an existing order key and do not -restart lifecycle from `Submitted`. - ---- - -## Projection and coexistence behavior (transitional) - -`OSEC-19` - `OrderSubmittedEvent` is the canonical authority for entering -`Submitted` in the current implemented boundary slice. - -`OSEC-20` - `CanonicalOrderProjection` is created/preserved at `submitted` from -the `OrderSubmittedEvent` reducer path in the current implemented slice. - -`OSEC-21` - `mark_intent_sent` remains compatibility/execution-control -bookkeeping during transition. - -`OSEC-22` - In current HFT runtime wiring, `OrderSubmittedEvent` processing is -performed before `mark_intent_sent` for successful `new` dispatches. Failed -`new` dispatches produce no `OrderSubmittedEvent`, and replace/cancel dispatches -produce no `OrderSubmittedEvent`. - -`OSEC-23` - Transitional coexistence requirement: `mark_intent_sent`-based -submitted sidecar seeding must be treated as idempotent/mirrored behavior under -future coexistence with `OrderSubmittedEvent`. - -`OSEC-24` - This contract introduces no post-submission transition authority. -Post-submission canonical authority remains deferred pending explicit canonical -execution-feedback source. - ---- - -## ProcessingPosition policy - -`OSEC-25` - Canonical acceptance order uses one global canonical position -counter across canonical event categories. - -`OSEC-26` - Category-local canonical counters are not allowed. - -`OSEC-27` - Position must not be derived from timestamps. - -`OSEC-28` - Ordering semantics must be coherent relative to canonical -`MarketEvent` and future canonical execution-feedback records. - ---- - -## Compatibility boundaries preserved - -`OSEC-29` - `OrderStateEvent` remains non-canonical. - -`OSEC-30` - `ingest_order_snapshots` behavior remains unchanged. - -`OSEC-31` - `DerivedFillEvent` remains compatibility projection behavior. - -`OSEC-32` - `FillEvent` ingress remains deferred. - -`OSEC-33` - Snapshot reducer behavior remains unchanged; no rewrite is introduced -by this contract. - -`OSEC-34` - This docs slice introduces no runtime behavior change. - ---- - -## No-double-authority rules - -`OSEC-35` - Submitted entry authority belongs to `OrderSubmittedEvent` in this -implemented slice. - -`OSEC-36` - Compatibility snapshots may mirror/advance sidecar projections only -under transitional compatibility rules; they are not canonical Submitted -authority. - -`OSEC-37` - Post-submission transitions remain deferred until explicit canonical -execution-feedback sources are defined and contracted. - -`OSEC-38` - Snapshot materialization must not become canonical Submitted -authority in this phase. - ---- - -## Explicitly out of scope - -`OSEC-39` - Changing `OrderSubmittedEvent` model shape beyond current implemented -contract fields. - -`OSEC-40` - Event taxonomy semantic reclassification beyond current implemented -`intent_related` status. - -`OSEC-41` - Runtime dispatch behavior expansion beyond current successful `new` -dispatch emission semantics. - -`OSEC-42` - `FillEvent` ingress implementation. - -`OSEC-43` - `OrderStateEvent` canonicalization. - -`OSEC-44` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - -`OSEC-45` - Broad order lifecycle migration or snapshot reducer migration. - ---- - -## Relationship to existing core contracts - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) - diff --git a/docs/order-submitted-event.md b/docs/order-submitted-event.md new file mode 100644 index 0000000..291d7ab --- /dev/null +++ b/docs/order-submitted-event.md @@ -0,0 +1,23 @@ +# OrderSubmittedEvent + +## Emission rule + +Runtime emits `OrderSubmittedEvent` only after a successful external `NEW` +dispatch. + +## Non-emission cases + +- Failed external `NEW` dispatch -> no `OrderSubmittedEvent`. +- Non-`NEW` commands (for example replace/cancel) -> no new + `OrderSubmittedEvent`. + +## Ordering rule in current MVP + +`OrderSubmittedEvent` is processed before `mark_intent_sent` on successful +`NEW` dispatch handling. + +## Architectural meaning + +- `OrderSubmittedEvent` is canonical. +- It represents canonical order-entry confirmation at dispatch success boundary. +- It does not claim full post-submission lifecycle canonicalization. diff --git a/docs/package-rename-stage-0-decision-v1.md b/docs/package-rename-stage-0-decision-v1.md deleted file mode 100644 index 94b5c8c..0000000 --- a/docs/package-rename-stage-0-decision-v1.md +++ /dev/null @@ -1,182 +0,0 @@ -# Package Rename Stage 0 Decision v1 - ---- - -## Purpose and scope - -This document records the Stage 0 naming decision for the package-rename track -across `core` and `core-runtime`. - -This is a planning record only. - -This page: - -- does not change production code; -- does not change tests; -- does not rename packages/directories in implementation yet; -- does not change imports, pyproject metadata, or JSON configs yet; -- does not change runtime behavior, adapters, reducers, or event taxonomy; -- does not implement deferred semantic items (`FillEvent` ingress, - `ExecutionFeedbackRecordSource`, `ProcessingContext`, replay/storage). - ---- - -## Inputs used - -- Current core distribution: `trading-framework` -- Current core import root: `trading_framework` -- Current core semantic subtree: `trading_framework.core` -- Current runtime distribution: `trading-runtime` -- Current runtime import root: `trading_runtime` -- Desired direction: align names with Core / Core Runtime terminology -- Critical design gate: top-level `core` import can create `core.core.*` unless - the current inner `core` package is also renamed/flattened. - ---- - -## Final naming targets (decision) - -### Core repository (`core`) - -- **Repository/folder display name:** keep `core` (already aligned) -- **Python import root target:** `tradingchassis_core` -- **Distribution/project name target:** `tradingchassis-core` -- **Internal package layout target:** keep current structure shape during rename - slice (`tradingchassis_core/core/...`) to avoid semantic/mechanical coupling -- **`core.core.*` decision:** avoid -- **Flatten `trading_framework.core.*` into `core.*` now?:** no (explicitly - deferred; would be a separate structural refactor) - -### Core Runtime repository (`core-runtime`) - -- **Repository/folder display name:** keep `core-runtime` (already aligned) -- **Python import root target:** `core_runtime` -- **Distribution/project name target:** `tradingchassis-core-runtime` -- **Internal package layout target:** keep current structure shape during rename - slice (`core_runtime/...`, same module topology as today) -- **`trading_runtime.*` to `core_runtime.*`:** yes - ---- - -## Candidate option comparison - -### A) Import root `core`, flattened layout, distribution `core` or `tradingchassis-core` - -- **Readability:** high if fully flattened -- **Collision risk:** medium/high (`core` is generic) -- **PyPI realism:** `core` is weak/high-conflict; `tradingchassis-core` is good -- **Import churn:** very high (import root + subtree flatten) -- **`class_path` churn:** medium (runtime still changes) -- **Docs alignment:** good -- **Maintainability:** potentially good long-term, but high migration risk now -- **Nested structure simplification:** yes - -### B) Import root `core`, accept `core.core.*` - -- **Readability:** low (`core.core.*` duplication) -- **Collision risk:** high (`core`) -- **PyPI realism:** weak if distribution is `core` -- **Import churn:** medium -- **`class_path` churn:** medium -- **Docs alignment:** partial -- **Maintainability:** poor naming ergonomics -- **Nested structure simplification:** no - -### C) Import root `tradingchassis_core`, distribution `tradingchassis-core` - -- **Readability:** good and explicit -- **Collision risk:** low -- **PyPI realism:** good -- **Import churn:** medium (mechanical, bounded) -- **`class_path` churn:** medium (runtime rename still required) -- **Docs alignment:** good (docs can still refer to Core conceptually) -- **Maintainability:** high (globally unique import root) -- **Nested structure simplification:** no immediate flatten; deferred - -### D) Hybrid: docs/repo names updated, imports remain `trading_framework` - -- **Readability:** mixed (conceptual and technical names diverge) -- **Collision risk:** low -- **PyPI realism:** unchanged -- **Import churn:** none now -- **`class_path` churn:** none now -- **Docs alignment:** partial -- **Maintainability:** medium/low (long transitional mismatch) -- **Nested structure simplification:** no - -### E) Compatibility aliases first before final rename - -- **Readability:** transitional complexity -- **Collision risk:** low if final names are unique -- **PyPI realism:** depends on chosen final names (good with option C targets) -- **Import churn:** staged, lower immediate blast radius -- **`class_path` churn:** staged with deprecation window -- **Docs alignment:** good if clearly documented -- **Maintainability:** good when time-boxed; poor if indefinite -- **Nested structure simplification:** not by itself - ---- - -## Recommended final target - -Adopt **Option C as final naming target** plus a **time-boxed Option E -compatibility phase** for migration safety. - -Rationale: - -1. Avoids the `core.core.*` naming trap without forcing an inner package - flatten/rename in the same slice. -2. Uses unique, realistic distribution names (`tradingchassis-*`) and avoids - generic package-name collision risk. -3. Preserves semantics and structure for a behavior-preserving mechanical rename. -4. Keeps room for a future separate structural simplification decision after the - rename has stabilized. - ---- - -## Explicit import and class_path mapping targets - -- `trading_framework.core.domain.types` -> - `tradingchassis_core.core.domain.types` -- `trading_framework.core.domain.processing` -> - `tradingchassis_core.core.domain.processing` -- `trading_runtime.backtest.engine.strategy_runner` -> - `core_runtime.backtest.engine.strategy_runner` -- `trading_runtime.strategies.debug_strategy:DebugStrategyV1` -> - `core_runtime.strategies.debug_strategy:DebugStrategyV1` - ---- - -## Compatibility strategy decision - -Use temporary compatibility shims as an explicit, time-boxed migration bridge: - -- Provide temporary re-export compatibility for: - - `trading_framework` -> `tradingchassis_core` - - `trading_runtime` -> `core_runtime` -- Maintain shims for one defined deprecation window (recommended: one minor - release cycle), with deprecation warnings. -- Require external JSON `strategy.class_path` and external imports to migrate - during that window. -- Remove shims after the window closes to prevent permanent dual-namespace debt. - ---- - -## Next implementation slice decision - -Choose **D: compatibility alias introduction first** as the smallest safe next -implementation slice after Stage 0. - -Then proceed with coordinated mechanical renames in both repos once compatibility -coverage is validated. - ---- - -## Non-goals for this decision - -- No implementation of package rename in this document. -- No reducer/event/runtime semantic changes. -- No adapter boundary changes. -- No replay/storage/event-stream persistence implementation. - ---- diff --git a/docs/post-mvp-roadmap.md b/docs/post-mvp-roadmap.md new file mode 100644 index 0000000..f34bc06 --- /dev/null +++ b/docs/post-mvp-roadmap.md @@ -0,0 +1,16 @@ +# Post-MVP Roadmap Boundaries + +The following items are explicitly post-MVP and out of scope for the accepted +baseline: + +- default flag flips +- GateDecision removal +- Strategy API redesign +- full order lifecycle model +- broad docs rewrite outside `core/docs` +- main docs repository cleanup + +## Additional scope guard + +Current MVP docs describe accepted transitional architecture and compatibility +bridges. They do not claim final-state completion. diff --git a/docs/post-submission-lifecycle-compatibility-map-v1.md b/docs/post-submission-lifecycle-compatibility-map-v1.md deleted file mode 100644 index 4f1b097..0000000 --- a/docs/post-submission-lifecycle-compatibility-map-v1.md +++ /dev/null @@ -1,144 +0,0 @@ -# Post-Submission Lifecycle Compatibility Map v1 - ---- - -## Purpose and scope - -This document freezes the current implementation-facing authority split for order -lifecycle semantics after submission in `core`. - -This is a docs-only contract slice: - -- it documents current lifecycle authority boundaries; -- it does not implement behavior; -- it does not change reducers or runtime behavior; -- it does not implement `FillEvent` ingress; -- it does not canonicalize `OrderStateEvent`. - -`PSLCM-01` - Main `docs` remains the semantic source of truth for Event, Event -Stream, Processing Order, Order lifecycle, and determinism semantics. - -`PSLCM-02` - This page is implementation-facing and freezes current authority -split behavior in `core` contracts; it does not redefine architecture semantics. - -`PSLCM-03` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` - ---- - -## Canonical authority today - -`PSLCM-04` - `OrderSubmittedEvent` is canonical authority for lifecycle entry at -`Submitted`. - -`PSLCM-05` - Current runtime wiring emits/processes `OrderSubmittedEvent` only -for successful `new` dispatches; failed `new` dispatches create no -`OrderSubmittedEvent`, and replace/cancel dispatches do not create new -`OrderSubmittedEvent` records. - -`PSLCM-06` - `ProcessingPosition` remains boundary/global acceptance-order -authority for canonical ingestion. Ordering authority is not timestamp-derived. - ---- - -## Compatibility authority today - -`PSLCM-07` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`PSLCM-08` - `ingest_order_snapshots` remains the compatibility snapshot -materialization path. - -`PSLCM-09` - `apply_order_state_event` remains the compatibility reducer and -projection path for post-submission lifecycle progression. - -`PSLCM-10` - `DerivedFillEvent` remains a non-canonical compatibility -projection artifact derived from snapshot progression. - -`PSLCM-11` - `mark_intent_sent` remains compatibility execution-control / -bookkeeping sidecar behavior and must not be interpreted as Event Stream -authority. - ---- - -## Current lifecycle compatibility map (frozen snapshot) - -`PSLCM-12` - Post-submission lifecycle progression remains -compatibility-governed until canonical execution-feedback source gates are -satisfied. - -| lifecycle transition | current source | canonical or compatibility classification | affected state/projection | semantic drift risk | future migration gate | -| --- | --- | --- | --- | --- | --- | -| none/new -> `Submitted` | successful `new` dispatch -> `OrderSubmittedEvent` canonical boundary processing (with `mark_intent_sent` bookkeeping sidecar) | canonical entry authority (`OrderSubmittedEvent`); sidecar bookkeeping remains compatibility | canonical order projection (`canonical_orders`) at `submitted`; bookkeeping (`inflight`, `last_sent_intents`) | medium (dual-path coexistence can be misread as dual authority) | retain single entry authority at `OrderSubmittedEvent`; keep `mark_intent_sent` non-authoritative | -| `Submitted` -> `Accepted` | snapshot ingestion/materialization (`ingest_order_snapshots` -> `OrderStateEvent` -> `apply_order_state_event`) | compatibility | compatibility order snapshots and sidecar lifecycle projection advancement | high (snapshot mapping and compatibility-state normalization) | canonical execution-feedback source and mapping required before authority move | -| `Submitted` -> `Rejected` | snapshot-derived `OrderStateEvent(state_type="rejected")` | compatibility | compatibility snapshots and sidecar projection | high | canonical execution-feedback source and deterministic correlation required | -| `Accepted` -> `PartiallyFilled` | snapshot-derived `OrderStateEvent(state_type="partially_filled")` | compatibility | compatibility snapshots; sidecar projection; snapshot-derived fill projection potential | high | canonical execution-feedback source with authoritative cumulative progression | -| `PartiallyFilled` -> `PartiallyFilled` | repeated snapshot cumulative progression updates | compatibility | compatibility snapshots; `DerivedFillEvent` projection emission on cumulative increase | high | explicit canonical fill granularity and no-double-counting policy | -| `Accepted`/`PartiallyFilled` -> `Filled` | snapshot-derived terminal state updates | compatibility | compatibility snapshots (terminal removal), sidecar projection terminal progression, snapshot-derived fill projection | high | canonical `FillEvent` ingress gates + explicit cutover policy | -| `Accepted`/`PartiallyFilled` -> `Canceled` | snapshot-derived terminal state updates | compatibility | compatibility snapshots (terminal removal), sidecar projection terminal progression | high | canonical execution-feedback source and deterministic ordering/correlation | - ---- - -## Guardrails (must hold in this phase) - -`PSLCM-13` - `OrderStateEvent` must remain rejected at canonical boundary -processing. - -`PSLCM-14` - `DerivedFillEvent` must remain non-canonical compatibility -projection behavior. - -`PSLCM-15` - Runtime `FillEvent` ingress remains gated by execution-feedback -source-authority requirements (`ExecutionFeedbackRecordSource` contract family). - -`PSLCM-16` - `mark_intent_sent` must not be treated as canonical Event Stream -authority. - -`PSLCM-17` - Snapshot progression must not be described or promoted as canonical -execution feedback in this phase. - ---- - -## Future migration gates - -`PSLCM-18` - Lifecycle authority migration for post-submission transitions may -begin only when all of the following are satisfied: - -- authoritative `ExecutionFeedbackRecordSource` exists for the target scope; -- deterministic strictly monotone non-timestamp `source_sequence` exists; -- source-authoritative liquidity and deterministic canonical correlation exist; -- explicit global `ProcessingPosition` merge policy exists; -- explicit no-double-counting cutover policy relative to `DerivedFillEvent` - exists. - -`PSLCM-19` - Post-submission lifecycle authority must move only after these -gates are satisfied and validated; until then, compatibility authority remains -frozen as documented here. - ---- - -## Explicit non-goals for this slice - -`PSLCM-20` - No snapshot-derived canonical `FillEvent` emission. - -`PSLCM-21` - No `OrderStateEvent` canonicalization. - -`PSLCM-22` - No `DerivedFillEvent` removal or behavior change. - -`PSLCM-23` - No lifecycle reducer rewrite. - -`PSLCM-24` - No adapter API work. - -`PSLCM-25` - No replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- diff --git a/docs/processing-context-event-stream-cursor-contract-v1.md b/docs/processing-context-event-stream-cursor-contract-v1.md deleted file mode 100644 index 487559c..0000000 --- a/docs/processing-context-event-stream-cursor-contract-v1.md +++ /dev/null @@ -1,198 +0,0 @@ -# ProcessingContext / EventStreamCursor Contract v1 - ---- - -## Purpose and scope - -This document defines docs-only ownership and boundary semantics for deferred -`ProcessingContext` abstraction work and runtime-owned `EventStreamCursor` -boundary responsibilities. - -This is a planning/contract slice only: - -- it does not implement `ProcessingContext`; -- it does not introduce new `EventStreamCursor` behavior; -- it does not change runtime behavior; -- it does not change reducers or event taxonomy; -- it does not implement canonical `FillEvent` ingress; -- it does not add adapter APIs; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not implement replay/storage/EventStream persistence. - -`PCESC-01` - Main `docs` remains the semantic source of truth for Event, -Event Stream, Processing Order, Configuration, Runtime, and Venue Adapter -semantics. - -`PCESC-02` - This page is implementation-facing boundary planning for -future abstraction ownership only. It does not redefine architecture semantics. - -`PCESC-03` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/determinism-model.md` - ---- - -## Responsibility split - -### EventStreamCursor responsibility (conceptual) - -`PCESC-04` - `EventStreamCursor` is an ordering-only abstraction. - -`PCESC-05` - `EventStreamCursor` conceptually allocates/advances global -canonical `ProcessingPosition` values for Runtime canonical entry formation. - -`PCESC-06` - Cursor sequence semantics are deterministic and strictly monotone. - -`PCESC-07` - `EventStreamCursor` must not carry event payloads. - -`PCESC-08` - `EventStreamCursor` must not carry `CoreConfiguration`. - -`PCESC-09` - `EventStreamCursor` must not carry adapter handles. - -`PCESC-10` - `EventStreamCursor` must not carry persistence/storage handles. - -### ProcessingContext responsibility (conceptual) - -`PCESC-11` - `ProcessingContext` is runtime-owned invocation scope metadata. - -`PCESC-12` - `ProcessingContext` conceptually carries explicit -`CoreConfiguration` reference for canonical boundary invocation scope. - -`PCESC-13` - `ProcessingContext` conceptually carries declared capability scope -and merge-policy selection metadata. - -`PCESC-14` - `ProcessingContext` must not carry canonical event history. - -`PCESC-15` - `ProcessingContext` must not mutate `StrategyState` directly. - -`PCESC-16` - `ProcessingContext` must not redefine adapter capability semantics. - -`PCESC-17` - `ProcessingContext` must not become canonical core input payload -shape in this contract slice. - ---- - -## Ownership model - -`PCESC-18` - Runtime owns `ProcessingContext` and `EventStreamCursor` -abstractions (if introduced in future implementation slices). - -`PCESC-19` - Core owns canonical boundary validation and reduction of -`EventStreamEntry`. - -`PCESC-20` - Adapter owns venue/source capability exposure only. - -`PCESC-21` - `EventStreamEntry` remains minimal (`position`, `event`). - -`PCESC-22` - Configuration remains call-level processing input and must not -move into `EventStreamEntry` payload shape. - ---- - -## Current state snapshot (frozen for this phase) - -`PCESC-23` - Current runtime runner uses a runtime-owned `EventStreamCursor` -ordering helper for canonical positioned entry formation. - -`PCESC-24` - Current runtime runner creates `EventStreamEntry` records at the -runner boundary before calling `process_event_entry(...)`. - -`PCESC-25` - Current runtime runner passes `CoreConfiguration` explicitly into -`process_event_entry(...)` as call-level processing input. - -`PCESC-26` - Current compatibility `rc == 3` order/account snapshot branch -continues to bypass canonical `EventStreamEntry` by design and remains -compatibility behavior in this phase. - ---- - -## Conceptual future relation (non-implemented) - -`PCESC-27` - Future slices may extend/refine `EventStreamCursor` integration -while preserving current global ordering semantics. - -`PCESC-28` - If implemented in a future slice, `ProcessingContext` would gather -run/session invocation-scope metadata without changing canonical payload shapes. - -`PCESC-29` - Runtime would remain responsible for constructing -`EventStreamEntry` values from canonical events and positioned ordering metadata. - -`PCESC-30` - Core would remain non-owner of adapter polling and position -allocation orchestration. - -`PCESC-31` - This relation introduces no replay/storage/persistence semantics. - ---- - -## Out of scope - -`PCESC-32` - Replay engine implementation. - -`PCESC-33` - Event Stream storage/persistence implementation. - -`PCESC-34` - Adapter interface design or adapter API implementation. - -`PCESC-35` - Canonical runtime `FillEvent` ingress implementation. - -`PCESC-36` - Post-submission lifecycle migration away from compatibility -snapshot authority. - -`PCESC-37` - ControlTimeEvent queue/rate authority migration. - -`PCESC-38` - `OrderStateEvent` canonicalization. - -`PCESC-39` - `DerivedFillEvent` behavior change/removal. - ---- - -## Guardrails - -`PCESC-40` - `EventStreamCursor` must not derive `ProcessingPosition` from -timestamps. - -`PCESC-41` - `EventStreamCursor` must not reset or fork sequence authority -within one canonical stream scope. - -`PCESC-42` - `ProcessingContext` must not hide mutable configuration changes. - -`PCESC-43` - `ProcessingContext` must not smuggle venue-specific schemas into -core canonical processing payload shapes. - -`PCESC-44` - `ProcessingContext` must not define hidden state-mutation authority -outside Event processing. - -`PCESC-45` - `EventStreamEntry` must remain config-free and minimal. - -`PCESC-46` - `ProcessingPosition` remains global canonical ordering authority -and must remain non-timestamp-derived. - ---- - -## Future implementation prerequisites - -`PCESC-47` - A future implementation slice requires an explicit runtime refactor -plan before code changes. - -`PCESC-48` - Tests must preserve existing canonical event ordering behavior. - -`PCESC-49` - Tests must preserve existing compatibility snapshot behavior. - -`PCESC-50` - Tests must demonstrate that cursor-emitted sequence matches current -counter sequence for currently wired canonical paths. - -`PCESC-51` - First implementation path must not require core reducer changes. - ---- diff --git a/docs/risk-vs-execution-control.md b/docs/risk-vs-execution-control.md new file mode 100644 index 0000000..f0a0146 --- /dev/null +++ b/docs/risk-vs-execution-control.md @@ -0,0 +1,26 @@ +# Risk vs ExecutionControl + +## Risk is policy-only + +Risk checks are policy/hygiene controls such as: + +- trading enabled/disabled +- max loss checks +- hard hygiene checks +- validation and limits + +## ExecutionControl owns dispatchability mechanics + +ExecutionControl owns venue-independent dispatchability mechanics: + +- queue handling +- dominance/effective pending work +- rate constraints +- inflight/sendability checks +- dispatchable selection +- scheduling obligation derivation + +## Boundary for migrated paths + +- Runtime must not productively call runtime `risk.decide_intents`. +- Runtime dispatches from `CoreStepResult.dispatchable_intents`. diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md deleted file mode 100644 index c116b9a..0000000 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ /dev/null @@ -1,811 +0,0 @@ -# Runtime/Adapter Execution Feedback Source Contract v1 - ---- - -## Purpose and scope - -This document defines the source-authority boundary that a future runtime/adapter -execution-feedback source must satisfy before canonical `FillEvent` ingress can -be implemented. - -This is a docs-contract slice only: - -- it does not implement canonical `FillEvent` ingress; -- it does not add or implement adapter APIs; -- it does not modify runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy. - -`RAEFSC-01` - Current runtime remains ineligible for canonical `FillEvent` -ingress under the source-authority requirements defined in this contract. - -`RAEFSC-02` - Snapshot-derived fill progression remains compatibility projection -behavior (`DerivedFillEvent`) in this phase. - ---- - -## Semantic source of truth and precedence - -`RAEFSC-03` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream semantics, Processing Order, execution/order -lifecycle, and determinism. - -`RAEFSC-04` - This document is an implementation-facing boundary/source contract -for future runtime/adapter work. It does not redefine architecture semantics. - -`RAEFSC-05` - Runtime execution-feedback eligibility statements must remain -consistent with: - -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Core Stable Contract v1](core-stable-contract-v1.md) - ---- - -## Current decision snapshot - -`RAEFSC-06` - No currently available runtime source satisfies canonical -`FillEvent` source-authority requirements (`REFC-10` through `REFC-16`). - -`RAEFSC-07` - Canonical runtime `FillEvent` ingress remains deferred. - -`RAEFSC-08` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`RAEFSC-09` - `DerivedFillEvent` remains compatibility projection and -non-canonical. - ---- - -## Source eligibility contract (v1) - -`RAEFSC-10` - A source is eligible for future canonical `FillEvent` ingress -only when records are explicit Venue or simulated-Venue execution-feedback -records from the execution path. - -`RAEFSC-11` - Source is explicitly ineligible when records are inferred from: - -- compatibility snapshot deltas; -- market trade feed inference; -- submit/modify/cancel synchronous return codes. - -`RAEFSC-12` - Offline/recorder artifacts are ineligible as runtime canonical -ingress unless replayed as authoritative Event Stream input under a positioned -ingestion contract that preserves deterministic `ProcessingPosition`. - ---- - -## Granularity contract (v1) - -`RAEFSC-13` - Acceptable v1 canonical execution-feedback granularity is -per-cumulative execution update. - -`RAEFSC-14` - Per-fill execution reports are acceptable only when each report -either: - -- carries authoritative cumulative filled quantity; or -- can be deterministically represented as cumulative updates without heuristic - reconstruction. - -`RAEFSC-15` - Cumulative filled quantity must be monotone per canonical order -key for accepted execution-feedback progression. - ---- - -## FillEvent field source-authority contract (v1) - -`RAEFSC-16` - Required `FillEvent` fields must be authoritative from execution -feedback source records (or direct deterministic mapping from those records and -canonical order lineage), not heuristic synthesis: - -- `ts_ns_exch` -- `ts_ns_local` -- `instrument` -- `client_order_id` -- `side` -- `filled_price` -- `cum_filled_qty` -- `time_in_force` -- `liquidity_flag` - -`RAEFSC-17` - Optional `FillEvent` fields, when present, must be source -authoritative: - -- `fee` -- `intended_price` -- `intended_qty` -- `remaining_qty` - -`RAEFSC-18` - Heuristic synthesis of required `FillEvent` fields is prohibited -in v1 unless a future explicit contract revision defines and permits that -behavior. - ---- - -## Liquidity flag policy (v1) - -`RAEFSC-19` - `liquidity_flag` classification (`maker`, `taker`, `unknown`) must -be source-authoritative execution-feedback data. - -`RAEFSC-20` - `unknown` is allowed only when the source explicitly reports -unknown or indeterminate liquidity classification. - -`RAEFSC-21` - Synthetic defaulting to `unknown` is prohibited in v1. - ---- - -## Identity and correlation contract (v1) - -`RAEFSC-22` - Canonical order key for this boundary is -`instrument + client_order_id`, unless a later explicit contract revision -changes canonical order identity semantics. - -`RAEFSC-23` - Source/runtime must provide deterministic correlation from -Venue-side order identifiers to canonical `client_order_id`. - -`RAEFSC-24` - Correlation to `OrderSubmittedEvent` lineage must be replay-stable -under equivalent input streams and configuration. - -`RAEFSC-25` - Replace/cancel successor identifiers require an explicit -deterministic mapping chain that preserves canonical order continuity and avoids -ambiguous identity resolution. - ---- - -## Ordering and ProcessingPosition contract (v1) - -`RAEFSC-26` - All future canonical `FillEvent` ingress must enter through -`EventStreamEntry` with global `ProcessingPosition` ordering at the canonical -boundary. - -`RAEFSC-27` - Processing acceptance order must not be derived from timestamps. -`Event Time` metadata does not define `ProcessingOrder`. - -`RAEFSC-28` - Source/adapter sequence contract must be deterministic and -replay-equivalent for equivalent inputs. - -`RAEFSC-29` - Runner merge ordering relative to canonical `MarketEvent`, -`OrderSubmittedEvent`, and `ControlTimeEvent` must be explicit and -replay-equivalent under the global positioned boundary. - ---- - -## No-double-counting contract (v1) - -`RAEFSC-30` - Before canonical `FillEvent` is enabled, one semantic authority -for fill progression must be defined for each source scope. - -`RAEFSC-31` - For overlapping scope, compatibility `DerivedFillEvent` path must -be either: - -- retired; or -- explicitly constrained to non-semantic observability with no canonical fill - progression side effects. - -`RAEFSC-32` - Duplicate semantic fill progression for the same canonical -order/cumulative state is prohibited. - -`RAEFSC-33` - Shadow/compare validation or explicit cutover reconciliation plan -is required before production dual-path operation. - ---- - -## Runtime/adapter API sketch (conceptual only) - -`RAEFSC-34` - A future conceptual source record (`ExecutionFeedbackRecord`) -should include, at minimum: - -- deterministic source sequence and/or source record id; -- authoritative execution-feedback payload for canonical `FillEvent` mapping; -- deterministic correlation fields needed to resolve canonical order identity. - -`RAEFSC-35` - Adapter guarantees (conceptual): - -- records are execution-feedback authoritative per eligibility clauses; -- sequence/id semantics are stable and deterministic; -- correlation fields are sufficient for replay-stable canonical mapping. - -`RAEFSC-36` - Runner assumptions (conceptual): - -- record-to-`FillEvent` mapping can be deterministic; -- positioned canonical merge can be performed via global `ProcessingPosition`; -- no-double-counting policy can be enforced at boundary cutover. - -`RAEFSC-37` - This section is conceptual only and does not define or introduce -implementation APIs in this phase. - ---- - -## Acceptance criteria for future implementation - -`RAEFSC-38` - Future implementation may begin only when all required -authoritative fields are available under this source contract. - -`RAEFSC-39` - Granularity semantics are stable and satisfy cumulative monotone -requirements per canonical order key. - -`RAEFSC-40` - Deterministic global ordering via positioned canonical boundary is -specified and testable. - -`RAEFSC-41` - Identity/correlation mapping is deterministic and replay-stable, -including replace/cancel successor handling. - -`RAEFSC-42` - Liquidity policy requirements are satisfied without synthetic -defaulting. - -`RAEFSC-43` - No-double-counting rules are explicit and testable. - -`RAEFSC-44` - Test plans can cover duplicates/regressions/idempotence and -ordering determinism before ingress rollout. - ---- - -## Explicitly out of scope for this contract slice - -`RAEFSC-45` - Implementing canonical runtime `FillEvent` ingress. - -`RAEFSC-46` - Adapter API implementation. - -`RAEFSC-47` - `OrderStateEvent` canonicalization. - -`RAEFSC-48` - `DerivedFillEvent` removal or behavior change. - -`RAEFSC-49` - Snapshot reducer rewrite or compatibility ingestion redesign. - -`RAEFSC-50` - Replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- - -## Appendix A: ExecutionFeedbackRecord adapter-facing source shape (Phase 4D) - -This appendix is adapter-facing and defines the minimum conceptual source shape -required before future canonical `FillEvent` ingress work may start. - -This appendix is docs-contract only: - -- it does not implement `FillEvent` ingress; -- it does not add adapter APIs; -- it does not make current runtime eligible; -- it does not modify runtime behavior; -- it does not change snapshot compatibility behavior. - -`RAEFSC-51` - Current feasibility decision remains **C**: no existing -runtime-adapter source satisfies this source contract end-to-end. - -`RAEFSC-52` - Canonical runtime `FillEvent` ingress remains deferred. - -`RAEFSC-53` - Compatibility projection authority is preserved in this phase: -`DerivedFillEvent` remains the active compatibility path and snapshot -materialization semantics remain unchanged. - ---- - -### A.1 Conceptual ExecutionFeedbackRecord source shape - -`RAEFSC-54` - The minimum conceptual adapter-facing source record -(`ExecutionFeedbackRecord`) for future canonical ingress requires: - -- `source_sequence` -- `ts_ns_exch` -- `ts_ns_local` -- `instrument` -- `client_order_id` -- optional `venue_order_id` -- `side` -- `time_in_force` -- `filled_price` -- `cum_filled_qty` -- `liquidity_flag` - -`RAEFSC-55` - Optional authoritative fields, when provided, include: - -- `fee` -- `remaining_qty` -- `intended_price` -- `intended_qty` -- source metadata such as `source_id`, `venue`, or adapter metadata when needed - for deterministic boundary mapping and observability. - -`RAEFSC-56` - This shape is conceptual boundary documentation only and does not -define or introduce implementation APIs in this phase. - ---- - -### A.2 source_sequence contract - -`RAEFSC-57` - `source_sequence` must be strictly monotone within the adapter's -execution-feedback source stream. - -`RAEFSC-58` - `source_sequence` must be deterministic for replay-equivalent -inputs and configuration. - -`RAEFSC-59` - `source_sequence` must not be timestamp-derived. - -`RAEFSC-60` - `source_sequence` must be stable enough for runner merge into -global `ProcessingPosition` ordering semantics. - ---- - -### A.3 Liquidity authority contract - -`RAEFSC-61` - `liquidity_flag` values (`maker`, `taker`, `unknown`) must be -source-authoritative. - -`RAEFSC-62` - `unknown` is allowed only when explicitly reported by the source -as unknown or indeterminate. - -`RAEFSC-63` - Synthetic defaulting to `unknown` is prohibited. - ---- - -### A.4 Identity and correlation contract - -`RAEFSC-64` - Canonical correlation to `instrument + client_order_id` is -required for source record eligibility. - -`RAEFSC-65` - `venue_order_id` is correlation metadata for v1 unless a future -explicit contract revision changes canonical identity semantics. - -`RAEFSC-66` - Replace/cancel successor correlation mapping must be explicit, -deterministic, and replay-stable. - -`RAEFSC-67` - Source records without deterministic canonical correlation are -ineligible for canonical ingress. - ---- - -### A.5 Ordering and merge contract - -`RAEFSC-68` - Adapter/source must provide deterministic source order for -execution-feedback records. - -`RAEFSC-69` - Runner owns merge into global `ProcessingPosition` ordering -across canonical `MarketEvent`, `OrderSubmittedEvent`, `ControlTimeEvent`, and -future canonical `FillEvent`. - -`RAEFSC-70` - `ProcessingOrder` must not be timestamp-derived. - -`RAEFSC-71` - Relative ordering policy for execution feedback versus other -canonical categories must be explicit before implementation. - ---- - -### A.6 No-double-counting cutover policy - -`RAEFSC-72` - Compatibility `DerivedFillEvent` progression remains current -authority until explicit cutover is defined and approved. - -`RAEFSC-73` - Future canonical `FillEvent` path must not duplicate semantic -fill progression for the same canonical order progression. - -`RAEFSC-74` - Pre-cutover operation requires either: - -- shadow-only comparison phase; or -- explicit authority cutover/reconciliation policy. - -`RAEFSC-75` - Duplicate semantic progression detection should include at least -`instrument`, `client_order_id`, and `cum_filled_qty`. - ---- - -### A.7 Ineligible current source classes (explicit) - -`RAEFSC-76` - The following source classes are ineligible in this phase: - -- order snapshots (compatibility materialization path); -- submit/modify/cancel return codes (not execution-feedback records); -- recorder/offline artifacts unless replayed through an authoritative positioned - stream contract; -- market trade feed inference; -- unwrapped `wait_order_response` without structured authoritative payload, - deterministic `source_sequence`, and required field authority. - ---- - -### A.8 Acceptance criteria before implementation planning - -`RAEFSC-77` - Implementation planning for canonical ingress requires all of: - -- source record channel exists; -- required fields are authoritative; -- liquidity semantics satisfy A.3; -- deterministic `source_sequence` exists; -- canonical correlation exists per A.4; -- merge ordering policy exists per A.5; -- no-double-counting policy exists per A.6; -- tests are possible for duplicates/regressions/idempotence/ordering. - -`RAEFSC-78` - Until `RAEFSC-77` is satisfied, feasibility remains decision **C** -and canonical runtime `FillEvent` ingress stays deferred. - ---- - -## Appendix B: Adapter API capability contract (Phase 4F) - -This appendix defines a docs-only adapter API capability contract for future -execution-feedback sources. - -This appendix is contract-only: - -- it does not add or implement production adapter APIs; -- it does not modify runtime behavior; -- it does not implement canonical `FillEvent` ingress; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` or snapshot compatibility behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`RAEFSC-79` - This appendix defines the future adapter-facing capability -contract for authoritative `ExecutionFeedbackRecord` sourcing only. - -`RAEFSC-80` - Current runtime remains ineligible for canonical `FillEvent` -ingress under this source-authority contract. - -`RAEFSC-81` - Snapshot-derived compatibility projection remains the active -semantic authority in this phase (`DerivedFillEvent` and snapshot path -unchanged). - -`RAEFSC-82` - Canonical runtime `FillEvent` ingress remains deferred. - ---- - -### B.1 Ownership and boundary contract - -`RAEFSC-83` - The execution-feedback source capability belongs to the -venue-side adapter boundary. - -`RAEFSC-84` - Existing execution command submission boundary remains outbound -only; it is not redefined by this appendix. - -`RAEFSC-85` - Runner remains responsible for orchestration and global -`ProcessingPosition` merge policy at the canonical boundary. - -`RAEFSC-86` - Adapter/source capability must not mutate `StrategyState` -directly. - -`RAEFSC-87` - Adapter/source capability must not emit canonical events directly -and must not call canonical processing entry points. - ---- - -### B.2 Conceptual capability interface (docs only) - -`RAEFSC-88` - Future conceptual interface name is -`ExecutionFeedbackRecordSource`. - -`RAEFSC-89` - Conceptual method: -`drain_execution_feedback_records() -> Sequence[ExecutionFeedbackRecord]`. - -`RAEFSC-90` - `drain_execution_feedback_records` is non-blocking. - -`RAEFSC-91` - When no records are available, the method returns an empty -sequence. - -`RAEFSC-92` - Already-drained records must not be returned again. - -`RAEFSC-93` - Records returned by one drain call must be in deterministic -source acceptance order. - -`RAEFSC-94` - This interface remains conceptual documentation only in Phase 4F -and introduces no code API additions. - ---- - -### B.3 source_sequence requirements - -`RAEFSC-95` - `source_sequence` must be strictly monotone within the source -stream. - -`RAEFSC-96` - `source_sequence` must be deterministic for replay-equivalent -inputs and configuration. - -`RAEFSC-97` - `source_sequence` must not be derived from timestamps. - -`RAEFSC-98` - Duplicate or regressing `source_sequence` values are hard -contract failures. - -`RAEFSC-99` - `source_sequence` semantics must be suitable for deterministic -runner merge policy into global `ProcessingPosition`. - ---- - -### B.4 Error semantics contract - -`RAEFSC-100` - Missing required authoritative fields for -`ExecutionFeedbackRecord` are hard contract failures. - -`RAEFSC-101` - Non-monotone `source_sequence` is a hard contract failure. - -`RAEFSC-102` - Invalid liquidity semantics relative to A.3 are hard contract -failures. - -`RAEFSC-103` - Unresolved canonical correlation relative to A.4 is a hard -contract failure. - -`RAEFSC-104` - Malformed authoritative records must not be silently dropped. - ---- - -### B.5 Runtime loop integration contract (future implementation boundary) - -`RAEFSC-105` - Future runner integration may perform at most one non-blocking -feedback drain per wakeup after timestamp adoption and before rc-specific -branch processing. - -`RAEFSC-106` - Feedback draining is orthogonal to rc-specific market (`rc == 2`) -and snapshot/order-response (`rc == 3`) branches. - -`RAEFSC-107` - Current market and snapshot branch behavior remains unchanged -until a later explicit implementation phase. - -`RAEFSC-108` - Drained execution-feedback records are source records only and do -not directly mutate state until mapped to canonical boundary events by the -runner. - -`RAEFSC-109` - Global `ProcessingPosition` assignment for canonical merge -remains runner responsibility. - ---- - -### B.6 FillEvent mapping boundary contract - -`RAEFSC-110` - Adapter/source provides `ExecutionFeedbackRecord` only. - -`RAEFSC-111` - Runner maps eligible records to canonical `FillEvent` at a later -implementation phase. - -`RAEFSC-112` - Adapter/source must not construct canonical `FillEvent`. - -`RAEFSC-113` - Adapter/source must not invoke canonical `process_event_entry`. - -`RAEFSC-114` - `liquidity_flag` must come from source records only under A.3. - -`RAEFSC-115` - Synthetic population of required canonical mapping fields is -prohibited. - ---- - -### B.7 No-double-counting and cutover contract - -`RAEFSC-116` - First implementation path should be shadow-only unless a -separate explicit cutover decision is approved. - -`RAEFSC-117` - During shadow-only operation, `DerivedFillEvent` and snapshot -compatibility path remain semantic authority. - -`RAEFSC-118` - Authority cutover by source scope requires explicit subsequent -decision and test-backed reconciliation rules. - -`RAEFSC-119` - Duplicate semantic progression key must include at least -`instrument`, `client_order_id`, and `cum_filled_qty`. - ---- - -### B.8 Test obligations before implementation - -`RAEFSC-120` - Adapter contract tests are required. - -`RAEFSC-121` - Deterministic `source_sequence` tests are required. - -`RAEFSC-122` - Drain idempotence tests are required. - -`RAEFSC-123` - Mapping contract tests are required. - -`RAEFSC-124` - No-double-counting shadow tests are required. - -`RAEFSC-125` - Runtime global merge ordering tests are required. - -`RAEFSC-126` - Snapshot and `DerivedFillEvent` regression guards are required. - ---- - -### B.9 Explicitly out of scope for Phase 4F - -`RAEFSC-127` - Code interface addition. - -`RAEFSC-128` - specific backtest runtime or other adapter implementation work. - -`RAEFSC-129` - Runtime canonical `FillEvent` ingress implementation. - -`RAEFSC-130` - Reducer changes. - -`RAEFSC-131` - `OrderStateEvent` canonicalization. - -`RAEFSC-132` - `DerivedFillEvent` removal or behavior change. - -`RAEFSC-133` - Replay/storage/`EventStreamCursor`/`ProcessingContext` -implementation. - ---- - -## Appendix C: backtest runtime source feasibility and gap decision (Phase 4H) - -This appendix records the backtest-runtime-specific feasibility decision from Phase -4G and documents the exact source/adapter gap required before any canonical -`FillEvent` ingress planning. - -This appendix is docs-contract only: - -- it does not implement canonical `FillEvent` ingress; -- it does not add or implement adapter APIs; -- it does not modify runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`RAEFSC-134` - Appendix C scope is backtest-runtime-specific feasibility and gap -documentation only; no implementation behavior changes are introduced. - ---- - -### C.1 Decision snapshot - -`RAEFSC-135` - Current backtest-runtime/core-runtime integration feasibility remains -decision **C** for `ExecutionFeedbackRecordSource` eligibility. - -`RAEFSC-136` - No currently exposed backtest-runtime/core-runtime source satisfies -the `ExecutionFeedbackRecordSource` contract end-to-end under Appendices A and -B. - -`RAEFSC-137` - Canonical runtime `FillEvent` ingress remains deferred for the -backtest-runtime integration in this phase. - ---- - -### C.2 Current exposed source classes and classification - -`RAEFSC-138` - `rc == 3` order snapshot path (`orders()` materialization and -compatibility ingestion) is classified as **partial/ineligible** for canonical -source authority: - -- some required fields may be present in snapshots; -- source class remains compatibility snapshot materialization, not explicit - execution-feedback records; -- required deterministic non-timestamp `source_sequence` is not exposed; -- source-authoritative `liquidity_flag` is not satisfied in current runtime - exposure; -- therefore it is ineligible for canonical ingress under A.7/B requirements. - -`RAEFSC-139` - submit/modify/cancel synchronous return codes are classified as -**ineligible**: - -- they represent outbound command status only; -- they are not execution-feedback records and cannot satisfy required - authoritative field payload requirements. - -`RAEFSC-140` - `wait_next(... include_order_resp=True)` response signaling is -classified as **insufficient/ineligible**: - -- it provides wakeup signaling that an order response occurred; -- it does not provide structured authoritative execution-feedback payload. - -`RAEFSC-141` - `wait_order_response` hook (as currently unwrapped/unused in -runtime boundary) is classified as **insufficient/ineligible**: - -- current exposure does not provide a structured authoritative source record - satisfying Appendix A required shape; -- deterministic source sequencing and full field authority are not provided by - current integration boundary. - -`RAEFSC-142` - `last_trades` market-trade feed is classified as **ineligible** -for canonical order execution feedback: - -- it is market-trade data, not deterministic own-order execution-feedback - records with canonical order correlation guarantees. - -`RAEFSC-143` - Latent backtest-runtime order-structure fields (including potential -maker/taker-style flags) are classified as **insufficient unless surfaced -through explicit authoritative execution-feedback records**: - -- latent/internal field presence alone does not satisfy source-channel - eligibility; -- required source contract semantics must be satisfied at the adapter-facing - record boundary. - ---- - -### C.3 Exact missing requirements - -`RAEFSC-144` - Current backtest-runtime/core-runtime integration lacks an explicit -adapter-facing execution-feedback record channel matching Appendix A required -shape and Appendix B drain semantics. - -`RAEFSC-145` - Current integration lacks deterministic, strictly monotone, -non-timestamp-derived `source_sequence` semantics suitable for runner merge. - -`RAEFSC-146` - Current integration lacks source-authoritative `liquidity_flag` -semantics satisfying A.3 without synthetic defaulting. - -`RAEFSC-147` - Current integration does not provide contracted authoritative -per-cumulative execution update granularity for canonical source records. - -`RAEFSC-148` - Current integration does not expose deterministic replay-stable -canonical correlation guarantees to `instrument + client_order_id` through an -explicit source record channel. - -`RAEFSC-149` - Deterministic replace/cancel successor correlation mapping chain -requirements are not currently satisfied at an authoritative source-record -boundary. - -`RAEFSC-150` - Global `ProcessingPosition` merge policy for future execution -feedback remains runner-owned and must be explicitly specified before -implementation. - -`RAEFSC-151` - No-double-counting cutover policy relative to -`DerivedFillEvent` compatibility progression remains required before canonical -dual-path operation. - ---- - -### C.4 Minimum required extension boundary (future, non-implemented) - -`RAEFSC-152` - Minimum required extension is a backtest-runtime wrapper/adapter -capability that provides authoritative `ExecutionFeedbackRecordSource` -semantics at the venue-side adapter boundary. - -`RAEFSC-153` - Future source records must satisfy Appendix A required source -shape, field authority, correlation, granularity, and liquidity clauses. - -`RAEFSC-154` - Adapter/source capability must satisfy Appendix B drain -capability semantics (`drain_execution_feedback_records`) including deterministic -ordering and non-replay of drained records. - -`RAEFSC-155` - Adapter/source capability must own and enforce deterministic -strictly monotone non-timestamp `source_sequence` semantics. - -`RAEFSC-156` - Runner remains owner of global `ProcessingPosition` merge policy -and canonical positioned ingestion ordering across categories. - -`RAEFSC-157` - Until explicit authority cutover, current snapshot compatibility -path (`OrderStateEvent` materialization and `DerivedFillEvent`) remains -unchanged semantic authority, and first future implementation path should be -shadow-only unless separately approved. - ---- - -### C.5 Explicit non-goals for Phase 4H - -`RAEFSC-158` - Do not promote order snapshots to canonical execution-feedback -authority in this phase. - -`RAEFSC-159` - Do not treat submit/modify/cancel return codes as canonical -execution feedback. - -`RAEFSC-160` - Do not infer canonical fill authority from market-trade feed. - -`RAEFSC-161` - Do not synthesize `liquidity_flag` to satisfy required field -authority. - -`RAEFSC-162` - Do not canonicalize `OrderStateEvent` in this phase. - -`RAEFSC-163` - Do not remove or alter `DerivedFillEvent` behavior in this -phase. - -`RAEFSC-164` - Do not implement adapter APIs in this phase. - ---- - -### C.6 Future implementation gate for backtest runtime scope - -`RAEFSC-165` - Canonical `FillEvent` implementation planning for backtest runtime -scope may begin only after all C.3 missing requirements are satisfied under -Appendix A/B contracts. - -`RAEFSC-166` - First implementation path should remain shadow-only unless a -separate explicit authority cutover decision is approved. - -`RAEFSC-167` - Before implementation/cutover, tests must be possible and -planned for: - -- deterministic `source_sequence` monotonicity and non-timestamp derivation; -- required-field source authority (including `liquidity_flag`); -- deterministic canonical correlation (including successor mapping where - applicable); -- no-double-counting behavior relative to `DerivedFillEvent`; -- deterministic global merge ordering under runner-owned `ProcessingPosition`. - ---- diff --git a/docs/runtime-execution-feedback-contract-v1.md b/docs/runtime-execution-feedback-contract-v1.md deleted file mode 100644 index 395c4c0..0000000 --- a/docs/runtime-execution-feedback-contract-v1.md +++ /dev/null @@ -1,166 +0,0 @@ -# Runtime Execution Feedback Contract v1 - ---- - -## Purpose and scope - -This document defines a boundary contract for when runtime is allowed to emit -canonical execution feedback into `core`, specifically `FillEvent`. - -This is a docs-contract slice only: - -- it does not implement `FillEvent` ingress; -- it does not change runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change compatibility projection behavior (`DerivedFillEvent`); -- it does not introduce new canonical event types. - ---- - -## Normative sources and precedence - -`REFC-01` - Main `docs` repository remains the semantic source of truth for -Event semantics, Event Stream, Processing Order, and execution/order lifecycle: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/time-model.md` -- `docs/docs/20-concepts/state-model.md` - -`REFC-02` - `core` implementation snapshot semantics are governed by -[Core Stable Contract v1](core-stable-contract-v1.md). - -`REFC-03` - This page is an implementation-facing core/runtime boundary contract -for current and near-future runtime execution feedback eligibility. It does not -redefine architecture semantics. - ---- - -## Current classification snapshot - -`REFC-04` - `FillEvent` is a canonical execution-event candidate in `core`. - -`REFC-05` - `DerivedFillEvent` is a compatibility projection artifact and is -non-canonical. - -`REFC-06` - `OrderStateEvent` is compatibility-only and non-canonical at the -canonical boundary. - -`REFC-07` - Snapshot-derived cumulative fill progression in current runtime flow -is not canonical-grade execution feedback for canonical `FillEvent` emission. - ---- - -## Runtime execution feedback contract v1 - -`REFC-08` - Runtime may emit canonical `FillEvent` only when source records are -explicit authoritative execution-feedback records from Venue or simulated Venue -execution path. - -`REFC-09` - Runtime must not emit canonical `FillEvent` from inference based -solely on compatibility order snapshots (`OrderStateEvent` materialization and -derived cumulative progression deltas). - ---- - -## FillEvent field source-authority matrix (v1) - -Current runtime-source statements below describe the current snapshot-driven path -as observed in this slice (`orders` snapshots -> `OrderStateEvent` -> -`DerivedFillEvent`), not a canonical execution feedback path. - -| FillEvent field | Required authority | Current runtime source availability | Sufficient now? | Reason if insufficient | -| --- | --- | --- | --- | --- | -| `ts_ns_exch` | Execution-feedback record timestamp for the execution update | Present from order snapshot timestamp | No | Snapshot timestamp is not guaranteed to represent an explicit canonical execution-feedback record boundary | -| `ts_ns_local` | Runtime receipt timestamp for the execution-feedback record | Present from order snapshot timestamp | No | Same boundary issue as `ts_ns_exch`; snapshot materialization is compatibility path | -| `instrument` | Execution-feedback record instrument identity | Present in snapshot/materialization context | No (as full contract) | Field exists, but source channel is compatibility snapshot path, not explicit execution feedback channel | -| `client_order_id` | Stable execution-feedback order identity | Present from snapshot order id | No (as full contract) | Identity exists, but source granularity/channel remains snapshot compatibility path | -| `side` | Authoritative side in execution feedback | Present in snapshot order view | No (as full contract) | Side exists, but source granularity/channel remains snapshot compatibility path | -| `filled_price` | Authoritative fill/execution-report price for emitted event granularity | Best-effort snapshot exec price may be present | No | Snapshot-provided price semantics are not contracted here as canonical execution-feedback granularity | -| `cum_filled_qty` | Authoritative cumulative filled quantity bound to execution-feedback record | Present as snapshot cumulative execution quantity | No | Available only via snapshot progression; not explicit execution feedback record channel | -| `time_in_force` | Authoritative order execution context | Present in snapshot order view | No (as full contract) | Field exists, but channel is compatibility snapshot materialization | -| `liquidity_flag` | Authoritative maker/taker/unknown classification in execution feedback contract | Not available in current snapshot-derived path | No | Required field lacks authoritative source in current runtime path | -| `intended_price` | Authoritative intended order price context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `intended_qty` | Authoritative intended order quantity context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `remaining_qty` | Authoritative remaining quantity context when provided | Present in snapshot order view | No (as full contract) | Optional field may be present, but canonical source-channel requirements are unmet | -| `fee` | Authoritative execution fee/rebate from execution feedback | Not available in current snapshot-derived path | No | Optional field unavailable in current path; no authoritative execution feedback source | - ---- - -## Minimum eligibility criteria for canonical FillEvent emission - -`REFC-10` - Source must be explicit execution feedback (Venue or simulated -Venue execution path), not inferred solely from compatibility snapshots. - -`REFC-11` - Runtime must define stable emitted-event granularity (for example, -per execution report or per cumulative execution update) and preserve it -deterministically across replay-equivalent runs. - -`REFC-12` - All required `FillEvent` fields must come from authoritative source -records under the runtime execution feedback contract. - -`REFC-13` - Required fields must not be heuristic/synthetic unless a future -explicit contract revision defines and permits such synthesis semantics. - -`REFC-14` - Canonical acceptance ordering must be deterministic via -`ProcessingPosition`, not timestamp-derived ordering. - -`REFC-15` - Runtime-emitted canonical `FillEvent` behavior must align with -existing `apply_fill_event` idempotence semantics (duplicate/regressing -cumulative progression as no-op). - -`REFC-16` - Runtime must define no-double-counting behavior between canonical -execution feedback path and compatibility projection path before any dual-path -operation. - ---- - -## Compatibility boundary preserved - -`REFC-17` - Snapshot-derived cumulative progression remains compatibility -projection (`DerivedFillEvent`) in current flow. - -`REFC-18` - `OrderStateEvent` remains compatibility-only and non-canonical at -the canonical boundary. - -`REFC-19` - This contract does not modify snapshot ingestion behavior. - ---- - -## Current deferred status - -`REFC-20` - Current runtime does not satisfy this v1 execution-feedback -contract for canonical `FillEvent` emission. - -`REFC-21` - Explicit runtime `FillEvent` ingress remains deferred. - -`REFC-22` - Snapshot-derived cumulative progression remains compatibility -projection behavior in this phase. - ---- - -## Prohibited behavior in this phase - -`REFC-23` - Do not promote `OrderStateEvent` to canonical execution event in -this phase. - -`REFC-24` - Do not derive canonical `FillEvent` from snapshot deltas alone. - -`REFC-25` - Do not synthesize required `liquidity_flag` as `"unknown"` unless a -future explicit contract revision permits and defines that behavior. - -`REFC-26` - Do not dual-write canonical `FillEvent` and `DerivedFillEvent` for -the same source path without explicit reconciliation/no-double-counting rules. - ---- - -## Future implementation gate - -`REFC-27` - Runtime `FillEvent` ingress implementation may start only after a -runtime adapter/source provides authoritative execution-feedback records that -satisfy `REFC-10` through `REFC-16`. - -`REFC-28` - Until then, execution feedback canonicalization remains deferred and -compatibility projection behavior remains unchanged. - diff --git a/docs/runtime-to-coreconfiguration-contract-v1.md b/docs/runtime-to-coreconfiguration-contract-v1.md deleted file mode 100644 index 159c90e..0000000 --- a/docs/runtime-to-coreconfiguration-contract-v1.md +++ /dev/null @@ -1,207 +0,0 @@ -# Runtime-to-CoreConfiguration Contract Boundary v1 - ---- - -## Purpose and scope - -This document defines a **boundary contract draft (v1)** for how runtime-owned run -configuration is mapped into `CoreConfiguration` before calling core canonical -processing APIs. - -This is a boundary-contract slice that originated as planning-oriented guidance -and now documents the currently implemented ownership boundary: - -- it defines ownership boundaries and validation expectations; -- it documents the minimum mapping target required by current core behavior; -- runtime mapping is implemented in `core-runtime` under runtime ownership; -- this page does not redefine runtime implementation internals; -- it does not introduce new core behavior. - ---- - -## Normative sources and precedence - -`RCC-01` — Semantic definitions remain in the main `docs` repository and are the -source of truth, including: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/state-model.md` -- `docs/docs/20-concepts/time-model.md` - -`RCC-02` — Current core implementation guarantees are defined by: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) - -`RCC-03` — If broader architecture targets in main docs exceed current core -implementation, this contract only defines runtime-to-core boundary obligations -needed to satisfy current core v1 behavior. - ---- - -## Boundary ownership model - -`RCC-04` — `core` consumes semantic configuration only through -`CoreConfiguration`. - -`RCC-05` — Runtime owns reading external run configuration inputs (for example: -run JSON, live config, backtest config). - -`RCC-06` — Runtime owns mapping run configuration into `CoreConfiguration` -before invoking core canonical processing/fold APIs. - -`RCC-07` — `core` must not read runtime JSON files directly. - -`RCC-08` — `core` must not depend on runtime/engine config classes (including -backtest engine config types, live engine config types, or runtime config classes) at the -configuration boundary. - -`RCC-09` — A run config may contain multiple sections (for example `engine`, -`strategy`, `risk`, `core`), but `core` receives only `CoreConfiguration`. - -`RCC-10` — No duplicate maintenance principle: - -- core-semantic values must have one semantic source of truth in run config; -- if runtime also needs those values, runtime reuses/maps from that same source, - rather than maintaining divergent duplicates. - ---- - -## Minimum v1 mapping target for current core behavior - -`RCC-11` — Runtime-produced `CoreConfiguration` must provide: - -- `CoreConfiguration.version` -- `CoreConfiguration.payload.market.instruments..tick_size` -- `CoreConfiguration.payload.market.instruments..lot_size` -- `CoreConfiguration.payload.market.instruments..contract_size` - -`RCC-12` — This v1 target is intentionally minimal and reflects current core -contract needs. It does not define a complete future runtime schema. - ---- - -## Validation and failure expectations - -`RCC-13` — Missing core-semantic configuration section at runtime boundary must -fail before canonical event processing (**explicit-or-fail**). - -`RCC-14` — Missing `market` / `instruments` / `` mapping path must -fail. - -`RCC-15` — Missing required instrument fields (`tick_size`, `lot_size`, -`contract_size`) must fail. - -`RCC-16` — Invalid values must fail, including: - -- `None` -- `bool` -- non-numeric -- non-finite -- non-positive - -`RCC-17` — Boundary failures must occur before events are folded into core -state transitions. - -`RCC-18` — Runtime validates before calling core; core boundary validation still -remains authoritative at call time. - ---- - -## Illustrative run-config shape (non-normative) - -This shape is an example for boundary explanation only; it is not a required -schema. - -```json -{ - "engine": { - "...": "..." - }, - "strategy": { - "...": "..." - }, - "risk": { - "...": "..." - }, - "core": { - "version": "v1", - "market": { - "instruments": { - "": { - "tick_size": 0.01, - "lot_size": 0.001, - "contract_size": 1.0 - } - } - } - } -} -``` - ---- - -## Corresponding CoreConfiguration shape produced by runtime - -```json -{ - "version": "v1", - "payload": { - "market": { - "instruments": { - "": { - "tick_size": 0.01, - "lot_size": 0.001, - "contract_size": 1.0 - } - } - } - } -} -``` - ---- - -## Boundary responsibility table - -| Boundary concern | Owner | Contract expectation | -| --- | --- | --- | -| Run JSON / live config / backtest config reading | Runtime | Runtime reads/parses external configuration inputs. | -| `CoreConfiguration` object construction | Runtime (constructs), core (consumes) | Runtime constructs `CoreConfiguration`; core accepts only `CoreConfiguration` at boundary APIs. | -| Reducer semantics | Core | Core owns deterministic reducer behavior and canonical boundary semantics. | -| Validation | Runtime + core | Runtime validates before call; core boundary still validates and rejects invalid/missing required semantics. | - ---- - -## Explicitly out of scope for this v1 draft - -`RCC-19` — Runtime implementation details. - -`RCC-20` — JSON schema implementation. - -`RCC-21` — Live/backtest adapter-specific mapping internals. - -`RCC-22` — Runtime storage/persistence semantics. - -`RCC-23` — Event Stream storage or replay engine implementation. - -`RCC-24` — Control-Time Event injection implementation details. - -`RCC-25` — New canonical event type introduction. - -`RCC-26` — `OrderStateEvent` canonicalization. - -`RCC-27` — Any change to `FillEvent`, `CoreConfiguration`, `EventStreamEntry`, -or core processing API behavior. - -`RCC-28` — `ProcessingContext` / `EventStreamCursor` introduction. - ---- - -## Future work notes (non-binding) - -- Future runtime phases may define concrete mapping mechanics and schemas under - runtime ownership. -- Any future expansion of canonical event taxonomy must be handled as a separate - explicit semantic change, not as part of this boundary draft. diff --git a/docs/semantic-core-upgrade-milestone-closure-v1.md b/docs/semantic-core-upgrade-milestone-closure-v1.md deleted file mode 100644 index c3ac0a8..0000000 --- a/docs/semantic-core-upgrade-milestone-closure-v1.md +++ /dev/null @@ -1,109 +0,0 @@ -# Semantic Core Upgrade Milestone Closure v1 - ---- - -## Purpose and scope - -This document records a docs-only milestone closure snapshot for the current -Semantic Core Upgrade state across `core` and `core-runtime`. - -This page: - -- does not change production code; -- does not change runtime behavior; -- does not change reducers or event taxonomy; -- does not implement `FillEvent` runtime ingress; -- does not add adapter APIs; -- does not canonicalize `OrderStateEvent`; -- does not change `DerivedFillEvent` or snapshot ingestion behavior; -- does not implement `ProcessingContext`; -- does not implement replay/storage/EventStream persistence. - ---- - -## Semantic source and contract references - -Main semantic source of truth remains the main `docs` repository, including: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/determinism-model.md` -- `docs/docs/20-concepts/state-model.md` - -Implementation-facing contract references in `core/docs`: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Venue Adapter Capability Model v1](venue-adapter-capability-model-v1.md) -- [ProcessingContext / EventStreamCursor Contract v1](processing-context-event-stream-cursor-contract-v1.md) -- [EventStreamCursor Characterization Note v1](event-stream-cursor-characterization-v1.md) -- [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) -- [Control-Time Event Contract v1](control-time-event-contract-v1.md) - ---- - -## Milestone status snapshot - -### Satisfied in current implementation - -- `EventStreamEntry` minimal contract (`position`, `event`) and call-level configuration input. -- `ProcessingPosition` monotonic canonical boundary ordering. -- `CoreConfiguration` (`version` / `payload` / stable `fingerprint`) with boundary typing. -- Positioned canonical `MarketEvent` path consuming `CoreConfiguration` instrument metadata with explicit-or-fail validation. -- Dispatch-time canonical `OrderSubmittedEvent` boundary for successful `new` dispatch. -- Canonical `ControlTimeEvent` runtime injection on realized scheduled deadline boundary. -- Runtime-only `EventStreamCursor` ordering helper implemented in `core-runtime` and used by strategy runner canonical paths. -- Compatibility boundary guards and semantics coverage remain in place (`OrderStateEvent` and `DerivedFillEvent` non-canonical at canonical boundary). -- Runtime-to-`CoreConfiguration` mapping implemented in `core-runtime` and validated at runtime boundary. - -### Transitional in current implementation - -- `StrategyState` contains canonical reducer paths and compatibility reducer/projection paths concurrently. -- Post-submission lifecycle progression after `Submitted` remains snapshot/compatibility-driven (`ingest_order_snapshots` / `OrderStateEvent` / `apply_order_state_event` / `DerivedFillEvent` projection). -- `ControlTimeEvent` reducer behavior is currently no-op transition slice (no queue/rate/control reducer migration implied). -- Backtest-runtime capability support is partial in the model: market/submitted/control-time boundaries are wired; execution-feedback source capability remains unsatisfied. - -### Deferred in current implementation - -- Runtime canonical `FillEvent` ingress. -- `ExecutionFeedbackRecordSource` capability satisfaction. -- Full post-submission lifecycle migration to canonical execution-feedback authority. -- Replay/storage/EventStream persistence implementation. -- `ProcessingContext` implementation. -- Full adapter interface abstraction rollout. - ---- - -## Usability statement - -Current usability decision: - -- Usable for current backtest runtime integrations: **Yes**. -- Usable as a transitional semantic milestone: **Yes**. -- Usable as final full canonical Event Stream implementation: **No**. - ---- - -## Test status at closure snapshot - -Requested suite status used for this closure snapshot: - -- `python -m pytest -q core/tests/semantics` -> `193 passed` -- `python -m pytest -q core-runtime/tests` -> `71 passed` - ---- - -## Closure decision - -For this milestone scope, the Semantic Core Upgrade milestone is considered -**closed as a transitional semantic implementation milestone**. - -This closure does not claim final canonical Event Stream completeness and does -not alter deferred gates documented in the execution-feedback, compatibility-map, -and adapter capability contracts. - ---- diff --git a/docs/venue-adapter-capability-model-v1.md b/docs/venue-adapter-capability-model-v1.md deleted file mode 100644 index ee9c462..0000000 --- a/docs/venue-adapter-capability-model-v1.md +++ /dev/null @@ -1,325 +0,0 @@ -# Venue Adapter Capability Model v1 - ---- - -## Purpose and scope - -This document defines a docs-only, venue-agnostic capability model for Runtime / -Venue Adapter source boundaries used by `core` processing. - -This slice is architecture-boundary documentation only: - -- it does not implement adapter APIs; -- it does not implement canonical `FillEvent` ingress; -- it does not change runtime behavior; -- it does not canonicalize `OrderStateEvent`; -- it does not change `DerivedFillEvent` behavior; -- it does not change snapshot ingestion behavior; -- it does not change reducers or event taxonomy; -- it does not implement replay/storage/`ProcessingContext`/`EventStreamCursor`. - -`VACM-01` - Main `docs` remains the semantic source of truth for Event, -Event Stream, Processing Order, Configuration, State, Intent, Order lifecycle, -determinism, Runtime, and Venue Adapter semantics. - -`VACM-02` - This page is implementation-facing boundary documentation for -capability classification and authority mapping only. It does not redefine -architecture semantics. - -`VACM-03` - `core` remains venue-agnostic. Runtime/adapters expose source -capabilities; `core` consumes canonical Events and explicit configuration -through existing contracts. - -`VACM-04` - This page must remain consistent with: - -- [Core Stable Contract v1](core-stable-contract-v1.md) -- [Post-Submission Lifecycle Compatibility Map v1](post-submission-lifecycle-compatibility-map-v1.md) -- [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) -- [Runtime/Adapter Execution Feedback Source Contract v1](runtime-adapter-execution-feedback-source-contract-v1.md) - -Normative semantic references from main `docs`: - -- `docs/docs/00-guides/terminology.md` -- `docs/docs/20-concepts/event-model.md` -- `docs/docs/20-concepts/order-lifecycle.md` -- `docs/docs/20-concepts/snapshot-driven-inputs.md` -- `docs/docs/20-concepts/determinism-model.md` - ---- - -## Authority classification model - -`VACM-05` - Adapter/runtime source capabilities are classified by semantic -authority at the canonical boundary: - -- **canonical event capable** -- **compatibility projection only** -- **runtime/internal only** -- **optional future capability** - -`VACM-06` - **canonical event capable** means the capability can provide source -input that may be represented as canonical Event Stream input under positioned -canonical ingestion and global `ProcessingPosition` ordering authority. - -`VACM-07` - **compatibility projection only** means the capability may feed -compatibility materialization/projection paths but must not be treated as -canonical Event Stream authority in this phase. - -`VACM-08` - **runtime/internal only** means the capability is orchestration or -transport behavior and must not be promoted to canonical Event Stream authority -without explicit separate contract changes. - -`VACM-09` - **optional future capability** means the capability is recognized as -architecturally valid but is not currently satisfied for canonical authority and -remains gated by explicit contracts before canonicalization. - -`VACM-10` - Data-field presence alone does not grant canonical authority. -Canonical authority requires eligible source class, deterministic ordering -contract, and boundary eligibility under existing contracts. - ---- - -## Capability categories and authority implications - -This section defines the capability categories in scope and their current -boundary implications. - -### 1) Market input capability - -`VACM-11` - Purpose: provide market observations/snapshots/deltas as Runtime -input that can be represented as canonical `MarketEvent` stream input. - -`VACM-12` - Possible classifications: - -- canonical event capable (current canonical path); -- compatibility projection only (if a specific runtime path uses non-canonical - projection materialization); -- runtime/internal only (for transport plumbing not entering canonical boundary). - -`VACM-13` - Current implication: market capability can produce canonical Event -Stream input when represented through canonical `MarketEvent` boundary handling. - -`VACM-14` - Guardrails/non-goals: - -- no timestamp-derived `ProcessingOrder`; -- no hidden mutable snapshot state outside Event processing; -- no renaming-only promotion of non-canonical snapshot plumbing to canonical - authority. - -### 2) Order submission result boundary capability - -`VACM-15` - Purpose: expose dispatch-success boundary semantics for order-entry -authority (`Submitted`) via canonical `OrderSubmittedEvent`. - -`VACM-16` - Possible classifications: - -- canonical event capable for dispatch-time Submitted entry authority; -- runtime/internal only for outbound command transport details. - -`VACM-17` - Current implication: successful `new` dispatch boundary can produce -canonical `OrderSubmittedEvent`; failed dispatch and non-entry command classes -remain non-entry behaviors per existing contract. - -`VACM-18` - Guardrails/non-goals: - -- no post-submission lifecycle authority is introduced by this capability; -- no reclassification of `OrderSubmittedEvent` as execution feedback; -- no change to existing compatibility sidecar bookkeeping semantics. - -### 3) Order snapshot capability - -`VACM-19` - Purpose: provide order-condition snapshots used by compatibility -materialization/projection paths after submission. - -`VACM-20` - Possible classifications: - -- compatibility projection only (current authority status); -- runtime/internal only (transport/materialization mechanisms). - -`VACM-21` - Current implication: snapshot order capability remains -compatibility-only via `ingest_order_snapshots` / `OrderStateEvent` / -`DerivedFillEvent` projection paths. - -`VACM-22` - Canonical Event Stream production from this capability is not -permitted in this phase for execution-feedback authority. - -`VACM-23` - Guardrails/non-goals: - -- no `OrderStateEvent` canonicalization; -- no snapshot-derived canonical execution feedback promotion; -- no reducer or snapshot lifecycle rewrite in this slice. - -### 4) Account snapshot capability - -`VACM-24` - Purpose: provide account-condition snapshots (balances/positions and -related account views) for runtime and/or compatibility projections. - -`VACM-25` - Possible classifications: - -- compatibility projection only; -- runtime/internal only; -- optional future capability for explicit canonical representation under a - separate contract. - -`VACM-26` - Current implication: account snapshot capability is -compatibility/runtime-internal unless separately and explicitly canonicalized in -future contract work. - -`VACM-27` - Guardrails/non-goals: - -- snapshot naming must not imply canonical authority; -- any future canonicalization must be explicit, versioned, and replay-stable; -- no implicit event taxonomy expansion in this slice. - -### 5) Control-time realization capability - -`VACM-28` - Purpose: realize non-canonical control scheduling obligations into -canonical `ControlTimeEvent` injection boundaries. - -`VACM-29` - Possible classifications: - -- canonical event capable for realized control-time boundaries; -- runtime/internal only for scheduling orchestration mechanics. - -`VACM-30` - Current implication: capability is canonical event capable for the -current sparse scheduled-deadline transition behavior. - -`VACM-31` - Guardrails/non-goals: - -- no periodic control tick introduction; -- no separate runtime tick authority outside Event processing; -- no queue/rate reducer migration introduced here. - -### 6) Execution feedback capability - -`VACM-32` - Purpose: provide authoritative execution-feedback source records that -may enable future canonical `FillEvent` mapping and ingress. - -`VACM-33` - Possible classifications: - -- optional future capability (current primary classification); -- canonical event capable only after explicit gate satisfaction under REFC/RAEFSC; -- runtime/internal only for ineligible signaling paths. - -`VACM-34` - Current implication: canonical `FillEvent` ingress remains deferred. -Snapshot-derived progression and compatibility artifacts remain non-canonical. - -`VACM-35` - Canonical Event Stream production from execution feedback capability -is gated and not enabled by this document. - -`VACM-36` - Guardrails/non-goals: - -- no `FillEvent` ingress implementation; -- no synthetic required-field authority (including `liquidity_flag`); -- no dual-authority fill progression without explicit no-double-counting policy. - ---- - -## Current backtest runtime capability map (Phase 6C snapshot) - -`VACM-37` - This table records current capability support classification for the -current backtest runtime adapter integration without changing behavior. - -| capability | current backtest runtime support | classification | current event/artifact path | notes / limitations | -| --- | --- | --- | --- | --- | -| market input capability | supported | canonical event capable | canonical `MarketEvent` positioned ingestion path | canonical market path active; ordering remains `ProcessingPosition` authority | -| order submission result boundary capability | supported (entry boundary) | canonical event capable | successful `new` dispatch -> canonical `OrderSubmittedEvent` | failed `new` dispatch emits no `OrderSubmittedEvent`; replace/cancel do not create new entry event | -| order snapshot capability | supported | compatibility projection only | `ingest_order_snapshots` -> `OrderStateEvent` -> `apply_order_state_event`; `DerivedFillEvent` projection | post-submission lifecycle remains compatibility authority in current phase | -| account snapshot capability | partially supported as runtime/compatibility views | compatibility projection only / runtime-internal only | runtime/account snapshot views and compatibility materialization where present | not canonical authority unless later explicit canonical contract work | -| control-time realization capability | supported (current transition slice) | canonical event capable | realized deadline obligation -> canonical `ControlTimeEvent` injection | sparse/deadline-style realization only; no periodic tick model | -| execution feedback capability | not supported as authoritative source | optional future capability (currently missing/ineligible) | no eligible `ExecutionFeedbackRecordSource` path in current integration | blocked by missing authoritative source channel, deterministic non-timestamp `source_sequence`, source-authoritative liquidity, and explicit canonical correlation gates | - -`VACM-38` - Current backtest runtime execution-feedback feasibility remains blocked by -the missing authoritative `ExecutionFeedbackRecordSource` capability. - -`VACM-39` - Snapshot compatibility path remains active semantic authority for -post-submission progression in this phase. - ---- - -## Future live venue capability expectations (non-implemented) - -`VACM-40` - A future live venue adapter may expose native execution-report -records that can satisfy `ExecutionFeedbackRecordSource` source-authority -requirements. - -`VACM-41` - A future live venue adapter may expose source-authoritative -liquidity classification (`maker` / `taker` / explicit `unknown`) suitable for -required-field authority. - -`VACM-42` - A future live venue adapter may expose deterministic replay-stable -correlation to canonical order identity (`instrument + client_order_id`), -including explicit successor-mapping chain behavior where applicable. - -`VACM-43` - A future live venue adapter may expose deterministic non-timestamp -`source_sequence` semantics suitable for runner merge policy into global -`ProcessingPosition`. - -`VACM-44` - Canonical runtime `FillEvent` ingress remains gated by REFC/RAEFSC -contracts and is not enabled by capability expectation statements alone. - ---- - -## Canonical vs compatibility implications - -`VACM-45` - Data availability does not equal canonical authority. - -`VACM-46` - Snapshot field availability must not be promoted to canonical -execution-feedback authority without explicit eligible source contract -satisfaction. - -`VACM-47` - Runtime/internal wakeups, signaling hooks, and synchronous return -codes are not canonical Event Stream authority. - -`VACM-48` - Compatibility projection paths remain compatibility authority until -explicit gates are satisfied and separately approved for canonical cutover. - -`VACM-49` - Optional future capabilities require explicit gate satisfaction, -ordering policy, and no-double-counting policy before any canonicalization -planning. - ---- - -## Guardrails - -`VACM-50` - `core` consumes canonical Events and explicit configuration at the -boundary; `core` does not consume venue-specific internal structures as semantic -authority. - -`VACM-51` - Adapter/runtime naming must not promote snapshots or internal -signals to canonical authority by terminology alone. - -`VACM-52` - Execution feedback capability must satisfy REFC/RAEFSC eligibility, -field authority, identity/correlation, deterministic ordering, and -no-double-counting requirements before canonical `FillEvent` ingress planning. - -`VACM-53` - `ProcessingPosition` remains global canonical acceptance-order -authority across canonical categories. - -`VACM-54` - `ProcessingOrder` must not be timestamp-derived. - -`VACM-55` - This model does not alter current canonical/non-canonical taxonomy -or compatibility boundaries in existing contracts. - ---- - -## Explicit non-goals for Phase 6C - -`VACM-56` - No adapter API methods/signatures are defined or implemented. - -`VACM-57` - No runtime-specific `core` semantics are introduced. - -`VACM-58` - No runtime canonical `FillEvent` ingress implementation. - -`VACM-59` - No `OrderStateEvent` canonicalization. - -`VACM-60` - No `DerivedFillEvent` removal or behavior change. - -`VACM-61` - No snapshot lifecycle rewrite. - -`VACM-62` - No reducer or runtime behavior change. - -`VACM-63` - No replay/storage/`ProcessingContext`/`EventStreamCursor` -implementation. - ---- From 3f971b316b1c8a8482870b27750eed1da60185a4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 21:44:47 +0000 Subject: [PATCH 25/53] docs(core): align root docs with CoreStep MVP baseline --- CHANGELOG.md | 82 +++++++------------ CONTRIBUTING.md | 81 +++++++++---------- README.md | 211 ++++++++++-------------------------------------- SECURITY.md | 78 ++++++------------ docs/README.md | 42 +++++----- 5 files changed, 154 insertions(+), 340 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5112f9..779ea67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,60 +1,40 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to the TradingChassis Core package are documented in this file. -The format is based on Keep a Changelog -and this project adheres to Semantic Versioning. +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project +adheres to Semantic Versioning. ## [Unreleased] -## [0.1.0] - 2026-02-17 +### Added -Initial public release of the core. +- CoreStep MVP baseline documentation index alignment in `docs/README.md`. -### Added +### Changed + +- Recorded accepted and frozen CoreStep MVP baseline behavior: + - MarketEvent CoreStep path behind `enable_core_step_market_dispatch` + - ControlTimeEvent CoreStep path behind `enable_core_step_control_time_dispatch` + - Mixed wakeup collapse behind `enable_core_step_wakeup_collapse` + - rc `== 3` order/execution feedback CoreStep path behind + `enable_core_step_order_feedback_dispatch` +- Clarified runtime dispatch contract for migrated flag-on paths: + runtime dispatches `CoreStepResult.dispatchable_intents`. +- Clarified `OrderSubmittedEvent` dispatch-success emission boundary and ordering before + `mark_intent_sent`. + +### Compatibility + +- `ControlSchedulingObligation` remains non-canonical Core output. +- Runtime continues to own pending obligation realization and `ControlTimeEvent` injection. +- `GateDecision` remains compatibility-only for legacy/default-off paths. +- Migration flags remain default `false`. + +### Documentation + +- Root repository documentation aligned to current MVP baseline language and boundaries. + +## [0.1.0] - 2026-02-17 -#### Core Domain -- Explicit order state machine -- Structured domain types and reject reasons -- Slot-based order tracking -- Event bus and event sink abstractions -- JSON schema validation for domain events - -#### Risk Layer -- Configurable risk engine -- Risk constraint enforcement -- Deterministic risk gating before execution - -#### Backtest Layer -- Integration with an external backtest runtime -- Strategy runner abstraction -- Venue adapter interface -- Deterministic event processing pipeline - -#### Orchestration -- Segment-based execution model -- Parameter sweep runtime -- Experiment and segment entrypoints -- Prometheus metrics integration -- MLflow-compatible logging hooks - -#### Execution Modes -- Fully local execution example -- Cloud-native runtime entrypoints -- S3-compatible storage adapter - -#### Strategy -- Base strategy interface -- Structured strategy configuration - -#### Testing -- Semantic invariant test suite -- Order state transition validation -- Queue dominance rules -- Risk constraint validation -- Schema conformance tests - -#### Tooling -- Dev container configuration -- Development validation scripts -- Dependency compilation helper +Initial public release of `tradingchassis_core`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0694e11..1622e13 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,63 +1,58 @@ -# Contributing +# Contributing to TradingChassis Core -Thank you for your interest in contributing! +Thanks for contributing to `tradingchassis_core`. -This repository focuses on deterministic, event-driven trading architecture. -Contributions should preserve clarity, explicitness and reproducibility. +This repository is the Core semantic package. Keep changes deterministic, explicit, and scoped to +Core responsibilities. ---- +## Repository Scope -## Design Principles +Core owns semantic models and deterministic processing contracts, including canonical event +reduction and CoreStep/CoreWakeupStep result contracts. -All contributions must respect the core design philosophy: +Core does not own runtime I/O, venue adapters, or runtime orchestration concerns. -- Determinism over convenience -- Explicit state modeling -- No hidden side effects -- Risk-first architecture -- Clear domain boundaries +## Development Setup -Avoid introducing implicit behavior or non-deterministic execution paths. +From the `core` repository root: ---- - -## Workflow - -1. Fork the repository -2. Create a feature branch -3. Commit small, logical changes -4. Open a Pull Request with clear description - ---- - -## Commit Style +```bash +python -m pip install -e ".[dev]" +python -m pytest +``` -Use clear messages: +Use Python 3.11+. -feat: add monitoring overlay -fix: correct SecretProviderClass parameters -docs: update bootstrap instructions +## Architecture Constraints ---- +Contributions must preserve the accepted MVP baseline and boundaries: -## Development Environment +- Core must not depend on runtime/hftbacktest integration layers +- Core consumes canonical events and returns deterministic Core outputs +- Runtime owns external I/O, dispatch execution, and scheduling realization timing +- For migrated flag-on paths, runtime dispatches from `CoreStepResult.dispatchable_intents` +- `ControlSchedulingObligation` remains non-canonical Core output +- `GateDecision` remains compatibility-only for legacy/default-off paths -Recommended: +Do not introduce behavior that implies final-architecture completion. -- Python 3.11.x -- Dev Container (provided in this repository) +## Testing Expectations -Alternatively: +- Add or update Core semantic tests for any behavior change +- Keep tests deterministic and scoped to the Core package +- Runtime integration validation may require a separate runtime environment; treat missing runtime + dependencies as environment/tooling blockers, not as Core semantic failures -```bash -pip install -e . -``` +## Documentation Expectations ---- +When semantics or boundaries change: -## Testing +- Update relevant files under `docs/` +- Keep MVP baseline, compatibility, and post-MVP boundaries explicit +- Avoid mixing speculative final-state claims into MVP docs -Before submitting: +## Contribution Hygiene -- The `./scripts/check.sh` script must pass -- All backtests must complete successfully and produce result artifacts +- Prefer small, focused pull requests +- Avoid mixing broad refactors with documentation-only or behavior-only changes +- Do not introduce docs tooling or generated docs unless explicitly requested for the phase diff --git a/README.md b/README.md index 1b18fc7..c13c57d 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,63 @@ -# TradingChassis — Core +# TradingChassis Core -![CI](https://github.com/TradingChassis/core/actions/workflows/tests.yaml/badge.svg) -![Python](https://img.shields.io/badge/python-3.11+-blue) -![License](https://img.shields.io/badge/license-MIT-green) +Deterministic semantic core package for TradingChassis. -Deterministic semantic Core library for TradingChassis. +This repository provides `tradingchassis_core`, the reusable core library that defines canonical +event processing contracts, state reduction boundaries, and CoreStep/CoreWakeupStep APIs. -This repository provides the reusable Core package (`tradingchassis_core`) that defines -event-driven processing semantics, state derivation boundaries, strategy interfaces, risk policy -contracts, and execution control primitives. +## What This Package Is ---- +- A deterministic core processing library for canonical event-driven semantics +- The home of CoreStep/CoreWakeupStep orchestration contracts +- A package that returns runtime-facing outputs such as + `CoreStepResult.dispatchable_intents` and compatibility bridge data where needed -## Overview +## What This Package Is Not -Core is a library, not a runtime shell. +- Not venue/runtime I/O ownership +- Not a venue adapter or hftbacktest runtime shell +- Not a complete final live trading stack by itself -- Canonical processing model: Event Stream + Configuration -> derived State -- Explicit Strategy, Risk Engine, and Execution Control boundaries -- Deterministic behavior under identical Event Stream and Configuration -- Runtime environments consume this package and provide integration wiring +## Current Status (Accepted MVP Baseline) ---- +- The CoreStep MVP baseline is accepted and frozen +- Migrated paths exist behind flags that remain default `false` +- Runtime dispatches `CoreStepResult.dispatchable_intents` on migrated flag-on paths +- Runtime does not productively use runtime `risk.decide_intents` or `GateDecision` on migrated + flag-on paths +- `GateDecision` remains a temporary compatibility mechanism for legacy/default-off paths +- This MVP is not the final architecture -## What Core is - -Core provides: - -- semantic/domain types and value models -- processing-order and state-derivation primitives -- risk-policy interfaces and enforcement boundaries -- execution-control abstractions -- strategy interfaces for emitting Intents from derived State - ---- - -## What Core is not - -Core does not provide: - -- local/cluster runtime entrypoints -- Kubernetes or Argo orchestration -- runtime image/deployment plumbing -- full runtime ingress, replay, or storage infrastructure - -Those responsibilities live in Core Runtime (`core-runtime`). - ---- - -## Current semantic status - -The transitional semantic upgrade milestone is closed. - -Core remains the canonical semantic library, and current runtime usage focuses on canonical -`MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent` paths. - -Compatibility/deferred runtime capabilities still exist and are intentionally not described here as -fully complete canonical coverage. - ---- - -## Key concepts - -Terminology follows `docs/docs/00-guides/terminology.md`: - -- Event -- Event Stream -- Processing Order -- Configuration -- State -- Intent -- Risk Engine -- Queue -- Queue Processing -- Execution Control -- Order -- Core -- Runtime -- Venue Adapter - ---- - -## Canonical boundary - -Core guarantees deterministic semantics and reusable contracts. - -Runtimes supply environment-specific concerns such as: - -- ingress wiring -- adapter implementations -- orchestration entrypoints -- persistence/replay infrastructure - ---- - -## Canonical vs compatibility artifacts - -At the Core level: - -- Canonical artifacts are semantic models and deterministic processing contracts -- Compatibility artifacts are transitional runtime-facing paths maintained for migration parity - -The runtime-level capability matrix is documented in `core-runtime/README.md`. - ---- - -## Package and import names - -- Human-facing concept name: Core -- Distribution/project name: `tradingchassis-core` -- Python import package: `tradingchassis_core` - -Install: - -```bash -python -m pip install -e . -``` - -Install with dev extras: - -```bash -python -m pip install -e ".[dev]" -``` - ---- - -## Repository structure - -```text -tradingchassis_core/ Core package root -tradingchassis_core/core/ Domain and semantic primitives -tradingchassis_core/strategies/ Strategy interfaces and config -tests/ Core test suites -scripts/ Developer helper scripts -``` - ---- - -## Development setup - -Requirements: - -- Python 3.11+ - -Recommended local setup: - -```bash -python -m pip install -e ".[dev]" -``` - ---- - -## Test commands +## Quickstart From the `core` repository root: ```bash +python -m pip install -e ".[dev]" python -m pytest ``` -From a monorepo parent containing `core/`: - -```bash -python -m pytest -q core/tests -``` - ---- - -## Relationship to Core Runtime - -Core Runtime (`core-runtime`) provides runtime execution around Core, including: - -- local backtest-runtime execution entrypoints -- Argo/runtime orchestration entrypoints -- runtime configuration and environment wiring -- local output artifacts under `.runtime/local/results/` - -Core provides the deterministic semantics those runtime paths consume. +Runtime integration tests may require separate runtime dependencies/environment setup. Keep core +package validation centered on `core` tests in this repository. ---- +## Architecture Entry Points -## Documentation index +- Docs start page: `docs/README.md` +- CoreStep MVP baseline: `docs/core-step-mvp-baseline.md` +- Core vs Runtime responsibilities: `docs/core-runtime-responsibility-model.md` +- Event model: `docs/event-model.md` +- Risk vs execution control boundary: `docs/risk-vs-execution-control.md` +- GateDecision compatibility status: `docs/gate-decision-compatibility.md` -- Terminology source of truth: `docs/docs/00-guides/terminology.md` -- Runtime capabilities and entrypoints: `core-runtime/README.md` +## Minimal Public API Orientation ---- +- Step entry points: `run_core_step`, `run_core_wakeup_step` +- Runtime-facing dispatch output: `CoreStepResult.dispatchable_intents` +- Canonical event models include `MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, and + `OrderExecutionFeedbackEvent` -## License and versioning +## Repository Guidance -MIT licensed. Versioning follows semantic versioning. +- Contributing guide: `CONTRIBUTING.md` +- Changelog: `CHANGELOG.md` +- Security policy: `SECURITY.md` diff --git a/SECURITY.md b/SECURITY.md index 9e224c4..4678273 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,72 +1,44 @@ # Security Policy -## Supported Versions +## Supported Versions and Status -Only the latest version on the `main` branch is actively maintained. +The active supported line is the current `main` branch and accepted MVP baseline for this Core +repository. -Older commits and historical states of the repository may not receive security updates or patches. - ---- +Older commits may not receive security fixes. ## Reporting a Vulnerability -If you discover a security vulnerability, please **do not open a public GitHub issue**. - -Instead, report it responsibly via: - -- GitHub Security Advisories -- Direct contact with the repository owner (if necessary) - -When submitting a report, please include: - -- A clear description of the vulnerability -- Steps to reproduce (if applicable) -- Potential impact and affected components -- Any suggested mitigation or fix - -Valid reports will be acknowledged in a timely manner and handled through responsible disclosure. - ---- - -## Security Scope - -This repository provides: - -- Deterministic backtesting architecture -- Risk-aware execution simulation -- Event-driven domain modeling - -It does **not** currently provide production-grade live trading infrastructure. - -Live exchange connectivity is under development and not feature-complete. - ---- +Do not report vulnerabilities in public issues. -## Dependency Security +Use a private security advisory workflow if available for this repository, or contact project +maintainers through the project's configured private channel. -- Dependencies are explicitly defined -- Python version is pinned (3.11.x) -- External libraries should be kept up to date +Include: -Security-related dependency updates are prioritized. +- affected component(s) +- reproduction details and impact +- suggested mitigations (if known) ---- +## Scope -## Responsible Usage +This policy covers the Core package in this repository, including: -This code is intended for research and controlled environments. +- semantic event-processing contracts +- state and decision model handling +- package integrity and dependency usage in Core -Users are responsible for: +## Out of Scope and Disclaimers -- Secure handling of API credentials -- Secure deployment of live trading components -- Validation of risk configurations +- No financial or trading performance guarantee is provided +- Safe live trading operation is not guaranteed without runtime/venue-specific validation -This repository does not assume liability for financial losses -resulting from misuse or incorrect configuration. +## Secrets and Credentials ---- +Never commit live secrets to this repository, including: -## Disclosure Policy +- API keys and venue credentials +- account identifiers tied to real accounts +- private trading data dumps -Please allow reasonable time for investigation and remediation before public disclosure of any reported vulnerabilities. +Tests and documentation examples must use synthetic or non-sensitive data only. diff --git a/docs/README.md b/docs/README.md index 4baba65..1ff508c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,30 +1,26 @@ -# Core Docs Baseline (MVP) +# TradingChassis Core Documentation -This directory documents the accepted and frozen CoreStep MVP baseline for the -`core` repository and its runtime integration boundary. +This directory is the current documentation start point for the `core` repository. -Use these docs for current architecture decisions in this phase. +Documentation intent follows a Concept -> Flow -> Code -> API progression. In this phase, docs are +still kept in a flat layout. A later phase will reorganize structure and add dedicated code-map and +reference pages. -## Read first +## Start Here -- [CoreStep MVP Baseline](core-step-mvp-baseline.md) -- [Core/Runtime Responsibility Model](core-runtime-responsibility-model.md) -- [Event Model](event-model.md) +1. [CoreStep MVP Baseline](core-step-mvp-baseline.md) +2. [Runtime/Core Responsibility Model](core-runtime-responsibility-model.md) +3. [Event Model](event-model.md) +4. [Risk vs ExecutionControl](risk-vs-execution-control.md) +5. [Control Time and Scheduling](control-time-and-scheduling.md) +6. [OrderSubmittedEvent](order-submitted-event.md) +7. [OrderExecutionFeedbackEvent (rc3 MVP path)](order-execution-feedback-event.md) +8. [GateDecision Compatibility](gate-decision-compatibility.md) +9. [Post-MVP Roadmap](post-mvp-roadmap.md) -## Topic guides +## Current Status Notes -- [Control Time and Scheduling](control-time-and-scheduling.md) -- [OrderSubmittedEvent](order-submitted-event.md) -- [OrderExecutionFeedbackEvent (rc3 MVP path)](order-execution-feedback-event.md) -- [Risk vs ExecutionControl](risk-vs-execution-control.md) -- [GateDecision Compatibility](gate-decision-compatibility.md) - -## Scope boundaries - -- [Post-MVP Roadmap](post-mvp-roadmap.md) - -## Status notes - -- Flags for migrated paths remain default `false`. -- GateDecision remains compatibility for legacy/default-off behavior. +- The CoreStep MVP baseline is accepted and frozen. +- Migrated paths remain behind flags that are default `false`. +- `GateDecision` remains compatibility for legacy/default-off behavior. - This MVP is not the final architecture. From c24666544c3471e66dc09163e347e4b8c40a2741 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 21:52:55 +0000 Subject: [PATCH 26/53] docs(core): restructure docs around Concept Flow Code API --- docs/README.md | 53 ++++++++--- docs/code-map/core-pipeline-map.md | 66 ++++++++++++++ docs/code-map/repository-map.md | 62 +++++++++++++ .../core-runtime-responsibility-model.md | 0 docs/{ => concepts}/event-model.md | 0 .../gate-decision-compatibility.md | 0 .../risk-vs-execution-control.md | 0 .../control-time-and-scheduling.md | 0 .../order-execution-feedback-event.md | 0 docs/{ => flows}/order-submitted-event.md | 0 docs/mvp/compatibility-matrix.md | 28 ++++++ docs/{ => mvp}/core-step-mvp-baseline.md | 0 docs/reference/events-reference.md | 20 ++++ docs/reference/public-api.md | 91 +++++++++++++++++++ docs/{ => roadmap}/post-mvp-roadmap.md | 0 15 files changed, 305 insertions(+), 15 deletions(-) create mode 100644 docs/code-map/core-pipeline-map.md create mode 100644 docs/code-map/repository-map.md rename docs/{ => concepts}/core-runtime-responsibility-model.md (100%) rename docs/{ => concepts}/event-model.md (100%) rename docs/{ => concepts}/gate-decision-compatibility.md (100%) rename docs/{ => concepts}/risk-vs-execution-control.md (100%) rename docs/{ => flows}/control-time-and-scheduling.md (100%) rename docs/{ => flows}/order-execution-feedback-event.md (100%) rename docs/{ => flows}/order-submitted-event.md (100%) create mode 100644 docs/mvp/compatibility-matrix.md rename docs/{ => mvp}/core-step-mvp-baseline.md (100%) create mode 100644 docs/reference/events-reference.md create mode 100644 docs/reference/public-api.md rename docs/{ => roadmap}/post-mvp-roadmap.md (100%) diff --git a/docs/README.md b/docs/README.md index 1ff508c..c6ff71e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,21 +2,44 @@ This directory is the current documentation start point for the `core` repository. -Documentation intent follows a Concept -> Flow -> Code -> API progression. In this phase, docs are -still kept in a flat layout. A later phase will reorganize structure and add dedicated code-map and -reference pages. - -## Start Here - -1. [CoreStep MVP Baseline](core-step-mvp-baseline.md) -2. [Runtime/Core Responsibility Model](core-runtime-responsibility-model.md) -3. [Event Model](event-model.md) -4. [Risk vs ExecutionControl](risk-vs-execution-control.md) -5. [Control Time and Scheduling](control-time-and-scheduling.md) -6. [OrderSubmittedEvent](order-submitted-event.md) -7. [OrderExecutionFeedbackEvent (rc3 MVP path)](order-execution-feedback-event.md) -8. [GateDecision Compatibility](gate-decision-compatibility.md) -9. [Post-MVP Roadmap](post-mvp-roadmap.md) +Documentation intent follows a Concept -> Flow -> Code -> API progression. + +Manual Markdown is used for now. MkDocs, mkdocstrings, and Mermaid are optional future tooling +choices and are not part of this baseline. + +## Recommended Reading Order + +### MVP and status + +1. [CoreStep MVP Baseline](mvp/core-step-mvp-baseline.md) +2. [Compatibility Matrix](mvp/compatibility-matrix.md) + +### Concepts + +3. [Runtime/Core Responsibility Model](concepts/core-runtime-responsibility-model.md) +4. [Event Model](concepts/event-model.md) +5. [Risk vs ExecutionControl](concepts/risk-vs-execution-control.md) +6. [GateDecision Compatibility](concepts/gate-decision-compatibility.md) + +### Flows + +7. [Control Time and Scheduling](flows/control-time-and-scheduling.md) +8. [OrderSubmittedEvent](flows/order-submitted-event.md) +9. [OrderExecutionFeedbackEvent (rc3 MVP path)](flows/order-execution-feedback-event.md) + +### Code map + +10. [Repository Map](code-map/repository-map.md) +11. [Core Pipeline Map](code-map/core-pipeline-map.md) + +### Reference + +12. [Public API Reference](reference/public-api.md) +13. [Events Reference](reference/events-reference.md) + +### Roadmap + +14. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) ## Current Status Notes diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md new file mode 100644 index 0000000..c0e08ae --- /dev/null +++ b/docs/code-map/core-pipeline-map.md @@ -0,0 +1,66 @@ +# Core Pipeline Map + +This page maps CoreStep/CoreWakeupStep processing from concept to code. + +Runtime provides canonical inputs and later dispatches results. Core performs deterministic +reduction and decision shaping, then returns `CoreStepResult`. + +## `run_core_step` pipeline + +1. Canonical `EventStreamEntry` input + - Responsibility: accept positioned canonical input from runtime. + - Key models/functions: `EventStreamEntry`, `ProcessingPosition`, `run_core_step`. + - Output: one step invocation context. + +2. State reduction + - Responsibility: reduce canonical event into `StrategyState`. + - Key functions: `process_event_entry`, `process_canonical_event`. + - Output: updated state snapshot. + +3. Strategy evaluation + - Responsibility: optionally evaluate strategy for generated intents. + - Key models/functions: `CoreStepStrategyEvaluator`, `CoreStepStrategyContext`. + - Output: `generated_intents`. + +4. Generated + queued candidate combination + - Responsibility: merge generated and queued intents deterministically. + - Key functions/models: `combine_candidate_intent_records`, `CandidateIntentRecord`. + - Output: `candidate_intent_records` and derived `candidate_intents`. + +5. Dominance / reconciliation + - Responsibility: resolve effective intent set by deterministic dominance rules. + - Key location: `intent_combination.py`. + - Output: effective candidate ordering for policy/execution stages. + +6. Policy admission + - Responsibility: side-effect-safe policy filtering for generated-origin candidates. + - Key functions/models: `apply_policy_to_candidate_records`, `PolicyRiskDecision`. + - Output: accepted/rejected policy projection. + +7. Execution-control planning/apply + - Responsibility: shape and optionally apply dispatchability mechanics. + - Key functions/models: `plan_execution_control_candidates`, `apply_execution_control_plan`, + `ExecutionControlDecision`. + - Output: queued/dispatchable/handled projections and optional scheduling obligation. + +8. Dispatchable output + - Responsibility: expose runtime-consumable dispatch set. + - Key model: `CoreStepResult.dispatchable_intents`. + - Output: dispatch candidates for runtime-owned execution. + +9. Control scheduling output + - Responsibility: return non-canonical scheduling handoff for runtime realization. + - Key model: `CoreStepResult.control_scheduling_obligation`. + - Output: optional `ControlSchedulingObligation`. + +## `run_core_wakeup_step` pipeline + +- `run_core_wakeup_step` is a convenience wrapper composed of: + 1. `run_core_wakeup_reduction` (reduce multiple entries + collect generated intents) + 2. `run_core_wakeup_decision` (candidate merge, policy admission, optional execution-control apply) +- It returns `CoreStepResult` with the same dispatchability/scheduling output shape used by + single-step processing. + +## Runtime boundary + +Runtime dispatch happens after Core returns. Core does not perform external venue dispatch itself. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md new file mode 100644 index 0000000..6f714ef --- /dev/null +++ b/docs/code-map/repository-map.md @@ -0,0 +1,62 @@ +# Repository Map + +This page is a code navigation guide for the Core repository. It is not an API reference. + +## High-level tree + +```text +core/ + tradingchassis_core/ + __init__.py + core/ + domain/ + execution_control/ + risk/ + events/ + ports/ + strategies/ + tests/ + docs/ +``` + +## Area responsibilities + +- `tradingchassis_core/__init__.py` + - Curated package export surface (`__all__`) and compatibility-facing public entry points. +- `tradingchassis_core/core/domain/` + - Core semantic models and orchestration primitives: + - event and intent models (`types.py`) + - processing boundaries (`processing.py`, `processing_order.py`) + - CoreStep/CoreWakeupStep pipeline (`processing_step.py`) + - result and decision models (`step_result.py`, `step_decision.py`) + - candidate/combination/policy/execution-control planning and apply helpers +- `tradingchassis_core/core/risk/` + - Policy and compatibility gate logic (`risk_policy.py`, `risk_engine.py`, `risk_config.py`). +- `tradingchassis_core/core/execution_control/` + - Queue/rate/inflight dispatchability mechanics and internal control helper types. +- `tradingchassis_core/core/events/` + - Event bus/sink and telemetry-style event record abstractions. +- `tradingchassis_core/core/ports/` + - Protocol-style integration boundaries (engine context, venue adapter/policy contracts). +- `tradingchassis_core/strategies/` + - Strategy interface and strategy-facing configuration models. +- `tests/` + - Core semantic/regression coverage for deterministic behavior and boundaries. +- `docs/` + - Core repository documentation (MVP baseline, concepts, flows, code map, and reference). + +## Recommended reading order + +1. `tradingchassis_core/__init__.py` (public exports and what is intentionally exposed) +2. `tradingchassis_core/core/domain/types.py` (event/intent models and canonical vocabulary) +3. `tradingchassis_core/core/domain/processing_step.py` (`run_core_step` and wakeup pipeline) +4. `tradingchassis_core/core/domain/processing.py` + `state.py` (reduction boundary and state updates) +5. `tradingchassis_core/core/domain/intent_combination.py` + `candidate_intent.py` +6. `tradingchassis_core/core/domain/policy_risk_decision.py` (+ compatibility mapping context) +7. `tradingchassis_core/core/domain/execution_control_apply.py` and related decision/plan modules +8. `tests/` to confirm expected behavior and migration/compatibility semantics + +## Boundary note + +Runtime and venue I/O internals are out of scope for this repository map. Treat runtime behavior as +an integration boundary consumed by Core outputs. diff --git a/docs/core-runtime-responsibility-model.md b/docs/concepts/core-runtime-responsibility-model.md similarity index 100% rename from docs/core-runtime-responsibility-model.md rename to docs/concepts/core-runtime-responsibility-model.md diff --git a/docs/event-model.md b/docs/concepts/event-model.md similarity index 100% rename from docs/event-model.md rename to docs/concepts/event-model.md diff --git a/docs/gate-decision-compatibility.md b/docs/concepts/gate-decision-compatibility.md similarity index 100% rename from docs/gate-decision-compatibility.md rename to docs/concepts/gate-decision-compatibility.md diff --git a/docs/risk-vs-execution-control.md b/docs/concepts/risk-vs-execution-control.md similarity index 100% rename from docs/risk-vs-execution-control.md rename to docs/concepts/risk-vs-execution-control.md diff --git a/docs/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md similarity index 100% rename from docs/control-time-and-scheduling.md rename to docs/flows/control-time-and-scheduling.md diff --git a/docs/order-execution-feedback-event.md b/docs/flows/order-execution-feedback-event.md similarity index 100% rename from docs/order-execution-feedback-event.md rename to docs/flows/order-execution-feedback-event.md diff --git a/docs/order-submitted-event.md b/docs/flows/order-submitted-event.md similarity index 100% rename from docs/order-submitted-event.md rename to docs/flows/order-submitted-event.md diff --git a/docs/mvp/compatibility-matrix.md b/docs/mvp/compatibility-matrix.md new file mode 100644 index 0000000..5b559ff --- /dev/null +++ b/docs/mvp/compatibility-matrix.md @@ -0,0 +1,28 @@ +# Compatibility Matrix + +This matrix reflects the accepted and frozen CoreStep MVP baseline. It separates migrated +CoreStep behavior from compatibility/legacy behavior. + +| Concern / path | MVP canonical or CoreStep behavior | Compatibility / legacy behavior | Flag | Default | Post-MVP note | +| --- | --- | --- | --- | --- | --- | +| MarketEvent path | CoreStep path exists and can drive migrated processing | Legacy/default-off path remains available | `enable_core_step_market_dispatch` | `false` | Candidate for default-on later | +| ControlTimeEvent path | CoreStep path exists for runtime-injected control re-entry | Legacy/default-off control handling remains | `enable_core_step_control_time_dispatch` | `false` | Keep runtime injection boundary | +| Mixed wakeup collapse | Wakeup collapse path exists in CoreStep wakeup model | Legacy/default-off wakeup behavior remains | `enable_core_step_wakeup_collapse` | `false` | Final wakeup model is post-MVP | +| rc `== 3` order/execution feedback | Runtime normalizes into canonical `OrderExecutionFeedbackEvent` and calls `run_core_step` | Legacy rc3 path remains when disabled | `enable_core_step_order_feedback_dispatch` | `false` | Full lifecycle redesign is post-MVP | +| Runtime dispatch | Migrated flag-on paths dispatch from `CoreStepResult.dispatchable_intents` | Legacy path may dispatch from compatibility gate output | path-specific migrated flags | `false` | Dispatch boundary remains runtime-owned | +| Runtime `risk.decide_intents` | Not productively used for migrated flag-on paths | May still be used by legacy/default-off paths | path-specific migrated flags | `false` | Runtime risk/gate productive role should shrink | +| `GateDecision` | Not the migrated-path dispatch contract | Temporary compatibility decision model | path-specific migrated flags | `false` | Removal is post-MVP | +| `CoreStepResult.compat_gate_decision` | Optional bridge field only | Used by compatibility flows where needed | path-specific migrated flags | `false` | Remove with GateDecision retirement | +| `ControlSchedulingObligation` | Non-canonical Core output for runtime wakeup planning | N/A (already compatibility-shaped handoff) | N/A | N/A | May evolve when final control model lands | +| `ControlTimeEvent` | Canonical only after runtime realizes obligation and injects event | N/A | `enable_core_step_control_time_dispatch` | `false` | Canonical control model can be refined later | +| `OrderSubmittedEvent` | Canonical, emitted only after successful external `NEW` dispatch and before `mark_intent_sent` | N/A | N/A | N/A | Full lifecycle semantics remain post-MVP | +| `FillEvent` | Canonical model in event taxonomy | Not used as snapshot-only rc3 feedback ingress | N/A | N/A | Expanded fill-centric lifecycle is post-MVP | +| `OrderExecutionFeedbackEvent` | Canonical MVP rc3 feedback ingress event | Replaced by legacy snapshot path when flag off | `enable_core_step_order_feedback_dispatch` | `false` | Keep canonical feedback ingress | +| `OrderStateEvent` | Non-canonical for current MVP semantics | Compatibility/snapshot projection record | N/A | N/A | Future lifecycle work may retire this role | + +## Notes + +- Migrated CoreStep paths are flag-gated and all migration flags remain default `false`. +- `GateDecision` remains compatibility for legacy/default-off behavior. +- `OrderExecutionFeedbackEvent` is the canonical rc3 MVP feedback ingress event. +- `FillEvent` is not the snapshot-only rc3 feedback ingress event in this MVP. diff --git a/docs/core-step-mvp-baseline.md b/docs/mvp/core-step-mvp-baseline.md similarity index 100% rename from docs/core-step-mvp-baseline.md rename to docs/mvp/core-step-mvp-baseline.md diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md new file mode 100644 index 0000000..e2c8cea --- /dev/null +++ b/docs/reference/events-reference.md @@ -0,0 +1,20 @@ +# Events Reference + +This table summarizes current event/model status for the accepted MVP baseline. + +| Event/model | Canonical status | Producer | Consumer/reducer | Purpose | Notes | +| --- | --- | --- | --- | --- | --- | +| `MarketEvent` | Canonical | Runtime (normalized ingress) | Core reduction (`process_event_entry` / `run_core_step`) | Market-state update input | CoreStep migrated market path is flag-gated and default `false` | +| `ControlTimeEvent` | Canonical after runtime injection | Runtime (when due obligation realized) | Core reduction (`process_event_entry` / `run_core_step`) | Canonical control re-entry boundary | Becomes canonical only after runtime injects it | +| `OrderSubmittedEvent` | Canonical | Runtime (post successful external `NEW` dispatch) | Core reduction | Canonical submission confirmation boundary | Emitted after successful `NEW`; ordered before `mark_intent_sent` | +| `OrderExecutionFeedbackEvent` | Canonical MVP ingress event | Runtime (normalized rc3 feedback/snapshot input) | Core reduction (`run_core_step`) | Canonical rc3 feedback ingress for MVP | Migrated rc3 path is flag-gated and default `false` | +| `FillEvent` | Canonical model status | Runtime/normalization boundary (model-level) | Core reduction (model support exists) | Fill-oriented execution event model | Not used as snapshot-only rc3 feedback ingress in MVP | +| `OrderStateEvent` | Compatibility/non-canonical | Compatibility snapshot/projection flows | Compatibility handling only | Legacy snapshot/materialization record | Not a canonical Event Stream record in current MVP | +| `ControlSchedulingObligation` | Non-canonical Core output (not an event) | Core output (`CoreStepResult`) | Runtime scheduling realization | Runtime wakeup planning handoff | Must not be treated as event-stream input/persisted event | + +## Notes + +- `OrderExecutionFeedbackEvent` is the canonical rc3 MVP feedback ingress event. +- `FillEvent` remains canonical in model terms but is not used for snapshot-only rc3 ingress. +- `GateDecision` remains compatibility for legacy/default-off paths and is not the migrated-path + dispatch contract. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md new file mode 100644 index 0000000..28ab92b --- /dev/null +++ b/docs/reference/public-api.md @@ -0,0 +1,91 @@ +# Public API Reference + +This reference is manual and curated for the current MVP baseline. Generated API docs may be added +later. + +Stability tags used here: + +- **Stable MVP**: current baseline contract to rely on +- **Compatibility**: transitional bridge surface +- **Internal-shape exposed**: exported today but better treated as implementation-adjacent + +## Core step APIs + +Stable MVP: + +- `run_core_step` +- `run_core_wakeup_reduction` +- `run_core_wakeup_decision` +- `run_core_wakeup_step` + +Purpose: deterministic Core entry points for step and wakeup-level processing. + +## Result and decision models + +Stable MVP (with transitional fields where noted): + +- `CoreStepResult` (includes compatibility bridge field `compat_gate_decision`) +- `CoreStepDecision` +- `PolicyRiskDecision` +- `ExecutionControlDecision` + +Purpose: structured step outcomes and policy/execution-control projections. + +## Candidate intent models + +Stable MVP: + +- `CandidateIntentRecord` +- `CandidateIntentOrigin` + +Purpose: explicit candidate provenance and deterministic merge ordering metadata. + +## Event stream models + +Stable MVP: + +- `ProcessingPosition` +- `EventStreamEntry` + +Purpose: canonical ingestion ordering envelope for deterministic reduction. + +## Canonical event models + +Stable MVP: + +- `MarketEvent` +- `ControlTimeEvent` +- `OrderSubmittedEvent` +- `OrderExecutionFeedbackEvent` + +Canonical model with caveat: + +- `FillEvent` is canonical in the model taxonomy, but it is not the snapshot-only rc3 MVP ingress. + +## Strategy/config/state-facing models + +Stable MVP: + +- `Strategy` +- `StrategyState` +- `CoreConfiguration` +- `EngineContext` + +Purpose: strategy contract and deterministic configuration/state interaction surface. + +## Compatibility and transitional surfaces + +Compatibility: + +- `GateDecision` (legacy/default-off compatibility decision model) +- `CoreStepResult.compat_gate_decision` (bridge field for compatibility paths) + +Compatibility/non-canonical model context: + +- `OrderStateEvent` remains compatibility/non-canonical in current MVP docs. + +## Export boundary note + +`tradingchassis_core.__init__` is the intended package export boundary. Not every exported symbol +should be treated as long-term stable final architecture; compatibility surfaces are explicitly +transitional. diff --git a/docs/post-mvp-roadmap.md b/docs/roadmap/post-mvp-roadmap.md similarity index 100% rename from docs/post-mvp-roadmap.md rename to docs/roadmap/post-mvp-roadmap.md From 026c74e91a48e6af3d5d47cde4cfe8f3ca64f7d8 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 21:55:22 +0000 Subject: [PATCH 27/53] docs(core): add MVP how-to guides --- docs/README.md | 8 ++- docs/how-to/add-canonical-event.md | 64 +++++++++++++++++++++++ docs/how-to/add-core-step-test.md | 69 +++++++++++++++++++++++++ docs/how-to/extend-execution-control.md | 59 +++++++++++++++++++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 docs/how-to/add-canonical-event.md create mode 100644 docs/how-to/add-core-step-test.md create mode 100644 docs/how-to/extend-execution-control.md diff --git a/docs/README.md b/docs/README.md index c6ff71e..a3ab5c2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,9 +37,15 @@ choices and are not part of this baseline. 12. [Public API Reference](reference/public-api.md) 13. [Events Reference](reference/events-reference.md) +### How-to + +14. [Add a Canonical Event](how-to/add-canonical-event.md) +15. [Add a CoreStep or CoreWakeupStep Test](how-to/add-core-step-test.md) +16. [Extend ExecutionControl](how-to/extend-execution-control.md) + ### Roadmap -14. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) +17. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) ## Current Status Notes diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md new file mode 100644 index 0000000..b4b41a5 --- /dev/null +++ b/docs/how-to/add-canonical-event.md @@ -0,0 +1,64 @@ +# Add a Canonical Event + +Use this guide when introducing a new canonical Core event in the MVP architecture. + +## When to add a canonical event + +- You need a durable Core event-stream input with deterministic reduction semantics. +- Runtime can normalize raw input into a stable Core model boundary. +- The event participates in Core reduction and/or CoreStep decision flow. + +## When not to add a canonical event + +- The data is runtime-only transport/adapter metadata. +- The artifact is compatibility-only or temporary bridge data. +- The artifact is a non-canonical Core output (for example `ControlSchedulingObligation`). + +## Required design questions + +- What raw input does Runtime receive, and how is it normalized? +- Is this canonical, or compatibility-only? +- Who produces it (Runtime boundary) and who reduces it (Core boundary)? +- Is it event-stream input, or a non-canonical output? +- Does it require `ProcessingPosition` ordering? +- Does it overlap with existing canonical events? + +## Implementation checklist + +- Add/update event model types in Core domain event/type modules. +- Update event taxonomy/boundary classification used by canonical processing. +- Add/update reducer boundary handling (`process_event_entry` / canonical processing path). +- Keep state mutation in Core reducer/state methods, not Runtime snapshots. +- Verify `run_core_step` / wakeup-step implications and outputs. +- Update manual references for public/event status docs. + +## Test checklist + +- Canonical taxonomy test (recognized as canonical or explicitly non-canonical). +- Canonical processing boundary test (accepted/rejected at correct boundary). +- Reducer/state transition test for deterministic effects. +- Guardrail test/search for no runtime/hftbacktest dependency creep in Core. +- Compatibility rejection/segregation tests where relevant. + +## Documentation checklist + +- Update `../reference/events-reference.md`. +- Update `../concepts/event-model.md`. +- Update relevant flow docs under `../flows/`. +- Update `../mvp/compatibility-matrix.md` if flags/compat behavior changed. + +## Anti-patterns + +- Passing raw venue/backtest objects directly into Core event models. +- Mutating runtime snapshots as if they were Core state reducers. +- Using `FillEvent` as snapshot-only rc3 feedback ingress. +- Canonicalizing `ControlSchedulingObligation` as event-stream input. +- Adding event types without reducer/test/docs updates. + +## Related docs + +- [Event Model](../concepts/event-model.md) +- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) +- [Events Reference](../reference/events-reference.md) +- [CoreStep MVP Baseline](../mvp/core-step-mvp-baseline.md) +- [Compatibility Matrix](../mvp/compatibility-matrix.md) diff --git a/docs/how-to/add-core-step-test.md b/docs/how-to/add-core-step-test.md new file mode 100644 index 0000000..77c788b --- /dev/null +++ b/docs/how-to/add-core-step-test.md @@ -0,0 +1,69 @@ +# Add a CoreStep or CoreWakeupStep Test + +Use this guide when adding behavior coverage for Core step orchestration. + +## When to write a CoreStep test + +- Single-entry deterministic behavior changes in `run_core_step`. +- Candidate generation/admission/dispatchability semantics change for one entry. +- `CoreStepResult` shape/field behavior changes. + +## When to write a CoreWakeupStep test + +- Multi-entry wakeup reduction/decision behavior changes. +- Deterministic ordering across multiple `EventStreamEntry` values is affected. +- Wakeup-level decision/apply behavior changes. + +## Core vs Runtime test ownership + +- Core tests should validate deterministic Core semantics only. +- Runtime tests should validate dispatch/integration behavior around Core outputs. + +## CoreStep test checklist + +- Use canonical `EventStreamEntry` input. +- Include `ProcessingPosition` ordering assumptions. +- Assert reducer/state effects. +- Assert strategy evaluator behavior (if evaluator is used). +- Assert generated and candidate intent record behavior. +- Assert policy risk decision projection behavior. +- Assert execution-control apply/decision behavior. +- Assert `dispatchable_intents` and `control_scheduling_obligation` outputs. +- Assert `compat_gate_decision` only when compatibility path is intentionally under test. + +## CoreWakeupStep test checklist + +- Use multiple canonical `EventStreamEntry` values. +- Assert deterministic processing order. +- Assert single wakeup-level decision/apply semantics. +- Assert no duplicated risk/dispatchability semantics within one wakeup flow. + +## Runtime migrated-path guardrail checklist + +- Runtime dispatches `CoreStepResult.dispatchable_intents`. +- Runtime does not productively re-decide equivalent work via runtime risk. +- Runtime does not use `GateDecision` as migrated-path final contract. +- Runtime preserves `OrderSubmittedEvent` dispatch-success behavior. +- Runtime realizes/apply scheduling obligations correctly. + +## Test placement guide + +- Place Core semantic tests in Core repository test suites. +- Place runtime integration/guardrail tests in runtime-owned suites. +- If runtime environment is blocked (dependencies/system), classify as tooling/environment blocker, + not as Core semantic failure. + +## Anti-patterns + +- Testing runtime I/O behavior inside Core-only tests. +- Treating `GateDecision` as the final migrated-path contract. +- Pulling hftbacktest/runtime dependencies into Core tests. +- Using broad brittle end-to-end tests for small semantic changes. + +## Related docs + +- [Core Pipeline Map](../code-map/core-pipeline-map.md) +- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) +- [GateDecision Compatibility](../concepts/gate-decision-compatibility.md) +- [CoreStep MVP Baseline](../mvp/core-step-mvp-baseline.md) +- [Compatibility Matrix](../mvp/compatibility-matrix.md) diff --git a/docs/how-to/extend-execution-control.md b/docs/how-to/extend-execution-control.md new file mode 100644 index 0000000..3f6a4cd --- /dev/null +++ b/docs/how-to/extend-execution-control.md @@ -0,0 +1,59 @@ +# Extend ExecutionControl + +Use this guide when changing queue/rate/inflight/sendability mechanics in Core. + +## What ExecutionControl owns + +- Queue reconciliation and effective pending work mechanics +- Rate-limit handling +- Inflight/sendability checks +- Dispatchable selection mechanics +- Scheduling obligation derivation + +## What ExecutionControl must not own + +- Policy-only risk checks +- External dispatch +- Venue/live/backtest I/O +- Runtime error handling/observability +- `OrderSubmittedEvent` emission +- Raw venue feedback interpretation/normalization + +## Design checklist + +- Is this change policy risk or execution-control mechanics? +- Does it change queue/rate/inflight/sendability/scheduling behavior? +- Does it require plan/apply separation updates? +- Does it affect `CandidateIntentOrigin` or candidate record interpretation? +- Does it affect `ControlSchedulingObligation` semantics? + +## Implementation checklist + +- Update planning path where candidate execution-control projections are formed. +- Update apply path where mutable queue/rate/inflight effects are realized. +- Keep state mutation boundaries inside Core state/execution-control layers. +- Keep output surfaces consistent (`ExecutionControlDecision`, `CoreStepResult`). +- Re-check compatibility interaction with `GateDecision` bridge behavior. + +## Test checklist + +- Add/update isolated execution-control behavior tests. +- Add/update CoreStep integration tests for changed dispatchability/scheduling outcomes. +- Add/update runtime migrated-path guardrails when dispatch behavior implications change. +- Add explicit scheduling-obligation behavior tests. + +## Anti-patterns + +- Reintroducing runtime risk decisions for migrated-path work. +- Dispatching externally from Core. +- Mutating runtime-owned state from Core. +- Hiding policy checks inside execution-control logic. +- Treating `GateDecision` as final architecture output. + +## Related docs + +- [Risk vs ExecutionControl](../concepts/risk-vs-execution-control.md) +- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) +- [GateDecision Compatibility](../concepts/gate-decision-compatibility.md) +- [Core Pipeline Map](../code-map/core-pipeline-map.md) +- [Compatibility Matrix](../mvp/compatibility-matrix.md) From 88da2380efef3f38b77d88491eb91f0d825c4965 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 10 May 2026 22:02:57 +0000 Subject: [PATCH 28/53] docs(core): add MVP architecture diagrams --- docs/code-map/core-pipeline-map.md | 17 +++++++++++++++++ .../core-runtime-responsibility-model.md | 18 ++++++++++++++++++ docs/concepts/event-model.md | 17 +++++++++++++++++ docs/flows/control-time-and-scheduling.md | 12 ++++++++++++ docs/flows/order-execution-feedback-event.md | 18 ++++++++++++++++++ 5 files changed, 82 insertions(+) diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index c0e08ae..ff97ca3 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -5,6 +5,23 @@ This page maps CoreStep/CoreWakeupStep processing from concept to code. Runtime provides canonical inputs and later dispatches results. Core performs deterministic reduction and decision shaping, then returns `CoreStepResult`. +This diagram summarizes the single-step pipeline and its outputs. External dispatch remains +runtime-owned and happens after Core returns. + +```mermaid +flowchart LR + A[EventStreamEntry] --> B[State reduction] + B --> C[Strategy evaluation] + C --> D[Generated + queued candidates] + D --> E[Dominance/reconciliation] + E --> F[Policy risk admission] + F --> G[Execution control plan/apply] + G --> H[CoreStepResult] + H --> I[dispatchable_intents] + H --> J[control_scheduling_obligation] + I --> K[Runtime external dispatch] +``` + ## `run_core_step` pipeline 1. Canonical `EventStreamEntry` input diff --git a/docs/concepts/core-runtime-responsibility-model.md b/docs/concepts/core-runtime-responsibility-model.md index d01ca66..7902e4a 100644 --- a/docs/concepts/core-runtime-responsibility-model.md +++ b/docs/concepts/core-runtime-responsibility-model.md @@ -2,6 +2,24 @@ This is the target architecture model that current MVP paths are moving toward. +The diagram below shows the migrated-path ownership boundary at a glance. Runtime owns external +I/O and dispatch execution, while Core owns deterministic semantic reduction and decision shaping. + +```mermaid +flowchart LR + A[Runtime raw venue/backtest/live input] --> B[Runtime normalize to canonical events] + B --> C[Runtime create ProcessingPosition + EventStreamEntry] + C --> D[Call run_core_step/run_core_wakeup_step] + D --> E[Core consume canonical events] + E --> F[Core reduce state + evaluate strategy] + F --> G[Core policy risk + execution control] + G --> H[CoreStepResult] + H --> I[Runtime dispatch dispatchable_intents] + I --> J[External venue I/O] + I --> K[Successful NEW] + K --> L[Runtime emits OrderSubmittedEvent] +``` + ## Runtime owns - Receiving raw venue/backtest/live inputs. diff --git a/docs/concepts/event-model.md b/docs/concepts/event-model.md index 90e09d3..fc44ed0 100644 --- a/docs/concepts/event-model.md +++ b/docs/concepts/event-model.md @@ -23,3 +23,20 @@ - Runtime owns canonical event construction and injection timing. - Canonical ingestion order is defined by runtime-assigned `ProcessingPosition`. - Runtime dispatches `CoreStepResult.dispatchable_intents` for migrated paths. + +The diagram below clarifies the event/intent/order boundary: Core works on canonical events and +intents, while Runtime handles external order dispatch and feedback normalization. + +```mermaid +flowchart LR + A[Canonical events] --> B[Core reduction + strategy] + B --> C[OrderIntent candidates] + C --> D[CoreStepResult.dispatchable_intents] + D --> E[Runtime external dispatch/orders] + E --> F[Successful NEW] + F --> G[Runtime emits OrderSubmittedEvent] + E --> H[Venue feedback] + H --> I[Runtime normalize feedback] + I --> J[OrderExecutionFeedbackEvent] + J --> A +``` diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md index 135cd78..9a4d5a8 100644 --- a/docs/flows/control-time-and-scheduling.md +++ b/docs/flows/control-time-and-scheduling.md @@ -17,6 +17,18 @@ future runtime wakeup boundary. It is not an event-stream input by itself. `ControlTimeEvent` is the canonical control re-entry event that Runtime injects when a due obligation is realized. +This diagram shows the handoff boundary: Core emits a non-canonical scheduling obligation, and +Runtime later injects the canonical `ControlTimeEvent` when due. + +```mermaid +flowchart LR + A[CoreStepResult.control_scheduling_obligation] --> B[Runtime store pending obligation] + B --> C[Due time reached] + C --> D[Runtime inject ControlTimeEvent] + D --> E[Runtime create EventStreamEntry] + E --> F[run_core_step or run_core_wakeup_step] +``` + ## Current MVP behavior - Control-time CoreStep path is behind `enable_core_step_control_time_dispatch`. diff --git a/docs/flows/order-execution-feedback-event.md b/docs/flows/order-execution-feedback-event.md index da4daa5..484d94e 100644 --- a/docs/flows/order-execution-feedback-event.md +++ b/docs/flows/order-execution-feedback-event.md @@ -4,6 +4,24 @@ This page describes the MVP rc3 feedback path, not a full lifecycle redesign. +The diagram below highlights the migrated rc3 flag-on flow and keeps legacy flag-off behavior as a +secondary compatibility branch. + +```mermaid +flowchart TB + A[rc == 3 feedback input] --> B[Runtime reads raw snapshot/feedback] + B --> C[Runtime normalize to OrderExecutionFeedbackEvent] + C --> D[Runtime create EventStreamEntry] + D --> E[run_core_step] + E --> F[Core reduce/evaluate/apply] + F --> G[CoreStepResult.dispatchable_intents] + G --> H[Runtime dispatch externally] + H --> I[Successful NEW] + I --> J[Runtime emits OrderSubmittedEvent] + + A -. flag off .-> K[Legacy rc3 compatibility path] +``` + ## Current MVP flow (flag on) - Runtime reads raw rc3 feedback/snapshot input as adapter input. From 005b931257d2bc75fb2e261a1a3d51a8b3aac8ad Mon Sep 17 00:00:00 2001 From: bxvtr Date: Mon, 11 May 2026 13:46:06 +0000 Subject: [PATCH 29/53] docs: improve language --- CONTRIBUTING.md | 4 ++-- README.md | 2 +- docs/how-to/add-canonical-event.md | 2 +- docs/how-to/add-core-step-test.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1622e13..c2479e0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to TradingChassis Core -Thanks for contributing to `tradingchassis_core`. +Thank you for contributing to `tradingchassis_core`. This repository is the Core semantic package. Keep changes deterministic, explicit, and scoped to Core responsibilities. @@ -27,7 +27,7 @@ Use Python 3.11+. Contributions must preserve the accepted MVP baseline and boundaries: -- Core must not depend on runtime/hftbacktest integration layers +- Core must not depend on runtime integration layers - Core consumes canonical events and returns deterministic Core outputs - Runtime owns external I/O, dispatch execution, and scheduling realization timing - For migrated flag-on paths, runtime dispatches from `CoreStepResult.dispatchable_intents` diff --git a/README.md b/README.md index c13c57d..5edc596 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ event processing contracts, state reduction boundaries, and CoreStep/CoreWakeupS ## What This Package Is Not - Not venue/runtime I/O ownership -- Not a venue adapter or hftbacktest runtime shell +- Not a venue adapter or venue runtime shell - Not a complete final live trading stack by itself ## Current Status (Accepted MVP Baseline) diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md index b4b41a5..4625f4a 100644 --- a/docs/how-to/add-canonical-event.md +++ b/docs/how-to/add-canonical-event.md @@ -37,7 +37,7 @@ Use this guide when introducing a new canonical Core event in the MVP architectu - Canonical taxonomy test (recognized as canonical or explicitly non-canonical). - Canonical processing boundary test (accepted/rejected at correct boundary). - Reducer/state transition test for deterministic effects. -- Guardrail test/search for no runtime/hftbacktest dependency creep in Core. +- Guardrail test/search for no runtime dependency creep in Core. - Compatibility rejection/segregation tests where relevant. ## Documentation checklist diff --git a/docs/how-to/add-core-step-test.md b/docs/how-to/add-core-step-test.md index 77c788b..ffbbbd2 100644 --- a/docs/how-to/add-core-step-test.md +++ b/docs/how-to/add-core-step-test.md @@ -57,7 +57,7 @@ Use this guide when adding behavior coverage for Core step orchestration. - Testing runtime I/O behavior inside Core-only tests. - Treating `GateDecision` as the final migrated-path contract. -- Pulling hftbacktest/runtime dependencies into Core tests. +- Pulling runtime dependencies into Core tests. - Using broad brittle end-to-end tests for small semantic changes. ## Related docs From dc4080d1136647ae0091146ee69794193a701702 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Tue, 12 May 2026 23:22:27 +0000 Subject: [PATCH 30/53] feat(core): add runnable CoreStep quickstart --- .gitignore | 1 - README.md | 8 + docs/README.md | 3 +- docs/code-map/core-pipeline-map.md | 4 + docs/how-to/core-step-quickstart.md | 32 ++++ examples/core_step_quickstart.py | 141 ++++++++++++++++++ .../examples/test_core_step_quickstart.py | 47 ++++++ 7 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 docs/how-to/core-step-quickstart.md create mode 100644 examples/core_step_quickstart.py create mode 100644 tests/semantics/examples/test_core_step_quickstart.py diff --git a/.gitignore b/.gitignore index df97312..ec65e06 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ venv.bak/ *.key *.crt *.pub -.oci/ # ============================== # Python packaging / build artifacts diff --git a/README.md b/README.md index 5edc596..66e9167 100644 --- a/README.md +++ b/README.md @@ -35,11 +35,19 @@ From the `core` repository root: ```bash python -m pip install -e ".[dev]" python -m pytest +python examples/core_step_quickstart.py ``` Runtime integration tests may require separate runtime dependencies/environment setup. Keep core package validation centered on `core` tests in this repository. +The runnable Core-only quickstart is at `examples/core_step_quickstart.py`. It demonstrates CoreStep +mechanics (`run_core_step` and `CoreStepResult`) without runtime/adapter dispatch. + +Note: the example uses `ControlTimeEvent` because it is the smallest canonical event model to +instantiate for a compact demo. This is not a claim that migrated runtime paths should productively +evaluate strategy on control-time events. + ## Architecture Entry Points - Docs start page: `docs/README.md` diff --git a/docs/README.md b/docs/README.md index a3ab5c2..d175213 100644 --- a/docs/README.md +++ b/docs/README.md @@ -42,10 +42,11 @@ choices and are not part of this baseline. 14. [Add a Canonical Event](how-to/add-canonical-event.md) 15. [Add a CoreStep or CoreWakeupStep Test](how-to/add-core-step-test.md) 16. [Extend ExecutionControl](how-to/extend-execution-control.md) +17. [CoreStep Core-only Quickstart](how-to/core-step-quickstart.md) ### Roadmap -17. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) +18. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) ## Current Status Notes diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index ff97ca3..4952136 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -5,6 +5,10 @@ This page maps CoreStep/CoreWakeupStep processing from concept to code. Runtime provides canonical inputs and later dispatches results. Core performs deterministic reduction and decision shaping, then returns `CoreStepResult`. +For a runnable minimal example of this pipeline in Core-only form, see +`examples/core_step_quickstart.py` and the short guide at +`docs/how-to/core-step-quickstart.md`. + This diagram summarizes the single-step pipeline and its outputs. External dispatch remains runtime-owned and happens after Core returns. diff --git a/docs/how-to/core-step-quickstart.md b/docs/how-to/core-step-quickstart.md new file mode 100644 index 0000000..bfc279c --- /dev/null +++ b/docs/how-to/core-step-quickstart.md @@ -0,0 +1,32 @@ +# CoreStep Core-only Quickstart + +Use `examples/core_step_quickstart.py` for the smallest runnable Core-only CoreStep example. + +## Run + +From the `core` repository root: + +```bash +python examples/core_step_quickstart.py +``` + +## What it demonstrates + +- Canonical event in (`EventStreamEntry` with `ControlTimeEvent`) +- `run_core_step` execution +- `CoreStepResult` inspection: + - `generated_intents` + - `candidate_intent_records` + - `dispatchable_intents` + +The script contains two slices: + +- v1 (smallest): strategy evaluator only, no policy/admission/apply; dispatchables are empty. +- v2 (optional): allow-all policy + execution-control apply; dispatchables become non-empty. + +## Semantics caveat + +`ControlTimeEvent` is used because it is the smallest canonical event model to construct for a +compact CoreStep mechanics demo. This is not a statement that migrated runtime paths should +productively evaluate strategy on control-time events. Runtime remains responsible for injecting +control-time events and for external dispatch after Core returns results. diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py new file mode 100644 index 0000000..3468b5b --- /dev/null +++ b/examples/core_step_quickstart.py @@ -0,0 +1,141 @@ +"""Core-only CoreStep quickstart example. + +Run from the core repository root: + python examples/core_step_quickstart.py + +This script demonstrates CoreStep mechanics only: +canonical event in -> run_core_step -> CoreStepResult out. + +ControlTimeEvent is used here because it is the smallest canonical event to +construct for a compact example. This is not a statement that migrated runtime +paths should productively evaluate strategy on ControlTimeEvent; runtime-owned +behavior remains documented in the corresponding architecture docs. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ in (None, ""): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import tradingchassis_core as tc + +# Internal import gap: ControlTimeEvent is canonical but not currently exported +# from the package root. +from tradingchassis_core.core.domain.types import ControlTimeEvent +# Internal import gap: StrategyState requires an EventBus; NullEventBus is the +# smallest no-op bus. +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +# Internal import gap: execution-control apply requires an ExecutionControl +# instance and this type is not currently exported from package root. +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl + +INSTRUMENT = "BTC-USDC-PERP" +INTENT_ID_V1 = "quickstart-new-v1" +INTENT_ID_V2 = "quickstart-new-v2" + + +class OneIntentEvaluator: + """Small evaluator that emits one deterministic new-order intent.""" + + def __init__(self, client_order_id: str) -> None: + self._client_order_id = client_order_id + + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + ts_ns_local=1_000, + instrument=INSTRUMENT, + client_order_id=self._client_order_id, + intents_correlation_id=f"corr-{self._client_order_id}", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class AllowAllPolicy: + """Policy evaluator that admits every generated candidate intent.""" + + def evaluate_policy_intent(self, *, intent: tc.OrderIntent, state: tc.StrategyState, now_ts_ns_local: int) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=ControlTimeEvent( + ts_ns_local_control=ts_ns_local, + reason="scheduled_control_recheck", + due_ts_ns_local=ts_ns_local, + realized_ts_ns_local=ts_ns_local, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts_ns_local, + runtime_correlation=None, + ), + ) + + +def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: + """Smallest CoreStep usage: generated and candidate intents only.""" + + result = tc.run_core_step( + state, + _control_time_entry(index=0, ts_ns_local=1_000), + strategy_evaluator=OneIntentEvaluator(INTENT_ID_V1), + ) + assert len(result.generated_intents) == 1 + assert result.generated_intents[0].client_order_id == INTENT_ID_V1 + assert len(result.candidate_intent_records) == 1 + assert result.candidate_intent_records[0].origin is tc.CandidateIntentOrigin.GENERATED + assert result.dispatchable_intents == () + return result + + +def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: + """Optional Core-only extension: policy admission + execution-control apply.""" + + result = tc.run_core_step( + state, + _control_time_entry(index=1, ts_ns_local=1_001), + strategy_evaluator=OneIntentEvaluator(INTENT_ID_V2), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=AllowAllPolicy(), + now_ts_ns_local=1_001, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=ExecutionControl(), + now_ts_ns_local=1_001, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.dispatchable_intents) == 1 + assert result.dispatchable_intents[0].client_order_id == INTENT_ID_V2 + return result + + +def main() -> None: + state = tc.StrategyState(event_bus=NullEventBus()) + result_v1 = run_v1_generated_only(state) + result_v2 = run_v2_with_policy_and_apply(state) + + print("CoreStep quickstart") + print("v1 generated:", [intent.client_order_id for intent in result_v1.generated_intents]) + print( + "v1 candidate origins:", + [record.origin.value for record in result_v1.candidate_intent_records], + ) + print("v1 dispatchable:", [intent.client_order_id for intent in result_v1.dispatchable_intents]) + print("v2 dispatchable:", [intent.client_order_id for intent in result_v2.dispatchable_intents]) + print("v2 obligation:", result_v2.control_scheduling_obligation) + + +if __name__ == "__main__": + main() diff --git a/tests/semantics/examples/test_core_step_quickstart.py b/tests/semantics/examples/test_core_step_quickstart.py new file mode 100644 index 0000000..d60236b --- /dev/null +++ b/tests/semantics/examples/test_core_step_quickstart.py @@ -0,0 +1,47 @@ +"""Semantics coverage for the Core-only CoreStep quickstart example.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import tradingchassis_core as tc +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus + +_MODULE_PATH = Path(__file__).resolve().parents[3] / "examples" / "core_step_quickstart.py" +_SPEC = importlib.util.spec_from_file_location("core_step_quickstart_example", _MODULE_PATH) +assert _SPEC is not None +assert _SPEC.loader is not None +_MODULE = importlib.util.module_from_spec(_SPEC) +_SPEC.loader.exec_module(_MODULE) + + +def test_core_step_quickstart_v1_generated_and_candidates() -> None: + state = tc.StrategyState(event_bus=NullEventBus()) + + result = _MODULE.run_v1_generated_only(state) + + assert isinstance(result, tc.CoreStepResult) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ( + _MODULE.INTENT_ID_V1, + ) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + tc.CandidateIntentOrigin.GENERATED, + ) + assert tuple(intent.client_order_id for intent in result.candidate_intents) == ( + _MODULE.INTENT_ID_V1, + ) + assert result.dispatchable_intents == () + + +def test_core_step_quickstart_v2_dispatchable_output() -> None: + state = tc.StrategyState(event_bus=NullEventBus()) + + # Keep the same call order as the script so ProcessingPosition remains monotonic. + _ = _MODULE.run_v1_generated_only(state) + result = _MODULE.run_v2_with_policy_and_apply(state) + + assert isinstance(result, tc.CoreStepResult) + assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ( + _MODULE.INTENT_ID_V2, + ) From 90e45e586c031d528cd114bf32d7c19f15ad1509 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 13:21:56 +0000 Subject: [PATCH 31/53] refactor(core)!: cut legacy compatibility from Core pipeline --- CHANGELOG.md | 41 +- CONTRIBUTING.md | 57 +- README.md | 74 +- docs/README.md | 63 +- docs/code-map/core-pipeline-map.md | 87 - docs/code-map/repository-map.md | 62 - .../core-runtime-responsibility-model.md | 57 - docs/concepts/event-model.md | 42 - docs/concepts/gate-decision-compatibility.md | 20 - docs/concepts/risk-vs-execution-control.md | 26 - docs/flows/control-time-and-scheduling.md | 37 - docs/flows/order-execution-feedback-event.md | 43 - docs/flows/order-submitted-event.md | 23 - docs/how-to/add-canonical-event.md | 64 - docs/how-to/add-core-step-test.md | 69 - docs/how-to/core-step-quickstart.md | 32 - docs/how-to/extend-execution-control.md | 59 - docs/mvp/compatibility-matrix.md | 28 - docs/mvp/core-step-mvp-baseline.md | 55 - docs/reference/events-reference.md | 20 - docs/reference/public-api.md | 100 +- docs/roadmap/post-mvp-roadmap.md | 16 - examples/core_step_quickstart.py | 42 +- .../examples/test_core_step_quickstart.py | 8 +- .../test_cancel_non_existing_rejected.py | 64 - .../test_duplicate_new_rejected.py | 93 - .../test_execution_control_accessor.py | 71 - .../test_inflight_blocks_replace.py | 98 - ...nts_do_not_enter_queue_characterization.py | 63 - .../test_replace_noop_handled.py | 88 - ...t_candidate_intent_combination_contract.py | 266 -- .../test_canonical_processing_boundary.py | 924 ------ ...nonical_processing_differential_harness.py | 362 --- .../test_canonical_reducer_authority_guard.py | 60 - .../test_core_configuration_contract.py | 70 - .../models/test_core_step_api_contract.py | 2479 ----------------- .../test_core_step_decision_contract.py | 131 - .../models/test_core_step_result_contract.py | 310 --- .../models/test_core_wakeup_step_contract.py | 526 ---- .../test_event_stream_entry_contract.py | 378 --- .../models/test_event_taxonomy_boundary.py | 191 -- .../test_execution_control_apply_contract.py | 146 - ...est_execution_control_decision_contract.py | 126 - .../test_execution_control_plan_contract.py | 168 -- ...test_fold_event_stream_entries_contract.py | 630 ----- .../models/test_import_compatibility_shim.py | 51 - ...arket_configuration_positioned_contract.py | 398 --- .../test_market_reducer_positioned_target.py | 331 --- .../models/test_models_against_schemas.py | 580 ---- .../test_policy_risk_decision_contract.py | 276 -- ...cessing_position_cursor_ownership_guard.py | 125 - .../test_public_canonical_api_surface.py | 50 - ..._scheduling_obligation_characterization.py | 135 - .../test_execution_control_apply_isolated.py | 451 --- ...nt_dominance_sequences_characterization.py | 103 - .../test_new_queued_on_rate_limit.py | 134 - .../test_queue_cancel_dominates_new.py | 94 - ...ate_pop_queued_intents_characterization.py | 158 -- .../state_transitions/test_new_to_working.py | 30 - .../test_replace_to_replaced.py | 36 - ...est_submitted_boundary_characterization.py | 619 ---- .../test_terminal_clears_inflight.py | 26 - .../test_working_to_filled.py | 30 - tests/semantics/test_core_pipeline_clean.py | 95 + .../semantics/test_no_runtime_dependencies.py | 13 + tests/semantics/test_public_api_clean.py | 30 + .../semantics/test_risk_engine_policy_only.py | 60 + tradingchassis_core/__init__.py | 78 +- tradingchassis_core/core/domain/__init__.py | 4 - .../core/domain/event_model.py | 34 +- .../core/domain/execution_control_decision.py | 20 +- .../core/domain/order_lifecycle.py | 63 - .../core/domain/order_state_machine.py | 83 - .../core/domain/policy_risk_decision.py | 59 +- .../core/domain/processing_step.py | 351 +-- tradingchassis_core/core/domain/state.py | 990 +------ .../core/domain/step_result.py | 12 +- tradingchassis_core/core/domain/types.py | 272 +- tradingchassis_core/core/events/events.py | 52 +- .../core/ports/engine_context.py | 14 - .../core/ports/venue_adapter.py | 35 - tradingchassis_core/core/risk/risk_config.py | 39 +- tradingchassis_core/core/risk/risk_engine.py | 381 +-- .../schemas/order_state_event.schema.json | 100 - tradingchassis_core/strategies/base.py | 56 - .../strategies/strategy_config.py | 70 - 86 files changed, 553 insertions(+), 14354 deletions(-) delete mode 100644 docs/code-map/core-pipeline-map.md delete mode 100644 docs/code-map/repository-map.md delete mode 100644 docs/concepts/core-runtime-responsibility-model.md delete mode 100644 docs/concepts/event-model.md delete mode 100644 docs/concepts/gate-decision-compatibility.md delete mode 100644 docs/concepts/risk-vs-execution-control.md delete mode 100644 docs/flows/control-time-and-scheduling.md delete mode 100644 docs/flows/order-execution-feedback-event.md delete mode 100644 docs/flows/order-submitted-event.md delete mode 100644 docs/how-to/add-canonical-event.md delete mode 100644 docs/how-to/add-core-step-test.md delete mode 100644 docs/how-to/core-step-quickstart.md delete mode 100644 docs/how-to/extend-execution-control.md delete mode 100644 docs/mvp/compatibility-matrix.md delete mode 100644 docs/mvp/core-step-mvp-baseline.md delete mode 100644 docs/reference/events-reference.md delete mode 100644 docs/roadmap/post-mvp-roadmap.md delete mode 100644 tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py delete mode 100644 tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py delete mode 100644 tests/semantics/gate_risk_invariants/test_execution_control_accessor.py delete mode 100644 tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py delete mode 100644 tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py delete mode 100644 tests/semantics/gate_risk_invariants/test_replace_noop_handled.py delete mode 100644 tests/semantics/models/test_candidate_intent_combination_contract.py delete mode 100644 tests/semantics/models/test_canonical_processing_boundary.py delete mode 100644 tests/semantics/models/test_canonical_processing_differential_harness.py delete mode 100644 tests/semantics/models/test_canonical_reducer_authority_guard.py delete mode 100644 tests/semantics/models/test_core_configuration_contract.py delete mode 100644 tests/semantics/models/test_core_step_api_contract.py delete mode 100644 tests/semantics/models/test_core_step_decision_contract.py delete mode 100644 tests/semantics/models/test_core_step_result_contract.py delete mode 100644 tests/semantics/models/test_core_wakeup_step_contract.py delete mode 100644 tests/semantics/models/test_event_stream_entry_contract.py delete mode 100644 tests/semantics/models/test_event_taxonomy_boundary.py delete mode 100644 tests/semantics/models/test_execution_control_apply_contract.py delete mode 100644 tests/semantics/models/test_execution_control_decision_contract.py delete mode 100644 tests/semantics/models/test_execution_control_plan_contract.py delete mode 100644 tests/semantics/models/test_fold_event_stream_entries_contract.py delete mode 100644 tests/semantics/models/test_import_compatibility_shim.py delete mode 100644 tests/semantics/models/test_market_configuration_positioned_contract.py delete mode 100644 tests/semantics/models/test_market_reducer_positioned_target.py delete mode 100644 tests/semantics/models/test_models_against_schemas.py delete mode 100644 tests/semantics/models/test_policy_risk_decision_contract.py delete mode 100644 tests/semantics/models/test_processing_position_cursor_ownership_guard.py delete mode 100644 tests/semantics/models/test_public_canonical_api_surface.py delete mode 100644 tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py delete mode 100644 tests/semantics/queue_semantics/test_execution_control_apply_isolated.py delete mode 100644 tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py delete mode 100644 tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py delete mode 100644 tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py delete mode 100644 tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py delete mode 100644 tests/semantics/state_transitions/test_new_to_working.py delete mode 100644 tests/semantics/state_transitions/test_replace_to_replaced.py delete mode 100644 tests/semantics/state_transitions/test_submitted_boundary_characterization.py delete mode 100644 tests/semantics/state_transitions/test_terminal_clears_inflight.py delete mode 100644 tests/semantics/state_transitions/test_working_to_filled.py create mode 100644 tests/semantics/test_core_pipeline_clean.py create mode 100644 tests/semantics/test_no_runtime_dependencies.py create mode 100644 tests/semantics/test_public_api_clean.py create mode 100644 tests/semantics/test_risk_engine_policy_only.py delete mode 100644 tradingchassis_core/core/domain/order_lifecycle.py delete mode 100644 tradingchassis_core/core/domain/order_state_machine.py delete mode 100644 tradingchassis_core/core/ports/engine_context.py delete mode 100644 tradingchassis_core/core/ports/venue_adapter.py delete mode 100644 tradingchassis_core/core/schemas/order_state_event.schema.json delete mode 100644 tradingchassis_core/strategies/base.py delete mode 100644 tradingchassis_core/strategies/strategy_config.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 779ea67..12ef9ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,40 +1,17 @@ # Changelog -All notable changes to the TradingChassis Core package are documented in this file. - -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project -adheres to Semantic Versioning. - ## [Unreleased] -### Added - -- CoreStep MVP baseline documentation index alignment in `docs/README.md`. - ### Changed -- Recorded accepted and frozen CoreStep MVP baseline behavior: - - MarketEvent CoreStep path behind `enable_core_step_market_dispatch` - - ControlTimeEvent CoreStep path behind `enable_core_step_control_time_dispatch` - - Mixed wakeup collapse behind `enable_core_step_wakeup_collapse` - - rc `== 3` order/execution feedback CoreStep path behind - `enable_core_step_order_feedback_dispatch` -- Clarified runtime dispatch contract for migrated flag-on paths: - runtime dispatches `CoreStepResult.dispatchable_intents`. -- Clarified `OrderSubmittedEvent` dispatch-success emission boundary and ordering before - `mark_intent_sent`. +- Phase R1 clean cut: + - removed `GateDecision` compatibility contract from Core APIs + - removed compatibility decision contexts from `run_core_step` + - removed snapshot-era `OrderStateEvent` model and reducers + - made `RiskEngine` policy-only (`evaluate_policy_intent`, constraints build) + - simplified docs/tests to one clean CoreStep/CoreWakeupStep architecture -### Compatibility - -- `ControlSchedulingObligation` remains non-canonical Core output. -- Runtime continues to own pending obligation realization and `ControlTimeEvent` injection. -- `GateDecision` remains compatibility-only for legacy/default-off paths. -- Migration flags remain default `false`. - -### Documentation - -- Root repository documentation aligned to current MVP baseline language and boundaries. - -## [0.1.0] - 2026-02-17 +### Added -Initial public release of `tradingchassis_core`. +- clean public exports for canonical events, `ExecutionControl`, and `NullEventBus` +- focused semantics tests for clean Core pipeline and API boundary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2479e0..214e4a1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,58 +1,23 @@ # Contributing to TradingChassis Core -Thank you for contributing to `tradingchassis_core`. +Keep contributions aligned to deterministic Core ownership. -This repository is the Core semantic package. Keep changes deterministic, explicit, and scoped to -Core responsibilities. +## Scope rules -## Repository Scope +- Keep Core focused on canonical models, reduction, policy admission, and execution-control semantics. +- Do not add runtime adapters, venue I/O integrations, dispatch implementations, or `hftbacktest` dependencies. +- Keep Core outputs deterministic and runtime-agnostic (`CoreStepResult`). -Core owns semantic models and deterministic processing contracts, including canonical event -reduction and CoreStep/CoreWakeupStep result contracts. +## Local validation -Core does not own runtime I/O, venue adapters, or runtime orchestration concerns. - -## Development Setup - -From the `core` repository root: +From `core`: ```bash python -m pip install -e ".[dev]" -python -m pytest +python examples/core_step_quickstart.py +python -m pytest -q tests/semantics ``` -Use Python 3.11+. - -## Architecture Constraints - -Contributions must preserve the accepted MVP baseline and boundaries: - -- Core must not depend on runtime integration layers -- Core consumes canonical events and returns deterministic Core outputs -- Runtime owns external I/O, dispatch execution, and scheduling realization timing -- For migrated flag-on paths, runtime dispatches from `CoreStepResult.dispatchable_intents` -- `ControlSchedulingObligation` remains non-canonical Core output -- `GateDecision` remains compatibility-only for legacy/default-off paths - -Do not introduce behavior that implies final-architecture completion. - -## Testing Expectations - -- Add or update Core semantic tests for any behavior change -- Keep tests deterministic and scoped to the Core package -- Runtime integration validation may require a separate runtime environment; treat missing runtime - dependencies as environment/tooling blockers, not as Core semantic failures - -## Documentation Expectations - -When semantics or boundaries change: - -- Update relevant files under `docs/` -- Keep MVP baseline, compatibility, and post-MVP boundaries explicit -- Avoid mixing speculative final-state claims into MVP docs - -## Contribution Hygiene +## Documentation -- Prefer small, focused pull requests -- Avoid mixing broad refactors with documentation-only or behavior-only changes -- Do not introduce docs tooling or generated docs unless explicitly requested for the phase +Update `README.md` and `docs/reference/public-api.md` when public API changes. diff --git a/README.md b/README.md index 66e9167..8c12870 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,41 @@ # TradingChassis Core -Deterministic semantic core package for TradingChassis. +`tradingchassis_core` is a deterministic Core package. -This repository provides `tradingchassis_core`, the reusable core library that defines canonical -event processing contracts, state reduction boundaries, and CoreStep/CoreWakeupStep APIs. +It owns one architecture: -## What This Package Is +`EventStreamEntry -> run_core_step/run_core_wakeup_step -> candidate intents -> policy admission -> execution-control apply -> CoreStepResult` -- A deterministic core processing library for canonical event-driven semantics -- The home of CoreStep/CoreWakeupStep orchestration contracts -- A package that returns runtime-facing outputs such as - `CoreStepResult.dispatchable_intents` and compatibility bridge data where needed +## Scope -## What This Package Is Not +Core owns: -- Not venue/runtime I/O ownership -- Not a venue adapter or venue runtime shell -- Not a complete final live trading stack by itself +- canonical event models (`MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, `FillEvent`) +- deterministic state reduction +- strategy evaluator protocol +- candidate intent combination + provenance +- policy admission semantics +- execution-control semantics (queue/rate/inflight/sendability) +- `CoreStepResult` outputs (`dispatchable_intents`, `control_scheduling_obligation`) -## Current Status (Accepted MVP Baseline) +Core does not own: -- The CoreStep MVP baseline is accepted and frozen -- Migrated paths exist behind flags that remain default `false` -- Runtime dispatches `CoreStepResult.dispatchable_intents` on migrated flag-on paths -- Runtime does not productively use runtime `risk.decide_intents` or `GateDecision` on migrated - flag-on paths -- `GateDecision` remains a temporary compatibility mechanism for legacy/default-off paths -- This MVP is not the final architecture +- venue/backtest/live I/O +- runtime dispatch and runtime execution errors +- adapter integrations or `hftbacktest` +- runtime config file loading and deployment wiring ## Quickstart -From the `core` repository root: +From the `core` directory: ```bash python -m pip install -e ".[dev]" -python -m pytest python examples/core_step_quickstart.py +python -m pytest -q tests/semantics/examples/test_core_step_quickstart.py ``` -Runtime integration tests may require separate runtime dependencies/environment setup. Keep core -package validation centered on `core` tests in this repository. +## Docs -The runnable Core-only quickstart is at `examples/core_step_quickstart.py`. It demonstrates CoreStep -mechanics (`run_core_step` and `CoreStepResult`) without runtime/adapter dispatch. - -Note: the example uses `ControlTimeEvent` because it is the smallest canonical event model to -instantiate for a compact demo. This is not a claim that migrated runtime paths should productively -evaluate strategy on control-time events. - -## Architecture Entry Points - -- Docs start page: `docs/README.md` -- CoreStep MVP baseline: `docs/core-step-mvp-baseline.md` -- Core vs Runtime responsibilities: `docs/core-runtime-responsibility-model.md` -- Event model: `docs/event-model.md` -- Risk vs execution control boundary: `docs/risk-vs-execution-control.md` -- GateDecision compatibility status: `docs/gate-decision-compatibility.md` - -## Minimal Public API Orientation - -- Step entry points: `run_core_step`, `run_core_wakeup_step` -- Runtime-facing dispatch output: `CoreStepResult.dispatchable_intents` -- Canonical event models include `MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, and - `OrderExecutionFeedbackEvent` - -## Repository Guidance - -- Contributing guide: `CONTRIBUTING.md` -- Changelog: `CHANGELOG.md` -- Security policy: `SECURITY.md` +- `docs/README.md` +- `docs/reference/public-api.md` diff --git a/docs/README.md b/docs/README.md index d175213..d10993e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,56 +1,19 @@ -# TradingChassis Core Documentation +# TradingChassis Core Docs -This directory is the current documentation start point for the `core` repository. +This documentation set describes the clean Core package only. -Documentation intent follows a Concept -> Flow -> Code -> API progression. +## Contents -Manual Markdown is used for now. MkDocs, mkdocstrings, and Mermaid are optional future tooling -choices and are not part of this baseline. +- `reference/public-api.md`: supported root exports and step contracts -## Recommended Reading Order +## Architectural baseline -### MVP and status +The only supported processing architecture is: -1. [CoreStep MVP Baseline](mvp/core-step-mvp-baseline.md) -2. [Compatibility Matrix](mvp/compatibility-matrix.md) - -### Concepts - -3. [Runtime/Core Responsibility Model](concepts/core-runtime-responsibility-model.md) -4. [Event Model](concepts/event-model.md) -5. [Risk vs ExecutionControl](concepts/risk-vs-execution-control.md) -6. [GateDecision Compatibility](concepts/gate-decision-compatibility.md) - -### Flows - -7. [Control Time and Scheduling](flows/control-time-and-scheduling.md) -8. [OrderSubmittedEvent](flows/order-submitted-event.md) -9. [OrderExecutionFeedbackEvent (rc3 MVP path)](flows/order-execution-feedback-event.md) - -### Code map - -10. [Repository Map](code-map/repository-map.md) -11. [Core Pipeline Map](code-map/core-pipeline-map.md) - -### Reference - -12. [Public API Reference](reference/public-api.md) -13. [Events Reference](reference/events-reference.md) - -### How-to - -14. [Add a Canonical Event](how-to/add-canonical-event.md) -15. [Add a CoreStep or CoreWakeupStep Test](how-to/add-core-step-test.md) -16. [Extend ExecutionControl](how-to/extend-execution-control.md) -17. [CoreStep Core-only Quickstart](how-to/core-step-quickstart.md) - -### Roadmap - -18. [Post-MVP Roadmap](roadmap/post-mvp-roadmap.md) - -## Current Status Notes - -- The CoreStep MVP baseline is accepted and frozen. -- Migrated paths remain behind flags that are default `false`. -- `GateDecision` remains compatibility for legacy/default-off behavior. -- This MVP is not the final architecture. +1. canonical `EventStreamEntry` ingestion +2. deterministic state reduction +3. strategy evaluation +4. candidate intent combination +5. policy admission +6. execution-control planning/apply +7. `CoreStepResult` outputs for runtime dispatch/scheduling diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md deleted file mode 100644 index 4952136..0000000 --- a/docs/code-map/core-pipeline-map.md +++ /dev/null @@ -1,87 +0,0 @@ -# Core Pipeline Map - -This page maps CoreStep/CoreWakeupStep processing from concept to code. - -Runtime provides canonical inputs and later dispatches results. Core performs deterministic -reduction and decision shaping, then returns `CoreStepResult`. - -For a runnable minimal example of this pipeline in Core-only form, see -`examples/core_step_quickstart.py` and the short guide at -`docs/how-to/core-step-quickstart.md`. - -This diagram summarizes the single-step pipeline and its outputs. External dispatch remains -runtime-owned and happens after Core returns. - -```mermaid -flowchart LR - A[EventStreamEntry] --> B[State reduction] - B --> C[Strategy evaluation] - C --> D[Generated + queued candidates] - D --> E[Dominance/reconciliation] - E --> F[Policy risk admission] - F --> G[Execution control plan/apply] - G --> H[CoreStepResult] - H --> I[dispatchable_intents] - H --> J[control_scheduling_obligation] - I --> K[Runtime external dispatch] -``` - -## `run_core_step` pipeline - -1. Canonical `EventStreamEntry` input - - Responsibility: accept positioned canonical input from runtime. - - Key models/functions: `EventStreamEntry`, `ProcessingPosition`, `run_core_step`. - - Output: one step invocation context. - -2. State reduction - - Responsibility: reduce canonical event into `StrategyState`. - - Key functions: `process_event_entry`, `process_canonical_event`. - - Output: updated state snapshot. - -3. Strategy evaluation - - Responsibility: optionally evaluate strategy for generated intents. - - Key models/functions: `CoreStepStrategyEvaluator`, `CoreStepStrategyContext`. - - Output: `generated_intents`. - -4. Generated + queued candidate combination - - Responsibility: merge generated and queued intents deterministically. - - Key functions/models: `combine_candidate_intent_records`, `CandidateIntentRecord`. - - Output: `candidate_intent_records` and derived `candidate_intents`. - -5. Dominance / reconciliation - - Responsibility: resolve effective intent set by deterministic dominance rules. - - Key location: `intent_combination.py`. - - Output: effective candidate ordering for policy/execution stages. - -6. Policy admission - - Responsibility: side-effect-safe policy filtering for generated-origin candidates. - - Key functions/models: `apply_policy_to_candidate_records`, `PolicyRiskDecision`. - - Output: accepted/rejected policy projection. - -7. Execution-control planning/apply - - Responsibility: shape and optionally apply dispatchability mechanics. - - Key functions/models: `plan_execution_control_candidates`, `apply_execution_control_plan`, - `ExecutionControlDecision`. - - Output: queued/dispatchable/handled projections and optional scheduling obligation. - -8. Dispatchable output - - Responsibility: expose runtime-consumable dispatch set. - - Key model: `CoreStepResult.dispatchable_intents`. - - Output: dispatch candidates for runtime-owned execution. - -9. Control scheduling output - - Responsibility: return non-canonical scheduling handoff for runtime realization. - - Key model: `CoreStepResult.control_scheduling_obligation`. - - Output: optional `ControlSchedulingObligation`. - -## `run_core_wakeup_step` pipeline - -- `run_core_wakeup_step` is a convenience wrapper composed of: - 1. `run_core_wakeup_reduction` (reduce multiple entries + collect generated intents) - 2. `run_core_wakeup_decision` (candidate merge, policy admission, optional execution-control apply) -- It returns `CoreStepResult` with the same dispatchability/scheduling output shape used by - single-step processing. - -## Runtime boundary - -Runtime dispatch happens after Core returns. Core does not perform external venue dispatch itself. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md deleted file mode 100644 index 6f714ef..0000000 --- a/docs/code-map/repository-map.md +++ /dev/null @@ -1,62 +0,0 @@ -# Repository Map - -This page is a code navigation guide for the Core repository. It is not an API reference. - -## High-level tree - -```text -core/ - tradingchassis_core/ - __init__.py - core/ - domain/ - execution_control/ - risk/ - events/ - ports/ - strategies/ - tests/ - docs/ -``` - -## Area responsibilities - -- `tradingchassis_core/__init__.py` - - Curated package export surface (`__all__`) and compatibility-facing public entry points. -- `tradingchassis_core/core/domain/` - - Core semantic models and orchestration primitives: - - event and intent models (`types.py`) - - processing boundaries (`processing.py`, `processing_order.py`) - - CoreStep/CoreWakeupStep pipeline (`processing_step.py`) - - result and decision models (`step_result.py`, `step_decision.py`) - - candidate/combination/policy/execution-control planning and apply helpers -- `tradingchassis_core/core/risk/` - - Policy and compatibility gate logic (`risk_policy.py`, `risk_engine.py`, `risk_config.py`). -- `tradingchassis_core/core/execution_control/` - - Queue/rate/inflight dispatchability mechanics and internal control helper types. -- `tradingchassis_core/core/events/` - - Event bus/sink and telemetry-style event record abstractions. -- `tradingchassis_core/core/ports/` - - Protocol-style integration boundaries (engine context, venue adapter/policy contracts). -- `tradingchassis_core/strategies/` - - Strategy interface and strategy-facing configuration models. -- `tests/` - - Core semantic/regression coverage for deterministic behavior and boundaries. -- `docs/` - - Core repository documentation (MVP baseline, concepts, flows, code map, and reference). - -## Recommended reading order - -1. `tradingchassis_core/__init__.py` (public exports and what is intentionally exposed) -2. `tradingchassis_core/core/domain/types.py` (event/intent models and canonical vocabulary) -3. `tradingchassis_core/core/domain/processing_step.py` (`run_core_step` and wakeup pipeline) -4. `tradingchassis_core/core/domain/processing.py` + `state.py` (reduction boundary and state updates) -5. `tradingchassis_core/core/domain/intent_combination.py` + `candidate_intent.py` -6. `tradingchassis_core/core/domain/policy_risk_decision.py` (+ compatibility mapping context) -7. `tradingchassis_core/core/domain/execution_control_apply.py` and related decision/plan modules -8. `tests/` to confirm expected behavior and migration/compatibility semantics - -## Boundary note - -Runtime and venue I/O internals are out of scope for this repository map. Treat runtime behavior as -an integration boundary consumed by Core outputs. diff --git a/docs/concepts/core-runtime-responsibility-model.md b/docs/concepts/core-runtime-responsibility-model.md deleted file mode 100644 index 7902e4a..0000000 --- a/docs/concepts/core-runtime-responsibility-model.md +++ /dev/null @@ -1,57 +0,0 @@ -# Core and Runtime Responsibility Model - -This is the target architecture model that current MVP paths are moving toward. - -The diagram below shows the migrated-path ownership boundary at a glance. Runtime owns external -I/O and dispatch execution, while Core owns deterministic semantic reduction and decision shaping. - -```mermaid -flowchart LR - A[Runtime raw venue/backtest/live input] --> B[Runtime normalize to canonical events] - B --> C[Runtime create ProcessingPosition + EventStreamEntry] - C --> D[Call run_core_step/run_core_wakeup_step] - D --> E[Core consume canonical events] - E --> F[Core reduce state + evaluate strategy] - F --> G[Core policy risk + execution control] - G --> H[CoreStepResult] - H --> I[Runtime dispatch dispatchable_intents] - I --> J[External venue I/O] - I --> K[Successful NEW] - K --> L[Runtime emits OrderSubmittedEvent] -``` - -## Runtime owns - -- Receiving raw venue/backtest/live inputs. -- Normalizing raw inputs into canonical Core events. -- Allocating `ProcessingPosition` and creating `EventStreamEntry`. -- Calling `CoreStep` / `CoreWakeupStep` APIs. -- Holding and realizing `ControlSchedulingObligation`. -- Injecting `ControlTimeEvent` when due. -- Dispatching only `CoreStepResult.dispatchable_intents` for migrated paths. -- Emitting `OrderSubmittedEvent` only after successful external `NEW` dispatch. -- Venue/runtime I/O and execution error observability. - -## Core owns - -- Consuming canonical events only. -- Deterministic state reduction. -- Strategy evaluation via the CoreStep evaluator path. -- Combining generated and queued intent candidates. -- Intent dominance/reconciliation. -- Policy-only risk decisions. -- Execution control decisions: - - queue behavior - - rate behavior - - inflight/sendability behavior - - scheduling obligation derivation -- Returning `CoreStepResult`. - -## Runtime must not own (migrated paths) - -- Productive strategy execution outside CoreStep. -- Productive runtime `risk.decide_intents` for migrated paths. -- Intent dominance decisions. -- Fachlich queue pop/merge business semantics. -- Venue-independent business semantics that belong to Core. -- GateDecision as final architecture. diff --git a/docs/concepts/event-model.md b/docs/concepts/event-model.md deleted file mode 100644 index fc44ed0..0000000 --- a/docs/concepts/event-model.md +++ /dev/null @@ -1,42 +0,0 @@ -# Event Model (Current MVP) - -## Canonical events today - -- `MarketEvent` is canonical. -- `ControlTimeEvent` is canonical only after Runtime realizes a due - `ControlSchedulingObligation` and injects the event. -- `OrderSubmittedEvent` is canonical and emitted by Runtime after successful - external `NEW` dispatch. -- `OrderExecutionFeedbackEvent` is canonical MVP ingress event for normalized - rc3 order/execution/account feedback. -- `FillEvent` is canonical in model terms, but is not used for snapshot-only rc3 - MVP ingress. - -## Compatibility/non-canonical today - -- `OrderStateEvent` remains compatibility/non-canonical. -- `ControlSchedulingObligation` is non-canonical output from Core. -- `GateDecision` is compatibility for legacy/default-off paths. - -## Processing and ordering notes - -- Runtime owns canonical event construction and injection timing. -- Canonical ingestion order is defined by runtime-assigned `ProcessingPosition`. -- Runtime dispatches `CoreStepResult.dispatchable_intents` for migrated paths. - -The diagram below clarifies the event/intent/order boundary: Core works on canonical events and -intents, while Runtime handles external order dispatch and feedback normalization. - -```mermaid -flowchart LR - A[Canonical events] --> B[Core reduction + strategy] - B --> C[OrderIntent candidates] - C --> D[CoreStepResult.dispatchable_intents] - D --> E[Runtime external dispatch/orders] - E --> F[Successful NEW] - F --> G[Runtime emits OrderSubmittedEvent] - E --> H[Venue feedback] - H --> I[Runtime normalize feedback] - I --> J[OrderExecutionFeedbackEvent] - J --> A -``` diff --git a/docs/concepts/gate-decision-compatibility.md b/docs/concepts/gate-decision-compatibility.md deleted file mode 100644 index bc16d81..0000000 --- a/docs/concepts/gate-decision-compatibility.md +++ /dev/null @@ -1,20 +0,0 @@ -# GateDecision Compatibility Status - -## Current role - -`GateDecision` is temporary compatibility for legacy/default-off paths. - -## What remains valid today - -- `CoreStepResult.compat_gate_decision` can exist as bridge data. -- Legacy/default-off paths may still rely on GateDecision behavior. - -## Migrated path rule - -For migrated flag-on paths, Runtime dispatches from -`CoreStepResult.dispatchable_intents`, not from `GateDecision.accepted_now`. - -## Architectural status - -- GateDecision is not the final architecture. -- GateDecision removal is post-MVP work. diff --git a/docs/concepts/risk-vs-execution-control.md b/docs/concepts/risk-vs-execution-control.md deleted file mode 100644 index f0a0146..0000000 --- a/docs/concepts/risk-vs-execution-control.md +++ /dev/null @@ -1,26 +0,0 @@ -# Risk vs ExecutionControl - -## Risk is policy-only - -Risk checks are policy/hygiene controls such as: - -- trading enabled/disabled -- max loss checks -- hard hygiene checks -- validation and limits - -## ExecutionControl owns dispatchability mechanics - -ExecutionControl owns venue-independent dispatchability mechanics: - -- queue handling -- dominance/effective pending work -- rate constraints -- inflight/sendability checks -- dispatchable selection -- scheduling obligation derivation - -## Boundary for migrated paths - -- Runtime must not productively call runtime `risk.decide_intents`. -- Runtime dispatches from `CoreStepResult.dispatchable_intents`. diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md deleted file mode 100644 index 9a4d5a8..0000000 --- a/docs/flows/control-time-and-scheduling.md +++ /dev/null @@ -1,37 +0,0 @@ -# Control Time and Scheduling - -## What `ControlSchedulingObligation` is - -`ControlSchedulingObligation` is a non-canonical Core output that requests a -future runtime wakeup boundary. It is not an event-stream input by itself. - -## Why it is non-canonical - -- It is an internal handoff from Core to Runtime. -- It does not directly reduce Core state as an event. -- Canonical status begins only when Runtime realizes the obligation and injects - a `ControlTimeEvent`. - -## What `ControlTimeEvent` is - -`ControlTimeEvent` is the canonical control re-entry event that Runtime injects -when a due obligation is realized. - -This diagram shows the handoff boundary: Core emits a non-canonical scheduling obligation, and -Runtime later injects the canonical `ControlTimeEvent` when due. - -```mermaid -flowchart LR - A[CoreStepResult.control_scheduling_obligation] --> B[Runtime store pending obligation] - B --> C[Due time reached] - C --> D[Runtime inject ControlTimeEvent] - D --> E[Runtime create EventStreamEntry] - E --> F[run_core_step or run_core_wakeup_step] -``` - -## Current MVP behavior - -- Control-time CoreStep path is behind `enable_core_step_control_time_dispatch`. -- Mixed wakeup collapse behavior is behind `enable_core_step_wakeup_collapse`. -- Runtime owns pending obligations and due-time realization. -- No periodic tick model is implied by this MVP. diff --git a/docs/flows/order-execution-feedback-event.md b/docs/flows/order-execution-feedback-event.md deleted file mode 100644 index 484d94e..0000000 --- a/docs/flows/order-execution-feedback-event.md +++ /dev/null @@ -1,43 +0,0 @@ -# OrderExecutionFeedbackEvent (rc3 MVP) - -## Scope - -This page describes the MVP rc3 feedback path, not a full lifecycle redesign. - -The diagram below highlights the migrated rc3 flag-on flow and keeps legacy flag-off behavior as a -secondary compatibility branch. - -```mermaid -flowchart TB - A[rc == 3 feedback input] --> B[Runtime reads raw snapshot/feedback] - B --> C[Runtime normalize to OrderExecutionFeedbackEvent] - C --> D[Runtime create EventStreamEntry] - D --> E[run_core_step] - E --> F[Core reduce/evaluate/apply] - F --> G[CoreStepResult.dispatchable_intents] - G --> H[Runtime dispatch externally] - H --> I[Successful NEW] - I --> J[Runtime emits OrderSubmittedEvent] - - A -. flag off .-> K[Legacy rc3 compatibility path] -``` - -## Current MVP flow (flag on) - -- Runtime reads raw rc3 feedback/snapshot input as adapter input. -- Runtime normalizes that input into `OrderExecutionFeedbackEvent`. -- Runtime calls `run_core_step`. -- Core reduces the canonical feedback event and evaluates strategy through the - CoreStep evaluator bridge. -- Runtime dispatches `CoreStepResult.dispatchable_intents`. - -## Flag and compatibility behavior - -- Migrated path flag: - `enable_core_step_order_feedback_dispatch`. -- When flag is `false`, legacy rc3 path remains available. - -## Explicit non-claims - -- No full order lifecycle model is introduced by this MVP. -- Do not treat snapshot-only rc3 ingress as `FillEvent` ingress. diff --git a/docs/flows/order-submitted-event.md b/docs/flows/order-submitted-event.md deleted file mode 100644 index 291d7ab..0000000 --- a/docs/flows/order-submitted-event.md +++ /dev/null @@ -1,23 +0,0 @@ -# OrderSubmittedEvent - -## Emission rule - -Runtime emits `OrderSubmittedEvent` only after a successful external `NEW` -dispatch. - -## Non-emission cases - -- Failed external `NEW` dispatch -> no `OrderSubmittedEvent`. -- Non-`NEW` commands (for example replace/cancel) -> no new - `OrderSubmittedEvent`. - -## Ordering rule in current MVP - -`OrderSubmittedEvent` is processed before `mark_intent_sent` on successful -`NEW` dispatch handling. - -## Architectural meaning - -- `OrderSubmittedEvent` is canonical. -- It represents canonical order-entry confirmation at dispatch success boundary. -- It does not claim full post-submission lifecycle canonicalization. diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md deleted file mode 100644 index 4625f4a..0000000 --- a/docs/how-to/add-canonical-event.md +++ /dev/null @@ -1,64 +0,0 @@ -# Add a Canonical Event - -Use this guide when introducing a new canonical Core event in the MVP architecture. - -## When to add a canonical event - -- You need a durable Core event-stream input with deterministic reduction semantics. -- Runtime can normalize raw input into a stable Core model boundary. -- The event participates in Core reduction and/or CoreStep decision flow. - -## When not to add a canonical event - -- The data is runtime-only transport/adapter metadata. -- The artifact is compatibility-only or temporary bridge data. -- The artifact is a non-canonical Core output (for example `ControlSchedulingObligation`). - -## Required design questions - -- What raw input does Runtime receive, and how is it normalized? -- Is this canonical, or compatibility-only? -- Who produces it (Runtime boundary) and who reduces it (Core boundary)? -- Is it event-stream input, or a non-canonical output? -- Does it require `ProcessingPosition` ordering? -- Does it overlap with existing canonical events? - -## Implementation checklist - -- Add/update event model types in Core domain event/type modules. -- Update event taxonomy/boundary classification used by canonical processing. -- Add/update reducer boundary handling (`process_event_entry` / canonical processing path). -- Keep state mutation in Core reducer/state methods, not Runtime snapshots. -- Verify `run_core_step` / wakeup-step implications and outputs. -- Update manual references for public/event status docs. - -## Test checklist - -- Canonical taxonomy test (recognized as canonical or explicitly non-canonical). -- Canonical processing boundary test (accepted/rejected at correct boundary). -- Reducer/state transition test for deterministic effects. -- Guardrail test/search for no runtime dependency creep in Core. -- Compatibility rejection/segregation tests where relevant. - -## Documentation checklist - -- Update `../reference/events-reference.md`. -- Update `../concepts/event-model.md`. -- Update relevant flow docs under `../flows/`. -- Update `../mvp/compatibility-matrix.md` if flags/compat behavior changed. - -## Anti-patterns - -- Passing raw venue/backtest objects directly into Core event models. -- Mutating runtime snapshots as if they were Core state reducers. -- Using `FillEvent` as snapshot-only rc3 feedback ingress. -- Canonicalizing `ControlSchedulingObligation` as event-stream input. -- Adding event types without reducer/test/docs updates. - -## Related docs - -- [Event Model](../concepts/event-model.md) -- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) -- [Events Reference](../reference/events-reference.md) -- [CoreStep MVP Baseline](../mvp/core-step-mvp-baseline.md) -- [Compatibility Matrix](../mvp/compatibility-matrix.md) diff --git a/docs/how-to/add-core-step-test.md b/docs/how-to/add-core-step-test.md deleted file mode 100644 index ffbbbd2..0000000 --- a/docs/how-to/add-core-step-test.md +++ /dev/null @@ -1,69 +0,0 @@ -# Add a CoreStep or CoreWakeupStep Test - -Use this guide when adding behavior coverage for Core step orchestration. - -## When to write a CoreStep test - -- Single-entry deterministic behavior changes in `run_core_step`. -- Candidate generation/admission/dispatchability semantics change for one entry. -- `CoreStepResult` shape/field behavior changes. - -## When to write a CoreWakeupStep test - -- Multi-entry wakeup reduction/decision behavior changes. -- Deterministic ordering across multiple `EventStreamEntry` values is affected. -- Wakeup-level decision/apply behavior changes. - -## Core vs Runtime test ownership - -- Core tests should validate deterministic Core semantics only. -- Runtime tests should validate dispatch/integration behavior around Core outputs. - -## CoreStep test checklist - -- Use canonical `EventStreamEntry` input. -- Include `ProcessingPosition` ordering assumptions. -- Assert reducer/state effects. -- Assert strategy evaluator behavior (if evaluator is used). -- Assert generated and candidate intent record behavior. -- Assert policy risk decision projection behavior. -- Assert execution-control apply/decision behavior. -- Assert `dispatchable_intents` and `control_scheduling_obligation` outputs. -- Assert `compat_gate_decision` only when compatibility path is intentionally under test. - -## CoreWakeupStep test checklist - -- Use multiple canonical `EventStreamEntry` values. -- Assert deterministic processing order. -- Assert single wakeup-level decision/apply semantics. -- Assert no duplicated risk/dispatchability semantics within one wakeup flow. - -## Runtime migrated-path guardrail checklist - -- Runtime dispatches `CoreStepResult.dispatchable_intents`. -- Runtime does not productively re-decide equivalent work via runtime risk. -- Runtime does not use `GateDecision` as migrated-path final contract. -- Runtime preserves `OrderSubmittedEvent` dispatch-success behavior. -- Runtime realizes/apply scheduling obligations correctly. - -## Test placement guide - -- Place Core semantic tests in Core repository test suites. -- Place runtime integration/guardrail tests in runtime-owned suites. -- If runtime environment is blocked (dependencies/system), classify as tooling/environment blocker, - not as Core semantic failure. - -## Anti-patterns - -- Testing runtime I/O behavior inside Core-only tests. -- Treating `GateDecision` as the final migrated-path contract. -- Pulling runtime dependencies into Core tests. -- Using broad brittle end-to-end tests for small semantic changes. - -## Related docs - -- [Core Pipeline Map](../code-map/core-pipeline-map.md) -- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) -- [GateDecision Compatibility](../concepts/gate-decision-compatibility.md) -- [CoreStep MVP Baseline](../mvp/core-step-mvp-baseline.md) -- [Compatibility Matrix](../mvp/compatibility-matrix.md) diff --git a/docs/how-to/core-step-quickstart.md b/docs/how-to/core-step-quickstart.md deleted file mode 100644 index bfc279c..0000000 --- a/docs/how-to/core-step-quickstart.md +++ /dev/null @@ -1,32 +0,0 @@ -# CoreStep Core-only Quickstart - -Use `examples/core_step_quickstart.py` for the smallest runnable Core-only CoreStep example. - -## Run - -From the `core` repository root: - -```bash -python examples/core_step_quickstart.py -``` - -## What it demonstrates - -- Canonical event in (`EventStreamEntry` with `ControlTimeEvent`) -- `run_core_step` execution -- `CoreStepResult` inspection: - - `generated_intents` - - `candidate_intent_records` - - `dispatchable_intents` - -The script contains two slices: - -- v1 (smallest): strategy evaluator only, no policy/admission/apply; dispatchables are empty. -- v2 (optional): allow-all policy + execution-control apply; dispatchables become non-empty. - -## Semantics caveat - -`ControlTimeEvent` is used because it is the smallest canonical event model to construct for a -compact CoreStep mechanics demo. This is not a statement that migrated runtime paths should -productively evaluate strategy on control-time events. Runtime remains responsible for injecting -control-time events and for external dispatch after Core returns results. diff --git a/docs/how-to/extend-execution-control.md b/docs/how-to/extend-execution-control.md deleted file mode 100644 index 3f6a4cd..0000000 --- a/docs/how-to/extend-execution-control.md +++ /dev/null @@ -1,59 +0,0 @@ -# Extend ExecutionControl - -Use this guide when changing queue/rate/inflight/sendability mechanics in Core. - -## What ExecutionControl owns - -- Queue reconciliation and effective pending work mechanics -- Rate-limit handling -- Inflight/sendability checks -- Dispatchable selection mechanics -- Scheduling obligation derivation - -## What ExecutionControl must not own - -- Policy-only risk checks -- External dispatch -- Venue/live/backtest I/O -- Runtime error handling/observability -- `OrderSubmittedEvent` emission -- Raw venue feedback interpretation/normalization - -## Design checklist - -- Is this change policy risk or execution-control mechanics? -- Does it change queue/rate/inflight/sendability/scheduling behavior? -- Does it require plan/apply separation updates? -- Does it affect `CandidateIntentOrigin` or candidate record interpretation? -- Does it affect `ControlSchedulingObligation` semantics? - -## Implementation checklist - -- Update planning path where candidate execution-control projections are formed. -- Update apply path where mutable queue/rate/inflight effects are realized. -- Keep state mutation boundaries inside Core state/execution-control layers. -- Keep output surfaces consistent (`ExecutionControlDecision`, `CoreStepResult`). -- Re-check compatibility interaction with `GateDecision` bridge behavior. - -## Test checklist - -- Add/update isolated execution-control behavior tests. -- Add/update CoreStep integration tests for changed dispatchability/scheduling outcomes. -- Add/update runtime migrated-path guardrails when dispatch behavior implications change. -- Add explicit scheduling-obligation behavior tests. - -## Anti-patterns - -- Reintroducing runtime risk decisions for migrated-path work. -- Dispatching externally from Core. -- Mutating runtime-owned state from Core. -- Hiding policy checks inside execution-control logic. -- Treating `GateDecision` as final architecture output. - -## Related docs - -- [Risk vs ExecutionControl](../concepts/risk-vs-execution-control.md) -- [Core and Runtime Responsibility Model](../concepts/core-runtime-responsibility-model.md) -- [GateDecision Compatibility](../concepts/gate-decision-compatibility.md) -- [Core Pipeline Map](../code-map/core-pipeline-map.md) -- [Compatibility Matrix](../mvp/compatibility-matrix.md) diff --git a/docs/mvp/compatibility-matrix.md b/docs/mvp/compatibility-matrix.md deleted file mode 100644 index 5b559ff..0000000 --- a/docs/mvp/compatibility-matrix.md +++ /dev/null @@ -1,28 +0,0 @@ -# Compatibility Matrix - -This matrix reflects the accepted and frozen CoreStep MVP baseline. It separates migrated -CoreStep behavior from compatibility/legacy behavior. - -| Concern / path | MVP canonical or CoreStep behavior | Compatibility / legacy behavior | Flag | Default | Post-MVP note | -| --- | --- | --- | --- | --- | --- | -| MarketEvent path | CoreStep path exists and can drive migrated processing | Legacy/default-off path remains available | `enable_core_step_market_dispatch` | `false` | Candidate for default-on later | -| ControlTimeEvent path | CoreStep path exists for runtime-injected control re-entry | Legacy/default-off control handling remains | `enable_core_step_control_time_dispatch` | `false` | Keep runtime injection boundary | -| Mixed wakeup collapse | Wakeup collapse path exists in CoreStep wakeup model | Legacy/default-off wakeup behavior remains | `enable_core_step_wakeup_collapse` | `false` | Final wakeup model is post-MVP | -| rc `== 3` order/execution feedback | Runtime normalizes into canonical `OrderExecutionFeedbackEvent` and calls `run_core_step` | Legacy rc3 path remains when disabled | `enable_core_step_order_feedback_dispatch` | `false` | Full lifecycle redesign is post-MVP | -| Runtime dispatch | Migrated flag-on paths dispatch from `CoreStepResult.dispatchable_intents` | Legacy path may dispatch from compatibility gate output | path-specific migrated flags | `false` | Dispatch boundary remains runtime-owned | -| Runtime `risk.decide_intents` | Not productively used for migrated flag-on paths | May still be used by legacy/default-off paths | path-specific migrated flags | `false` | Runtime risk/gate productive role should shrink | -| `GateDecision` | Not the migrated-path dispatch contract | Temporary compatibility decision model | path-specific migrated flags | `false` | Removal is post-MVP | -| `CoreStepResult.compat_gate_decision` | Optional bridge field only | Used by compatibility flows where needed | path-specific migrated flags | `false` | Remove with GateDecision retirement | -| `ControlSchedulingObligation` | Non-canonical Core output for runtime wakeup planning | N/A (already compatibility-shaped handoff) | N/A | N/A | May evolve when final control model lands | -| `ControlTimeEvent` | Canonical only after runtime realizes obligation and injects event | N/A | `enable_core_step_control_time_dispatch` | `false` | Canonical control model can be refined later | -| `OrderSubmittedEvent` | Canonical, emitted only after successful external `NEW` dispatch and before `mark_intent_sent` | N/A | N/A | N/A | Full lifecycle semantics remain post-MVP | -| `FillEvent` | Canonical model in event taxonomy | Not used as snapshot-only rc3 feedback ingress | N/A | N/A | Expanded fill-centric lifecycle is post-MVP | -| `OrderExecutionFeedbackEvent` | Canonical MVP rc3 feedback ingress event | Replaced by legacy snapshot path when flag off | `enable_core_step_order_feedback_dispatch` | `false` | Keep canonical feedback ingress | -| `OrderStateEvent` | Non-canonical for current MVP semantics | Compatibility/snapshot projection record | N/A | N/A | Future lifecycle work may retire this role | - -## Notes - -- Migrated CoreStep paths are flag-gated and all migration flags remain default `false`. -- `GateDecision` remains compatibility for legacy/default-off behavior. -- `OrderExecutionFeedbackEvent` is the canonical rc3 MVP feedback ingress event. -- `FillEvent` is not the snapshot-only rc3 feedback ingress event in this MVP. diff --git a/docs/mvp/core-step-mvp-baseline.md b/docs/mvp/core-step-mvp-baseline.md deleted file mode 100644 index 471c5a2..0000000 --- a/docs/mvp/core-step-mvp-baseline.md +++ /dev/null @@ -1,55 +0,0 @@ -# CoreStep MVP Baseline - -This document records the accepted and frozen CoreStep MVP baseline. - -## Accepted baseline (current behavior) - -- MarketEvent CoreStep path exists behind `enable_core_step_market_dispatch`. -- ControlTimeEvent CoreStep path exists behind - `enable_core_step_control_time_dispatch`. -- Mixed wakeup collapse exists behind `enable_core_step_wakeup_collapse`. -- rc `== 3` order/execution feedback CoreStep path exists behind - `enable_core_step_order_feedback_dispatch`. -- Runtime dispatches `CoreStepResult.dispatchable_intents` on migrated - flag-on paths. -- Runtime does not productively use runtime `risk.decide_intents` or - GateDecision for migrated flag-on paths. -- Runtime emits `OrderSubmittedEvent` only after successful external `NEW` - dispatch. -- `OrderSubmittedEvent` remains ordered before `mark_intent_sent`. -- `ControlSchedulingObligation` remains non-canonical Core output. -- Runtime owns pending `ControlSchedulingObligation` and injects - `ControlTimeEvent` when due. -- `GateDecision` remains temporary compatibility for legacy/default-off paths. -- All migrated flags remain default `false`. - -## Core API/model surface used by MVP - -Core result and decision models: - -- `CoreStepResult` -- `CoreStepDecision` -- `PolicyRiskDecision` -- `ExecutionControlDecision` -- `CandidateIntentOrigin` -- `CandidateIntentRecord` -- `CoreWakeupReductionResult` - -Core orchestration and helper APIs: - -- `run_core_step` -- `run_core_wakeup_reduction` -- `run_core_wakeup_decision` -- `run_core_wakeup_step` -- `apply_policy_to_candidate_records` -- `plan_execution_control_candidates` -- `apply_execution_control_plan` -- `combine_candidate_intent_records` - -## Important boundaries in this MVP - -- Runtime dispatches from `dispatchable_intents` for migrated flag-on paths. -- Runtime-owned risk/gate logic is compatibility-only for those migrated paths. -- No full order lifecycle model is part of this MVP. -- Legacy rc3 path remains available when - `enable_core_step_order_feedback_dispatch` is `false`. diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md deleted file mode 100644 index e2c8cea..0000000 --- a/docs/reference/events-reference.md +++ /dev/null @@ -1,20 +0,0 @@ -# Events Reference - -This table summarizes current event/model status for the accepted MVP baseline. - -| Event/model | Canonical status | Producer | Consumer/reducer | Purpose | Notes | -| --- | --- | --- | --- | --- | --- | -| `MarketEvent` | Canonical | Runtime (normalized ingress) | Core reduction (`process_event_entry` / `run_core_step`) | Market-state update input | CoreStep migrated market path is flag-gated and default `false` | -| `ControlTimeEvent` | Canonical after runtime injection | Runtime (when due obligation realized) | Core reduction (`process_event_entry` / `run_core_step`) | Canonical control re-entry boundary | Becomes canonical only after runtime injects it | -| `OrderSubmittedEvent` | Canonical | Runtime (post successful external `NEW` dispatch) | Core reduction | Canonical submission confirmation boundary | Emitted after successful `NEW`; ordered before `mark_intent_sent` | -| `OrderExecutionFeedbackEvent` | Canonical MVP ingress event | Runtime (normalized rc3 feedback/snapshot input) | Core reduction (`run_core_step`) | Canonical rc3 feedback ingress for MVP | Migrated rc3 path is flag-gated and default `false` | -| `FillEvent` | Canonical model status | Runtime/normalization boundary (model-level) | Core reduction (model support exists) | Fill-oriented execution event model | Not used as snapshot-only rc3 feedback ingress in MVP | -| `OrderStateEvent` | Compatibility/non-canonical | Compatibility snapshot/projection flows | Compatibility handling only | Legacy snapshot/materialization record | Not a canonical Event Stream record in current MVP | -| `ControlSchedulingObligation` | Non-canonical Core output (not an event) | Core output (`CoreStepResult`) | Runtime scheduling realization | Runtime wakeup planning handoff | Must not be treated as event-stream input/persisted event | - -## Notes - -- `OrderExecutionFeedbackEvent` is the canonical rc3 MVP feedback ingress event. -- `FillEvent` remains canonical in model terms but is not used for snapshot-only rc3 ingress. -- `GateDecision` remains compatibility for legacy/default-off paths and is not the migrated-path - dispatch contract. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 28ab92b..c49e6ca 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -1,91 +1,51 @@ # Public API Reference -This reference is manual and curated for the current MVP baseline. Generated API docs may be added -later. +The package export boundary is `tradingchassis_core`. -Stability tags used here: +## Canonical events -- **Stable MVP**: current baseline contract to rely on -- **Compatibility**: transitional bridge surface -- **Internal-shape exposed**: exported today but better treated as implementation-adjacent - -## Core step APIs +- `MarketEvent` +- `ControlTimeEvent` +- `OrderSubmittedEvent` +- `OrderExecutionFeedbackEvent` +- `FillEvent` -Stable MVP: +## Step APIs - `run_core_step` - `run_core_wakeup_reduction` - `run_core_wakeup_decision` - `run_core_wakeup_step` -Purpose: deterministic Core entry points for step and wakeup-level processing. - -## Result and decision models +## Step inputs/outputs -Stable MVP (with transitional fields where noted): - -- `CoreStepResult` (includes compatibility bridge field `compat_gate_decision`) +- `EventStreamEntry` +- `ProcessingPosition` +- `CorePolicyAdmissionContext` +- `CoreExecutionControlApplyContext` - `CoreStepDecision` -- `PolicyRiskDecision` -- `ExecutionControlDecision` +- `CoreStepResult` +- `CoreWakeupReductionResult` -Purpose: structured step outcomes and policy/execution-control projections. - -## Candidate intent models - -Stable MVP: +## Supporting deterministic models +- `CoreConfiguration` +- `StrategyState` - `CandidateIntentRecord` - `CandidateIntentOrigin` +- `PolicyRiskDecision` +- `ExecutionControlDecision` +- `ExecutionControl` +- `ControlSchedulingObligation` -Purpose: explicit candidate provenance and deterministic merge ordering metadata. - -## Event stream models - -Stable MVP: - -- `ProcessingPosition` -- `EventStreamEntry` - -Purpose: canonical ingestion ordering envelope for deterministic reduction. - -## Canonical event models - -Stable MVP: - -- `MarketEvent` -- `ControlTimeEvent` -- `OrderSubmittedEvent` -- `OrderExecutionFeedbackEvent` - -Canonical model with caveat: - -- `FillEvent` is canonical in the model taxonomy, but it is not the snapshot-only rc3 MVP ingress. - -## Strategy/config/state-facing models - -Stable MVP: - -- `Strategy` -- `StrategyState` -- `CoreConfiguration` -- `EngineContext` - -Purpose: strategy contract and deterministic configuration/state interaction surface. - -## Compatibility and transitional surfaces - -Compatibility: - -- `GateDecision` (legacy/default-off compatibility decision model) -- `CoreStepResult.compat_gate_decision` (bridge field for compatibility paths) - -Compatibility/non-canonical model context: +## Utility exports -- `OrderStateEvent` remains compatibility/non-canonical in current MVP docs. +- `NullEventBus` +- `RiskEngine` (policy-only evaluator) -## Export boundary note +## Removed compatibility contracts -`tradingchassis_core.__init__` is the intended package export boundary. Not every exported symbol -should be treated as long-term stable final architecture; compatibility surfaces are explicitly -transitional. +- `GateDecision` +- `CoreStepResult.compat_gate_decision` +- `ControlTimeQueueReevaluationContext` +- `CoreDecisionContext` diff --git a/docs/roadmap/post-mvp-roadmap.md b/docs/roadmap/post-mvp-roadmap.md deleted file mode 100644 index f34bc06..0000000 --- a/docs/roadmap/post-mvp-roadmap.md +++ /dev/null @@ -1,16 +0,0 @@ -# Post-MVP Roadmap Boundaries - -The following items are explicitly post-MVP and out of scope for the accepted -baseline: - -- default flag flips -- GateDecision removal -- Strategy API redesign -- full order lifecycle model -- broad docs rewrite outside `core/docs` -- main docs repository cleanup - -## Additional scope guard - -Current MVP docs describe accepted transitional architecture and compatibility -bridges. They do not claim final-state completion. diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 3468b5b..1ffe4fd 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -1,16 +1,4 @@ -"""Core-only CoreStep quickstart example. - -Run from the core repository root: - python examples/core_step_quickstart.py - -This script demonstrates CoreStep mechanics only: -canonical event in -> run_core_step -> CoreStepResult out. - -ControlTimeEvent is used here because it is the smallest canonical event to -construct for a compact example. This is not a statement that migrated runtime -paths should productively evaluate strategy on ControlTimeEvent; runtime-owned -behavior remains documented in the corresponding architecture docs. -""" +"""Core-only CoreStep quickstart example.""" from __future__ import annotations @@ -22,16 +10,6 @@ import tradingchassis_core as tc -# Internal import gap: ControlTimeEvent is canonical but not currently exported -# from the package root. -from tradingchassis_core.core.domain.types import ControlTimeEvent -# Internal import gap: StrategyState requires an EventBus; NullEventBus is the -# smallest no-op bus. -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -# Internal import gap: execution-control apply requires an ExecutionControl -# instance and this type is not currently exported from package root. -from tradingchassis_core.core.execution_control.execution_control import ExecutionControl - INSTRUMENT = "BTC-USDC-PERP" INTENT_ID_V1 = "quickstart-new-v1" INTENT_ID_V2 = "quickstart-new-v2" @@ -63,7 +41,13 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: class AllowAllPolicy: """Policy evaluator that admits every generated candidate intent.""" - def evaluate_policy_intent(self, *, intent: tc.OrderIntent, state: tc.StrategyState, now_ts_ns_local: int) -> tuple[bool, str | None]: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: _ = (intent, state, now_ts_ns_local) return True, None @@ -71,7 +55,7 @@ def evaluate_policy_intent(self, *, intent: tc.OrderIntent, state: tc.StrategySt def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), - event=ControlTimeEvent( + event=tc.ControlTimeEvent( ts_ns_local_control=ts_ns_local, reason="scheduled_control_recheck", due_ts_ns_local=ts_ns_local, @@ -84,8 +68,6 @@ def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: - """Smallest CoreStep usage: generated and candidate intents only.""" - result = tc.run_core_step( state, _control_time_entry(index=0, ts_ns_local=1_000), @@ -100,8 +82,6 @@ def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: - """Optional Core-only extension: policy admission + execution-control apply.""" - result = tc.run_core_step( state, _control_time_entry(index=1, ts_ns_local=1_001), @@ -111,7 +91,7 @@ def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: now_ts_ns_local=1_001, ), execution_control_apply_context=tc.CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), + execution_control=tc.ExecutionControl(), now_ts_ns_local=1_001, activate_dispatchable_outputs=True, ), @@ -122,7 +102,7 @@ def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: def main() -> None: - state = tc.StrategyState(event_bus=NullEventBus()) + state = tc.StrategyState(event_bus=tc.NullEventBus()) result_v1 = run_v1_generated_only(state) result_v2 = run_v2_with_policy_and_apply(state) diff --git a/tests/semantics/examples/test_core_step_quickstart.py b/tests/semantics/examples/test_core_step_quickstart.py index d60236b..c75337a 100644 --- a/tests/semantics/examples/test_core_step_quickstart.py +++ b/tests/semantics/examples/test_core_step_quickstart.py @@ -6,7 +6,6 @@ from pathlib import Path import tradingchassis_core as tc -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus _MODULE_PATH = Path(__file__).resolve().parents[3] / "examples" / "core_step_quickstart.py" _SPEC = importlib.util.spec_from_file_location("core_step_quickstart_example", _MODULE_PATH) @@ -17,8 +16,7 @@ def test_core_step_quickstart_v1_generated_and_candidates() -> None: - state = tc.StrategyState(event_bus=NullEventBus()) - + state = tc.StrategyState(event_bus=tc.NullEventBus()) result = _MODULE.run_v1_generated_only(state) assert isinstance(result, tc.CoreStepResult) @@ -35,9 +33,7 @@ def test_core_step_quickstart_v1_generated_and_candidates() -> None: def test_core_step_quickstart_v2_dispatchable_output() -> None: - state = tc.StrategyState(event_bus=NullEventBus()) - - # Keep the same call order as the script so ProcessingPosition remains monotonic. + state = tc.StrategyState(event_bus=tc.NullEventBus()) _ = _MODULE.run_v1_generated_only(state) result = _MODULE.run_v2_with_policy_and_apply(state) diff --git a/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py b/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py deleted file mode 100644 index b346d93..0000000 --- a/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Semantic test: CANCEL intent for a non-existing order must be rejected. - -Invariant: -A CANCEL intent requires an existing order with the same -(instrument, client_order_id). Otherwise it must be rejected. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NotionalLimits, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_cancel_for_non_existing_order_is_rejected() -> None: - """Reject CANCEL intent when no order with the given id exists.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "missing-order" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.ORDER_NOT_FOUND - - # ---------- assert no side effects ---------- - assert not state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py b/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py deleted file mode 100644 index 2c531fe..0000000 --- a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Semantic test: Duplicate NEW intent must be rejected. - -Invariant: -(client_order_id, instrument) must be unique while an order id is busy -(working ∪ queued). A NEW with the same id must be rejected. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - NotionalLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_duplicate_new_is_rejected_when_working_order_exists() -> None: - """Reject NEW intent when a working order with same (instrument, client_order_id) exists.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Create an existing WORKING order in state via canonical snapshot ingestion. - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - # Minimal risk config (notional_limits is required by validation). - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - duplicate_new = NewOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=101.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[duplicate_new], - state=state, - now_ts_ns_local=2, - ) - - # Must be rejected with DUPLICATE_ID. - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.DUPLICATE_ID - - # State must remain: working still exists; no queued intent for same id. - assert state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py b/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py deleted file mode 100644 index 953adb1..0000000 --- a/tests/semantics/gate_risk_invariants/test_execution_control_accessor.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Semantics tests for RiskEngine.execution_control accessor contract.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.types import NotionalLimits -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -class _CaptureSink: - def __init__(self) -> None: - self.events: list[object] = [] - - def on_event(self, event: object) -> None: - self.events.append(event) - - -def _risk_cfg() -> RiskConfig: - return RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - ) - - -def test_execution_control_accessor_returns_owned_stateful_instance_without_side_effects( - monkeypatch: pytest.MonkeyPatch, -) -> None: - sink = _CaptureSink() - risk = RiskEngine(risk_cfg=_risk_cfg(), event_bus=EventBus(sinks=[sink])) - - before_rate_state = copy.deepcopy(risk._execution_control._rate_state) - - monkeypatch.setattr( - risk, - "decide_intents", - lambda **_: (_ for _ in ()).throw( - AssertionError("execution_control accessor must not call decide_intents") - ), - ) - - first = risk.execution_control - second = risk.execution_control - - assert first is second - assert first is risk._execution_control - assert risk._execution_control._rate_state == before_rate_state - assert sink.events == [] - - -def test_execution_control_accessor_preserves_rate_state_continuity_across_calls() -> None: - risk = RiskEngine(risk_cfg=_risk_cfg(), event_bus=EventBus(sinks=[])) - ts_ns_local = 1_000_000_000 - - # Same timestamp, same bucket; the third consume must fail after two accepts. - allowed_1, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) - allowed_2, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) - allowed_3, _ = risk.execution_control.consume_rate("order", ts_ns_local, 2.0) - - assert allowed_1 is True - assert allowed_2 is True - assert allowed_3 is False diff --git a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py b/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py deleted file mode 100644 index 2d5c62c..0000000 --- a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Semantic test: inflight intent blocks further REPLACE intents. - -Invariant: -While an order id is inflight, no additional REPLACE -may be sent. Such intents must be queued instead. - -NEW with the same client_order_id is always rejected. -CANCEL is always allowed. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NotionalLimits, - OrderStateEvent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_inflight_blocks_new_intent_and_queues_it() -> None: - """NEW intent must be queued when an inflight action exists for the same id.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 1, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - # Simulate inflight action (previous replace already sent) - state.mark_intent_sent( - instrument=instrument, - client_order_id=client_order_id, - intent_type="replace", - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - replace_intent = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=101.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - - decision = risk_engine.decide_intents( - raw_intents=[replace_intent], - state=state, - now_ts_ns_local=2, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.rejected == [] - assert decision.handled_in_queue == [] - assert len(decision.queued) == 1 - - # ---------- assert state ---------- - assert state.has_working_order(instrument, client_order_id) - assert state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py b/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py deleted file mode 100644 index 8460b73..0000000 --- a/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Characterization test: rejected/denied intents must not enter the queue. - -This pins current behavior that hard rejects do not mutate StrategyState.queued_intents. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - NotionalLimits, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_trading_disabled_rejects_new_without_queue_side_effects_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=False, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.handled_in_queue == [] - assert len(decision.rejected) == 1 - assert decision.rejected[0].reason == RejectReason.TRADING_DISABLED - - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py b/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py deleted file mode 100644 index fdd95c7..0000000 --- a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Semantic test: REPLACE intent with no effective change must be handled as no-op. - -Invariant: -A REPLACE that does not change price or quantity after normalization -must not be sent, queued, or rejected. It must be handled as no-op. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NotionalLimits, - OrderStateEvent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_replace_without_effective_change_is_handled_noop() -> None: - """REPLACE with identical price/qty must be handled and dropped.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - existing_order = OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - state.apply_order_state_event(existing_order) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - extra={}, - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - replace_noop = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - - decision = risk_engine.decide_intents( - raw_intents=[replace_noop], - state=state, - now_ts_ns_local=2, - ) - - # ---------- assert decision ---------- - assert decision.accepted_now == [] - assert decision.queued == [] - assert decision.rejected == [] - assert len(decision.handled_in_queue) == 1 - - # ---------- assert no side effects ---------- - assert state.has_working_order(instrument, client_order_id) - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/models/test_candidate_intent_combination_contract.py b/tests/semantics/models/test_candidate_intent_combination_contract.py deleted file mode 100644 index 436ab9d..0000000 --- a/tests/semantics/models/test_candidate_intent_combination_contract.py +++ /dev/null @@ -1,266 +0,0 @@ -"""Semantics tests for Core-step candidate intent combination helper.""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin -from tradingchassis_core.core.domain.intent_combination import ( - combine_candidate_intent_records, - combine_candidate_intents, -) -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - Price, - Quantity, - ReplaceOrderIntent, -) - - -def _new(*, client_order_id: str, ts: int = 1) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=ts, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-new", - side="buy", - 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(*, client_order_id: str, ts: int = 1, px: float = 101.0) -> ReplaceOrderIntent: - return ReplaceOrderIntent( - ts_ns_local=ts, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-replace", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=px), - ) - - -def _cancel(*, client_order_id: str, ts: int = 1) -> CancelOrderIntent: - return CancelOrderIntent( - ts_ns_local=ts, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-cancel", - ) - - -def test_combine_candidate_intents_empty_inputs_returns_empty() -> None: - result = combine_candidate_intents(generated_intents=(), queued_intents=()) - assert result == () - - -def test_combine_candidate_intent_records_empty_inputs_returns_empty() -> None: - result = combine_candidate_intent_records(generated_intents=(), queued_intents=()) - assert result == () - - -def test_combine_candidate_intents_generated_only() -> None: - generated = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) - result = combine_candidate_intents(generated_intents=generated, queued_intents=()) - assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") - - -def test_combine_candidate_intent_records_generated_only_origin_is_generated() -> None: - generated = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) - result = combine_candidate_intent_records(generated_intents=generated, queued_intents=()) - - assert tuple(record.intent.client_order_id for record in result) == ("c1", "r1", "n1") - assert tuple(record.origin for record in result) == ( - CandidateIntentOrigin.GENERATED, - CandidateIntentOrigin.GENERATED, - CandidateIntentOrigin.GENERATED, - ) - - -def test_combine_candidate_intents_queued_only() -> None: - queued = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) - result = combine_candidate_intents(generated_intents=(), queued_intents=queued) - assert tuple(it.client_order_id for it in result) == ("c1", "r1", "n1") - - -def test_combine_candidate_intent_records_queued_only_origin_is_queued() -> None: - queued = (_new(client_order_id="n1"), _replace(client_order_id="r1"), _cancel(client_order_id="c1")) - result = combine_candidate_intent_records(generated_intents=(), queued_intents=queued) - - assert tuple(record.intent.client_order_id for record in result) == ("c1", "r1", "n1") - assert tuple(record.origin for record in result) == ( - CandidateIntentOrigin.QUEUED, - CandidateIntentOrigin.QUEUED, - CandidateIntentOrigin.QUEUED, - ) - - -def test_combine_candidate_intents_keeps_different_keys() -> None: - queued = (_new(client_order_id="order-a"),) - generated = (_replace(client_order_id="order-b"),) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert tuple((it.intent_type, it.client_order_id) for it in result) == ( - ("replace", "order-b"), - ("new", "order-a"), - ) - - -def test_combine_candidate_intent_records_keeps_different_keys_with_origins() -> None: - queued = (_new(client_order_id="order-a"),) - generated = (_replace(client_order_id="order-b"),) - result = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert tuple((r.intent.intent_type, r.intent.client_order_id, r.origin) for r in result) == ( - ("replace", "order-b", CandidateIntentOrigin.GENERATED), - ("new", "order-a", CandidateIntentOrigin.QUEUED), - ) - - -def test_generated_cancel_dominates_queued_replace_or_new_same_key() -> None: - key = "same-key" - queued = (_new(client_order_id=key), _replace(client_order_id=key)) - generated = (_cancel(client_order_id=key),) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert len(result) == 1 - assert result[0].intent_type == "cancel" - - -def test_generated_cancel_dominates_queued_replace_or_new_same_key_origin_generated() -> None: - key = "same-key" - queued = (_new(client_order_id=key), _replace(client_order_id=key)) - generated = (_cancel(client_order_id=key),) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert len(records) == 1 - assert records[0].intent.intent_type == "cancel" - assert records[0].origin == CandidateIntentOrigin.GENERATED - - -def test_generated_replace_dominates_queued_new_same_key() -> None: - key = "same-key" - queued = (_new(client_order_id=key),) - generated = (_replace(client_order_id=key),) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert len(result) == 1 - assert result[0].intent_type == "replace" - - -def test_generated_replace_dominates_queued_new_same_key_origin_generated() -> None: - key = "same-key" - queued = (_new(client_order_id=key),) - generated = (_replace(client_order_id=key),) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert len(records) == 1 - assert records[0].intent.intent_type == "replace" - assert records[0].origin == CandidateIntentOrigin.GENERATED - - -def test_queued_cancel_dominates_generated_replace_same_key() -> None: - key = "same-key" - queued = (_cancel(client_order_id=key),) - generated = (_replace(client_order_id=key),) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert len(result) == 1 - assert result[0].intent_type == "cancel" - - -def test_queued_cancel_dominates_generated_replace_same_key_origin_queued() -> None: - key = "same-key" - queued = (_cancel(client_order_id=key),) - generated = (_replace(client_order_id=key),) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert len(records) == 1 - assert records[0].intent.intent_type == "cancel" - assert records[0].origin == CandidateIntentOrigin.QUEUED - - -def test_same_type_conflict_latest_wins_by_merge_order() -> None: - key = "same-key" - queued = (_replace(client_order_id=key, px=101.0),) - generated = (_replace(client_order_id=key, px=102.0),) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert len(result) == 1 - assert result[0].intent_type == "replace" - assert result[0].intended_price.value == 102.0 - - -def test_same_type_conflict_latest_wins_origin_follows_winner() -> None: - key = "same-key" - queued = (_replace(client_order_id=key, px=101.0),) - generated = (_replace(client_order_id=key, px=102.0),) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert len(records) == 1 - assert records[0].intent.intent_type == "replace" - assert records[0].intent.intended_price.value == 102.0 - assert records[0].origin == CandidateIntentOrigin.GENERATED - - -def test_output_order_is_priority_then_merge_order_then_key() -> None: - queued = ( - _new(client_order_id="n-queued"), - _replace(client_order_id="r-queued"), - _cancel(client_order_id="c-queued"), - ) - generated = ( - _cancel(client_order_id="c-generated"), - _replace(client_order_id="r-generated"), - _new(client_order_id="n-generated"), - ) - result = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert tuple((it.intent_type, it.client_order_id) for it in result) == ( - ("cancel", "c-queued"), - ("cancel", "c-generated"), - ("replace", "r-queued"), - ("replace", "r-generated"), - ("new", "n-queued"), - ("new", "n-generated"), - ) - - -def test_record_output_order_is_priority_then_merge_order_then_key() -> None: - queued = ( - _new(client_order_id="n-queued"), - _replace(client_order_id="r-queued"), - _cancel(client_order_id="c-queued"), - ) - generated = ( - _cancel(client_order_id="c-generated"), - _replace(client_order_id="r-generated"), - _new(client_order_id="n-generated"), - ) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert tuple((record.intent.intent_type, record.intent.client_order_id) for record in records) == ( - ("cancel", "c-queued"), - ("cancel", "c-generated"), - ("replace", "r-queued"), - ("replace", "r-generated"), - ("new", "n-queued"), - ("new", "n-generated"), - ) - assert tuple(record.merge_index for record in records) == (2, 3, 1, 4, 0, 5) - - -def test_combine_candidate_intents_matches_intent_view_of_record_helper() -> None: - queued = (_new(client_order_id="q-new"), _cancel(client_order_id="q-cancel")) - generated = (_replace(client_order_id="g-replace"), _new(client_order_id="g-new")) - - compat = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - records = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - - assert compat == tuple(record.intent for record in records) - - -def test_inputs_are_not_mutated() -> None: - queued = [_new(client_order_id="q1")] - generated = [_cancel(client_order_id="g1")] - _ = combine_candidate_intent_records(generated_intents=generated, queued_intents=queued) - _ = combine_candidate_intents(generated_intents=generated, queued_intents=queued) - assert len(queued) == 1 - assert len(generated) == 1 diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py deleted file mode 100644 index 53a050c..0000000 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ /dev/null @@ -1,924 +0,0 @@ -"""Semantics tests for the minimal canonical processing boundary.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.event_model import is_canonical_stream_candidate_type -from tradingchassis_core.core.domain.processing import process_canonical_event, 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.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderExecutionFeedbackEvent, - OrderExecutionFeedbackSnapshot, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import DerivedFillEvent, RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - } - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float = 100.0, - best_ask: float = 101.0, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float = 0.25, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - state_type: str = "accepted", -) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type=state_type, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _order_execution_feedback_event( - *, - instrument: str, - ts_ns_local_feedback: int, - order_id: str = "101", -) -> OrderExecutionFeedbackEvent: - return OrderExecutionFeedbackEvent( - ts_ns_local_feedback=ts_ns_local_feedback, - instrument=instrument, - position=1.25, - balance=10_000.0, - fee=3.5, - trading_volume=20.0, - trading_value=2_050.0, - num_trades=7, - order_snapshots=( - OrderExecutionFeedbackSnapshot( - order_id=order_id, - 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, - ts_ns_exch=ts_ns_local_feedback - 1, - ts_ns_local=ts_ns_local_feedback, - ), - ), - runtime_correlation={"source": "unit-test"}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - reason: str = "rate_limit_recheck", - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason=reason, - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def _control_state_snapshot(state: StrategyState) -> dict[str, object]: - return { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - - -def test_process_canonical_event_accepts_market_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event(state, event) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.best_bid_qty == 2.0 - assert market.best_ask_qty == 3.0 - assert market.mid == 100.5 - - -def test_process_canonical_event_accepts_market_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - position = ProcessingPosition(index=5) - - process_canonical_event(state, event, position=position, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.best_bid_qty == 2.0 - assert market.best_ask_qty == 3.0 - assert market.mid == 100.5 - assert state._last_processing_position_index == 5 - - -def test_process_canonical_event_accepts_fill_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - - process_canonical_event(state, event) - - fills = state.fills["BTC-USDC-PERP"] - assert len(fills) == 1 - assert fills[0] == event - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_canonical_event_accepts_fill_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - position = ProcessingPosition(index=12) - - process_canonical_event(state, event, position=position) - - fills = state.fills["BTC-USDC-PERP"] - assert len(fills) == 1 - assert fills[0] == event - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - assert state._last_processing_position_index == 12 - - -def test_process_canonical_event_accepts_order_execution_feedback_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_execution_feedback_event( - instrument="BTC-USDC-PERP", - ts_ns_local_feedback=250, - ) - - process_canonical_event(state, event) - - account = state.account["BTC-USDC-PERP"] - assert account.position == 1.25 - assert account.balance == 10_000.0 - assert account.fee == 3.5 - assert account.trading_volume == 20.0 - assert account.trading_value == 2_050.0 - assert account.num_trades == 7 - assert state.orders["BTC-USDC-PERP"]["101"].state_type == "working" - - -def test_process_canonical_event_accepts_order_execution_feedback_event_with_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_execution_feedback_event( - instrument="BTC-USDC-PERP", - ts_ns_local_feedback=260, - order_id="102", - ) - - process_canonical_event( - state, - event, - position=ProcessingPosition(index=13), - ) - - assert state._last_processing_position_index == 13 - assert state.account["BTC-USDC-PERP"].num_trades == 7 - assert state.orders["BTC-USDC-PERP"]["102"].state_type == "working" - - -def test_process_canonical_event_accepts_order_submitted_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=300, - ) - - process_canonical_event(state, event) - - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - - -def test_process_canonical_event_accepts_order_submitted_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=300, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=13)) - - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - assert state._last_processing_position_index == 13 - - -def test_control_time_event_requires_due_or_realized_timestamp() -> None: - with pytest.raises( - ValueError, - match="at least one of due_ts_ns_local or realized_ts_ns_local is required", - ): - _control_time_event(ts_ns_local_control=500) - - -def test_control_time_event_rejects_extra_fields() -> None: - with pytest.raises(ValueError, match="Extra inputs are not permitted"): - ControlTimeEvent( - ts_ns_local_control=501, - reason="rate_limit_recheck", - due_ts_ns_local=600, - extra_field="unexpected", - ) - - -def test_process_canonical_event_accepts_control_time_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=510, - due_ts_ns_local=520, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=14)) - - assert state._last_processing_position_index == 14 - - -def test_process_canonical_event_control_time_event_does_not_mutate_state_buckets() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=530, - realized_ts_ns_local=531, - ) - before = _control_state_snapshot(state) - - process_canonical_event(state, event, position=ProcessingPosition(index=15)) - - after = _control_state_snapshot(state) - assert after == before - assert state._last_processing_position_index == 15 - - -def test_control_time_event_still_obeys_global_processing_position_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _control_time_event( - ts_ns_local_control=540, - due_ts_ns_local=550, - ) - repeated = _control_time_event( - ts_ns_local_control=541, - due_ts_ns_local=551, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=16)) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, repeated, position=ProcessingPosition(index=16)) - - assert state._last_processing_position_index == 16 - - -def test_first_positioned_event_is_accepted() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event( - state, - event, - position=ProcessingPosition(index=0), - configuration=_market_configuration(), - ) - - assert state._last_processing_position_index == 0 - - -def test_increasing_positions_are_accepted() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - - process_canonical_event( - state, - first, - position=ProcessingPosition(index=10), - configuration=_market_configuration(), - ) - process_canonical_event(state, second, position=ProcessingPosition(index=11)) - - assert state._last_processing_position_index == 11 - - -def test_repeated_position_is_rejected_without_state_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - accepted = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - - process_canonical_event( - state, - accepted, - position=ProcessingPosition(index=3), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=3)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 3 - - -def test_regressing_position_is_rejected_without_state_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - accepted = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=102, - ts_ns_exch=92, - ) - - process_canonical_event( - state, - accepted, - position=ProcessingPosition(index=8), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=7)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 8 - - -def test_position_none_remains_allowed_and_does_not_advance_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - - process_canonical_event(state, event, position=None) - - assert state._last_processing_position_index is None - - positioned = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ) - process_canonical_event(state, positioned, position=ProcessingPosition(index=0)) - assert state._last_processing_position_index == 0 - - -def test_processing_position_is_not_derived_from_event_time() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=1_000_000, ts_ns_exch=900_000) - position = ProcessingPosition(index=1) - - process_canonical_event(state, event, position=position, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == event.ts_ns_local - assert market.last_ts_ns_exch == event.ts_ns_exch - - -def test_event_time_out_of_order_but_position_increasing_is_accepted_at_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=200, ts_ns_exch=190) - second = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=95) - - configuration = _market_configuration() - process_canonical_event(state, first, position=ProcessingPosition(index=1), configuration=configuration) - process_canonical_event(state, second, position=ProcessingPosition(index=2), configuration=configuration) - - assert state._last_processing_position_index == 2 - # Positioned canonical market events are now ProcessingPosition-driven. - market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - - -def test_position_out_of_order_but_event_time_increasing_is_rejected_at_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - - process_canonical_event( - state, - first, - position=ProcessingPosition(index=5), - configuration=_market_configuration(), - ) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, second, position=ProcessingPosition(index=4)) - - after = _state_subset_snapshot(state) - assert after == before - assert state._last_processing_position_index == 5 - - -@pytest.mark.parametrize("second_cum_filled_qty", [0.25, 0.20]) -def test_positioned_fill_ordering_divergence_advances_cursor_but_keeps_fill_state_idempotent( - second_cum_filled_qty: float, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - cum_filled_qty=0.25, - ) - second = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=181, - cum_filled_qty=second_cum_filled_qty, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=20)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, second, position=ProcessingPosition(index=21)) - - assert state._last_processing_position_index == 21 - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_interleaved_positioned_and_unpositioned_processing_preserves_cursor_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - positioned_10 = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=90, - ) - unpositioned = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - cum_filled_qty=0.25, - ) - positioned_11 = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=102, - ts_ns_exch=92, - ) - rejected = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=103, - ts_ns_exch=93, - cum_filled_qty=0.50, - ) - - configuration = _market_configuration() - process_canonical_event( - state, - positioned_10, - position=ProcessingPosition(index=10), - configuration=configuration, - ) - assert state._last_processing_position_index == 10 - - process_canonical_event(state, unpositioned, position=None) - assert state._last_processing_position_index == 10 - - process_canonical_event( - state, - positioned_11, - position=ProcessingPosition(index=11), - configuration=configuration, - ) - assert state._last_processing_position_index == 11 - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=10)) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_canonical_event(state, rejected, position=ProcessingPosition(index=11)) - - assert state._last_processing_position_index == 11 - - -def test_positioned_market_tiebreak_no_longer_gates_positioned_market_updates() -> None: - state = StrategyState(event_bus=NullEventBus()) - base = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=200, - best_bid=100.0, - best_ask=101.0, - ) - lower_exch = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=199, - best_bid=80.0, - best_ask=81.0, - ) - higher_exch = _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=300, - ts_ns_exch=201, - best_bid=120.0, - best_ask=121.0, - ) - - configuration = _market_configuration() - process_canonical_event(state, base, position=ProcessingPosition(index=30), configuration=configuration) - process_canonical_event( - state, - lower_exch, - position=ProcessingPosition(index=31), - configuration=configuration, - ) - - market = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 31 - assert market.last_ts_ns_local == 300 - assert market.last_ts_ns_exch == 199 - assert market.best_bid == 80.0 - assert market.best_ask == 81.0 - - process_canonical_event( - state, - higher_exch, - position=ProcessingPosition(index=32), - configuration=configuration, - ) - - market_after_higher = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 32 - assert market_after_higher.last_ts_ns_local == 300 - assert market_after_higher.last_ts_ns_exch == 201 - assert market_after_higher.best_bid == 120.0 - assert market_after_higher.best_ask == 121.0 - - -def test_valid_processing_position_can_authorize_boundary_order_while_reducer_noops() -> None: - """Valid ProcessingPosition advances causal boundary while reducer may still no-op.""" - state = StrategyState(event_bus=NullEventBus()) - first = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=400, - ts_ns_exch=390, - cum_filled_qty=0.40, - ) - duplicate = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=401, - ts_ns_exch=391, - cum_filled_qty=0.40, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=40)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=41)) - - assert state._last_processing_position_index == 41 - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - - -def test_positioned_order_submitted_duplicate_is_idempotent_while_cursor_advances() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-dup-1", - ts_ns_local_dispatch=700, - ) - duplicate = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-dup-1", - ts_ns_local_dispatch=701, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=42)) - projection_before = copy.deepcopy( - state.canonical_orders[("BTC-USDC-PERP", "order-submitted-dup-1")] - ) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=43)) - - projection_after = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-dup-1")] - assert state._last_processing_position_index == 43 - assert projection_after == projection_before - - -def test_order_submitted_event_does_not_regress_existing_canonical_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - key = ("BTC-USDC-PERP", "order-no-regress-1") - first = _order_submitted_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local_dispatch=800, - ) - accepted = _fill_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local=810, - ts_ns_exch=805, - cum_filled_qty=0.25, - ) - late_submitted = _order_submitted_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local_dispatch=820, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=50)) - state.apply_order_state_event( - _order_state_event( - instrument=key[0], - client_order_id=key[1], - ts_ns_local=815, - ts_ns_exch=815, - state_type="accepted", - ) - ) - process_canonical_event(state, accepted, position=ProcessingPosition(index=51)) - process_canonical_event(state, late_submitted, position=ProcessingPosition(index=52)) - - projection = state.canonical_orders[key] - assert projection.state == "accepted" - assert projection.submitted_ts_ns_local == 800 - assert projection.updated_ts_ns_local == 815 - assert state._last_processing_position_index == 52 - - -def test_order_submitted_event_does_not_mutate_snapshot_orders() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-snapshot-isolation-1", - ts_ns_local_dispatch=900, - ) - - process_canonical_event(state, event, position=ProcessingPosition(index=60)) - - assert state.orders == {} - assert state.canonical_orders[("BTC-USDC-PERP", "order-snapshot-isolation-1")].state == "submitted" - - -def test_process_canonical_event_rejects_order_state_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ts_ns_local=300, - ts_ns_exch=290, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, event) - - -def test_process_canonical_event_rejects_order_state_event_with_processing_position() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ts_ns_local=300, - ts_ns_exch=290, - ) - position = ProcessingPosition(index=20) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, event, position=position) - - -def test_process_event_entry_rejects_derived_fill_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = DerivedFillEvent( - ts_ns_local=300, - instrument="BTC-USDC-PERP", - client_order_id="order-compat-derived-1", - side="buy", - delta_qty=0.25, - cum_qty=0.25, - price=100.0, - ) - entry = EventStreamEntry( - position=ProcessingPosition(index=21), - event=event, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) - - -def test_process_canonical_event_rejects_telemetry_record() -> None: - state = StrategyState(event_bus=NullEventBus()) - telemetry = RiskDecisionEvent( - ts_ns_local=400, - accepted=1, - queued=0, - rejected=0, - handled=0, - reject_reasons={}, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, telemetry) - - -def test_event_bus_remains_non_canonical() -> None: - assert is_canonical_stream_candidate_type(EventBus) is False - - -def test_processing_position_zero_index_is_valid() -> None: - position = ProcessingPosition(index=0) - assert position.index == 0 - - -def test_processing_position_negative_index_is_rejected() -> None: - with pytest.raises(ValueError, match="must be non-negative"): - ProcessingPosition(index=-1) - diff --git a/tests/semantics/models/test_canonical_processing_differential_harness.py b/tests/semantics/models/test_canonical_processing_differential_harness.py deleted file mode 100644 index 85b3102..0000000 --- a/tests/semantics/models/test_canonical_processing_differential_harness.py +++ /dev/null @@ -1,362 +0,0 @@ -"""Differential characterization tests for canonical reducer boundary parity.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _apply_market_direct(state: StrategyState, event: MarketEvent) -> None: - assert event.book is not None - best_bid_level = event.book.bids[0] - best_ask_level = event.book.asks[0] - state.update_market( - instrument=event.instrument, - best_bid=best_bid_level.price.value, - best_ask=best_ask_level.price.value, - best_bid_qty=best_bid_level.quantity.value, - best_ask_qty=best_ask_level.quantity.value, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=event.ts_ns_local, - ts_ns_exch=event.ts_ns_exch, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_qty: float, -) -> FillEvent: - remaining = max(0.0, 1.0 - cum_qty) - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=remaining), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - } - - -def test_market_parity_single_event_canonical_equals_direct() -> None: - instrument = "BTC-USDC-PERP" - event = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=90, - best_bid=100.0, - best_ask=101.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, event) - _apply_market_direct(direct_state, event) - - assert canonical_state.market == direct_state.market - - -def test_market_parity_newer_local_timestamp_replaces_older() -> None: - instrument = "BTC-USDC-PERP" - older = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=90, - best_bid=100.0, - best_ask=101.0, - ) - newer = _book_market_event( - instrument=instrument, - ts_ns_local=101, - ts_ns_exch=80, - best_bid=102.0, - best_ask=103.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, older) - process_canonical_event(canonical_state, newer) - _apply_market_direct(direct_state, older) - _apply_market_direct(direct_state, newer) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 102.0 - assert canonical_state.market[instrument].best_ask == 103.0 - - -def test_market_parity_older_local_timestamp_is_ignored() -> None: - instrument = "BTC-USDC-PERP" - newer = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=120, - best_bid=105.0, - best_ask=106.0, - ) - older = _book_market_event( - instrument=instrument, - ts_ns_local=199, - ts_ns_exch=500, - best_bid=90.0, - best_ask=91.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, newer) - process_canonical_event(canonical_state, older) - _apply_market_direct(direct_state, newer) - _apply_market_direct(direct_state, older) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 105.0 - assert canonical_state.market[instrument].best_ask == 106.0 - - -def test_market_parity_equal_local_timestamp_uses_exchange_tiebreak() -> None: - instrument = "BTC-USDC-PERP" - base = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=100, - best_bid=110.0, - best_ask=111.0, - ) - higher_exch = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=101, - best_bid=112.0, - best_ask=113.0, - ) - lower_exch = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=99, - best_bid=80.0, - best_ask=81.0, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, base) - process_canonical_event(canonical_state, higher_exch) - process_canonical_event(canonical_state, lower_exch) - _apply_market_direct(direct_state, base) - _apply_market_direct(direct_state, higher_exch) - _apply_market_direct(direct_state, lower_exch) - - assert canonical_state.market == direct_state.market - assert canonical_state.market[instrument].best_bid == 112.0 - assert canonical_state.market[instrument].best_ask == 113.0 - assert canonical_state.market[instrument].last_ts_ns_exch == 101 - - -def test_fill_parity_single_event_canonical_equals_direct() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - event = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=400, - ts_ns_exch=390, - cum_qty=0.25, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - process_canonical_event(canonical_state, event) - direct_state.apply_fill_event(event) - - assert canonical_state.fills == direct_state.fills - assert canonical_state.fill_cum_qty == direct_state.fill_cum_qty - - -def test_fill_parity_duplicate_and_non_increasing_cumulative_are_idempotent() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - first = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=500, - ts_ns_exch=490, - cum_qty=0.25, - ) - duplicate = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=501, - ts_ns_exch=491, - cum_qty=0.25, - ) - lower = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=502, - ts_ns_exch=492, - cum_qty=0.20, - ) - higher = _fill_event( - instrument=instrument, - client_order_id=client_order_id, - ts_ns_local=503, - ts_ns_exch=493, - cum_qty=0.40, - ) - - canonical_state = StrategyState(event_bus=NullEventBus()) - direct_state = StrategyState(event_bus=NullEventBus()) - - for event in (first, duplicate, lower, higher): - process_canonical_event(canonical_state, event) - direct_state.apply_fill_event(event) - - assert canonical_state.fills == direct_state.fills - assert canonical_state.fill_cum_qty == direct_state.fill_cum_qty - assert len(canonical_state.fills[instrument]) == 2 - assert canonical_state.fill_cum_qty[instrument][client_order_id] == 0.4 - - -@pytest.mark.parametrize( - "artifact", - [ - pytest.param("order_state_event", id="order-state-event"), - pytest.param("risk_decision_event", id="risk-decision-telemetry"), - ], -) -def test_rejected_non_canonical_artifacts_do_not_mutate_state(artifact: str) -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - seed_market = _book_market_event( - instrument=instrument, - ts_ns_local=700, - ts_ns_exch=690, - best_bid=120.0, - best_ask=121.0, - ) - seed_fill = _fill_event( - instrument=instrument, - client_order_id="order-1", - ts_ns_local=710, - ts_ns_exch=700, - cum_qty=0.25, - ) - process_canonical_event(state, seed_market) - process_canonical_event(state, seed_fill) - - before = _state_subset_snapshot(state) - - if artifact == "order_state_event": - non_canonical = _order_state_event(instrument=instrument, client_order_id="order-compat-1") - else: - non_canonical = RiskDecisionEvent( - ts_ns_local=720, - accepted=1, - queued=0, - rejected=0, - handled=0, - reject_reasons={}, - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, non_canonical) - - after = _state_subset_snapshot(state) - assert after == before diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py deleted file mode 100644 index 480154c..0000000 --- a/tests/semantics/models/test_canonical_reducer_authority_guard.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Architectural guard for canonical reducer authority hardening.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -_ALLOWED_CALLER = Path("tradingchassis_core/core/domain/processing.py") -_TARGET_METHODS = frozenset( - { - "update_market", - "apply_fill_event", - "apply_order_submitted_event", - "apply_control_time_event", - } -) - - -def _iter_python_files(root: Path) -> list[Path]: - return sorted(path for path in root.rglob("*.py") if path.is_file()) - - -def _find_target_calls(path: Path) -> list[tuple[int, int, str]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int, str]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - method_name = node.func.attr - if method_name not in _TARGET_METHODS: - continue - calls.append((node.lineno, node.col_offset, method_name)) - - return calls - - -def test_direct_reducer_calls_are_limited_to_canonical_processing_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - calls = _find_target_calls(file_path) - if not calls: - continue - - if relative_path == _ALLOWED_CALLER: - continue - - for lineno, col, method_name in calls: - violations.append(f"{relative_path}:{lineno}:{col} calls {method_name}(...)") - - assert not violations, "Unexpected direct reducer calls outside canonical boundary:\n" + "\n".join( - violations - ) diff --git a/tests/semantics/models/test_core_configuration_contract.py b/tests/semantics/models/test_core_configuration_contract.py deleted file mode 100644 index 251d294..0000000 --- a/tests/semantics/models/test_core_configuration_contract.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Semantics tests for CoreConfiguration identity and stability contract.""" - -from __future__ import annotations - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration - - -def test_same_version_and_semantic_payload_produce_same_fingerprint() -> None: - left = CoreConfiguration( - version="v1", - payload={ - "a": 1, - "b": [True, {"x": "y", "z": None}], - }, - ) - right = CoreConfiguration( - version="v1", - payload={ - "b": [True, {"z": None, "x": "y"}], - "a": 1, - }, - ) - - assert left.fingerprint == right.fingerprint - assert left.payload == right.payload - - -def test_different_payload_produces_different_fingerprint() -> None: - left = CoreConfiguration(version="v1", payload={"a": 1}) - right = CoreConfiguration(version="v1", payload={"a": 2}) - - assert left.fingerprint != right.fingerprint - - -def test_different_version_produces_different_fingerprint() -> None: - left = CoreConfiguration(version="v1", payload={"a": 1}) - right = CoreConfiguration(version="v2", payload={"a": 1}) - - assert left.fingerprint != right.fingerprint - - -def test_rejects_unsupported_payload_values() -> None: - with pytest.raises(TypeError, match="Unsupported configuration payload value type"): - CoreConfiguration(version="v1", payload={"unsupported": object()}) - - with pytest.raises(TypeError, match="mapping keys must be strings"): - CoreConfiguration(version="v1", payload={1: "x"}) # type: ignore[dict-item] - - -def test_external_payload_mutation_does_not_change_configuration_identity() -> None: - source = { - "limits": { - "max_orders": 10, - "enabled": True, - }, - "symbols": ["BTC-USDC-PERP", "ETH-USDC-PERP"], - } - - configuration = CoreConfiguration(version="v1", payload=source) - original_fingerprint = configuration.fingerprint - original_payload = configuration.payload - - source["limits"]["max_orders"] = 99 - source["symbols"].append("SOL-USDC-PERP") - source["limits"]["new_key"] = "added" - - assert configuration.fingerprint == original_fingerprint - assert configuration.payload == original_payload diff --git a/tests/semantics/models/test_core_step_api_contract.py b/tests/semantics/models/test_core_step_api_contract.py deleted file mode 100644 index 830cf97..0000000 --- a/tests/semantics/models/test_core_step_api_contract.py +++ /dev/null @@ -1,2479 +0,0 @@ -"""Semantics tests for the transitional Core step API skeleton.""" - -from __future__ import annotations - -import copy - -import pytest - -import tradingchassis_core as tc -import tradingchassis_core.core.domain.processing_step as processing_step_module -from tradingchassis_core.core.domain import run_core_step as domain_run_core_step -from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_apply import ( - ExecutionControlApplyResult, - ExecutionControlDispatchableRecord, -) -from tradingchassis_core.core.domain.execution_control_decision import ( - ExecutionControlDecision, -) -from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision -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.processing_step import ( - ControlTimeQueueReevaluationContext, - CoreDecisionContext, - CoreExecutionControlApplyContext, - CorePolicyAdmissionContext, - CoreStepStrategyContext, - run_core_step, -) -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.step_decision import CoreStepDecision -from tradingchassis_core.core.domain.step_result import CoreStepResult -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - ControlTimeEvent, - FillEvent, - MarketEvent, - NewOrderIntent, - NotionalLimits, - OrderIntent, - OrderRateLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.execution_control import ExecutionControl -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, RejectedIntent, RiskEngine - - -def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": 100.0}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": 101.0}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float = 0.25, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _market_configuration(*, instrument: str = "BTC-USDC-PERP") -> tc.CoreConfiguration: - return tc.CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": 0.1, - "lot_size": 0.01, - "contract_size": 1.0, - } - } - } - }, - ) - - -def _control_time_event( - *, - due_ts_ns_local: int, - realized_ts_ns_local: int, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=realized_ts_ns_local, - reason="scheduled_control_recheck", - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation=None, - ) - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - "last_processing_position_index": state._last_processing_position_index, - } - - -def test_run_core_step_public_exports_identity() -> None: - assert domain_run_core_step is run_core_step - assert hasattr(tc, "run_core_step") - assert tc.run_core_step is run_core_step - assert hasattr(tc, "CoreExecutionControlApplyContext") - - -def test_run_core_step_delegates_and_returns_default_core_step_result() -> None: - baseline_state = StrategyState(event_bus=NullEventBus()) - skeleton_state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=5), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-1", - ts_ns_local=200, - ts_ns_exch=180, - ), - ) - - process_event_entry(baseline_state, entry) - result = run_core_step(skeleton_state, entry) - - assert isinstance(result, CoreStepResult) - assert result.generated_intents == () - assert result.candidate_intent_records == () - assert result.candidate_intents == () - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - assert result.core_step_decision is None - assert result.compat_gate_decision is None - assert _state_subset_snapshot(skeleton_state) == _state_subset_snapshot(baseline_state) - - -def test_run_core_step_omitting_strategy_evaluator_preserves_existing_behavior() -> None: - baseline_state = StrategyState(event_bus=NullEventBus()) - no_strategy_state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=6), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-omitted-evaluator", - ts_ns_local=300, - ts_ns_exch=280, - ), - ) - - process_event_entry(baseline_state, entry) - result = run_core_step(no_strategy_state, entry) - - assert result.generated_intents == () - assert result.candidate_intent_records == () - assert result.candidate_intents == () - assert result.core_step_decision is None - assert result == CoreStepResult() - assert _state_subset_snapshot(no_strategy_state) == _state_subset_snapshot(baseline_state) - - -def test_run_core_step_propagates_non_canonical_rejection() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=1), - event=_order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ), - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - run_core_step(state, entry) - - -def test_run_core_step_propagates_non_monotonic_position_and_preserves_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = EventStreamEntry( - position=ProcessingPosition(index=10), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-1", - ts_ns_local=100, - ts_ns_exch=90, - ), - ) - second = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-1", - ts_ns_local=101, - ts_ns_exch=91, - cum_filled_qty=0.5, - ), - ) - repeated = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-1", - ts_ns_local=102, - ts_ns_exch=92, - cum_filled_qty=0.75, - ), - ) - - run_core_step(state, first) - run_core_step(state, second) - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - run_core_step(state, repeated) - - assert _state_subset_snapshot(state) == before - - -def test_run_core_step_positioned_market_requires_configuration() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=0), - event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), - ) - - with pytest.raises( - ValueError, - match="CoreConfiguration is required for positioned canonical MarketEvent processing", - ): - run_core_step(state, entry, configuration=None) - - -def test_run_core_step_passes_configuration_through_to_market_processing() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=0), - event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), - ) - - result = run_core_step(state, entry, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert isinstance(result, CoreStepResult) - assert state._last_processing_position_index == 0 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - - -def test_run_core_step_calls_strategy_evaluator_once_with_post_reducer_context() -> None: - state = StrategyState(event_bus=NullEventBus()) - configuration = _market_configuration(instrument="BTC-USDC-PERP") - entry = EventStreamEntry( - position=ProcessingPosition(index=12), - event=_book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=1_200, - ts_ns_exch=1_100, - ), - ) - generated_intent = _new_intent(client_order_id="generated-not-dispatchable-yet") - captured_contexts: list[CoreStepStrategyContext] = [] - - class _EvaluatorSpy: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - captured_contexts.append(context) - return [generated_intent] - - result = run_core_step( - state, - entry, - configuration=configuration, - strategy_evaluator=_EvaluatorSpy(), - ) - - assert len(captured_contexts) == 1 - context = captured_contexts[0] - assert context.event is entry.event - assert context.position == entry.position - assert context.configuration is configuration - assert context.state is state - assert context.state._last_processing_position_index == 12 - assert context.state.market["BTC-USDC-PERP"].best_bid == 100.0 - assert result.generated_intents == (generated_intent,) - assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert result.candidate_intents == (generated_intent,) - assert result.dispatchable_intents == () - assert result.core_step_decision is None - assert result.compat_gate_decision is None - - -def test_run_core_step_boundary_remains_non_canonical_for_compatibility_artifacts() -> None: - assert is_canonical_stream_candidate_type(CoreStepResult) is False - assert canonical_category_for_type(CoreStepResult) is None - assert is_canonical_stream_candidate_type(ControlSchedulingObligation) is False - assert canonical_category_for_type(ControlSchedulingObligation) is None - assert is_canonical_stream_candidate_type(GateDecision) is False - assert canonical_category_for_type(GateDecision) is None - - state = StrategyState(event_bus=NullEventBus()) - entries = ( - EventStreamEntry(position=ProcessingPosition(index=1), event=CoreStepResult()), - EventStreamEntry( - position=ProcessingPosition(index=2), - event=ControlSchedulingObligation( - due_ts_ns_local=1_000_000_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ), - ), - EventStreamEntry( - position=ProcessingPosition(index=3), - event=GateDecision( - ts_ns_local=123, - accepted_now=[_new_intent(client_order_id="accepted-now")], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ), - ), - ) - - for entry in entries: - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - run_core_step(state, entry) - - -def test_run_core_step_control_time_with_context_processes_canonical_then_queue_and_risk() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-1") - state.merge_intents_into_queue(instrument, [queued_intent]) - - calls: list[str] = [] - popped_raw_intents: list[list[NewOrderIntent]] = [] - - original_pop = state.pop_queued_intents - - def _spy_pop_queued_intents(target_instrument: str) -> list[NewOrderIntent]: - assert target_instrument == instrument - # Canonical processing runs first and advances the positioned cursor. - assert state._last_processing_position_index == 7 - calls.append("pop") - return original_pop(target_instrument) # type: ignore[return-value] - - state.pop_queued_intents = _spy_pop_queued_intents # type: ignore[method-assign] - - accepted_now = _new_intent(client_order_id="accepted-now") - obligation_a = ControlSchedulingObligation( - due_ts_ns_local=42, - reason="rate_limit", - scope_key=f"instrument:{instrument}", - source="execution_control_rate_limit", - obligation_key="z-key", - ) - obligation_b = ControlSchedulingObligation( - due_ts_ns_local=42, - reason="rate_limit", - scope_key=f"instrument:{instrument}", - source="execution_control_rate_limit", - obligation_key="a-key", - ) - obligation_c = ControlSchedulingObligation( - due_ts_ns_local=17, - reason="rate_limit", - scope_key=f"instrument:{instrument}", - source="execution_control_rate_limit", - obligation_key="x-key", - ) - - class _RiskSpy: - def decide_intents( - self, - *, - raw_intents: list[NewOrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - assert state is not None - assert now_ts_ns_local == 1_000 - calls.append("risk") - popped_raw_intents.append(list(raw_intents)) - return GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=[accepted_now], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=17, - control_scheduling_obligations=(obligation_a, obligation_b, obligation_c), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=7), - event=_control_time_event(due_ts_ns_local=999, realized_ts_ns_local=1_000), - ) - result = run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - instrument=instrument, - now_ts_ns_local=1_000, - ), - ) - - assert calls == ["pop", "risk"] - assert len(popped_raw_intents) == 1 - assert [it.client_order_id for it in popped_raw_intents[0]] == [queued_intent.client_order_id] - assert tuple(it.client_order_id for it in result.dispatchable_intents) == ("accepted-now",) - assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( - "queued-1", - ) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.QUEUED, - ) - assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-1",) - assert isinstance(result.core_step_decision, CoreStepDecision) - assert tuple( - it.client_order_id for it in result.core_step_decision.dispatchable_intents - ) == ("accepted-now",) - assert result.core_step_decision.control_scheduling_obligation is not None - assert result.core_step_decision.control_scheduling_obligation.due_ts_ns_local == 17 - assert isinstance( - result.core_step_decision.execution_control_decision, - ExecutionControlDecision, - ) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("accepted-now",) - assert ( - result.core_step_decision.execution_control_decision.control_scheduling_obligation - == result.control_scheduling_obligation - ) - assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.accepted_intents - ) == ("accepted-now",) - assert result.core_step_decision.policy_risk_decision.rejected_intents == () - assert result.core_step_decision.queued_effective_intents == () - assert result.core_step_decision.policy_rejected_intents == () - assert result.core_step_decision.execution_handled_intents == () - assert result.compat_gate_decision is not None - assert result.control_scheduling_obligation is not None - assert result.control_scheduling_obligation.due_ts_ns_local == 17 - assert result.control_scheduling_obligation.obligation_key == "x-key" - assert ( - result.core_step_decision.control_scheduling_obligation - == result.control_scheduling_obligation - ) - - -def test_run_core_step_non_control_time_ignores_control_time_context() -> None: - state = StrategyState(event_bus=NullEventBus()) - - class _RiskMustNotRun: - def decide_intents(self, **_: object) -> GateDecision: - raise AssertionError("risk must not run for non-control events") - - def _pop_must_not_run(_: str) -> list[NewOrderIntent]: - raise AssertionError("queue pop must not run for non-control events") - - state.pop_queued_intents = _pop_must_not_run # type: ignore[method-assign] - - entry = EventStreamEntry( - position=ProcessingPosition(index=5), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-no-control", - ts_ns_local=5, - ts_ns_exch=4, - ), - ) - result = run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] - instrument="BTC-USDC-PERP", - now_ts_ns_local=5, - ), - ) - - assert result == CoreStepResult() - assert result.core_step_decision is None - - -def test_run_core_step_non_control_candidate_context_disabled_keeps_scaffold_behavior() -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="generated-disabled-context") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 40 - return [generated_intent] - - class _RiskMustNotRun: - def decide_intents(self, **_: object) -> GateDecision: - raise AssertionError("risk must not run when candidate decision context is disabled") - - entry = EventStreamEntry( - position=ProcessingPosition(index=40), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-disabled-context", - ts_ns_local=40, - ts_ns_exch=39, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - core_decision_context=CoreDecisionContext( - risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] - now_ts_ns_local=40, - enable_candidate_intent_decision=False, - capture_only=True, - ), - ) - - assert result.generated_intents == (generated_intent,) - assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert result.candidate_intents == (generated_intent,) - assert result.core_step_decision is None - assert result.compat_gate_decision is None - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - - -def test_run_core_step_non_control_candidate_context_enabled_capture_only_maps_decision() -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="generated-candidate-risk") - accepted_now = _new_intent(client_order_id="accepted-now-candidate") - queued_effective = _new_intent(client_order_id="queued-effective-candidate") - rejected_intent = _new_intent(client_order_id="rejected-candidate") - handled_intent = CancelOrderIntent( - ts_ns_local=41, - instrument="BTC-USDC-PERP", - client_order_id="handled-candidate", - intents_correlation_id="corr-handled-candidate", - ) - obligation = ControlSchedulingObligation( - due_ts_ns_local=77, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - captured_raw_intents: list[list[NewOrderIntent]] = [] - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 41 - return [generated_intent] - - class _RiskSpy: - def decide_intents( - self, - *, - raw_intents: list[NewOrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - assert state is not None - assert now_ts_ns_local == 41 - captured_raw_intents.append(list(raw_intents)) - return GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=[accepted_now], - queued=[queued_effective], - rejected=[RejectedIntent(intent=rejected_intent, reason="policy_reject")], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[handled_intent], - execution_rejected=[], - next_send_ts_ns_local=77, - control_scheduling_obligations=(obligation,), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=41), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-candidate-risk", - ts_ns_local=41, - ts_ns_exch=40, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - core_decision_context=CoreDecisionContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - now_ts_ns_local=41, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - - assert len(captured_raw_intents) == 1 - assert tuple(it.client_order_id for it in captured_raw_intents[0]) == ( - "generated-candidate-risk", - ) - assert result.generated_intents == (generated_intent,) - assert tuple(record.intent for record in result.candidate_intent_records) == (generated_intent,) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert result.candidate_intents == (generated_intent,) - assert result.compat_gate_decision is not None - assert result.core_step_decision is not None - assert tuple( - it.client_order_id for it in result.core_step_decision.dispatchable_intents - ) == ("accepted-now-candidate",) - assert tuple( - it.client_order_id for it in result.core_step_decision.queued_effective_intents - ) == ("queued-effective-candidate",) - assert tuple( - it.client_order_id for it in result.core_step_decision.policy_rejected_intents - ) == ("rejected-candidate",) - assert tuple( - it.client_order_id for it in result.core_step_decision.execution_handled_intents - ) == ("handled-candidate",) - assert result.core_step_decision.policy_risk_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.accepted_intents - ) == ("accepted-now-candidate",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.rejected_intents - ) == ("rejected-candidate",) - assert result.core_step_decision.execution_control_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("accepted-now-candidate",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.queued_effective_intents - ) == ("queued-effective-candidate",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.execution_handled_intents - ) == ("handled-candidate",) - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - - -def test_run_core_step_non_control_candidate_context_enabled_empty_candidates_skips_risk() -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"risk": 0} - - class _RiskSpy: - def decide_intents(self, **_: object) -> GateDecision: - calls["risk"] += 1 - raise AssertionError("risk must not run when candidate intents are empty") - - entry = EventStreamEntry( - position=ProcessingPosition(index=42), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-empty-candidates", - ts_ns_local=42, - ts_ns_exch=41, - ), - ) - result = run_core_step( - state, - entry, - core_decision_context=CoreDecisionContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - now_ts_ns_local=42, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - - assert calls == {"risk": 0} - assert result.generated_intents == () - assert result.candidate_intent_records == () - assert result.candidate_intents == () - assert result.core_step_decision is None - assert result.compat_gate_decision is None - assert result.dispatchable_intents == () - - -def test_run_core_step_non_control_candidate_context_capture_only_false_not_supported() -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="generated-capture-false") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 43 - return [generated_intent] - - class _RiskMustNotRun: - def decide_intents(self, **_: object) -> GateDecision: - raise AssertionError("risk must not run when capture_only=False is unsupported") - - entry = EventStreamEntry( - position=ProcessingPosition(index=43), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-capture-false", - ts_ns_local=43, - ts_ns_exch=42, - ), - ) - with pytest.raises(NotImplementedError, match="capture_only=False is not supported yet"): - run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - core_decision_context=CoreDecisionContext( - risk_engine=_RiskMustNotRun(), # type: ignore[arg-type] - now_ts_ns_local=43, - enable_candidate_intent_decision=True, - capture_only=False, - ), - ) - - -def test_run_core_step_control_time_maps_compat_fields_to_core_step_decision() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-pop-source") - state.merge_intents_into_queue(instrument, [queued_intent]) - - accepted_now = _new_intent(client_order_id="accepted-now-mapped") - queued_effective = _new_intent(client_order_id="queued-effective") - rejected_intent = _new_intent(client_order_id="rejected-policy") - handled_intent = CancelOrderIntent( - ts_ns_local=33, - instrument=instrument, - client_order_id="handled-in-queue", - intents_correlation_id="corr-handled", - ) - - class _RiskSpy: - def decide_intents( - self, - *, - raw_intents: list[NewOrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - assert state is not None - assert now_ts_ns_local == 33 - assert [it.client_order_id for it in raw_intents] == [queued_intent.client_order_id] - return GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=[accepted_now], - queued=[queued_effective], - rejected=[RejectedIntent(intent=rejected_intent, reason="policy_reject")], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[handled_intent], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=33), - event=_control_time_event(due_ts_ns_local=33, realized_ts_ns_local=33), - ) - result = run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - instrument=instrument, - now_ts_ns_local=33, - ), - ) - - assert result.core_step_decision is not None - assert tuple( - it.client_order_id for it in result.core_step_decision.dispatchable_intents - ) == ("accepted-now-mapped",) - assert tuple( - it.client_order_id for it in result.core_step_decision.queued_effective_intents - ) == ("queued-effective",) - assert tuple( - it.client_order_id for it in result.core_step_decision.policy_rejected_intents - ) == ("rejected-policy",) - assert tuple( - it.client_order_id for it in result.core_step_decision.execution_handled_intents - ) == ("handled-in-queue",) - assert isinstance( - result.core_step_decision.execution_control_decision, - ExecutionControlDecision, - ) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.queued_effective_intents - ) == ("queued-effective",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("accepted-now-mapped",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.execution_handled_intents - ) == ("handled-in-queue",) - assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.accepted_intents - ) == ("accepted-now-mapped",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.rejected_intents - ) == ("rejected-policy",) - - -def test_run_core_step_includes_queued_snapshot_in_candidate_intents_without_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-candidate") - state.merge_intents_into_queue(instrument, [queued_intent]) - - entry = EventStreamEntry( - position=ProcessingPosition(index=21), - event=_fill_event( - instrument=instrument, - client_order_id="fill-with-queued-candidate", - ts_ns_local=21, - ts_ns_exch=20, - ), - ) - result = run_core_step(state, entry) - - assert tuple(it.client_order_id for it in result.generated_intents) == () - assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( - "queued-candidate", - ) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.QUEUED, - ) - assert tuple(it.client_order_id for it in result.candidate_intents) == ("queued-candidate",) - assert result.dispatchable_intents == () - assert state.has_queued_intent(instrument, "queued-candidate") - - -def test_run_core_step_candidate_intents_apply_generated_vs_queued_dominance_without_queue_mutation() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - key = "same-key" - queued_new = _new_intent(client_order_id=key) - state.merge_intents_into_queue(instrument, [queued_new]) - - generated_cancel = CancelOrderIntent( - ts_ns_local=22, - instrument=instrument, - client_order_id=key, - intents_correlation_id="corr-cancel", - ) - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[CancelOrderIntent]: - assert context.state._last_processing_position_index == 22 - return [generated_cancel] - - entry = EventStreamEntry( - position=ProcessingPosition(index=22), - event=_fill_event( - instrument=instrument, - client_order_id="fill-generated-vs-queued", - ts_ns_local=22, - ts_ns_exch=21, - ), - ) - result = run_core_step(state, entry, strategy_evaluator=_Evaluator()) - - assert tuple(it.intent_type for it in result.generated_intents) == ("cancel",) - assert tuple(record.intent.intent_type for record in result.candidate_intent_records) == ("cancel",) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert tuple(it.intent_type for it in result.candidate_intents) == ("cancel",) - assert result.dispatchable_intents == () - assert state.has_queued_intent(instrument, key) - - -def test_run_core_step_does_not_call_strategy_evaluator_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - called = {"evaluate": 0} - combine_called = {"value": 0} - - class _EvaluatorSpy: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - _ = context - called["evaluate"] += 1 - return [] - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - monkeypatch.setattr( - processing_step_module, - "combine_candidate_intent_records", - lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=10), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-failure-evaluator", - ts_ns_local=10, - ts_ns_exch=9, - ), - ) - - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step(state, entry, strategy_evaluator=_EvaluatorSpy()) - - assert called == {"evaluate": 0} - assert combine_called == {"value": 0} - - -def test_run_core_step_candidate_decision_context_not_reached_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"risk": 0} - - class _RiskSpy: - def decide_intents(self, **_: object) -> GateDecision: - calls["risk"] += 1 - return GateDecision( - ts_ns_local=99, - accepted_now=[], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - - entry = EventStreamEntry( - position=ProcessingPosition(index=44), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-process-fail-context", - ts_ns_local=44, - ts_ns_exch=43, - ), - ) - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step( - state, - entry, - core_decision_context=CoreDecisionContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - now_ts_ns_local=44, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - assert calls == {"risk": 0} - - -def test_run_core_step_with_strategy_and_control_time_context_orders_calls_deterministically() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-with-strategy") - state.merge_intents_into_queue(instrument, [queued_intent]) - - calls: list[str] = [] - - class _EvaluatorSpy: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 20 - calls.append("evaluate") - return [_new_intent(client_order_id="generated-captured")] - - original_pop = state.pop_queued_intents - - def _spy_pop_queued_intents(target_instrument: str) -> list[NewOrderIntent]: - assert target_instrument == instrument - calls.append("pop") - return original_pop(target_instrument) # type: ignore[return-value] - - state.pop_queued_intents = _spy_pop_queued_intents # type: ignore[method-assign] - - accepted_now = _new_intent(client_order_id="accepted-control-time") - - class _RiskSpy: - def decide_intents( - self, - *, - raw_intents: list[NewOrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - assert state is not None - assert now_ts_ns_local == 2_000 - assert [it.client_order_id for it in raw_intents] == [queued_intent.client_order_id] - calls.append("risk") - return GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=[accepted_now], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=20), - event=_control_time_event(due_ts_ns_local=2_000, realized_ts_ns_local=2_000), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorSpy(), - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - instrument=instrument, - now_ts_ns_local=2_000, - ), - ) - - assert calls == ["evaluate", "pop", "risk"] - assert tuple(it.client_order_id for it in result.generated_intents) == ( - "generated-captured", - ) - assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( - "queued-with-strategy", - "generated-captured", - ) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.QUEUED, - CandidateIntentOrigin.GENERATED, - ) - assert tuple(it.client_order_id for it in result.candidate_intents) == ( - "queued-with-strategy", - "generated-captured", - ) - assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( - "accepted-control-time", - ) - assert isinstance(result.core_step_decision, CoreStepDecision) - assert tuple( - it.client_order_id for it in result.core_step_decision.dispatchable_intents - ) == ("accepted-control-time",) - assert isinstance( - result.core_step_decision.execution_control_decision, - ExecutionControlDecision, - ) - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("accepted-control-time",) - assert isinstance(result.core_step_decision.policy_risk_decision, PolicyRiskDecision) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.accepted_intents - ) == ("accepted-control-time",) - - -def test_run_core_step_strategy_evaluator_exception_propagates_and_skips_control_time_queue_path( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - state.merge_intents_into_queue(instrument, [_new_intent(client_order_id="queued-before-failure")]) - calls = {"pop": 0, "risk": 0} - combine_called = {"value": 0} - - def _pop_spy(_: str) -> list[NewOrderIntent]: - calls["pop"] += 1 - return [] - - state.pop_queued_intents = _pop_spy # type: ignore[method-assign] - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 30 - raise RuntimeError("strategy evaluator failed") - - class _RiskSpy: - def decide_intents(self, **_: object) -> GateDecision: - calls["risk"] += 1 - raise AssertionError("risk should not run after strategy evaluator failure") - - monkeypatch.setattr( - processing_step_module, - "combine_candidate_intent_records", - lambda **_: combine_called.__setitem__("value", combine_called["value"] + 1), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=30), - event=_control_time_event(due_ts_ns_local=30, realized_ts_ns_local=30), - ) - - with pytest.raises(RuntimeError, match="strategy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorBoom(), - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - instrument=instrument, - now_ts_ns_local=30, - ), - ) - - assert calls == {"pop": 0, "risk": 0} - assert combine_called == {"value": 0} - - -def test_run_core_step_candidate_decision_context_not_reached_when_strategy_fails() -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"risk": 0} - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 45 - raise RuntimeError("strategy evaluator failed") - - class _RiskSpy: - def decide_intents(self, **_: object) -> GateDecision: - calls["risk"] += 1 - return GateDecision( - ts_ns_local=45, - accepted_now=[], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=45), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-evaluator-fail-context", - ts_ns_local=45, - ts_ns_exch=44, - ), - ) - with pytest.raises(RuntimeError, match="strategy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorBoom(), - core_decision_context=CoreDecisionContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - now_ts_ns_local=45, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - assert calls == {"risk": 0} - - -def test_run_core_step_candidate_decision_context_propagates_risk_failure() -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="generated-risk-failure") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 46 - return [generated_intent] - - class _RiskBoom: - def decide_intents(self, **_: object) -> GateDecision: - raise RuntimeError("risk engine failed in candidate capture path") - - entry = EventStreamEntry( - position=ProcessingPosition(index=46), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-risk-failure-context", - ts_ns_local=46, - ts_ns_exch=45, - ), - ) - with pytest.raises(RuntimeError, match="risk engine failed in candidate capture path"): - run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - core_decision_context=CoreDecisionContext( - risk_engine=_RiskBoom(), # type: ignore[arg-type] - now_ts_ns_local=46, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - - -def test_run_core_step_candidate_decision_context_side_effects_are_opt_in_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "opt-in-side-effect-order" - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits(max_orders_per_second=0), - ) - - baseline_state = StrategyState(event_bus=NullEventBus()) - enabled_state = StrategyState(event_bus=NullEventBus()) - enabled_risk = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index in (47, 48) - return [ - NewOrderIntent( - ts_ns_local=context.position.index, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id="corr-opt-in", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - ] - - baseline_entry = EventStreamEntry( - position=ProcessingPosition(index=47), - event=_fill_event( - instrument=instrument, - client_order_id="fill-side-effect-baseline", - ts_ns_local=47, - ts_ns_exch=46, - ), - ) - enabled_entry = EventStreamEntry( - position=ProcessingPosition(index=48), - event=_fill_event( - instrument=instrument, - client_order_id="fill-side-effect-enabled", - ts_ns_local=48, - ts_ns_exch=47, - ), - ) - - baseline_result = run_core_step( - baseline_state, - baseline_entry, - strategy_evaluator=_Evaluator(), - ) - enabled_result = run_core_step( - enabled_state, - enabled_entry, - strategy_evaluator=_Evaluator(), - core_decision_context=CoreDecisionContext( - risk_engine=enabled_risk, - now_ts_ns_local=48, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - assert baseline_result.core_step_decision is None - assert baseline_result.compat_gate_decision is None - assert baseline_result.dispatchable_intents == () - assert not baseline_state.has_queued_intent(instrument, client_order_id) - - assert enabled_result.core_step_decision is not None - assert enabled_result.compat_gate_decision is not None - assert enabled_result.dispatchable_intents == () - assert enabled_state.has_queued_intent(instrument, client_order_id) - - -def test_run_core_step_control_time_with_both_contexts_preserves_existing_control_time_path() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-control-both-contexts") - state.merge_intents_into_queue(instrument, [queued_intent]) - - calls = {"control_risk": 0, "candidate_risk": 0} - accepted_now = _new_intent(client_order_id="accepted-control-both-contexts") - - class _ControlRiskSpy: - def decide_intents( - self, - *, - raw_intents: list[NewOrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - calls["control_risk"] += 1 - assert state is not None - assert now_ts_ns_local == 49 - assert [it.client_order_id for it in raw_intents] == ( - [queued_intent.client_order_id] - ) - return GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=[accepted_now], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - class _CandidateRiskMustNotRun: - def decide_intents(self, **_: object) -> GateDecision: - calls["candidate_risk"] += 1 - raise AssertionError( - "candidate decision path must not run on control-time compatibility path" - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=49), - event=_control_time_event(due_ts_ns_local=49, realized_ts_ns_local=49), - ) - result = run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_ControlRiskSpy(), # type: ignore[arg-type] - instrument=instrument, - now_ts_ns_local=49, - ), - core_decision_context=CoreDecisionContext( - risk_engine=_CandidateRiskMustNotRun(), # type: ignore[arg-type] - now_ts_ns_local=49, - enable_candidate_intent_decision=True, - capture_only=True, - ), - ) - - assert calls == {"control_risk": 1, "candidate_risk": 0} - assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( - "accepted-control-both-contexts", - ) - assert result.compat_gate_decision is not None - assert result.core_step_decision is not None - - -def test_run_core_step_does_not_pop_or_gate_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"pop": 0, "risk": 0} - - def _pop_spy(_: str) -> list[NewOrderIntent]: - calls["pop"] += 1 - return [] - - state.pop_queued_intents = _pop_spy # type: ignore[method-assign] - - class _RiskSpy: - def decide_intents(self, **_: object) -> GateDecision: - calls["risk"] += 1 - raise AssertionError("risk should not run when boundary fails first") - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - - entry = EventStreamEntry( - position=ProcessingPosition(index=9), - event=_control_time_event(due_ts_ns_local=9, realized_ts_ns_local=9), - ) - - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_RiskSpy(), # type: ignore[arg-type] - instrument="BTC-USDC-PERP", - now_ts_ns_local=9, - ), - ) - - assert calls == {"pop": 0, "risk": 0} - - -def test_run_core_step_policy_admission_context_populates_policy_decision_only() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-passthrough") - state.merge_intents_into_queue(instrument, [queued_intent]) - - generated_new = _new_intent(client_order_id="generated-new-rejected") - generated_cancel = CancelOrderIntent( - ts_ns_local=50, - instrument=instrument, - client_order_id="generated-cancel-accepted", - intents_correlation_id="corr-generated-cancel", - ) - risk_cfg = RiskConfig( - scope="test", - trading_enabled=False, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: - assert context.state._last_processing_position_index == 50 - return [generated_new, generated_cancel] - - entry = EventStreamEntry( - position=ProcessingPosition(index=50), - event=_fill_event( - instrument=instrument, - client_order_id="fill-policy-context", - ts_ns_local=50, - ts_ns_exch=49, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=risk_engine, - now_ts_ns_local=50, - ), - ) - - assert tuple(record.intent.client_order_id for record in result.candidate_intent_records) == ( - "generated-cancel-accepted", - "queued-passthrough", - "generated-new-rejected", - ) - assert result.core_step_decision is not None - assert result.core_step_decision.execution_control_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.queued_effective_intents - ) == ( - "generated-cancel-accepted", - "queued-passthrough", - ) - assert ( - result.core_step_decision.execution_control_decision.dispatchable_intents - == () - ) - assert ( - result.core_step_decision.execution_control_decision.execution_handled_intents - == () - ) - assert ( - result.core_step_decision.execution_control_decision.control_scheduling_obligation - is None - ) - assert tuple(it.client_order_id for it in result.core_step_decision.policy_rejected_intents) == ( - "generated-new-rejected", - ) - assert result.core_step_decision.policy_risk_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.accepted_intents - ) == ("generated-cancel-accepted",) - assert tuple( - it.client_order_id - for it in result.core_step_decision.policy_risk_decision.rejected_intents - ) == ("generated-new-rejected",) - assert result.core_step_decision.queued_effective_intents == () - assert result.core_step_decision.dispatchable_intents == () - assert result.core_step_decision.execution_handled_intents == () - assert result.core_step_decision.control_scheduling_obligation is None - assert result.core_step_decision.dispatchable_intents == () - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - assert result.compat_gate_decision is None - - -def test_run_core_step_policy_admission_context_queued_only_skips_policy_evaluation() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - state.merge_intents_into_queue( - instrument, - [_new_intent(client_order_id="queued-only-record")], - ) - calls = {"evaluate": 0} - - class _Evaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - calls["evaluate"] += 1 - return True, None - - entry = EventStreamEntry( - position=ProcessingPosition(index=51), - event=_fill_event( - instrument=instrument, - client_order_id="fill-queued-only", - ts_ns_local=51, - ts_ns_exch=50, - ), - ) - result = run_core_step( - state, - entry, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_Evaluator(), # type: ignore[arg-type] - now_ts_ns_local=51, - ), - ) - - assert calls == {"evaluate": 0} - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.QUEUED, - ) - assert result.core_step_decision is not None - assert result.core_step_decision.policy_risk_decision == PolicyRiskDecision() - assert result.core_step_decision.policy_rejected_intents == () - assert result.core_step_decision.execution_control_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.queued_effective_intents - ) == ("queued-only-record",) - assert result.core_step_decision.execution_control_decision.dispatchable_intents == () - assert ( - result.core_step_decision.execution_control_decision.execution_handled_intents - == () - ) - assert ( - result.core_step_decision.execution_control_decision.control_scheduling_obligation - is None - ) - assert result.dispatchable_intents == () - - -def test_run_core_step_policy_admission_context_not_reached_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"evaluate": 0} - - class _Evaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - calls["evaluate"] += 1 - return True, None - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - - entry = EventStreamEntry( - position=ProcessingPosition(index=52), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-process-fail", - ts_ns_local=52, - ts_ns_exch=51, - ), - ) - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step( - state, - entry, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_Evaluator(), # type: ignore[arg-type] - now_ts_ns_local=52, - ), - ) - assert calls == {"evaluate": 0} - - -def test_run_core_step_policy_planner_not_reached_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"planner": 0} - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - def _planner_spy(*_: object, **__: object) -> object: - calls["planner"] += 1 - raise AssertionError("planner must not run when boundary fails") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - monkeypatch.setattr( - processing_step_module, - "plan_execution_control_candidates", - _planner_spy, - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=55), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-planner-process-fail", - ts_ns_local=55, - ts_ns_exch=54, - ), - ) - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step( - state, - entry, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=object(), # type: ignore[arg-type] - now_ts_ns_local=55, - ), - ) - assert calls == {"planner": 0} - - -def test_run_core_step_policy_admission_context_not_reached_when_strategy_fails() -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"evaluate": 0} - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 53 - raise RuntimeError("strategy evaluator failed") - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - calls["evaluate"] += 1 - return True, None - - entry = EventStreamEntry( - position=ProcessingPosition(index=53), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-strategy-fail", - ts_ns_local=53, - ts_ns_exch=52, - ), - ) - with pytest.raises(RuntimeError, match="strategy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorBoom(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=53, - ), - ) - assert calls == {"evaluate": 0} - - -def test_run_core_step_policy_planner_not_reached_when_strategy_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"planner": 0} - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 56 - raise RuntimeError("strategy evaluator failed") - - def _planner_spy(*_: object, **__: object) -> object: - calls["planner"] += 1 - raise AssertionError("planner must not run when strategy evaluation fails") - - monkeypatch.setattr( - processing_step_module, - "plan_execution_control_candidates", - _planner_spy, - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=56), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-planner-strategy-fail", - ts_ns_local=56, - ts_ns_exch=55, - ), - ) - with pytest.raises(RuntimeError, match="strategy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorBoom(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=object(), # type: ignore[arg-type] - now_ts_ns_local=56, - ), - ) - assert calls == {"planner": 0} - - -def test_run_core_step_policy_admission_context_is_side_effect_safe_characterization() -> None: - class _CaptureSink: - def __init__(self) -> None: - self.events: list[object] = [] - - def on_event(self, event: object) -> None: - self.events.append(event) - - sink = _CaptureSink() - event_bus = EventBus(sinks=[sink]) - state = StrategyState(event_bus=event_bus) - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits(max_orders_per_second=0), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=event_bus) - - generated_intent = _new_intent(client_order_id="side-effect-safe-generated") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: - assert context.state._last_processing_position_index == 54 - return [generated_intent] - - before_rate_state = copy.deepcopy(risk_engine._execution_control._rate_state) - before_queue = state.queued_intents_snapshot("BTC-USDC-PERP") - - entry = EventStreamEntry( - position=ProcessingPosition(index=54), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-side-effect-safe", - ts_ns_local=54, - ts_ns_exch=53, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=risk_engine, - now_ts_ns_local=54, - ), - ) - - assert state.queued_intents_snapshot("BTC-USDC-PERP") == before_queue - assert risk_engine._execution_control._rate_state == before_rate_state - assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) - assert result.dispatchable_intents == () - assert result.compat_gate_decision is None - - -def test_run_core_step_apply_context_requires_policy_admission_context() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=57), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-without-policy", - ts_ns_local=57, - ts_ns_exch=56, - ), - ) - - with pytest.raises( - ValueError, - match="execution_control_apply_context requires policy_admission_context", - ): - run_core_step( - state, - entry, - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=57, - ), - ) - - -def test_run_core_step_control_time_rejects_mixed_compat_and_unified_contexts() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=58), - event=_control_time_event(due_ts_ns_local=58, realized_ts_ns_local=58), - ) - - class _ControlTimeRiskMustNotRun: - def decide_intents(self, **_: object) -> GateDecision: - raise AssertionError("control-time compatibility risk must not run") - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - with pytest.raises( - ValueError, - match=( - "control_time_queue_context cannot be combined with " - "policy_admission_context or execution_control_apply_context" - ), - ): - run_core_step( - state, - entry, - control_time_queue_context=ControlTimeQueueReevaluationContext( - risk_engine=_ControlTimeRiskMustNotRun(), # type: ignore[arg-type] - instrument="BTC-USDC-PERP", - now_ts_ns_local=58, - ), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=58, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=58, - ), - ) - - -def test_run_core_step_control_time_accepts_policy_and_apply_context_and_emits_dispatchables( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-control-unified") - state.merge_intents_into_queue(instrument, [queued_intent]) - obligation = ControlSchedulingObligation( - due_ts_ns_local=77, - reason="rate_limit", - scope_key=f"instrument:{instrument}", - source="execution_control_rate_limit", - ) - apply_calls = {"count": 0} - - class _PolicyEvaluator: - def evaluate_policy_intent( - self, - *, - intent: OrderIntent, - state: StrategyState, - now_ts_ns_local: int, - ) -> tuple[bool, str | None]: - _ = (intent, state, now_ts_ns_local) - return True, None - - def _apply_spy( - plan: object, - context: object, - ) -> ExecutionControlApplyResult: - _ = context - apply_calls["count"] += 1 - active_records = plan.active_records # type: ignore[attr-defined] - dispatchable = ( - ExecutionControlDispatchableRecord(record=active_records[0]), - ) - decision = ExecutionControlDecision( - queued_effective_intents=tuple(record.intent for record in active_records), - dispatchable_intents=tuple(item.record.intent for item in dispatchable), - execution_handled_intents=(), - control_scheduling_obligation=obligation, - ) - return ExecutionControlApplyResult( - queued_effective_records=tuple(active_records), - dispatchable_records=dispatchable, - execution_handled_records=(), - blocked_records=(), - control_scheduling_obligation=obligation, - execution_control_decision=decision, - ) - - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_spy, - ) - monkeypatch.setattr( - state, - "pop_queued_intents", - lambda _: (_ for _ in ()).throw( - AssertionError("compatibility control-time queue path must not run") - ), - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=58), - event=_control_time_event(due_ts_ns_local=58, realized_ts_ns_local=58), - ) - result = run_core_step( - state, - entry, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=58, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=58, - activate_dispatchable_outputs=True, - ), - ) - - assert apply_calls["count"] == 1 - assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( - queued_intent.client_order_id, - ) - assert result.control_scheduling_obligation == obligation - assert result.compat_gate_decision is None - assert result.core_step_decision is not None - - -def test_run_core_step_apply_integration_orders_policy_plan_apply_and_maps_result() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - queued_intent = _new_intent(client_order_id="queued-passthrough") - state.merge_intents_into_queue(instrument, [queued_intent]) - generated_new = _new_intent(client_order_id="generated-new-rejected") - generated_cancel = CancelOrderIntent( - ts_ns_local=59, - instrument=instrument, - client_order_id="generated-cancel-accepted", - intents_correlation_id="corr-generated-cancel-accepted", - ) - calls: list[str] = [] - observed_apply_active_ids: list[tuple[str, ...]] = [] - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[OrderIntent]: - assert context.state._last_processing_position_index == 59 - return [generated_new, generated_cancel] - - class _PolicyEvaluator: - def evaluate_policy_intent( - self, - *, - intent: OrderIntent, - state: StrategyState, - now_ts_ns_local: int, - ) -> tuple[bool, str | None]: - assert state is not None - assert now_ts_ns_local == 59 - return intent.client_order_id == "generated-cancel-accepted", "policy_rejected" - - original_policy = processing_step_module.apply_policy_to_candidate_records - original_plan = processing_step_module.plan_execution_control_candidates - obligation = ControlSchedulingObligation( - due_ts_ns_local=88, - reason="rate_limit", - scope_key=f"instrument:{instrument}", - source="execution_control_rate_limit", - ) - - def _policy_spy(*args: object, **kwargs: object) -> object: - calls.append("policy") - return original_policy(*args, **kwargs) - - def _plan_spy(*args: object, **kwargs: object) -> object: - calls.append("plan") - return original_plan(*args, **kwargs) - - def _apply_spy(*args: object, **kwargs: object) -> ExecutionControlApplyResult: - calls.append("apply") - plan = args[0] - context = args[1] - assert context.state is state - observed_apply_active_ids.append( - tuple(record.intent.client_order_id for record in plan.active_records) - ) - dispatchable = ( - ExecutionControlDispatchableRecord(record=plan.active_records[0]), - ) - decision = ExecutionControlDecision( - queued_effective_intents=tuple(record.intent for record in plan.active_records), - dispatchable_intents=tuple(item.record.intent for item in dispatchable), - execution_handled_intents=(), - control_scheduling_obligation=obligation, - ) - return ExecutionControlApplyResult( - queued_effective_records=tuple(plan.active_records), - dispatchable_records=dispatchable, - execution_handled_records=(), - blocked_records=(), - control_scheduling_obligation=obligation, - execution_control_decision=decision, - ) - - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr( - processing_step_module, - "apply_policy_to_candidate_records", - _policy_spy, - ) - monkeypatch.setattr( - processing_step_module, - "plan_execution_control_candidates", - _plan_spy, - ) - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_spy, - ) - try: - entry = EventStreamEntry( - position=ProcessingPosition(index=59), - event=_fill_event( - instrument=instrument, - client_order_id="fill-apply-ordering", - ts_ns_local=59, - ts_ns_exch=58, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=59, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=59, - activate_dispatchable_outputs=False, - ), - ) - finally: - monkeypatch.undo() - - assert calls == ["policy", "plan", "apply"] - assert observed_apply_active_ids == [ - ("generated-cancel-accepted", "queued-passthrough"), - ] - assert result.core_step_decision is not None - assert result.core_step_decision.execution_control_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("generated-cancel-accepted",) - assert result.core_step_decision.control_scheduling_obligation == obligation - assert result.control_scheduling_obligation == obligation - assert result.dispatchable_intents == () - assert result.compat_gate_decision is None - - -def test_run_core_step_apply_integration_can_activate_top_level_dispatchables() -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="generated-dispatchable-activated") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 60 - return [generated_intent] - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - entry = EventStreamEntry( - position=ProcessingPosition(index=60), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-dispatchable-activation", - ts_ns_local=60, - ts_ns_exch=59, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=60, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=60, - activate_dispatchable_outputs=True, - ), - ) - - assert tuple(it.client_order_id for it in result.dispatchable_intents) == ( - "generated-dispatchable-activated", - ) - assert result.core_step_decision is not None - assert result.core_step_decision.execution_control_decision is not None - assert tuple( - it.client_order_id - for it in result.core_step_decision.execution_control_decision.dispatchable_intents - ) == ("generated-dispatchable-activated",) - assert result.compat_gate_decision is None - - -def test_run_core_step_apply_context_not_reached_when_process_event_entry_fails( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"apply": 0} - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - def _boom(*_: object, **__: object) -> None: - raise RuntimeError("process boundary failed") - - def _apply_spy(*_: object, **__: object) -> object: - calls["apply"] += 1 - raise AssertionError("apply must not run when boundary fails") - - monkeypatch.setattr(processing_step_module, "process_event_entry", _boom) - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_spy, - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=61), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-process-fail", - ts_ns_local=61, - ts_ns_exch=60, - ), - ) - with pytest.raises(RuntimeError, match="process boundary failed"): - run_core_step( - state, - entry, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=61, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=61, - ), - ) - assert calls == {"apply": 0} - - -def test_run_core_step_apply_context_not_reached_when_strategy_fails() -> None: - state = StrategyState(event_bus=NullEventBus()) - calls = {"apply": 0} - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 62 - raise RuntimeError("strategy evaluator failed") - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - def _apply_spy(*_: object, **__: object) -> object: - calls["apply"] += 1 - raise AssertionError("apply must not run when strategy fails") - - monkeypatch = pytest.MonkeyPatch() - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_spy, - ) - try: - entry = EventStreamEntry( - position=ProcessingPosition(index=62), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-strategy-fail", - ts_ns_local=62, - ts_ns_exch=61, - ), - ) - with pytest.raises(RuntimeError, match="strategy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_EvaluatorBoom(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=62, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=62, - ), - ) - finally: - monkeypatch.undo() - - assert calls == {"apply": 0} - - -def test_run_core_step_apply_path_does_not_call_risk_decide_intents_or_emit_risk_events( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class _CaptureSink: - def __init__(self) -> None: - self.events: list[object] = [] - - def on_event(self, event: object) -> None: - self.events.append(event) - - sink = _CaptureSink() - state = StrategyState(event_bus=EventBus(sinks=[sink])) - generated_intent = _new_intent(client_order_id="apply-no-risk-decide") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 63 - return [generated_intent] - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - def _boom(*_: object, **__: object) -> object: - raise AssertionError("RiskEngine.decide_intents must not run in apply path") - - monkeypatch.setattr(RiskEngine, "decide_intents", _boom) - - entry = EventStreamEntry( - position=ProcessingPosition(index=63), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-no-risk-decide", - ts_ns_local=63, - ts_ns_exch=62, - ), - ) - result = run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=63, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=63, - ), - ) - - assert result.core_step_decision is not None - assert result.compat_gate_decision is None - assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) - - -def test_run_core_step_apply_path_policy_failure_short_circuits_plan_and_apply( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="policy-boom-generated") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 64 - return [generated_intent] - - class _PolicyBoom: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - raise RuntimeError("policy evaluator failed") - - def _planner_must_not_run(*_: object, **__: object) -> object: - raise AssertionError("planner must not run when policy admission fails") - - def _apply_must_not_run(*_: object, **__: object) -> object: - raise AssertionError("apply must not run when policy admission fails") - - monkeypatch.setattr( - processing_step_module, - "plan_execution_control_candidates", - _planner_must_not_run, - ) - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_must_not_run, - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=64), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-policy-fail-short-circuit", - ts_ns_local=64, - ts_ns_exch=63, - ), - ) - with pytest.raises(RuntimeError, match="policy evaluator failed"): - run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyBoom(), # type: ignore[arg-type] - now_ts_ns_local=64, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=64, - ), - ) - - -def test_run_core_step_apply_path_apply_failure_propagates_without_partial_result( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - generated_intent = _new_intent(client_order_id="apply-boom-generated") - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - assert context.state._last_processing_position_index == 65 - return [generated_intent] - - class _PolicyOk: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - def _apply_boom(*_: object, **__: object) -> object: - raise RuntimeError("apply failed") - - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - _apply_boom, - ) - - entry = EventStreamEntry( - position=ProcessingPosition(index=65), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="fill-apply-fail-propagates", - ts_ns_local=65, - ts_ns_exch=64, - ), - ) - with pytest.raises(RuntimeError, match="apply failed"): - run_core_step( - state, - entry, - strategy_evaluator=_Evaluator(), - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyOk(), # type: ignore[arg-type] - now_ts_ns_local=65, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=65, - ), - ) diff --git a/tests/semantics/models/test_core_step_decision_contract.py b/tests/semantics/models/test_core_step_decision_contract.py deleted file mode 100644 index 21eef41..0000000 --- a/tests/semantics/models/test_core_step_decision_contract.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Semantics tests for the CoreStepDecision scaffold contract model.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_decision import ( - ExecutionControlDecision, -) -from tradingchassis_core.core.domain.policy_risk_decision import PolicyRiskDecision -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.step_decision import CoreStepDecision -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def test_default_decision_is_empty_and_none_obligation() -> None: - decision = CoreStepDecision() - - assert decision.policy_rejected_intents == () - assert decision.policy_risk_decision is None - assert decision.execution_control_decision is None - assert decision.queued_effective_intents == () - assert decision.dispatchable_intents == () - assert decision.execution_handled_intents == () - assert decision.control_scheduling_obligation is None - - -def test_core_step_decision_tuple_fields_normalize() -> None: - rejected = _new_intent(client_order_id="rejected") - queued = _new_intent(client_order_id="queued") - dispatchable = _new_intent(client_order_id="dispatchable") - handled = _new_intent(client_order_id="handled") - - decision = CoreStepDecision( - policy_rejected_intents=[rejected], - queued_effective_intents=[queued], - dispatchable_intents=[dispatchable], - execution_handled_intents=[handled], - ) - - assert decision.policy_rejected_intents == (rejected,) - assert decision.queued_effective_intents == (queued,) - assert decision.dispatchable_intents == (dispatchable,) - assert decision.execution_handled_intents == (handled,) - - -def test_core_step_decision_is_immutable() -> None: - decision = CoreStepDecision() - - with pytest.raises(FrozenInstanceError): - decision.control_scheduling_obligation = None - - -def test_core_step_decision_is_non_canonical_and_not_classified() -> None: - assert is_canonical_stream_candidate_type(CoreStepDecision) is False - assert canonical_category_for_type(CoreStepDecision) is None - - -def test_canonical_processing_boundary_rejects_core_step_decision() -> None: - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, CoreStepDecision()) - - -def test_core_step_decision_can_carry_dispatchable_and_obligation() -> None: - dispatchable = _new_intent(client_order_id="dispatchable") - obligation = ControlSchedulingObligation( - due_ts_ns_local=1_000_000_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - decision = CoreStepDecision( - dispatchable_intents=[dispatchable], - control_scheduling_obligation=obligation, - ) - - assert decision.dispatchable_intents == (dispatchable,) - assert decision.control_scheduling_obligation is obligation - - -def test_core_step_decision_can_carry_policy_risk_decision() -> None: - accepted = _new_intent(client_order_id="accepted") - rejected = _new_intent(client_order_id="rejected") - policy = PolicyRiskDecision( - accepted_intents=[accepted], - rejected_intents=[rejected], - ) - decision = CoreStepDecision(policy_risk_decision=policy) - - assert decision.policy_risk_decision is policy - - -def test_core_step_decision_can_carry_execution_control_decision() -> None: - dispatchable = _new_intent(client_order_id="dispatchable") - execution_control = ExecutionControlDecision( - dispatchable_intents=[dispatchable], - ) - decision = CoreStepDecision(execution_control_decision=execution_control) - - assert decision.execution_control_decision is execution_control - - -def test_public_root_export_identity_when_root_exported() -> None: - assert hasattr(tc, "CoreStepDecision") - assert tc.CoreStepDecision is CoreStepDecision diff --git a/tests/semantics/models/test_core_step_result_contract.py b/tests/semantics/models/test_core_step_result_contract.py deleted file mode 100644 index 611afd1..0000000 --- a/tests/semantics/models/test_core_step_result_contract.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Semantics tests for the CoreStepResult contract model.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.candidate_intent import ( - CandidateIntentOrigin, - CandidateIntentRecord, -) -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.step_decision import CoreStepDecision -from tradingchassis_core.core.domain.step_result import CoreStepResult -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def test_default_result_is_empty_and_none_compat() -> None: - result = CoreStepResult() - - assert result.generated_intents == () - assert result.candidate_intent_records == () - assert result.candidate_intents == () - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - assert result.core_step_decision is None - assert result.compat_gate_decision is None - - -def test_result_is_immutable() -> None: - result = CoreStepResult() - - with pytest.raises(FrozenInstanceError): - result.compat_gate_decision = None - - -def test_dispatchable_intents_normalize_to_tuple() -> None: - intent_one = _new_intent(client_order_id="new-1") - intent_two = _new_intent(client_order_id="new-2") - - result = CoreStepResult(dispatchable_intents=[intent_one, intent_two]) - - assert isinstance(result.dispatchable_intents, tuple) - assert result.dispatchable_intents == (intent_one, intent_two) - - -def test_generated_intents_normalize_to_tuple() -> None: - intent_one = _new_intent(client_order_id="generated-1") - intent_two = _new_intent(client_order_id="generated-2") - - result = CoreStepResult(generated_intents=[intent_one, intent_two]) - - assert isinstance(result.generated_intents, tuple) - assert result.generated_intents == (intent_one, intent_two) - - -def test_generated_intents_are_distinct_from_dispatchable_intents() -> None: - generated = _new_intent(client_order_id="generated-only") - candidate = _new_intent(client_order_id="candidate-only") - dispatchable = _new_intent(client_order_id="dispatchable-only") - - result = CoreStepResult( - generated_intents=[generated], - candidate_intents=[candidate], - dispatchable_intents=[dispatchable], - ) - - assert result.generated_intents == (generated,) - assert result.candidate_intents == (candidate,) - assert result.dispatchable_intents == (dispatchable,) - - -def test_candidate_intent_records_normalize_to_tuple() -> None: - intent = _new_intent(client_order_id="record-candidate") - record = CandidateIntentRecord( - intent=intent, - origin=CandidateIntentOrigin.GENERATED, - logical_key=f"order:{intent.client_order_id}", - merge_index=0, - priority=2, - ) - result = CoreStepResult(candidate_intent_records=[record]) - - assert isinstance(result.candidate_intent_records, tuple) - assert result.candidate_intent_records == (record,) - - -def test_candidate_intents_follow_candidate_intent_records_when_present() -> None: - record_intent = _new_intent(client_order_id="record-wins-intent-view") - stale_view_intent = _new_intent(client_order_id="stale-candidate-view") - record = CandidateIntentRecord( - intent=record_intent, - origin=CandidateIntentOrigin.QUEUED, - logical_key=f"order:{record_intent.client_order_id}", - merge_index=4, - priority=2, - ) - result = CoreStepResult( - candidate_intent_records=[record], - candidate_intents=[stale_view_intent], - ) - - assert result.candidate_intent_records == (record,) - assert result.candidate_intents == (record_intent,) - - -def test_candidate_intent_records_are_distinct_from_dispatchable_intents() -> None: - candidate_intent = _new_intent(client_order_id="candidate-record-only") - dispatchable_intent = _new_intent(client_order_id="dispatchable-only") - record = CandidateIntentRecord( - intent=candidate_intent, - origin=CandidateIntentOrigin.GENERATED, - logical_key=f"order:{candidate_intent.client_order_id}", - merge_index=1, - priority=2, - ) - result = CoreStepResult( - candidate_intent_records=[record], - dispatchable_intents=[dispatchable_intent], - ) - - assert tuple(r.intent.client_order_id for r in result.candidate_intent_records) == ( - "candidate-record-only", - ) - assert tuple(i.client_order_id for i in result.candidate_intents) == ("candidate-record-only",) - assert tuple(i.client_order_id for i in result.dispatchable_intents) == ("dispatchable-only",) - - -def test_generated_intents_accept_new_replace_cancel_intents() -> None: - new_intent = _new_intent(client_order_id="new-intent") - replace_intent = ReplaceOrderIntent( - ts_ns_local=2, - instrument="BTC-USDC-PERP", - client_order_id="replace-intent", - intents_correlation_id="corr-replace", - side="buy", - order_type="limit", - intended_qty=Quantity(value=2.0, unit="contracts"), - intended_price=Price(currency="USDC", value=101.0), - ) - cancel_intent = CancelOrderIntent( - ts_ns_local=3, - instrument="BTC-USDC-PERP", - client_order_id="cancel-intent", - intents_correlation_id="corr-cancel", - ) - - result = CoreStepResult( - generated_intents=[new_intent, replace_intent, cancel_intent], - ) - - assert result.generated_intents == (new_intent, replace_intent, cancel_intent) - - -def test_candidate_intents_normalize_to_tuple() -> None: - intent_one = _new_intent(client_order_id="candidate-1") - intent_two = _new_intent(client_order_id="candidate-2") - - result = CoreStepResult(candidate_intents=[intent_one, intent_two]) - - assert isinstance(result.candidate_intents, tuple) - assert result.candidate_intents == (intent_one, intent_two) - - -def test_candidate_intents_are_not_dispatchable_by_default() -> None: - candidate = _new_intent(client_order_id="candidate-only") - result = CoreStepResult(candidate_intents=[candidate]) - - assert result.candidate_intents == (candidate,) - assert result.dispatchable_intents == () - - -def test_candidate_intent_origin_values_are_stable() -> None: - assert CandidateIntentOrigin.GENERATED.value == "generated" - assert CandidateIntentOrigin.QUEUED.value == "queued" - - -def test_candidate_intent_record_is_immutable() -> None: - candidate_intent = _new_intent(client_order_id="immutable-candidate-record") - record = CandidateIntentRecord( - intent=candidate_intent, - origin=CandidateIntentOrigin.GENERATED, - logical_key=f"order:{candidate_intent.client_order_id}", - merge_index=0, - priority=2, - ) - - with pytest.raises(FrozenInstanceError): - record.merge_index = 7 - - -def test_can_carry_optional_control_scheduling_obligation() -> None: - obligation = ControlSchedulingObligation( - due_ts_ns_local=1_000_000_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - - result = CoreStepResult(control_scheduling_obligation=obligation) - - assert result.control_scheduling_obligation is obligation - - -def test_can_carry_optional_compat_gate_decision() -> None: - accepted_intent = _new_intent(client_order_id="accepted-now") - compat_decision = GateDecision( - ts_ns_local=123, - accepted_now=[accepted_intent], - queued=[], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - result = CoreStepResult(compat_gate_decision=compat_decision) - - assert result.compat_gate_decision is compat_decision - - -def test_can_carry_optional_core_step_decision() -> None: - dispatchable = _new_intent(client_order_id="dispatchable") - decision = CoreStepDecision(dispatchable_intents=[dispatchable]) - - result = CoreStepResult(core_step_decision=decision) - - assert result.core_step_decision is decision - - -def test_core_step_result_dispatchable_intents_are_independent_from_core_step_decision() -> None: - top_level_dispatchable = _new_intent(client_order_id="top-level") - decision_dispatchable = _new_intent(client_order_id="decision") - decision = CoreStepDecision(dispatchable_intents=[decision_dispatchable]) - result = CoreStepResult( - dispatchable_intents=[top_level_dispatchable], - core_step_decision=decision, - ) - - assert result.dispatchable_intents == (top_level_dispatchable,) - assert result.core_step_decision is decision - assert result.core_step_decision.dispatchable_intents == (decision_dispatchable,) - - -def test_core_step_result_is_non_canonical_and_not_classified() -> None: - assert is_canonical_stream_candidate_type(CoreStepResult) is False - assert canonical_category_for_type(CoreStepResult) is None - - -def test_canonical_processing_boundary_rejects_core_step_result() -> None: - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, CoreStepResult()) - - -def test_candidate_intent_record_is_non_canonical_and_rejected_if_misrouted() -> None: - candidate_intent = _new_intent(client_order_id="candidate-record-boundary") - candidate_record = CandidateIntentRecord( - intent=candidate_intent, - origin=CandidateIntentOrigin.GENERATED, - logical_key=f"order:{candidate_intent.client_order_id}", - merge_index=0, - priority=2, - ) - state = StrategyState(event_bus=NullEventBus()) - - assert is_canonical_stream_candidate_type(CandidateIntentRecord) is False - assert canonical_category_for_type(CandidateIntentRecord) is None - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, candidate_record) - - -def test_public_root_export_identity_when_root_exported() -> None: - assert hasattr(tc, "CoreStepResult") - assert tc.CoreStepResult is CoreStepResult diff --git a/tests/semantics/models/test_core_wakeup_step_contract.py b/tests/semantics/models/test_core_wakeup_step_contract.py deleted file mode 100644 index efd9420..0000000 --- a/tests/semantics/models/test_core_wakeup_step_contract.py +++ /dev/null @@ -1,526 +0,0 @@ -"""Semantics tests for Core two-phase wakeup scaffold APIs.""" - -from __future__ import annotations - -from typing import Any - -import pytest - -import tradingchassis_core as tc -import tradingchassis_core.core.domain.processing_step as processing_step_module -from tradingchassis_core.core.domain.candidate_intent import CandidateIntentOrigin -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_apply import ( - ExecutionControlApplyResult, - ExecutionControlDispatchableRecord, -) -from tradingchassis_core.core.domain.execution_control_decision import ( - ExecutionControlDecision, -) -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.processing_step import ( - CoreExecutionControlApplyContext, - CorePolicyAdmissionContext, - CoreStepStrategyContext, - CoreWakeupReductionResult, - run_core_wakeup_decision, - run_core_wakeup_reduction, - run_core_wakeup_step, -) -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - ControlTimeEvent, - FillEvent, - NewOrderIntent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.execution_control import ExecutionControl -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def _fill_event(*, ts: int, client_order_id: str) -> FillEvent: - return FillEvent( - ts_ns_local=ts, - ts_ns_exch=max(1, ts - 1), - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=0.5), - remaining_qty=Quantity(unit="contracts", value=0.5), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _control_event(*, ts: int) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts, - reason="scheduled_control_recheck", - due_ts_ns_local=ts, - realized_ts_ns_local=ts, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=ts, - runtime_correlation=None, - ) - - -def _new_intent(*, client_order_id: str, ts_ns_local: int = 1) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id=f"corr-{client_order_id}", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _cancel_intent(*, client_order_id: str, ts_ns_local: int = 1) -> CancelOrderIntent: - return CancelOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id=f"corr-cancel-{client_order_id}", - ) - - -def test_run_core_wakeup_exports_identity() -> None: - assert tc.run_core_wakeup_reduction is run_core_wakeup_reduction - assert tc.run_core_wakeup_decision is run_core_wakeup_decision - assert tc.run_core_wakeup_step is run_core_wakeup_step - assert tc.CoreWakeupReductionResult is CoreWakeupReductionResult - - -def test_run_core_wakeup_reduction_processes_entries_in_order() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry_a = EventStreamEntry(position=ProcessingPosition(index=5), event=_fill_event(ts=10, client_order_id="fill-a")) - entry_b = EventStreamEntry(position=ProcessingPosition(index=6), event=_control_event(ts=11)) - calls: list[tuple[str, int]] = [] - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - calls.append((type(context.event).__name__, context.position.index)) - return [_new_intent(client_order_id=f"gen-{context.position.index}", ts_ns_local=context.position.index)] - - reduction = run_core_wakeup_reduction( - state, - (entry_a, entry_b), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda event: isinstance(event, FillEvent), - ) - - assert state._last_processing_position_index == 6 - assert calls == [("FillEvent", 5)] - assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ("gen-5",) - assert reduction.entries == (entry_a, entry_b) - - -def test_run_core_wakeup_reduction_failure_short_circuits_and_skips_later_entries( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry_a = EventStreamEntry(position=ProcessingPosition(index=1), event=_fill_event(ts=1, client_order_id="first")) - entry_b = EventStreamEntry(position=ProcessingPosition(index=2), event=_fill_event(ts=2, client_order_id="second")) - processed: list[int] = [] - evaluate_calls = {"count": 0} - original_process = processing_step_module.process_event_entry - - def _process_spy(state_obj: StrategyState, entry: EventStreamEntry, *, configuration: object | None = None) -> None: - _ = configuration - processed.append(entry.position.index) - if entry.position.index == 1: - raise RuntimeError("boom-reducer") - original_process(state_obj, entry) - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - _ = context - evaluate_calls["count"] += 1 - return [] - - monkeypatch.setattr(processing_step_module, "process_event_entry", _process_spy) - with pytest.raises(RuntimeError, match="boom-reducer"): - run_core_wakeup_reduction( - state, - (entry_a, entry_b), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda _: True, - ) - assert processed == [1] - assert evaluate_calls["count"] == 0 - assert state._last_processing_position_index is None - - -def test_run_core_wakeup_reduction_does_not_evaluate_control_event_without_explicit_filter() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry_fill = EventStreamEntry(position=ProcessingPosition(index=1), event=_fill_event(ts=1, client_order_id="fill")) - entry_control = EventStreamEntry(position=ProcessingPosition(index=2), event=_control_event(ts=2)) - seen: list[str] = [] - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - seen.append(type(context.event).__name__) - return [_new_intent(client_order_id=f"from-{type(context.event).__name__}")] - - reduction = run_core_wakeup_reduction( - state, - (entry_fill, entry_control), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda event: isinstance(event, FillEvent), - ) - assert seen == ["FillEvent"] - assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ( - "from-FillEvent", - ) - - -def test_run_core_wakeup_reduction_can_evaluate_control_event_when_filter_allows_it() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry_control = EventStreamEntry(position=ProcessingPosition(index=9), event=_control_event(ts=9)) - seen: list[str] = [] - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - seen.append(type(context.event).__name__) - return [_new_intent(client_order_id="from-control")] - - reduction = run_core_wakeup_reduction( - state, - (entry_control,), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda event: isinstance(event, ControlTimeEvent), - ) - assert seen == ["ControlTimeEvent"] - assert tuple(intent.client_order_id for intent in reduction.generated_intents) == ( - "from-control", - ) - - -def test_run_core_wakeup_decision_combines_generated_and_post_reduction_queue_snapshot_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - queued = _new_intent(client_order_id="same-key") - state.merge_intents_into_queue("BTC-USDC-PERP", [queued]) - reduction = CoreWakeupReductionResult( - generated_intents=(_cancel_intent(client_order_id="same-key"),), - ) - combine_calls: list[tuple[tuple[str, ...], tuple[str, ...]]] = [] - original_combine = processing_step_module.combine_candidate_intent_records - - def _combine_spy(*, generated_intents: Any, queued_intents: Any) -> Any: - combine_calls.append( - ( - tuple(intent.client_order_id for intent in generated_intents), - tuple(intent.client_order_id for intent in queued_intents), - ) - ) - return original_combine( - generated_intents=generated_intents, - queued_intents=queued_intents, - ) - - monkeypatch.setattr(processing_step_module, "combine_candidate_intent_records", _combine_spy) - result = run_core_wakeup_decision(state, reduction, snapshot_instrument="BTC-USDC-PERP") - - assert combine_calls == [(("same-key",), ("same-key",))] - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert tuple(intent.intent_type for intent in result.candidate_intents) == ("cancel",) - - -def test_run_core_wakeup_decision_without_policy_context_returns_candidates_only() -> None: - state = StrategyState(event_bus=NullEventBus()) - queued = _new_intent(client_order_id="queued-only") - state.merge_intents_into_queue("BTC-USDC-PERP", [queued]) - before_queue = state.queued_intents_snapshot("BTC-USDC-PERP") - reduction = CoreWakeupReductionResult(generated_intents=(_new_intent(client_order_id="generated"),)) - - result = run_core_wakeup_decision(state, reduction, snapshot_instrument="BTC-USDC-PERP") - - assert tuple(record.origin for record in result.candidate_intent_records) == ( - CandidateIntentOrigin.QUEUED, - CandidateIntentOrigin.GENERATED, - ) - assert result.core_step_decision is None - assert result.dispatchable_intents == () - assert result.control_scheduling_obligation is None - assert state.queued_intents_snapshot("BTC-USDC-PERP") == before_queue - - -def test_run_core_wakeup_decision_policy_only_populates_policy_and_plan_without_apply() -> None: - state = StrategyState(event_bus=NullEventBus()) - state.merge_intents_into_queue("BTC-USDC-PERP", [_new_intent(client_order_id="queued")]) - reduction = CoreWakeupReductionResult( - generated_intents=( - _new_intent(client_order_id="generated-reject"), - _cancel_intent(client_order_id="generated-accept"), - ) - ) - - class _PolicyEvaluator: - def evaluate_policy_intent(self, **kwargs: object) -> tuple[bool, str | None]: - intent = kwargs["intent"] - if intent.intent_type == "cancel": - return True, None - return False, "policy_rejected" - - result = run_core_wakeup_decision( - state, - reduction, - snapshot_instrument="BTC-USDC-PERP", - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyEvaluator(), # type: ignore[arg-type] - now_ts_ns_local=99, - ), - ) - - assert result.core_step_decision is not None - assert result.core_step_decision.policy_risk_decision is not None - assert tuple( - intent.client_order_id for intent in result.core_step_decision.policy_risk_decision.rejected_intents - ) == ("generated-reject",) - assert tuple( - intent.client_order_id for intent in result.core_step_decision.execution_control_decision.queued_effective_intents - ) == ("generated-accept", "queued") - assert result.dispatchable_intents == () - - -@pytest.mark.parametrize( - ("activate_outputs", "expected_dispatchables"), - [ - (False, ()), - (True, ("generated-apply",)), - ], -) -def test_run_core_wakeup_decision_policy_plus_apply_runs_once_and_maps_outputs( - monkeypatch: pytest.MonkeyPatch, - activate_outputs: bool, - expected_dispatchables: tuple[str, ...], -) -> None: - state = StrategyState(event_bus=NullEventBus()) - reduction = CoreWakeupReductionResult( - generated_intents=(_new_intent(client_order_id="generated-apply"),) - ) - obligation = ControlSchedulingObligation( - due_ts_ns_local=1_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - apply_calls = {"count": 0} - - class _PolicyOk: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - def _apply_spy(plan: object, context: object) -> ExecutionControlApplyResult: - _ = context - apply_calls["count"] += 1 - active_records = plan.active_records # type: ignore[attr-defined] - dispatchable_records = ( - ExecutionControlDispatchableRecord(record=active_records[0]), - ) - decision = ExecutionControlDecision( - queued_effective_intents=tuple(record.intent for record in active_records), - dispatchable_intents=tuple(item.record.intent for item in dispatchable_records), - execution_handled_intents=(), - control_scheduling_obligation=obligation, - ) - return ExecutionControlApplyResult( - queued_effective_records=tuple(active_records), - dispatchable_records=dispatchable_records, - execution_handled_records=(), - blocked_records=(), - control_scheduling_obligation=obligation, - execution_control_decision=decision, - ) - - monkeypatch.setattr(processing_step_module, "apply_execution_control_plan", _apply_spy) - monkeypatch.setattr( - RiskEngine, - "decide_intents", - lambda *args, **kwargs: (_ for _ in ()).throw( - AssertionError("RiskEngine.decide_intents must not run in wakeup decision/apply") - ), - ) - result = run_core_wakeup_decision( - state, - reduction, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyOk(), # type: ignore[arg-type] - now_ts_ns_local=123, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=123, - activate_dispatchable_outputs=activate_outputs, - ), - ) - - assert apply_calls["count"] == 1 - assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == expected_dispatchables - assert result.control_scheduling_obligation == obligation - assert result.compat_gate_decision is None - - -def test_run_core_wakeup_decision_apply_requires_policy_context() -> None: - state = StrategyState(event_bus=NullEventBus()) - with pytest.raises( - ValueError, - match="execution_control_apply_context requires policy_admission_context", - ): - run_core_wakeup_decision( - state, - CoreWakeupReductionResult(), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=1, - ), - ) - - -def test_run_core_wakeup_failure_behavior_short_circuits() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry(position=ProcessingPosition(index=10), event=_fill_event(ts=10, client_order_id="boom")) - - class _EvaluatorBoom: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - _ = context - raise RuntimeError("strategy failed") - - with pytest.raises(RuntimeError, match="strategy failed"): - run_core_wakeup_reduction( - state, - (entry,), - strategy_evaluator=_EvaluatorBoom(), - strategy_event_filter=lambda _: True, - ) - - -def test_run_core_wakeup_decision_policy_failure_short_circuits_apply( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - reduction = CoreWakeupReductionResult( - generated_intents=(_new_intent(client_order_id="generated-policy-fail"),) - ) - - class _PolicyBoom: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - raise RuntimeError("policy failed") - - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - lambda *args, **kwargs: (_ for _ in ()).throw( - AssertionError("apply must not run after policy failure") - ), - ) - with pytest.raises(RuntimeError, match="policy failed"): - run_core_wakeup_decision( - state, - reduction, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyBoom(), # type: ignore[arg-type] - now_ts_ns_local=1, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=1, - ), - ) - - -def test_run_core_wakeup_decision_apply_failure_propagates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - reduction = CoreWakeupReductionResult( - generated_intents=(_new_intent(client_order_id="generated-apply-fail"),) - ) - - class _PolicyOk: - def evaluate_policy_intent(self, **_: object) -> tuple[bool, str | None]: - return True, None - - monkeypatch.setattr( - processing_step_module, - "apply_execution_control_plan", - lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("apply failed")), - ) - with pytest.raises(RuntimeError, match="apply failed"): - run_core_wakeup_decision( - state, - reduction, - policy_admission_context=CorePolicyAdmissionContext( - policy_evaluator=_PolicyOk(), # type: ignore[arg-type] - now_ts_ns_local=2, - ), - execution_control_apply_context=CoreExecutionControlApplyContext( - execution_control=ExecutionControl(), - now_ts_ns_local=2, - ), - ) - - -def test_run_core_wakeup_step_wrapper_matches_manual_two_phase() -> None: - state_manual = StrategyState(event_bus=NullEventBus()) - state_wrapper = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry(position=ProcessingPosition(index=5), event=_fill_event(ts=5, client_order_id="fill")) - - class _Evaluator: - def evaluate(self, context: CoreStepStrategyContext) -> list[NewOrderIntent]: - _ = context - return [_new_intent(client_order_id="generated-manual")] - - reduction = run_core_wakeup_reduction( - state_manual, - (entry,), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda _: True, - ) - manual_result = run_core_wakeup_decision( - state_manual, - reduction, - snapshot_instrument="BTC-USDC-PERP", - ) - wrapper_result = run_core_wakeup_step( - state_wrapper, - (entry,), - strategy_evaluator=_Evaluator(), - strategy_event_filter=lambda _: True, - snapshot_instrument="BTC-USDC-PERP", - ) - - assert wrapper_result == manual_result - assert state_wrapper._last_processing_position_index == state_manual._last_processing_position_index - - -def test_core_wakeup_reduction_result_remains_non_canonical_boundary_artifact() -> None: - assert is_canonical_stream_candidate_type(CoreWakeupReductionResult) is False - assert canonical_category_for_type(CoreWakeupReductionResult) is None - - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry( - position=ProcessingPosition(index=1), - event=CoreWakeupReductionResult(), - ) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py deleted file mode 100644 index e9c0923..0000000 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ /dev/null @@ -1,378 +0,0 @@ -"""Semantics tests for minimal EventStreamEntry contract (Phase 2B.1).""" - -from __future__ import annotations - -import copy -import dataclasses -import inspect - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.event_model import is_canonical_stream_candidate_type -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - 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.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": 100.0}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": 101.0}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float = 0.25, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason="rate_limit_recheck", - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - } - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def test_event_stream_entry_requires_processing_position() -> None: - with pytest.raises(TypeError, match="position must be a ProcessingPosition"): - EventStreamEntry(position=object(), event={"x": 1}) - - -def test_event_stream_entry_contract_has_no_configuration_field() -> None: - field_names = {field.name for field in dataclasses.fields(EventStreamEntry)} - assert field_names == {"position", "event"} - assert "configuration" not in field_names - - -def test_configuration_is_call_level_input_not_entry_level_shape() -> None: - process_signature = inspect.signature(process_event_entry) - fold_signature = inspect.signature(fold_event_stream_entries) - - assert "configuration" in process_signature.parameters - assert "configuration" in fold_signature.parameters - assert "configuration" not in {field.name for field in dataclasses.fields(EventStreamEntry)} - - -def test_process_event_entry_processes_market_and_advances_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - - process_event_entry(state, entry, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert state._last_processing_position_index == 0 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 90 - - -def test_process_event_entry_processes_fill_and_updates_fill_state() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=5), event=event) - - process_event_entry(state, entry) - - assert state._last_processing_position_index == 5 - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_event_entry_processes_order_submitted_and_updates_projection() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=250, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=6), event=event) - - process_event_entry(state, entry) - - assert state._last_processing_position_index == 6 - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 250 - assert projection.updated_ts_ns_local == 250 - - -def test_process_event_entry_processes_control_time_event_and_advances_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - event = _control_time_event( - ts_ns_local_control=260, - due_ts_ns_local=300, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=7), event=event) - before = { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - - process_event_entry(state, entry) - - after = { - "queued_intents": copy.deepcopy(state.queued_intents), - "inflight": copy.deepcopy(state.inflight), - "orders": copy.deepcopy(state.orders), - "canonical_orders": copy.deepcopy(state.canonical_orders), - "fills": copy.deepcopy(state.fills), - "market": copy.deepcopy(state.market), - "account": copy.deepcopy(state.account), - } - assert state._last_processing_position_index == 7 - assert after == before - - -def test_process_event_entry_rejects_non_canonical_payload() -> None: - state = StrategyState(event_bus=NullEventBus()) - compat_event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ) - entry = EventStreamEntry(position=ProcessingPosition(index=1), event=compat_event) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) - - -def test_process_event_entry_enforces_processing_position_monotonicity() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = EventStreamEntry( - position=ProcessingPosition(index=10), - event=_book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90), - ) - second = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=101, - ts_ns_exch=91, - ), - ) - repeated = EventStreamEntry( - position=ProcessingPosition(index=11), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=102, - ts_ns_exch=92, - ), - ) - regressing = EventStreamEntry( - position=ProcessingPosition(index=9), - event=_fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=103, - ts_ns_exch=93, - ), - ) - - process_event_entry(state, first, configuration=_market_configuration()) - process_event_entry(state, second, configuration=None) - assert state._last_processing_position_index == 11 - before = _state_subset_snapshot(state) - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_event_entry(state, repeated) - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - process_event_entry(state, regressing) - - assert _state_subset_snapshot(state) == before - assert state._last_processing_position_index == 11 - - -def test_process_event_entry_positioned_market_requires_configuration() -> None: - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises( - ValueError, - match="CoreConfiguration is required for positioned canonical MarketEvent processing", - ): - process_event_entry(state, entry, configuration=None) - - -def test_process_event_entry_positioned_fill_remains_configuration_agnostic() -> None: - event = _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=200, - ts_ns_exch=180, - ) - entry = EventStreamEntry(position=ProcessingPosition(index=5), event=event) - state = StrategyState(event_bus=NullEventBus()) - - process_event_entry(state, entry, configuration=None) - - assert state._last_processing_position_index == 5 - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - - -def test_process_event_entry_rejects_non_core_configuration() -> None: - event = _book_market_event(instrument="BTC-USDC-PERP", ts_ns_local=100, ts_ns_exch=90) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=event) - state = StrategyState(event_bus=NullEventBus()) - - with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - process_event_entry(state, entry, configuration={"version": "v1"}) - - -def test_event_bus_remains_non_canonical_event_stream_input() -> None: - assert is_canonical_stream_candidate_type(EventBus) is False - - state = StrategyState(event_bus=NullEventBus()) - entry = EventStreamEntry(position=ProcessingPosition(index=0), event=EventBus()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry) diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py deleted file mode 100644 index 69b798f..0000000 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Semantics tests for the lightweight core event taxonomy boundary.""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.event_model import ( - CANONICAL_EVENT_CATEGORY_NAMES, - COMPATIBILITY_PROJECTION_TYPES, - NON_CANONICAL_CONTROL_HELPER_TYPES, - TELEMETRY_EVENT_TYPES, - CanonicalEventCategory, - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderExecutionFeedbackEvent, - OrderStateEvent, - OrderSubmittedEvent, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import ( - DerivedFillEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, - RiskDecisionEvent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision - - -def test_canonical_event_category_names_are_stable() -> None: - """Canonical category names remain docs-aligned and stable.""" - - assert CANONICAL_EVENT_CATEGORY_NAMES == ( - "market", - "intent_related", - "execution", - "control", - ) - - -def test_canonical_stream_candidate_classification_current_slice() -> None: - """Current slice markers keep canonical candidates explicit and minimal.""" - - assert is_canonical_stream_candidate_type(MarketEvent) is True - assert canonical_category_for_type(MarketEvent) == CanonicalEventCategory.MARKET - - assert is_canonical_stream_candidate_type(FillEvent) is True - assert canonical_category_for_type(FillEvent) == CanonicalEventCategory.EXECUTION - - assert is_canonical_stream_candidate_type(OrderExecutionFeedbackEvent) is True - assert ( - canonical_category_for_type(OrderExecutionFeedbackEvent) - == CanonicalEventCategory.EXECUTION - ) - - assert is_canonical_stream_candidate_type(OrderSubmittedEvent) is True - assert ( - canonical_category_for_type(OrderSubmittedEvent) - == CanonicalEventCategory.INTENT_RELATED - ) - - assert is_canonical_stream_candidate_type(ControlTimeEvent) is True - assert canonical_category_for_type(ControlTimeEvent) == CanonicalEventCategory.CONTROL - - # Compatibility execution feedback remains non-canonical in this slice. - assert is_canonical_stream_candidate_type(OrderStateEvent) is False - assert OrderStateEvent in COMPATIBILITY_PROJECTION_TYPES - - -def test_event_bus_is_not_canonical_stream_record() -> None: - """EventBus remains a transport abstraction, not a canonical event.""" - - assert is_canonical_stream_candidate_type(EventBus) is False - assert canonical_category_for_type(EventBus) is None - - -def test_gate_decision_is_not_canonical_stream_record() -> None: - """GateDecision remains a compatibility decision contract, not an event.""" - - assert is_canonical_stream_candidate_type(GateDecision) is False - assert canonical_category_for_type(GateDecision) is None - - -def test_control_scheduling_obligation_is_not_an_event() -> None: - """ControlSchedulingObligation is explicitly non-canonical.""" - - assert is_canonical_stream_candidate_type(ControlSchedulingObligation) is False - assert canonical_category_for_type(ControlSchedulingObligation) is None - assert ControlSchedulingObligation in NON_CANONICAL_CONTROL_HELPER_TYPES - - -def test_telemetry_records_are_not_canonical_stream_candidates() -> None: - """Telemetry/observability records remain outside canonical stream markers.""" - - telemetry_types = ( - RiskDecisionEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, - ) - - for record_type in telemetry_types: - assert record_type in TELEMETRY_EVENT_TYPES - assert is_canonical_stream_candidate_type(record_type) is False - assert canonical_category_for_type(record_type) is None - - # Compatibility projection artifact is also non-canonical. - assert DerivedFillEvent in COMPATIBILITY_PROJECTION_TYPES - assert is_canonical_stream_candidate_type(DerivedFillEvent) is False - assert canonical_category_for_type(DerivedFillEvent) is None - - -def test_process_canonical_event_rejects_order_state_event_guard() -> None: - """Canonical processing boundary rejects compatibility OrderStateEvent records.""" - - state = StrategyState(event_bus=NullEventBus()) - compatibility_record = OrderStateEvent( - ts_ns_local=1, - ts_ns_exch=1, - instrument="BTC-USDC-PERP", - client_order_id="compat-1", - order_type="limit", - state_type="accepted", - side="buy", - intended_price={"currency": "USDC", "value": 100.0}, - filled_price=None, - intended_qty={"unit": "contracts", "value": 1.0}, - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - try: - process_canonical_event(state, compatibility_record) - except TypeError as exc: - assert "Unsupported non-canonical event type" in str(exc) - else: - raise AssertionError("Expected process_canonical_event to reject OrderStateEvent") - - -def test_process_canonical_event_rejects_derived_fill_event_guard() -> None: - """Canonical processing boundary rejects compatibility DerivedFillEvent records.""" - - state = StrategyState(event_bus=NullEventBus()) - compatibility_record = DerivedFillEvent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id="compat-derived-1", - side="buy", - delta_qty=0.1, - cum_qty=0.1, - price=100.5, - ) - - try: - process_canonical_event(state, compatibility_record) - except TypeError as exc: - assert "Unsupported non-canonical event type" in str(exc) - else: - raise AssertionError("Expected process_canonical_event to reject DerivedFillEvent") - - -def test_process_canonical_event_rejects_control_scheduling_obligation_guard() -> None: - """Canonical processing boundary rejects non-canonical control obligations.""" - - state = StrategyState(event_bus=NullEventBus()) - non_canonical_helper = ControlSchedulingObligation( - due_ts_ns_local=1_000_000_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - - try: - process_canonical_event(state, non_canonical_helper) - except TypeError as exc: - assert "Unsupported non-canonical event type" in str(exc) - else: - raise AssertionError( - "Expected process_canonical_event to reject ControlSchedulingObligation" - ) - diff --git a/tests/semantics/models/test_execution_control_apply_contract.py b/tests/semantics/models/test_execution_control_apply_contract.py deleted file mode 100644 index e5e12e3..0000000 --- a/tests/semantics/models/test_execution_control_apply_contract.py +++ /dev/null @@ -1,146 +0,0 @@ -"""Semantics tests for isolated execution-control apply API models.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.candidate_intent import ( - CandidateIntentOrigin, - CandidateIntentRecord, -) -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_apply import ( - ExecutionControlApplyContext, - ExecutionControlApplyResult, - ExecutionControlBlockedRecord, - ExecutionControlDispatchableRecord, - ExecutionControlHandledRecord, -) -from tradingchassis_core.core.domain.execution_control_decision import ( - ExecutionControlDecision, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.execution_control import ExecutionControl -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _record(*, client_order_id: str) -> CandidateIntentRecord: - return CandidateIntentRecord( - intent=_new_intent(client_order_id=client_order_id), - origin=CandidateIntentOrigin.GENERATED, - logical_key=f"order:{client_order_id}", - merge_index=0, - priority=2, - ) - - -def test_execution_control_apply_context_is_immutable_reference_holder() -> None: - context = ExecutionControlApplyContext( - state=StrategyState(event_bus=NullEventBus()), - execution_control=ExecutionControl(), - now_ts_ns_local=1, - ) - with pytest.raises(FrozenInstanceError): - context.now_ts_ns_local = 2 - - -def test_execution_control_apply_record_models_are_immutable() -> None: - record = _record(client_order_id="cid-immutable") - - dispatchable = ExecutionControlDispatchableRecord(record=record) - blocked = ExecutionControlBlockedRecord(record=record, reason="rate_limit") - handled = ExecutionControlHandledRecord(record=record, reason="queue_local_handled") - - with pytest.raises(FrozenInstanceError): - dispatchable.record = record - with pytest.raises(FrozenInstanceError): - blocked.reason = "other" - with pytest.raises(FrozenInstanceError): - handled.reason = "other" - - -def test_execution_control_apply_result_defaults_and_tuple_normalization() -> None: - record = _record(client_order_id="cid-normalize") - obligation = ControlSchedulingObligation( - due_ts_ns_local=100, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - result = ExecutionControlApplyResult( - queued_effective_records=[record], - dispatchable_records=[ExecutionControlDispatchableRecord(record=record)], - execution_handled_records=[ - ExecutionControlHandledRecord(record=record, reason="queue_local_handled") - ], - blocked_records=[ - ExecutionControlBlockedRecord( - record=record, - reason="rate_limit", - scheduling_obligation=obligation, - ) - ], - execution_control_decision=ExecutionControlDecision(), - ) - - assert result.queued_effective_records == (record,) - assert len(result.dispatchable_records) == 1 - assert len(result.execution_handled_records) == 1 - assert len(result.blocked_records) == 1 - - -def test_execution_control_apply_models_are_non_canonical_and_boundary_rejects_them() -> None: - state = StrategyState(event_bus=NullEventBus()) - context = ExecutionControlApplyContext( - state=state, - execution_control=ExecutionControl(), - now_ts_ns_local=1, - ) - result = ExecutionControlApplyResult() - - assert is_canonical_stream_candidate_type(ExecutionControlApplyContext) is False - assert canonical_category_for_type(ExecutionControlApplyContext) is None - assert is_canonical_stream_candidate_type(ExecutionControlApplyResult) is False - assert canonical_category_for_type(ExecutionControlApplyResult) is None - assert is_canonical_stream_candidate_type(ExecutionControlDispatchableRecord) is False - assert canonical_category_for_type(ExecutionControlDispatchableRecord) is None - assert is_canonical_stream_candidate_type(ExecutionControlBlockedRecord) is False - assert canonical_category_for_type(ExecutionControlBlockedRecord) is None - assert is_canonical_stream_candidate_type(ExecutionControlHandledRecord) is False - assert canonical_category_for_type(ExecutionControlHandledRecord) is None - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, context) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, result) - - -def test_execution_control_apply_public_root_exports_identity() -> None: - assert hasattr(tc, "ExecutionControlApplyContext") - assert hasattr(tc, "ExecutionControlApplyResult") - assert hasattr(tc, "ExecutionControlDispatchableRecord") - assert hasattr(tc, "ExecutionControlBlockedRecord") - assert hasattr(tc, "ExecutionControlHandledRecord") diff --git a/tests/semantics/models/test_execution_control_decision_contract.py b/tests/semantics/models/test_execution_control_decision_contract.py deleted file mode 100644 index 77677b3..0000000 --- a/tests/semantics/models/test_execution_control_decision_contract.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Semantics tests for the ExecutionControlDecision scaffold contract model.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_decision import ( - ExecutionControlDecision, - map_compat_gate_decision_to_execution_control_decision, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def test_default_execution_control_decision_is_empty() -> None: - decision = ExecutionControlDecision() - assert decision.queued_effective_intents == () - assert decision.dispatchable_intents == () - assert decision.execution_handled_intents == () - assert decision.control_scheduling_obligation is None - - -def test_execution_control_decision_tuple_fields_normalize() -> None: - queued = _new_intent(client_order_id="queued") - dispatchable = _new_intent(client_order_id="dispatchable") - handled = _new_intent(client_order_id="handled") - decision = ExecutionControlDecision( - queued_effective_intents=[queued], - dispatchable_intents=[dispatchable], - execution_handled_intents=[handled], - ) - assert decision.queued_effective_intents == (queued,) - assert decision.dispatchable_intents == (dispatchable,) - assert decision.execution_handled_intents == (handled,) - - -def test_execution_control_decision_is_immutable() -> None: - decision = ExecutionControlDecision() - with pytest.raises(FrozenInstanceError): - decision.dispatchable_intents = () - - -def test_execution_control_decision_is_non_canonical_and_not_classified() -> None: - assert is_canonical_stream_candidate_type(ExecutionControlDecision) is False - assert canonical_category_for_type(ExecutionControlDecision) is None - - -def test_canonical_processing_boundary_rejects_execution_control_decision() -> None: - state = StrategyState(event_bus=NullEventBus()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, ExecutionControlDecision()) - - -def test_execution_control_decision_can_carry_obligation() -> None: - obligation = ControlSchedulingObligation( - due_ts_ns_local=1_000_000_000, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - decision = ExecutionControlDecision(control_scheduling_obligation=obligation) - assert decision.control_scheduling_obligation is obligation - - -def test_map_compat_gate_decision_to_execution_control_decision_projection() -> None: - dispatchable = _new_intent(client_order_id="dispatchable") - queued = _new_intent(client_order_id="queued") - handled = _new_intent(client_order_id="handled") - obligation = ControlSchedulingObligation( - due_ts_ns_local=123, - reason="rate_limit", - scope_key="instrument:BTC-USDC-PERP", - source="execution_control_rate_limit", - ) - gate = GateDecision( - ts_ns_local=123, - accepted_now=[dispatchable], - queued=[queued], - rejected=[], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[handled], - execution_rejected=[], - next_send_ts_ns_local=123, - control_scheduling_obligations=(obligation,), - ) - - decision = map_compat_gate_decision_to_execution_control_decision( - gate, - control_scheduling_obligation=obligation, - ) - - assert decision.queued_effective_intents == (queued,) - assert decision.dispatchable_intents == (dispatchable,) - assert decision.execution_handled_intents == (handled,) - assert decision.control_scheduling_obligation is obligation - - -def test_public_root_export_identity_when_root_exported() -> None: - assert hasattr(tc, "ExecutionControlDecision") - assert tc.ExecutionControlDecision is ExecutionControlDecision diff --git a/tests/semantics/models/test_execution_control_plan_contract.py b/tests/semantics/models/test_execution_control_plan_contract.py deleted file mode 100644 index e3e2def..0000000 --- a/tests/semantics/models/test_execution_control_plan_contract.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Semantics tests for execution-control candidate planning scaffolds.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -from tradingchassis_core.core.domain.candidate_intent import ( - CandidateIntentOrigin, - CandidateIntentRecord, -) -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.execution_control_plan import ( - ExecutionControlCandidateInput, - ExecutionControlPlan, - plan_execution_control_candidates, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _record(*, client_order_id: str, origin: CandidateIntentOrigin, merge_index: int) -> CandidateIntentRecord: - return CandidateIntentRecord( - intent=_new_intent(client_order_id=client_order_id), - origin=origin, - logical_key=f"order:{client_order_id}", - merge_index=merge_index, - priority=2, - ) - - -def test_execution_control_candidate_input_defaults_empty() -> None: - planning_input = ExecutionControlCandidateInput() - assert planning_input.accepted_generated == () - assert planning_input.passthrough_queued == () - - -def test_execution_control_candidate_input_normalizes_tuples_and_is_immutable() -> None: - generated = _record( - client_order_id="generated-1", - origin=CandidateIntentOrigin.GENERATED, - merge_index=0, - ) - queued = _record( - client_order_id="queued-1", - origin=CandidateIntentOrigin.QUEUED, - merge_index=1, - ) - planning_input = ExecutionControlCandidateInput( - accepted_generated=[generated], - passthrough_queued=[queued], - ) - assert planning_input.accepted_generated == (generated,) - assert planning_input.passthrough_queued == (queued,) - with pytest.raises(FrozenInstanceError): - planning_input.accepted_generated = () - - -def test_execution_control_plan_defaults_empty() -> None: - plan = ExecutionControlPlan() - assert plan.active_records == () - assert plan.queued_effective_records == () - assert plan.dispatchable_records == () - assert plan.execution_handled_records == () - assert plan.execution_control_decision.queued_effective_intents == () - assert plan.execution_control_decision.dispatchable_intents == () - assert plan.execution_control_decision.execution_handled_intents == () - assert plan.execution_control_decision.control_scheduling_obligation is None - - -def test_execution_control_plan_is_non_canonical_and_rejected_by_canonical_boundary() -> None: - assert is_canonical_stream_candidate_type(ExecutionControlCandidateInput) is False - assert canonical_category_for_type(ExecutionControlCandidateInput) is None - assert is_canonical_stream_candidate_type(ExecutionControlPlan) is False - assert canonical_category_for_type(ExecutionControlPlan) is None - - state = StrategyState(event_bus=NullEventBus()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, ExecutionControlCandidateInput()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, ExecutionControlPlan()) - - -def test_plan_execution_control_candidates_preserves_origin_and_order_capture_only() -> None: - accepted_generated_a = _record( - client_order_id="generated-a", - origin=CandidateIntentOrigin.GENERATED, - merge_index=10, - ) - accepted_generated_b = _record( - client_order_id="generated-b", - origin=CandidateIntentOrigin.GENERATED, - merge_index=11, - ) - passthrough_queued = _record( - client_order_id="queued-a", - origin=CandidateIntentOrigin.QUEUED, - merge_index=3, - ) - - planning_input = ExecutionControlCandidateInput( - accepted_generated=[accepted_generated_a, accepted_generated_b], - passthrough_queued=[passthrough_queued], - ) - plan = plan_execution_control_candidates(planning_input) - - assert plan.active_records == ( - accepted_generated_a, - accepted_generated_b, - passthrough_queued, - ) - assert tuple(record.origin for record in plan.active_records) == ( - CandidateIntentOrigin.GENERATED, - CandidateIntentOrigin.GENERATED, - CandidateIntentOrigin.QUEUED, - ) - assert plan.queued_effective_records == plan.active_records - assert plan.dispatchable_records == () - assert plan.execution_handled_records == () - assert tuple( - intent.client_order_id - for intent in plan.execution_control_decision.queued_effective_intents - ) == ("generated-a", "generated-b", "queued-a") - assert plan.execution_control_decision.dispatchable_intents == () - assert plan.execution_control_decision.execution_handled_intents == () - assert plan.execution_control_decision.control_scheduling_obligation is None - - -def test_plan_execution_control_candidates_does_not_mutate_input() -> None: - accepted_generated = _record( - client_order_id="generated-immutable", - origin=CandidateIntentOrigin.GENERATED, - merge_index=1, - ) - passthrough_queued = _record( - client_order_id="queued-immutable", - origin=CandidateIntentOrigin.QUEUED, - merge_index=2, - ) - planning_input = ExecutionControlCandidateInput( - accepted_generated=(accepted_generated,), - passthrough_queued=(passthrough_queued,), - ) - - _ = plan_execution_control_candidates(planning_input) - - assert planning_input.accepted_generated == (accepted_generated,) - assert planning_input.passthrough_queued == (passthrough_queued,) diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py deleted file mode 100644 index 2b3d212..0000000 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ /dev/null @@ -1,630 +0,0 @@ -"""Semantics tests for minimal deterministic fold/replay contract (Phase 2B.2).""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import fold_event_stream_entries -from tradingchassis_core.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - ControlTimeEvent, - FillEvent, - MarketEvent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_filled_qty: float, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_filled_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_filled_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_submitted_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _control_time_event( - *, - ts_ns_local_control: int, - due_ts_ns_local: int | None = None, - realized_ts_ns_local: int | None = None, -) -> ControlTimeEvent: - return ControlTimeEvent( - ts_ns_local_control=ts_ns_local_control, - reason="rate_limit_recheck", - due_ts_ns_local=due_ts_ns_local, - realized_ts_ns_local=realized_ts_ns_local, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=due_ts_ns_local, - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: - return { - "market": copy.deepcopy(state.market), - "fills": copy.deepcopy(state.fills), - "fill_cum_qty": copy.deepcopy(state.fill_cum_qty), - "processing_position": state._last_processing_position_index, - } - - -def _entry(position: int, event: object) -> EventStreamEntry: - return EventStreamEntry(position=ProcessingPosition(index=position), event=event) - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, - version: str = "v1", -) -> CoreConfiguration: - return CoreConfiguration( - version=version, - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def test_fold_same_entries_same_configuration_produces_equivalent_final_state() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - configuration = _market_configuration() - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration) - fold_event_stream_entries(right, entries, configuration=configuration) - - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_uses_single_explicit_configuration_input_with_stable_identity() -> None: - """Phase 2B guardrail: one fold call has one explicit CoreConfiguration input.""" - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - cfg_v1_left = _market_configuration(version="v1") - cfg_v1_right = _market_configuration(version="v1") - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=cfg_v1_left) - fold_event_stream_entries(right, entries, configuration=cfg_v1_right) - - assert cfg_v1_left.fingerprint == cfg_v1_right.fingerprint - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_same_prefix_produces_equivalent_prefix_state() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ), - ), - _entry( - 2, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=202, - ts_ns_exch=192, - cum_filled_qty=0.25, - ), - ), - ] - configuration = _market_configuration() - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries[:2], configuration=configuration) - fold_event_stream_entries(right, entries[:2], configuration=configuration) - - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) - - -def test_fold_repeated_or_regressing_processing_position_raises_deterministically() -> None: - repeated_state = StrategyState(event_bus=NullEventBus()) - repeated_entries = [ - _entry( - 10, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 10, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(repeated_state, repeated_entries, configuration=_market_configuration()) - - regressing_state = StrategyState(event_bus=NullEventBus()) - regressing_entries = [ - _entry( - 11, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 9, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=202, - ts_ns_exch=192, - cum_filled_qty=0.50, - ), - ), - ] - - with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(regressing_state, regressing_entries, configuration=_market_configuration()) - - -def test_fold_positioned_market_ordering_follows_processing_position_not_event_time() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 1, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 2, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ), - ), - ] - - fold_event_stream_entries(state, entries, configuration=_market_configuration()) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 120.0 - assert market.best_ask == 121.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - assert state._last_processing_position_index == 2 - - -def test_fold_interleaved_market_submitted_control_uses_single_global_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _order_submitted_event( - instrument="BTC-USDC-PERP", - client_order_id="order-submitted-1", - ts_ns_local_dispatch=205, - ), - ), - _entry( - 2, - _control_time_event( - ts_ns_local_control=206, - due_ts_ns_local=210, - ), - ), - ] - - fold_event_stream_entries(state, entries, configuration=_market_configuration()) - - assert state._last_processing_position_index == 2 - projection = state.canonical_orders[("BTC-USDC-PERP", "order-submitted-1")] - assert projection.state == "submitted" - - -def test_fold_fill_event_cumulative_idempotence_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 20, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=400, - ts_ns_exch=390, - cum_filled_qty=0.25, - ), - ), - _entry( - 21, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=401, - ts_ns_exch=391, - cum_filled_qty=0.25, - ), - ), - _entry( - 22, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=402, - ts_ns_exch=392, - cum_filled_qty=0.20, - ), - ), - ] - - fold_event_stream_entries(state, entries) - - assert len(state.fills["BTC-USDC-PERP"]) == 1 - assert state.fill_cum_qty["BTC-USDC-PERP"]["order-1"] == 0.25 - assert state._last_processing_position_index == 22 - - -def test_fold_rejects_non_canonical_entry_payload_via_existing_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 1, - _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ), - ) - ] - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - fold_event_stream_entries(state, entries) - - -def test_fold_returns_same_state_object_for_ergonomics() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ) - ] - - configuration = _market_configuration() - returned = fold_event_stream_entries(state, entries, configuration=configuration) - - assert returned is state - - -def test_fold_rejects_non_core_configuration() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ) - ] - - with pytest.raises(TypeError, match="configuration must be CoreConfiguration or None"): - fold_event_stream_entries(state, entries, configuration={"version": "v1"}) - - -def test_fold_different_market_configuration_values_produce_different_market_metadata() -> None: - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - configuration_a = _market_configuration( - tick_size=0.1, - lot_size=0.01, - contract_size=1.0, - version="v1", - ) - configuration_b = _market_configuration( - tick_size=0.5, - lot_size=0.05, - contract_size=2.0, - version="v2", - ) - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration_a) - fold_event_stream_entries(right, entries, configuration=configuration_b) - - assert configuration_a.fingerprint != configuration_b.fingerprint - assert _state_subset_snapshot(left) != _state_subset_snapshot(right) - left_market = left.market["BTC-USDC-PERP"] - right_market = right.market["BTC-USDC-PERP"] - assert (left_market.tick_size, left_market.lot_size, left_market.contract_size) == ( - 0.1, - 0.01, - 1.0, - ) - assert (right_market.tick_size, right_market.lot_size, right_market.contract_size) == ( - 0.5, - 0.05, - 2.0, - ) - - -def test_fold_configuration_identity_stays_stable_after_source_payload_mutation() -> None: - """Transitional guardrail: configuration identity remains stable during fold.""" - entries = [ - _entry( - 0, - _book_market_event( - instrument="BTC-USDC-PERP", - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ), - ), - _entry( - 1, - _fill_event( - instrument="BTC-USDC-PERP", - client_order_id="order-1", - ts_ns_local=201, - ts_ns_exch=191, - cum_filled_qty=0.25, - ), - ), - ] - source_payload = { - "market": { - "instruments": { - "BTC-USDC-PERP": { - "tick_size": 0.1, - "lot_size": 0.01, - "contract_size": 1.0, - } - } - } - } - configuration = CoreConfiguration(version="v1", payload=source_payload) - fingerprint_before = configuration.fingerprint - payload_before = configuration.payload - - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["tick_size"] = 0.5 - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["lot_size"] = 0.5 - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["contract_size"] = 5.0 - - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - - fold_event_stream_entries(left, entries, configuration=configuration) - fold_event_stream_entries(right, entries, configuration=configuration) - - source_payload["market"]["instruments"]["BTC-USDC-PERP"]["tick_size"] = 99.0 - - assert configuration.fingerprint == fingerprint_before - assert configuration.payload == payload_before - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) diff --git a/tests/semantics/models/test_import_compatibility_shim.py b/tests/semantics/models/test_import_compatibility_shim.py deleted file mode 100644 index 320e8bb..0000000 --- a/tests/semantics/models/test_import_compatibility_shim.py +++ /dev/null @@ -1,51 +0,0 @@ -from __future__ import annotations - -import warnings - - -def test_nested_modules_share_identity_across_import_sites() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - import tradingchassis_core.core.domain.processing as old_processing - import tradingchassis_core.core.domain.types as old_types - - import tradingchassis_core.core.domain.processing as new_processing - import tradingchassis_core.core.domain.types as new_types - - assert old_types is new_types - assert old_processing is new_processing - - -def test_symbols_share_identity_across_import_sites() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) - from tradingchassis_core.core.domain.configuration import ( - CoreConfiguration as OldCoreConfiguration, - ) - from tradingchassis_core.core.domain.types import ( - ControlTimeEvent as OldControlTimeEvent, - ) - from tradingchassis_core.core.domain.types import ( - MarketEvent as OldMarketEvent, - ) - from tradingchassis_core.core.domain.types import ( - OrderSubmittedEvent as OldOrderSubmittedEvent, - ) - - from tradingchassis_core.core.domain.configuration import ( - CoreConfiguration as NewCoreConfiguration, - ) - from tradingchassis_core.core.domain.types import ( - ControlTimeEvent as NewControlTimeEvent, - ) - from tradingchassis_core.core.domain.types import ( - MarketEvent as NewMarketEvent, - ) - from tradingchassis_core.core.domain.types import ( - OrderSubmittedEvent as NewOrderSubmittedEvent, - ) - - assert OldMarketEvent is NewMarketEvent - assert OldOrderSubmittedEvent is NewOrderSubmittedEvent - assert OldControlTimeEvent is NewControlTimeEvent - assert OldCoreConfiguration is NewCoreConfiguration diff --git a/tests/semantics/models/test_market_configuration_positioned_contract.py b/tests/semantics/models/test_market_configuration_positioned_contract.py deleted file mode 100644 index e7633dd..0000000 --- a/tests/semantics/models/test_market_configuration_positioned_contract.py +++ /dev/null @@ -1,398 +0,0 @@ -"""Semantics contract matrix for positioned MarketEvent configuration consumption. - -Phase 3A.3 treats this module as the primary guardrail reference for the -CoreConfiguration -> positioned canonical MarketEvent contract. -""" - -from __future__ import annotations - -import ast -import copy -from pathlib import Path - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - process_canonical_event, - 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.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _book_market_event( - *, - instrument: str = "BTC-USDC-PERP", - ts_ns_local: int = 100, - ts_ns_exch: int = 90, - best_bid: float = 100.0, - best_ask: float = 101.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": 2.0}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": 3.0}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event(*, instrument: str = "BTC-USDC-PERP", cum_qty: float = 0.25) -> FillEvent: - return FillEvent( - ts_ns_local=200, - ts_ns_exch=190, - instrument=instrument, - client_order_id="order-1", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event() -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def _market_configuration(*, instrument: str = "BTC-USDC-PERP", tick: object = 0.1, lot: object = 0.01, contract: object = 1.0) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick, - "lot_size": lot, - "contract_size": contract, - } - } - } - }, - ) - - -def _entry(position: int, event: object) -> EventStreamEntry: - return EventStreamEntry(position=ProcessingPosition(index=position), event=event) - - -def _market_and_cursor_snapshot(state: StrategyState) -> tuple[dict[str, object], int | None]: - return copy.deepcopy(state.market), state._last_processing_position_index - - -def test_fold_positioned_market_requires_configuration_when_none() -> None: - state = StrategyState(event_bus=NullEventBus()) - entries = [_entry(0, _book_market_event())] - - with pytest.raises( - ValueError, - match="CoreConfiguration is required for positioned canonical MarketEvent processing", - ): - fold_event_stream_entries(state, entries, configuration=None) - - -def test_process_event_entry_missing_market_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration(version="v1", payload={"not_market": {}}) - - with pytest.raises(ValueError, match="payload.market"): - process_event_entry(state, entry, configuration=cfg) - - -def test_process_event_entry_missing_instruments_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration(version="v1", payload={"market": {"not_instruments": {}}}) - - with pytest.raises(ValueError, match="payload.market.instruments"): - process_event_entry(state, entry, configuration=cfg) - - -def test_process_event_entry_missing_instrument_entry_raises() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event(instrument="BTC-USDC-PERP")) - cfg = _market_configuration(instrument="ETH-USDC-PERP") - - with pytest.raises(ValueError, match="payload.market.instruments.BTC-USDC-PERP"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize( - ("payload", "expected"), - [ - ({"lot_size": 0.01, "contract_size": 1.0}, "tick_size"), - ({"tick_size": 0.1, "contract_size": 1.0}, "lot_size"), - ({"tick_size": 0.1, "lot_size": 0.01}, "contract_size"), - ], -) -def test_process_event_entry_missing_required_field_raises( - payload: dict[str, object], - expected: str, -) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match=expected): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_none_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = None - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match=field_name): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_invalid_type_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = "invalid" - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(TypeError, match="must be numeric"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -def test_process_event_entry_bool_field_raises(field_name: str) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = True - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(TypeError, match="must be numeric"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("field_name", ["tick_size", "lot_size", "contract_size"]) -@pytest.mark.parametrize("value", [0.0, -1.0]) -def test_process_event_entry_non_positive_field_raises(field_name: str, value: float) -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _book_market_event()) - payload = {"tick_size": 0.1, "lot_size": 0.01, "contract_size": 1.0} - payload[field_name] = value - cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": payload}}}, - ) - - with pytest.raises(ValueError, match="must be > 0"): - process_event_entry(state, entry, configuration=cfg) - - -@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) -def test_non_finite_market_metadata_rejected_by_core_configuration_validation(bad: float) -> None: - with pytest.raises(ValueError, match="non-finite float"): - _market_configuration(tick=bad) - - -def test_positioned_market_failure_does_not_mutate_market_or_cursor() -> None: - state = StrategyState(event_bus=NullEventBus()) - seed_entry = _entry(1, _book_market_event(ts_ns_local=100, ts_ns_exch=90)) - bad_entry = _entry(2, _book_market_event(ts_ns_local=101, ts_ns_exch=91)) - good_cfg = _market_configuration() - bad_cfg = CoreConfiguration( - version="v1", - payload={"market": {"instruments": {"BTC-USDC-PERP": {"tick_size": 0.1}}}}, - ) - - process_event_entry(state, seed_entry, configuration=good_cfg) - before_market, before_cursor = _market_and_cursor_snapshot(state) - - with pytest.raises(ValueError): - process_event_entry(state, bad_entry, configuration=bad_cfg) - - after_market, after_cursor = _market_and_cursor_snapshot(state) - assert after_market == before_market - assert after_cursor == before_cursor - - -def test_same_positioned_market_stream_semantically_equivalent_configuration_equivalent_state() -> None: - left = StrategyState(event_bus=NullEventBus()) - right = StrategyState(event_bus=NullEventBus()) - entries = [ - _entry(0, _book_market_event(ts_ns_local=100, ts_ns_exch=90)), - _entry(1, _book_market_event(ts_ns_local=101, ts_ns_exch=91, best_bid=102.0, best_ask=103.0)), - ] - cfg_left = _market_configuration(tick=0.1, lot=0.01, contract=1) - cfg_right = _market_configuration(tick=0.1, lot=0.01, contract=1.0) - - fold_event_stream_entries(left, entries, configuration=cfg_left) - fold_event_stream_entries(right, entries, configuration=cfg_right) - - assert left.market == right.market - - -def test_direct_update_market_compatibility_path_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - - state.update_market( - instrument="BTC-USDC-PERP", - best_bid=100.0, - best_ask=101.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=200, - ts_ns_exch=190, - ) - state.update_market( - instrument="BTC-USDC-PERP", - best_bid=120.0, - best_ask=121.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=100, - ts_ns_exch=95, - ) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_unpositioned_market_compatibility_path_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _book_market_event(ts_ns_local=200, ts_ns_exch=190, best_bid=100.0, best_ask=101.0) - second = _book_market_event(ts_ns_local=100, ts_ns_exch=95, best_bid=120.0, best_ask=121.0) - cfg = _market_configuration(tick=0.5, lot=0.5, contract=5.0) - - process_canonical_event(state, first, position=None, configuration=cfg) - process_canonical_event(state, second, position=None, configuration=cfg) - - market = state.market["BTC-USDC-PERP"] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.tick_size == 0.0 - assert market.lot_size == 0.0 - assert market.contract_size == 1.0 - - -def test_fill_event_behavior_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - first = _entry(10, _fill_event(cum_qty=0.25)) - duplicate = _entry(11, _fill_event(cum_qty=0.25)) - regressing = _entry(12, _fill_event(cum_qty=0.20)) - - process_event_entry(state, first, configuration=None) - fills_before = copy.deepcopy(state.fills) - cum_before = copy.deepcopy(state.fill_cum_qty) - process_event_entry(state, duplicate, configuration=None) - process_event_entry(state, regressing, configuration=None) - - assert state.fills == fills_before - assert state.fill_cum_qty == cum_before - assert state._last_processing_position_index == 12 - - -def test_order_state_event_remains_compatibility_only() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = _entry(0, _order_state_event()) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_event_entry(state, entry, configuration=_market_configuration()) - - -def test_positioned_market_contract_does_not_import_runtime_configuration_mapping() -> None: - """Guardrail: canonical market reducer contract stays CoreConfiguration-only.""" - repo_root = Path(__file__).resolve().parents[3] - processing_path = repo_root / "tradingchassis_core/core/domain/processing.py" - tree = ast.parse(processing_path.read_text(encoding="utf-8"), filename=str(processing_path)) - - forbidden_modules = ( - "core_runtime", - "trading_runtime", - "backtest_engine_config", - "live_engine_config", - ) - forbidden_symbols = { - "BacktestEngineConfig", - "LiveEngineConfig", - "RiskConfig", - } - - for node in ast.walk(tree): - if isinstance(node, ast.Import): - for alias in node.names: - assert not alias.name.startswith(forbidden_modules) - assert alias.name not in forbidden_symbols - if isinstance(node, ast.ImportFrom): - if node.module is not None: - assert not node.module.startswith(forbidden_modules) - for alias in node.names: - assert alias.name not in forbidden_symbols diff --git a/tests/semantics/models/test_market_reducer_positioned_target.py b/tests/semantics/models/test_market_reducer_positioned_target.py deleted file mode 100644 index 94d2758..0000000 --- a/tests/semantics/models/test_market_reducer_positioned_target.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Target tests for positioned MarketEvent reducer-ordering migration (Phase 2 / Slice 2A.3A). - -This file intentionally includes docs-aligned target tests that are expected-red -until the production market reducer migrates from timestamp-compatibility -ordering to ProcessingPosition-driven causal ordering for positioned canonical -MarketEvents. -""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.processing_order import ProcessingPosition -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _market_configuration( - *, - instrument: str = "BTC-USDC-PERP", - tick_size: float = 0.1, - lot_size: float = 0.01, - contract_size: float = 1.0, -) -> CoreConfiguration: - return CoreConfiguration( - version="v1", - payload={ - "market": { - "instruments": { - instrument: { - "tick_size": tick_size, - "lot_size": lot_size, - "contract_size": contract_size, - } - } - } - }, - ) - - -def _book_market_event( - *, - instrument: str, - ts_ns_local: int, - ts_ns_exch: int, - best_bid: float, - best_ask: float, - best_bid_qty: float = 2.0, - best_ask_qty: float = 3.0, -) -> MarketEvent: - return MarketEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - event_type="book", - book={ - "book_type": "snapshot", - "bids": [ - { - "price": {"currency": "USDC", "value": best_bid}, - "quantity": {"unit": "contracts", "value": best_bid_qty}, - } - ], - "asks": [ - { - "price": {"currency": "USDC", "value": best_ask}, - "quantity": {"unit": "contracts", "value": best_ask_qty}, - } - ], - "depth": 1, - }, - trade=None, - ) - - -def _fill_event( - *, - instrument: str, - client_order_id: str, - ts_ns_local: int, - ts_ns_exch: int, - cum_qty: float, -) -> FillEvent: - return FillEvent( - ts_ns_local=ts_ns_local, - ts_ns_exch=ts_ns_exch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=Price(currency="USDC", value=100.5), - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=Quantity(unit="contracts", value=cum_qty), - remaining_qty=Quantity(unit="contracts", value=max(0.0, 1.0 - cum_qty)), - time_in_force="GTC", - liquidity_flag="maker", - fee=None, - ) - - -def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_local=300, - ts_ns_exch=290, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="accepted", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - - -def test_target_positioned_market_lower_local_timestamp_still_advances_state() -> None: - """TARGET (expected-red pre-migration): positioned MarketEvent follows ProcessingPosition causality.""" - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - first = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ) - older_local_second = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ) - - configuration = _market_configuration(instrument=instrument) - process_canonical_event( - state, - first, - position=ProcessingPosition(index=1), - configuration=configuration, - ) - process_canonical_event( - state, - older_local_second, - position=ProcessingPosition(index=2), - configuration=configuration, - ) - - market = state.market[instrument] - assert state._last_processing_position_index == 2 - # Docs-aligned target: positioned acceptance implies reducer advancement. - assert market.best_bid == 120.0 - assert market.best_ask == 121.0 - assert market.last_ts_ns_local == 100 - assert market.last_ts_ns_exch == 95 - - -def test_target_positioned_market_lower_exchange_timestamp_still_advances_state() -> None: - """TARGET (expected-red pre-migration): exchange-time tie-break must not gate positioned events.""" - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - base = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=200, - best_bid=100.0, - best_ask=101.0, - ) - lower_exchange_second = _book_market_event( - instrument=instrument, - ts_ns_local=300, - ts_ns_exch=199, - best_bid=80.0, - best_ask=81.0, - ) - - configuration = _market_configuration(instrument=instrument) - process_canonical_event( - state, - base, - position=ProcessingPosition(index=10), - configuration=configuration, - ) - process_canonical_event( - state, - lower_exchange_second, - position=ProcessingPosition(index=11), - configuration=configuration, - ) - - market = state.market[instrument] - assert state._last_processing_position_index == 11 - # Docs-aligned target: ProcessingPosition is causal; event-time fields are metadata. - assert market.best_bid == 80.0 - assert market.best_ask == 81.0 - assert market.last_ts_ns_local == 300 - assert market.last_ts_ns_exch == 199 - - -def test_migration_guard_unpositioned_canonical_market_keeps_timestamp_compatibility_behavior() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - first = _book_market_event( - instrument=instrument, - ts_ns_local=200, - ts_ns_exch=190, - best_bid=100.0, - best_ask=101.0, - ) - second = _book_market_event( - instrument=instrument, - ts_ns_local=100, - ts_ns_exch=95, - best_bid=120.0, - best_ask=121.0, - ) - - process_canonical_event(state, first, position=None) - process_canonical_event(state, second, position=None) - - market = state.market[instrument] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_migration_guard_direct_update_market_keeps_timestamp_compatibility_behavior() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - state.update_market( - instrument=instrument, - best_bid=100.0, - best_ask=101.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=200, - ts_ns_exch=190, - ) - state.update_market( - instrument=instrument, - best_bid=120.0, - best_ask=121.0, - best_bid_qty=2.0, - best_ask_qty=3.0, - tick_size=0.0, - lot_size=0.0, - contract_size=1.0, - ts_ns_local=100, - ts_ns_exch=95, - ) - - market = state.market[instrument] - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 - - -def test_migration_guard_fill_event_cumulative_idempotence_remains_unchanged() -> None: - state = StrategyState(event_bus=NullEventBus()) - instrument = "BTC-USDC-PERP" - order_id = "order-1" - - first = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=400, - ts_ns_exch=390, - cum_qty=0.25, - ) - duplicate = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=401, - ts_ns_exch=391, - cum_qty=0.25, - ) - regressing = _fill_event( - instrument=instrument, - client_order_id=order_id, - ts_ns_local=402, - ts_ns_exch=392, - cum_qty=0.20, - ) - - process_canonical_event(state, first, position=ProcessingPosition(index=20)) - fills_before = copy.deepcopy(state.fills) - fill_cum_before = copy.deepcopy(state.fill_cum_qty) - - process_canonical_event(state, duplicate, position=ProcessingPosition(index=21)) - process_canonical_event(state, regressing, position=ProcessingPosition(index=22)) - - assert state.fills == fills_before - assert state.fill_cum_qty == fill_cum_before - assert len(state.fills[instrument]) == 1 - assert state.fill_cum_qty[instrument][order_id] == 0.25 - - -def test_migration_guard_order_state_event_remains_rejected_by_canonical_boundary() -> None: - state = StrategyState(event_bus=NullEventBus()) - compat_event = _order_state_event( - instrument="BTC-USDC-PERP", - client_order_id="order-compat-1", - ) - - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, compat_event, position=ProcessingPosition(index=1)) diff --git a/tests/semantics/models/test_models_against_schemas.py b/tests/semantics/models/test_models_against_schemas.py deleted file mode 100644 index 6a44694..0000000 --- a/tests/semantics/models/test_models_against_schemas.py +++ /dev/null @@ -1,580 +0,0 @@ -"""Schema conformance tests for core Pydantic models. - -This test suite validates that core Pydantic models both accept valid inputs -and reject invalid ones in strict alignment with their corresponding JSON -Schemas. The tests are intentionally verbose and repetitive to ensure full -coverage and explicit failure modes. -""" - -# pylint: disable=line-too-long,missing-function-docstring -# pylint: disable=redefined-outer-name,global-statement -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any, TypeVar - -import pytest -from jsonschema import ValidationError as JsonSchemaValidationError -from jsonschema import validate as jsonschema_validate -from pydantic import TypeAdapter -from pydantic import ValidationError as PydanticValidationError -from referencing import Registry, Resource -from referencing.jsonschema import DRAFT202012 - -from tradingchassis_core.core.domain.types import ( - FillEvent, - MarketEvent, - OrderIntent, - OrderStateEvent, - RiskConstraints, -) - -SCHEMA_REGISTRY = Registry() - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def load_schema(name: str) -> dict: - """ - Load JSON schema from project root. - """ - global SCHEMA_REGISTRY - - root = Path(__file__).parent.parent.parent.parent - name = "tradingchassis_core/core/schemas/" + name - schema_path = root / name - - with schema_path.open("r", encoding="utf-8") as f: - schema = json.load(f) - - schema_id = schema.get("$id") - if isinstance(schema_id, str) and schema_id: - resource = Resource.from_contents(schema, default_specification=DRAFT202012) - SCHEMA_REGISTRY = SCHEMA_REGISTRY.with_resource(schema_id, resource) - - return schema - - -def dump_for_jsonschema(model: Any) -> dict: - """ - Dump a Pydantic model to a JSON-compatible dict for schema validation. - Excludes None values so optional fields are omitted instead of null. - """ - # All validated objects in this test suite are BaseModel instances. - return model.model_dump(mode="json", exclude_none=True) - - -T = TypeVar("T") - - -def pydantic_validate(model_type: Any, data: dict[str, Any]) -> Any: - """ - Validate input using Pydantic for both: - - BaseModel subclasses (e.g., MarketEvent) - - Discriminated unions (e.g., OrderIntent is an Annotated Union) - """ - adapter = TypeAdapter(model_type) - return adapter.validate_python(data) - - -def assert_pydantic_then_schema_ok(model_type: Any, data: dict[str, Any], schema: dict[str, Any]) -> dict: - """ - Validate with Pydantic first, then validate the dumped instance with JSON Schema. - Returns the dumped instance. - """ - obj = pydantic_validate(model_type, data) - instance = dump_for_jsonschema(obj) - jsonschema_validate(instance=instance, schema=schema, registry=SCHEMA_REGISTRY) - return instance - - -def assert_schema_invalid_but_pydantic_rejects(model_type: Any, data: dict[str, Any], schema: dict[str, Any]): - """ - Ensures Pydantic is at least as strict as the JSON Schema for the given input. - If schema rejects, Pydantic must reject too (otherwise model is too lax). - """ - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=schema, registry=SCHEMA_REGISTRY) - - with pytest.raises(PydanticValidationError): - pydantic_validate(model_type, data) - - -def mk_price(value: float, currency: str = "USD") -> dict[str, Any]: - return {"currency": currency, "value": value} - - -def mk_qty(value: float, unit: str = "contracts") -> dict[str, Any]: - return {"value": value, "unit": unit} - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest.fixture(scope="module", autouse=True) -def _load_common_schema() -> None: - load_schema("common.schema.json") - - -@pytest.fixture(scope="module") -def market_event_schema() -> dict: - return load_schema("market_event.schema.json") - - -@pytest.fixture(scope="module") -def order_intent_schema() -> dict: - return load_schema("order_intent.schema.json") - - -@pytest.fixture(scope="module") -def risk_constraints_schema() -> dict: - return load_schema("risk_constraints.schema.json") - - -@pytest.fixture(scope="module") -def fill_event_schema() -> dict: - return load_schema("fill_event.schema.json") - - -@pytest.fixture(scope="module") -def order_state_event_schema() -> dict: - return load_schema("order_state_event.schema.json") - - -# --------------------------------------------------------------------------- -# MarketEvent -# --------------------------------------------------------------------------- - -def make_book_event(**book_overrides) -> dict[str, Any]: - book = { - "book_type": "snapshot", - "bids": [{"price": mk_price(100.0), "quantity": mk_qty(1.0)}], - "asks": [{"price": mk_price(100.5), "quantity": mk_qty(1.5)}], - } - book.update(book_overrides) - return { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "event_type": "book", - "book": book, - } - - -def make_trade_event(**trade_overrides) -> dict[str, Any]: - trade = { - "side": "buy", - "price": mk_price(100.25), - "quantity": mk_qty(0.5), - } - trade.update(trade_overrides) - return { - "ts_ns_local": 123456790, - "ts_ns_exch": 123456789, - "instrument": "BTC-USD", - "event_type": "trade", - "trade": trade, - } - - -def test_market_event_book_valid_minimal(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(), market_event_schema) - - -def test_market_event_trade_valid_minimal(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_trade_event(trade_id="T123"), market_event_schema) - - -def test_market_event_enforces_payload_presence(): - with pytest.raises(PydanticValidationError): - pydantic_validate( - MarketEvent, - {"ts_ns_local": 2, "ts_ns_exch": 1, "instrument": "BTC-USD", "event_type": "book"}, - ) - with pytest.raises(PydanticValidationError): - pydantic_validate( - MarketEvent, - {"ts_ns_local": 2, "ts_ns_exch": 1, "instrument": "BTC-USD", "event_type": "trade"}, - ) - - -def test_market_event_rejects_extra_top_level_fields(market_event_schema): - data = make_book_event() - data["unexpected"] = 123 - - with pytest.raises(PydanticValidationError): - pydantic_validate(MarketEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=market_event_schema, registry=SCHEMA_REGISTRY) - - -def test_market_event_book_depth_optional_valid_values(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(depth=0), market_event_schema) - assert_pydantic_then_schema_ok(MarketEvent, make_book_event(depth=1), market_event_schema) - - -def test_market_event_book_depth_negative_rejected(market_event_schema): - bad = make_book_event(depth=-1) - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_trade_id_min_length(market_event_schema): - assert_pydantic_then_schema_ok(MarketEvent, make_trade_event(trade_id="X"), market_event_schema) - - bad = make_trade_event(trade_id="") - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_instrument_min_length(market_event_schema): - bad = make_book_event() - bad["instrument"] = "" - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_ts_ns_exclusive_minimum(market_event_schema): - bad = make_book_event() - bad["ts_ns_local"] = 0 - assert_schema_invalid_but_pydantic_rejects(MarketEvent, bad, market_event_schema) - - -def test_market_event_xor_book_trade_enforced(market_event_schema): - # Schema forbids having both book and trade. - bad = make_book_event() - bad["trade"] = make_trade_event()["trade"] - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=bad, schema=market_event_schema, registry=SCHEMA_REGISTRY) - - with pytest.raises(PydanticValidationError): - pydantic_validate(MarketEvent, bad) - - -# --------------------------------------------------------------------------- -# OrderIntent -# --------------------------------------------------------------------------- - -def make_new_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'new' intent (limit by default). - Note: intended_price is required for both limit and market orders in this system. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456790, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "new", - "order_type": "limit", - "side": "buy", - "intended_qty": mk_qty(1.0), - "intended_price": mk_price(100.0), - "time_in_force": "GTC", - } - data.update(overrides) - return data - - -def make_cancel_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'cancel' intent. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456791, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "cancel", - } - data.update(overrides) - return data - - -def make_replace_intent(**overrides) -> dict[str, Any]: - """ - Build a valid minimal 'replace' intent (limit-only). - time_in_force is not allowed because it is not modifiable by the execution binding. - """ - data: dict[str, Any] = { - "ts_ns_local": 123456792, - "client_order_id": "C-1", - "instrument": "BTC-USD", - "intent_type": "replace", - "side": "buy", - "order_type": "limit", - "intended_qty": mk_qty(2.0), - "intended_price": mk_price(101.0), - } - data.update(overrides) - return data - - -def test_order_intent_valid_new_limit_with_optional_correlation_id(order_intent_schema): - data = make_new_intent(intents_correlation_id="CORR-1") - assert_pydantic_then_schema_ok(OrderIntent, data, order_intent_schema) - - -def test_order_intent_new_market_requires_intended_price(order_intent_schema): - data = make_new_intent(order_type="market") - assert_pydantic_then_schema_ok(OrderIntent, data, order_intent_schema) - - bad = make_new_intent(order_type="market") - bad.pop("intended_price", None) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_new_limit_requires_intended_price(order_intent_schema): - bad = make_new_intent(order_type="limit") - bad.pop("intended_price", None) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_cancel_valid_minimal(order_intent_schema): - assert_pydantic_then_schema_ok(OrderIntent, make_cancel_intent(), order_intent_schema) - - -def test_order_intent_cancel_forbids_order_fields(order_intent_schema): - # Cancel must not contain order-creation fields. - bad = make_cancel_intent(side="buy") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(order_type="limit") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(intended_qty=1.0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(intended_price=100.0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - bad = make_cancel_intent(time_in_force="GTC") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_replace_valid_minimal(order_intent_schema): - assert_pydantic_then_schema_ok(OrderIntent, make_replace_intent(), order_intent_schema) - - -def test_order_intent_replace_requires_limit(order_intent_schema): - bad = make_replace_intent(order_type="market") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_replace_forbids_time_in_force(order_intent_schema): - # Replace must not contain time_in_force (not modifiable). - bad = make_replace_intent(time_in_force="GTC") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad, order_intent_schema) - - -def test_order_intent_min_length_optionals(order_intent_schema): - bad_corr = make_new_intent(intents_correlation_id="") - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_corr, order_intent_schema) - - -def test_order_intent_exclusive_minimum_constraints(order_intent_schema): - bad_ts = make_new_intent(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_ts, order_intent_schema) - - bad_price = make_new_intent(intended_price=mk_price(-1.0)) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_price, order_intent_schema) - - bad_qty = make_new_intent(intended_qty=mk_qty(-1.0)) - assert_schema_invalid_but_pydantic_rejects(OrderIntent, bad_qty, order_intent_schema) - - -def test_order_intent_rejects_additional_properties(order_intent_schema): - data = make_new_intent() - data["unexpected"] = "x" - - with pytest.raises(PydanticValidationError): - pydantic_validate(OrderIntent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=order_intent_schema, registry=SCHEMA_REGISTRY) - - -# --------------------------------------------------------------------------- -# RiskConstraints -# --------------------------------------------------------------------------- - -def make_risk_constraints(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 987654321, - "scope": "BTC-USD:default", - "trading_enabled": True, - } - data.update(overrides) - return data - - -def test_risk_constraints_valid_with_optionals(risk_constraints_schema): - data = make_risk_constraints( - notional_limits={ - "currency": "USD", - "max_gross_notional": 1_000_000.0, - "max_single_order_notional": 100_000.0, - }, - position_limits={"currency": "BTC", "max_position": 10.0}, - quote_limits={ - "currency": "USD", - "max_gross_quote_notional": 500_000.0, - "max_net_quote_notional": 0.0, - "max_active_quotes": 100, - }, - order_rate_limits={"max_orders_per_second": 50.0, "max_cancels_per_second": 100.0}, - max_loss={"currency": "USD", "max_drawdown": -10_000.0, "rolling_loss": -1000.0, "rolling_loss_window": 60}, - extra={"desk": "alpha", "risk_mode": "conservative", "debug": None}, - ) - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - -def test_risk_constraints_missing_optional_notional_limits_is_ok(risk_constraints_schema): - data = make_risk_constraints() - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - -def test_risk_constraints_minimum_constraints(risk_constraints_schema): - bad_pos = make_risk_constraints(position_limits={"currency": "BTC", "max_position": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_pos, risk_constraints_schema) - - bad_notional = make_risk_constraints(notional_limits={"currency": "USD", "max_gross_notional": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_notional, risk_constraints_schema) - - bad_quotes = make_risk_constraints(quote_limits={"currency": "USD", "max_gross_quote_notional": -1.0}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_quotes, risk_constraints_schema) - - bad_active = make_risk_constraints(quote_limits={"currency": "USD", "max_gross_quote_notional": 1.0, "max_active_quotes": -1}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_active, risk_constraints_schema) - - bad_rate = make_risk_constraints(order_rate_limits={"max_orders_per_second": -0.1}) - assert_schema_invalid_but_pydantic_rejects(RiskConstraints, bad_rate, risk_constraints_schema) - - -def test_risk_constraints_rejects_additional_properties(risk_constraints_schema): - data = make_risk_constraints() - data["unexpected"] = 1 - - with pytest.raises(PydanticValidationError): - pydantic_validate(RiskConstraints, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=risk_constraints_schema, registry=SCHEMA_REGISTRY) - - -def test_risk_constraints_extra_values_are_schema_compatible(risk_constraints_schema): - data = make_risk_constraints(extra={"ok": "x", "n": 1, "b": True, "z": None}) - assert_pydantic_then_schema_ok(RiskConstraints, data, risk_constraints_schema) - - bad = make_risk_constraints(extra={"nested": {"a": 1}}) - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=bad, schema=risk_constraints_schema, registry=SCHEMA_REGISTRY) - # Pydantic will reject too because extra is typed as primitive union. - - -# --------------------------------------------------------------------------- -# FillEvent -# --------------------------------------------------------------------------- - -def make_fill(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "client_order_id": "C-1", - "side": "buy", - "filled_price": mk_price(100.5), - "cum_filled_qty": mk_qty(0.25), - "time_in_force": "GTC", - "liquidity_flag": "maker", - } - data.update(overrides) - return data - - -def test_fill_event_valid_with_all_optionals(fill_event_schema): - data = make_fill( - intended_price=mk_price(100.0), - intended_qty=mk_qty(1.0), - remaining_qty=mk_qty(0.75), - fee={"currency": "USD", "amount": -0.1}, - ) - assert_pydantic_then_schema_ok(FillEvent, data, fill_event_schema) - - -def test_fill_event_exclusive_minimum_constraints(fill_event_schema): - bad_ts = make_fill(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(FillEvent, bad_ts, fill_event_schema) - - -def test_fill_event_min_length_optionals(fill_event_schema): - bad_order_id = make_fill(client_order_id="") - assert_schema_invalid_but_pydantic_rejects(FillEvent, bad_order_id, fill_event_schema) - - -def test_fill_event_rejects_additional_properties(fill_event_schema): - data = make_fill() - data["unexpected"] = "x" - - with pytest.raises(PydanticValidationError): - pydantic_validate(FillEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=fill_event_schema, registry=SCHEMA_REGISTRY) - - -# --------------------------------------------------------------------------- -# OrderStateEvent -# --------------------------------------------------------------------------- - -def make_state(**overrides) -> dict[str, Any]: - data = { - "ts_ns_local": 123456789, - "ts_ns_exch": 123456089, - "instrument": "BTC-USD", - "client_order_id": "C-1", - "order_type": "limit", - "state_type": "accepted", - "side": "buy", - "intended_price": mk_price(100.0), - "intended_qty": mk_qty(1.0), - "time_in_force": "GTC", - } - data.update(overrides) - return data - - -def test_order_state_event_valid_with_all_optionals(order_state_event_schema): - data = make_state( - filled_price=mk_price(100.1), - cum_filled_qty=mk_qty(0.5), - remaining_qty=mk_qty(0.5), - reason="Partial fill", - raw={"venue_status": "PARTIAL"}, - ) - assert_pydantic_then_schema_ok(OrderStateEvent, data, order_state_event_schema) - - -def test_order_state_event_min_length_optionals(order_state_event_schema): - bad_order_id = make_state(client_order_id="") - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad_order_id, order_state_event_schema) - - bad_reason = make_state(reason="") - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad_reason, order_state_event_schema) - - -def test_order_state_event_exclusive_minimum_ts(order_state_event_schema): - bad = make_state(ts_ns_local=0) - assert_schema_invalid_but_pydantic_rejects(OrderStateEvent, bad, order_state_event_schema) - - -def test_order_state_event_rejects_additional_properties(order_state_event_schema): - data = make_state() - data["unexpected"] = 1 - - with pytest.raises(PydanticValidationError): - pydantic_validate(OrderStateEvent, data) - - with pytest.raises(JsonSchemaValidationError): - jsonschema_validate(instance=data, schema=order_state_event_schema, registry=SCHEMA_REGISTRY) diff --git a/tests/semantics/models/test_policy_risk_decision_contract.py b/tests/semantics/models/test_policy_risk_decision_contract.py deleted file mode 100644 index b5c4c61..0000000 --- a/tests/semantics/models/test_policy_risk_decision_contract.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Semantics tests for the PolicyRiskDecision scaffold contract model.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.candidate_intent import ( - CandidateIntentOrigin, - CandidateIntentRecord, -) -from tradingchassis_core.core.domain.event_model import ( - canonical_category_for_type, - is_canonical_stream_candidate_type, -) -from tradingchassis_core.core.domain.policy_risk_decision import ( - PolicyAdmissionResult, - PolicyRejectedCandidate, - PolicyRiskDecision, - apply_policy_to_candidate_records, - map_compat_gate_decision_to_policy_risk_decision, -) -from tradingchassis_core.core.domain.processing import process_canonical_event -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import NewOrderIntent, Price, Quantity -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_engine import GateDecision, RejectedIntent - - -def _new_intent(*, client_order_id: str) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=1.0, unit="contracts"), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def test_default_policy_risk_decision_is_empty() -> None: - decision = PolicyRiskDecision() - assert decision.accepted_intents == () - assert decision.rejected_intents == () - - -def test_policy_risk_decision_tuple_fields_normalize() -> None: - accepted = _new_intent(client_order_id="accepted") - rejected = _new_intent(client_order_id="rejected") - decision = PolicyRiskDecision( - accepted_intents=[accepted], - rejected_intents=[rejected], - ) - assert decision.accepted_intents == (accepted,) - assert decision.rejected_intents == (rejected,) - - -def test_policy_risk_decision_is_immutable() -> None: - decision = PolicyRiskDecision() - with pytest.raises(FrozenInstanceError): - decision.accepted_intents = () - - -def test_policy_risk_decision_is_non_canonical_and_not_classified() -> None: - assert is_canonical_stream_candidate_type(PolicyRiskDecision) is False - assert canonical_category_for_type(PolicyRiskDecision) is None - - -def test_canonical_processing_boundary_rejects_policy_risk_decision() -> None: - state = StrategyState(event_bus=NullEventBus()) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, PolicyRiskDecision()) - - -def test_map_compat_gate_decision_to_policy_risk_decision_projection() -> None: - accepted_now = _new_intent(client_order_id="accepted-now") - queued = _new_intent(client_order_id="queued") - rejected = _new_intent(client_order_id="rejected") - decision = GateDecision( - ts_ns_local=123, - accepted_now=[accepted_now], - queued=[queued], - rejected=[RejectedIntent(intent=rejected, reason="policy_reject")], - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - policy = map_compat_gate_decision_to_policy_risk_decision(decision) - - assert policy.accepted_intents == (accepted_now,) - assert policy.rejected_intents == (rejected,) - - -def test_public_root_export_identity_when_root_exported() -> None: - assert hasattr(tc, "PolicyRiskDecision") - assert tc.PolicyRiskDecision is PolicyRiskDecision - - -def test_policy_rejected_candidate_is_immutable() -> None: - record = CandidateIntentRecord( - intent=_new_intent(client_order_id="generated-rejected"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:generated-rejected", - merge_index=0, - priority=2, - ) - rejected = PolicyRejectedCandidate(record=record, reason="policy_reject") - with pytest.raises(FrozenInstanceError): - rejected.reason = "changed" - - -def test_policy_admission_result_defaults_are_empty() -> None: - result = PolicyAdmissionResult() - assert result.accepted_generated == () - assert result.rejected_generated == () - assert result.passthrough_queued == () - assert result.policy_risk_decision == PolicyRiskDecision() - - -def test_policy_admission_result_tuple_normalization() -> None: - generated_record = CandidateIntentRecord( - intent=_new_intent(client_order_id="generated-accepted"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:generated-accepted", - merge_index=1, - priority=2, - ) - queued_record = CandidateIntentRecord( - intent=_new_intent(client_order_id="queued-passthrough"), - origin=CandidateIntentOrigin.QUEUED, - logical_key="order:queued-passthrough", - merge_index=2, - priority=2, - ) - rejected = PolicyRejectedCandidate( - record=generated_record, - reason="policy_reject", - ) - result = PolicyAdmissionResult( - accepted_generated=[generated_record], - rejected_generated=[rejected], - passthrough_queued=[queued_record], - ) - assert result.accepted_generated == (generated_record,) - assert result.rejected_generated == (rejected,) - assert result.passthrough_queued == (queued_record,) - - -def test_policy_admission_result_and_related_models_are_non_canonical() -> None: - assert is_canonical_stream_candidate_type(PolicyRejectedCandidate) is False - assert canonical_category_for_type(PolicyRejectedCandidate) is None - assert is_canonical_stream_candidate_type(PolicyAdmissionResult) is False - assert canonical_category_for_type(PolicyAdmissionResult) is None - - -def test_canonical_processing_boundary_rejects_policy_admission_models() -> None: - state = StrategyState(event_bus=NullEventBus()) - record = CandidateIntentRecord( - intent=_new_intent(client_order_id="boundary-record"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:boundary-record", - merge_index=0, - priority=2, - ) - rejected = PolicyRejectedCandidate(record=record, reason="policy_reject") - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, rejected) - with pytest.raises(TypeError, match="Unsupported non-canonical event type"): - process_canonical_event(state, PolicyAdmissionResult()) - - -def test_apply_policy_to_candidate_records_partitions_generated_and_queued() -> None: - accepted_generated = CandidateIntentRecord( - intent=_new_intent(client_order_id="accepted-generated"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:accepted-generated", - merge_index=0, - priority=2, - ) - rejected_generated = CandidateIntentRecord( - intent=_new_intent(client_order_id="rejected-generated"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:rejected-generated", - merge_index=1, - priority=2, - ) - passthrough_queued = CandidateIntentRecord( - intent=_new_intent(client_order_id="queued-record"), - origin=CandidateIntentOrigin.QUEUED, - logical_key="order:queued-record", - merge_index=2, - priority=2, - ) - state = StrategyState(event_bus=NullEventBus()) - calls: list[str] = [] - - class _Evaluator: - def evaluate_policy_intent( - self, - *, - intent: NewOrderIntent, - state: StrategyState, - now_ts_ns_local: int, - ) -> tuple[bool, str | None]: - _ = state - assert now_ts_ns_local == 123 - calls.append(intent.client_order_id) - if intent.client_order_id == "rejected-generated": - return False, "policy_reject" - return True, None - - result = apply_policy_to_candidate_records( - ( - accepted_generated, - rejected_generated, - passthrough_queued, - ), - state=state, - now_ts_ns_local=123, - policy_evaluator=_Evaluator(), - ) - - assert calls == ["accepted-generated", "rejected-generated"] - assert result.accepted_generated == (accepted_generated,) - assert result.passthrough_queued == (passthrough_queued,) - assert len(result.rejected_generated) == 1 - assert result.rejected_generated[0].record == rejected_generated - assert result.rejected_generated[0].reason == "policy_reject" - assert tuple(it.client_order_id for it in result.policy_risk_decision.accepted_intents) == ( - "accepted-generated", - ) - assert tuple(it.client_order_id for it in result.policy_risk_decision.rejected_intents) == ( - "rejected-generated", - ) - - -def test_apply_policy_to_candidate_records_does_not_call_decide_intents() -> None: - record = CandidateIntentRecord( - intent=_new_intent(client_order_id="generated-only"), - origin=CandidateIntentOrigin.GENERATED, - logical_key="order:generated-only", - merge_index=0, - priority=2, - ) - - class _Evaluator: - def evaluate_policy_intent( - self, - *, - intent: NewOrderIntent, - state: StrategyState, - now_ts_ns_local: int, - ) -> tuple[bool, str | None]: - _ = (intent, state, now_ts_ns_local) - return True, None - - def decide_intents(self, **_: object) -> GateDecision: - raise AssertionError("decide_intents must not be called by policy helper") - - result = apply_policy_to_candidate_records( - (record,), - state=StrategyState(event_bus=NullEventBus()), - now_ts_ns_local=1, - policy_evaluator=_Evaluator(), # type: ignore[arg-type] - ) - - assert result.accepted_generated == (record,) diff --git a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py deleted file mode 100644 index 429d3ae..0000000 --- a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Architectural guard for ProcessingPosition cursor ownership.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -_ALLOWED_CALLER = Path("tradingchassis_core/core/domain/processing.py") -_ALLOWED_MUTATION_FILE = Path("tradingchassis_core/core/domain/state.py") -_TARGET_METHOD = "_advance_processing_position" -_TARGET_ATTR = "_last_processing_position_index" -_POSITIONED_MARKET_TARGET_METHOD = "_update_market_from_positioned_canonical_event" - - -def _iter_python_files(root: Path) -> list[Path]: - return sorted(path for path in root.rglob("*.py") if path.is_file()) - - -def _find_target_method_calls(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - if node.func.attr != _TARGET_METHOD: - continue - calls.append((node.lineno, node.col_offset)) - - return calls - - -def _find_positioned_market_target_method_calls(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - calls: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if not isinstance(node, ast.Call): - continue - if not isinstance(node.func, ast.Attribute): - continue - if node.func.attr != _POSITIONED_MARKET_TARGET_METHOD: - continue - calls.append((node.lineno, node.col_offset)) - - return calls - - -def _find_target_attr_mutations(path: Path) -> list[tuple[int, int]]: - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - writes: list[tuple[int, int]] = [] - - for node in ast.walk(tree): - if isinstance(node, ast.Assign): - targets = node.targets - elif isinstance(node, ast.AnnAssign): - targets = [node.target] - elif isinstance(node, ast.AugAssign): - targets = [node.target] - else: - continue - - for target in targets: - if isinstance(target, ast.Attribute) and target.attr == _TARGET_ATTR: - writes.append((target.lineno, target.col_offset)) - - return writes - - -def test_processing_position_cursor_is_mutated_only_via_canonical_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - call_violations: list[str] = [] - mutation_violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - - method_calls = _find_target_method_calls(file_path) - if method_calls and relative_path != _ALLOWED_CALLER: - for lineno, col in method_calls: - call_violations.append( - f"{relative_path}:{lineno}:{col} calls {_TARGET_METHOD}(...)" - ) - - attr_writes = _find_target_attr_mutations(file_path) - if attr_writes and relative_path != _ALLOWED_MUTATION_FILE: - for lineno, col in attr_writes: - mutation_violations.append( - f"{relative_path}:{lineno}:{col} writes {_TARGET_ATTR}" - ) - - assert not call_violations, ( - "Unexpected ProcessingPosition cursor helper calls outside canonical boundary:\n" - + "\n".join(call_violations) - ) - assert not mutation_violations, ( - "Unexpected ProcessingPosition cursor mutations outside StrategyState:\n" - + "\n".join(mutation_violations) - ) - - -def test_positioned_market_helper_is_called_only_via_canonical_boundary() -> None: - repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "tradingchassis_core" - - call_violations: list[str] = [] - - for file_path in _iter_python_files(production_root): - relative_path = file_path.relative_to(repo_root) - method_calls = _find_positioned_market_target_method_calls(file_path) - if method_calls and relative_path != _ALLOWED_CALLER: - for lineno, col in method_calls: - call_violations.append( - f"{relative_path}:{lineno}:{col} calls " - f"{_POSITIONED_MARKET_TARGET_METHOD}(...)" - ) - - assert not call_violations, ( - "Unexpected positioned market helper calls outside canonical boundary:\n" - + "\n".join(call_violations) - ) diff --git a/tests/semantics/models/test_public_canonical_api_surface.py b/tests/semantics/models/test_public_canonical_api_surface.py deleted file mode 100644 index 0244701..0000000 --- a/tests/semantics/models/test_public_canonical_api_surface.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Public import-surface contract for canonical core processing APIs.""" - -from __future__ import annotations - -import tradingchassis_core as tc -from tradingchassis_core.core.domain.configuration import CoreConfiguration -from tradingchassis_core.core.domain.processing import ( - fold_event_stream_entries, - 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.types import ControlTimeEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_public_root_exposes_canonical_processing_symbols() -> None: - assert hasattr(tc, "CoreConfiguration") - assert hasattr(tc, "ProcessingPosition") - assert hasattr(tc, "EventStreamEntry") - assert hasattr(tc, "process_event_entry") - assert hasattr(tc, "fold_event_stream_entries") - - -def test_public_root_canonical_processing_symbols_have_identity_with_deep_implementations() -> None: - assert tc.CoreConfiguration is CoreConfiguration - assert tc.ProcessingPosition is ProcessingPosition - assert tc.EventStreamEntry is EventStreamEntry - assert tc.process_event_entry is process_event_entry - assert tc.fold_event_stream_entries is fold_event_stream_entries - - -def test_public_process_event_entry_smoke_for_non_market_canonical_event() -> None: - state = StrategyState(event_bus=NullEventBus()) - entry = tc.EventStreamEntry( - position=tc.ProcessingPosition(index=0), - event=ControlTimeEvent( - ts_ns_local_control=100, - reason="rate_limit_recheck", - due_ts_ns_local=110, - realized_ts_ns_local=None, - obligation_reason="rate_limit", - obligation_due_ts_ns_local=110, - runtime_correlation={"engine": "test", "seq": 1}, - ), - ) - - tc.process_event_entry(state, entry) - - assert state._last_processing_position_index == 0 diff --git a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py deleted file mode 100644 index d754fd2..0000000 --- a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Characterization tests for internal scheduling obligation mapping.""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control import ExecutionControl -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_rate_limited_mixed_intents_keep_minimum_next_send_timestamp() -> None: - """Compatibility: next_send_ts remains the minimum blocked wake timestamp.""" - - instrument = "BTC-USDC-PERP" - new_client_order_id = "order-new" - cancel_client_order_id = "order-cancel" - state = StrategyState(event_bus=NullEventBus()) - - # CANCEL requires a known working order to pass existence gating. - state.apply_order_state_event( - OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=cancel_client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, # wake: next second boundary at 1_000_000_000 - max_cancels_per_second=2, # wake: 0.5s at 500_000_000 - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=new_client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=cancel_client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent, cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert len(decision.queued) == 2 - assert decision.next_send_ts_ns_local == 500_000_000 - assert len(decision.control_scheduling_obligations) == 2 - assert min( - o.due_ts_ns_local for o in decision.control_scheduling_obligations - ) == 500_000_000 - - -def test_rate_limit_routing_sets_internal_obligation_reason_characterization() -> None: - """Internal semantic contract: rate-limit blocking emits a rate_limit obligation.""" - - execution_control = ExecutionControl() - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument="BTC-USDC-PERP", - client_order_id="order-1", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - result = execution_control.route_after_policy_rate_limit( - new_intent, - now_ts_ns_local=1, - max_orders_per_sec=0, - max_cancels_per_sec=None, - ) - - assert result.accept_now is False - assert result.stage_to_queue is True - assert result.scheduling_obligation is not None - obligation = result.scheduling_obligation - assert obligation.due_ts_ns_local == 1_000_000_000 - assert obligation.ts_ns_local == 1_000_000_000 - assert obligation.reason == "rate_limit" - assert obligation.scope_key == "instrument:BTC-USDC-PERP" - assert obligation.source == "execution_control_rate_limit" - assert ( - obligation.obligation_key - == "execution_control_rate_limit|instrument:BTC-USDC-PERP|rate_limit|1000000000" - ) - diff --git a/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py b/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py deleted file mode 100644 index 63db895..0000000 --- a/tests/semantics/queue_semantics/test_execution_control_apply_isolated.py +++ /dev/null @@ -1,451 +0,0 @@ -"""Isolated mutable execution-control apply semantics tests.""" - -from __future__ import annotations - -import copy - -import pytest - -from tradingchassis_core.core.domain.candidate_intent import ( - CandidateIntentOrigin, - CandidateIntentRecord, -) -from tradingchassis_core.core.domain.execution_control_apply import ( - ExecutionControlApplyContext, - apply_execution_control_plan, -) -from tradingchassis_core.core.domain.execution_control_plan import ExecutionControlPlan -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - OrderIntent, - OrderStateEvent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.event_bus import EventBus -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.execution_control.execution_control import ExecutionControl -from tradingchassis_core.core.risk.risk_config import NotionalLimits, RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def _new_intent( - *, - client_order_id: str, - ts_ns_local: int = 1, - px: float = 100.0, - qty: float = 1.0, -) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-1", - side="buy", - order_type="limit", - intended_qty=Quantity(value=qty, unit="contracts"), - intended_price=Price(currency="USDC", value=px), - time_in_force="GTC", - ) - - -def _replace_intent( - *, - client_order_id: str, - ts_ns_local: int = 1, - px: float = 101.0, - qty: float = 2.0, -) -> ReplaceOrderIntent: - return ReplaceOrderIntent( - ts_ns_local=ts_ns_local, - instrument="BTC-USDC-PERP", - client_order_id=client_order_id, - intents_correlation_id="corr-replace", - side="buy", - intended_price=Price(currency="USDC", value=px), - intended_qty=Quantity(value=qty, unit="contracts"), - ) - - -def _record( - intent: OrderIntent, - *, - origin: CandidateIntentOrigin, - merge_index: int, -) -> CandidateIntentRecord: - return CandidateIntentRecord( - intent=intent, - origin=origin, - logical_key=f"order:{intent.client_order_id}", - merge_index=merge_index, - priority=0 if intent.intent_type == "cancel" else 1 if intent.intent_type == "replace" else 2, - ) - - -def _plan(*records: CandidateIntentRecord) -> ExecutionControlPlan: - return ExecutionControlPlan(active_records=records) - - -def test_apply_execution_control_plan_empty_plan_has_no_side_effects() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - queue_before = state.queued_intents_snapshot() - rate_before = copy.deepcopy(execution_control._rate_state) - - result = apply_execution_control_plan( - _plan(), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert result.dispatchable_records == () - assert result.blocked_records == () - assert result.execution_handled_records == () - assert result.queued_effective_records == () - assert result.control_scheduling_obligation is None - assert state.queued_intents_snapshot() == queue_before - assert execution_control._rate_state == rate_before - - -def test_apply_execution_control_plan_generated_dispatchable_path() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - intent = _new_intent(client_order_id="generated-dispatchable") - record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert tuple(item.record.origin for item in result.dispatchable_records) == ( - CandidateIntentOrigin.GENERATED, - ) - assert tuple(item.record.intent.client_order_id for item in result.dispatchable_records) == ( - "generated-dispatchable", - ) - assert result.blocked_records == () - assert result.execution_handled_records == () - assert result.execution_control_decision.dispatchable_intents == (intent,) - assert not state.has_queued_intent(intent.instrument, intent.client_order_id) - - -def test_apply_execution_control_plan_generated_rate_blocked_path() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - intent = _new_intent(client_order_id="generated-rate-blocked") - record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - rate_before = copy.deepcopy(execution_control._rate_state) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - max_orders_per_sec=1, - ), - ) - - assert result.dispatchable_records == () - assert len(result.blocked_records) == 1 - assert result.blocked_records[0].record.origin == CandidateIntentOrigin.GENERATED - assert result.blocked_records[0].reason == "rate_limit" - assert result.blocked_records[0].scheduling_obligation is not None - assert result.control_scheduling_obligation is not None - assert result.execution_control_decision.control_scheduling_obligation is not None - assert tuple(it.client_order_id for it in result.execution_control_decision.queued_effective_intents) == ( - "generated-rate-blocked", - ) - assert state.has_queued_intent(intent.instrument, intent.client_order_id) - assert execution_control._rate_state != rate_before - assert execution_control._rate_state["order"]["last_ts"] == 1 - - -def test_apply_execution_control_plan_queued_dispatchable_removes_from_queue() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - queued_intent = _new_intent(client_order_id="queued-dispatchable") - state.merge_intents_into_queue(queued_intent.instrument, [queued_intent]) - record = _record( - queued_intent, - origin=CandidateIntentOrigin.QUEUED, - merge_index=0, - ) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert tuple(item.record.origin for item in result.dispatchable_records) == ( - CandidateIntentOrigin.QUEUED, - ) - assert tuple(item.record.intent.client_order_id for item in result.dispatchable_records) == ( - "queued-dispatchable", - ) - assert not state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) - - -def test_apply_execution_control_plan_queued_rate_blocked_keeps_resident() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - queued_intent = _new_intent(client_order_id="queued-rate-blocked") - state.merge_intents_into_queue(queued_intent.instrument, [queued_intent]) - record = _record( - queued_intent, - origin=CandidateIntentOrigin.QUEUED, - merge_index=0, - ) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - max_orders_per_sec=1, - ), - ) - - assert result.dispatchable_records == () - assert len(result.blocked_records) == 1 - assert result.blocked_records[0].record.origin == CandidateIntentOrigin.QUEUED - assert result.blocked_records[0].reason == "rate_limit" - assert result.blocked_records[0].scheduling_obligation is not None - assert result.control_scheduling_obligation is not None - assert state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) - - -def test_apply_execution_control_plan_replace_against_queued_new_is_handled_locally() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - queued_new = _new_intent(client_order_id="queued-replace-local") - state.merge_intents_into_queue(queued_new.instrument, [queued_new]) - generated_replace = _replace_intent(client_order_id="queued-replace-local", px=111.0, qty=3.0) - record = _record( - generated_replace, - origin=CandidateIntentOrigin.GENERATED, - merge_index=0, - ) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=2, - ), - ) - - assert result.dispatchable_records == () - assert tuple(item.reason for item in result.execution_handled_records) == ( - "queue_local_handled", - ) - updated_new = state.find_queued_new_intent( - queued_new.instrument, - queued_new.client_order_id, - ) - assert updated_new is not None - assert updated_new.intended_price.value == 111.0 - assert updated_new.intended_qty.value == 3.0 - - -def test_apply_execution_control_plan_side_effect_boundaries_do_not_use_risk_engine( - monkeypatch: pytest.MonkeyPatch, -) -> None: - class _CaptureSink: - def __init__(self) -> None: - self.events: list[object] = [] - - def on_event(self, event: object) -> None: - self.events.append(event) - - sink = _CaptureSink() - state = StrategyState(event_bus=EventBus(sinks=[sink])) - execution_control = ExecutionControl() - intent = _new_intent(client_order_id="side-effect-check") - record = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - - def _boom(*args: object, **kwargs: object) -> object: - _ = (args, kwargs) - raise AssertionError("RiskEngine.decide_intents must not be called by apply") - - monkeypatch.setattr(RiskEngine, "decide_intents", _boom) - _ = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - ) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert len(result.dispatchable_records) == 1 - assert all(not isinstance(event, RiskDecisionEvent) for event in sink.events) - - -def test_apply_execution_control_plan_stale_queued_origin_is_handled_without_crash() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - queued_intent = _new_intent(client_order_id="stale-queued") - record = _record( - queued_intent, - origin=CandidateIntentOrigin.QUEUED, - merge_index=0, - ) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert result.dispatchable_records == () - assert result.blocked_records == () - assert tuple(item.reason for item in result.execution_handled_records) == ( - "queued_record_missing", - ) - assert not state.has_queued_intent(queued_intent.instrument, queued_intent.client_order_id) - - -def test_apply_execution_control_plan_duplicate_generated_candidates_do_not_double_dispatch() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - intent = _new_intent(client_order_id="dup-generated") - record_a = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - record_b = _record(intent, origin=CandidateIntentOrigin.GENERATED, merge_index=1) - - result = apply_execution_control_plan( - _plan(record_a, record_b), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - ), - ) - - assert tuple(it.client_order_id for it in result.execution_control_decision.dispatchable_intents) == ( - "dup-generated", - ) - assert len(result.dispatchable_records) == 1 - assert tuple(item.reason for item in result.execution_handled_records) == ( - "duplicate_candidate_record", - ) - - -def test_apply_execution_control_plan_inflight_blocks_before_rate_and_does_not_consume_tokens() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "inflight-order" - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - - state.apply_order_state_event( - OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 1, "source": "snapshot"}, - ) - ) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="replace") - - replace_intent = _replace_intent(client_order_id=client_order_id, px=101.0, qty=1.0) - record = _record(replace_intent, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - rate_before = copy.deepcopy(execution_control._rate_state) - - result = apply_execution_control_plan( - _plan(record), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=2, - max_orders_per_sec=10, - max_cancels_per_sec=10, - ), - ) - - assert result.dispatchable_records == () - assert len(result.blocked_records) == 1 - assert result.blocked_records[0].reason == "inflight" - assert execution_control._rate_state == rate_before - - -def test_apply_execution_control_plan_obligation_collapse_is_deterministic() -> None: - state = StrategyState(event_bus=NullEventBus()) - execution_control = ExecutionControl() - - a = _new_intent(client_order_id="o-a") - b = _new_intent(client_order_id="o-b") - b = b.model_copy(update={"instrument": "ETH-USDC-PERP"}) - - record_a = _record(a, origin=CandidateIntentOrigin.GENERATED, merge_index=0) - record_b = _record(b, origin=CandidateIntentOrigin.GENERATED, merge_index=1) - - _ = apply_execution_control_plan( - _plan(_record(_new_intent(client_order_id="warm"), origin=CandidateIntentOrigin.GENERATED, merge_index=99)), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - max_orders_per_sec=1, - ), - ) - - result = apply_execution_control_plan( - _plan(record_a, record_b), - ExecutionControlApplyContext( - state=state, - execution_control=execution_control, - now_ts_ns_local=1, - max_orders_per_sec=1, - ), - ) - - assert len(result.blocked_records) == 2 - collapsed = result.control_scheduling_obligation - assert collapsed is not None - expected = min( - (br.scheduling_obligation for br in result.blocked_records if br.scheduling_obligation is not None), - key=lambda o: (o.due_ts_ns_local, o.obligation_key), - ) - assert collapsed == expected - assert result.execution_control_decision.control_scheduling_obligation == collapsed diff --git a/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py b/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py deleted file mode 100644 index 04c091b..0000000 --- a/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py +++ /dev/null @@ -1,103 +0,0 @@ -""" -Characterization tests: mixed dominance sequences within queued intents. - -These pin the current behavior for sequences like: -NEW queued -> REPLACE on same logical key -> CANCEL on same logical key - -This suite is intentionally descriptive of current behavior (not prescriptive). -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_new_then_replace_then_cancel_on_same_key_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - max_cancels_per_second=0, - ), - ) - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - # Step 1: NEW is queued due to order rate-limit. - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - d1 = risk_engine.decide_intents(raw_intents=[new_intent], state=state, now_ts_ns_local=1) - assert d1.accepted_now == [] - assert d1.rejected == [] - assert [it.intent_type for it in d1.queued] == ["new"] - assert state.has_queued_intent(instrument, client_order_id) - - # Step 2: REPLACE arrives while there is no working order, but a queued NEW exists. - # Characterization: the REPLACE is handled locally and results in an updated queued NEW. - replace_intent = ReplaceOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=101.0), - intended_qty=Quantity(unit="contracts", value=2.0), - ) - d2 = risk_engine.decide_intents(raw_intents=[replace_intent], state=state, now_ts_ns_local=2) - assert d2.accepted_now == [] - assert d2.rejected == [] - assert len(d2.handled_in_queue) == 1 - assert d2.handled_in_queue[0].intent_type == "replace" - assert [it.intent_type for it in d2.queued] == ["new"] - - queued_new = state.find_queued_new_intent(instrument, client_order_id) - assert queued_new is not None - assert queued_new.intended_price.value == 101.0 - assert queued_new.intended_qty.value == 2.0 - - # Step 3: CANCEL arrives while only queued state exists (no working order). - # Characterization: the CANCEL clears queued intents for that key and is handled locally. - cancel_intent = CancelOrderIntent( - ts_ns_local=3, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - d3 = risk_engine.decide_intents(raw_intents=[cancel_intent], state=state, now_ts_ns_local=3) - assert d3.accepted_now == [] - assert d3.rejected == [] - assert len(d3.handled_in_queue) == 1 - assert d3.handled_in_queue[0].intent_type == "cancel" - assert d3.queued == [] - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py b/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py deleted file mode 100644 index 6d4ecd8..0000000 --- a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py +++ /dev/null @@ -1,134 +0,0 @@ -""" -Semantic test: NEW intents are queued under active rate-limit backpressure. - -Invariant: -Queue is a backpressure buffer. NEW intents must be queued (not accepted_now) -if a temporary send blocker exists (e.g. rate limit exhausted). -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - OrderStateEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_new_is_queued_when_rate_limit_blocks() -> None: - """NEW intent must be queued when order rate-limit blocks sending.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Configure strict order rate-limit to force backpressure - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert len(decision.queued) == 1 - # Characterization: rate-limit backpressure sets a "wake up no earlier than" timestamp. - # With ts_ns_local=1 and max_orders_per_second=0, wake timestamp is next local-second boundary. - assert decision.next_send_ts_ns_local == 1_000_000_000 - - -def test_cancel_is_queued_when_cancel_rate_limit_blocks_and_sets_next_send_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # A CANCEL only passes existence gating if a working order exists. - state.apply_order_state_event( - OrderStateEvent( - ts_ns_exch=1, - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type="working", - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": 0, "source": "snapshot"}, - ) - ) - - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_cancels_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - cancel_intent = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision.accepted_now == [] - assert decision.rejected == [] - assert [it.intent_type for it in decision.queued] == ["cancel"] - assert decision.next_send_ts_ns_local == 1_000_000_000 diff --git a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py b/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py deleted file mode 100644 index 428b4d9..0000000 --- a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Semantic test: CANCEL dominates queued NEW intents. - -Invariant: -If a NEW intent is already queued for an order id and a CANCEL intent -for the same order id arrives while the order is still not inflight, -the queued NEW must be removed and replaced by the CANCEL. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - NotionalLimits, - OrderRateLimits, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus -from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import RiskEngine - - -def test_cancel_dominates_queued_new() -> None: - """CANCEL must remove a queued NEW for the same order id.""" - - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Configure rate-limit to force backpressure (queueing) - risk_cfg = RiskConfig( - scope="test", - trading_enabled=True, - notional_limits=NotionalLimits( - currency="USDC", - max_gross_notional=1e18, - max_single_order_notional=1e18, - ), - order_rate_limits=OrderRateLimits( - max_orders_per_second=0, - ), - ) - - risk_engine = RiskEngine(risk_cfg=risk_cfg, event_bus=NullEventBus()) - - # Step 1: NEW intent is queued due to rate-limit backpressure - new_intent = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - decision_1 = risk_engine.decide_intents( - raw_intents=[new_intent], - state=state, - now_ts_ns_local=1, - ) - - assert decision_1.accepted_now == [] - assert decision_1.rejected == [] - assert len(decision_1.queued) == 1 - - # Step 2: CANCEL arrives for the same order id - cancel_intent = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - ) - - decision_2 = risk_engine.decide_intents( - raw_intents=[cancel_intent], - state=state, - now_ts_ns_local=2, - ) - - # --- Queue mutation assertions --- - assert decision_2.accepted_now == [] - assert decision_2.rejected == [] - assert len(decision_2.handled_in_queue) == 1 - assert decision_2.queued == [] - - # Queue must contain only CANCEL - assert not state.has_queued_intent(instrument, client_order_id) diff --git a/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py b/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py deleted file mode 100644 index 5bf4505..0000000 --- a/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py +++ /dev/null @@ -1,158 +0,0 @@ -""" -Characterization tests: StrategyState outbox queue pop semantics. - -These tests pin the *current* behavior of: -- StrategyState.pop_queued_intents ordering (priority then FIFO) -- inflight filtering (skips blocked ids without dequeuing them) - -This suite is intentionally explicit and should not be interpreted as desired semantics. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - CancelOrderIntent, - NewOrderIntent, - Price, - Quantity, - ReplaceOrderIntent, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_pop_queued_intents_orders_by_priority_then_fifo_characterization() -> None: - instrument = "BTC-USDC-PERP" - - state = StrategyState(event_bus=NullEventBus()) - - new_1 = NewOrderIntent( - ts_ns_local=30, - instrument=instrument, - client_order_id="new-1", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - new_2 = NewOrderIntent( - ts_ns_local=10, - instrument=instrument, - client_order_id="new-2", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=101.0), - time_in_force="GTC", - ) - replace_1 = ReplaceOrderIntent( - ts_ns_local=20, - instrument=instrument, - client_order_id="replace-1", - intents_correlation_id=None, - side="buy", - intended_price=Price(currency="USDC", value=102.0), - intended_qty=Quantity(unit="contracts", value=1.0), - ) - cancel_1 = CancelOrderIntent( - ts_ns_local=40, - instrument=instrument, - client_order_id="cancel-1", - intents_correlation_id=None, - ) - cancel_2 = CancelOrderIntent( - ts_ns_local=5, - instrument=instrument, - client_order_id="cancel-2", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[new_1, replace_1, cancel_1, new_2, cancel_2], - ) - - popped = state.pop_queued_intents(instrument) - popped_ids = [it.client_order_id for it in popped] - - # Characterization: selection is computed by priority + queued_at_ts_ns, - # but the returned list preserves the queue's iteration order for the selected set. - # Since all intents are eligible here, this matches enqueue order. - assert popped_ids == ["new-1", "replace-1", "cancel-1", "new-2", "cancel-2"] - - -def test_pop_queued_intents_filters_inflight_without_dequeuing_characterization() -> None: - instrument = "BTC-USDC-PERP" - - state = StrategyState(event_bus=NullEventBus()) - - blocked_new = NewOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id="blocked", - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - allowed_cancel = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id="allowed", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue(instrument=instrument, intents=[blocked_new, allowed_cancel]) - - state.mark_intent_sent( - instrument=instrument, - client_order_id="blocked", - intent_type="new", - ) - - popped_1 = state.pop_queued_intents(instrument) - assert [it.client_order_id for it in popped_1] == ["allowed"] - - # Characterization: the inflight-blocked intent remains queued (not removed). - assert state.has_queued_intent(instrument, "blocked") - assert not state.has_queued_intent(instrument, "allowed") - - # After inflight clears, it becomes eligible. - state._clear_inflight(instrument=instrument, client_order_id="blocked") - popped_2 = state.pop_queued_intents(instrument) - assert [it.client_order_id for it in popped_2] == ["blocked"] - - -def test_pop_queued_intents_respects_max_items_characterization() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - cancel_a = CancelOrderIntent( - ts_ns_local=1, - instrument=instrument, - client_order_id="a", - intents_correlation_id=None, - ) - cancel_b = CancelOrderIntent( - ts_ns_local=2, - instrument=instrument, - client_order_id="b", - intents_correlation_id=None, - ) - cancel_c = CancelOrderIntent( - ts_ns_local=3, - instrument=instrument, - client_order_id="c", - intents_correlation_id=None, - ) - - state.merge_intents_into_queue(instrument=instrument, intents=[cancel_c, cancel_a, cancel_b]) - - popped = state.pop_queued_intents(instrument, max_items=2) - assert [it.client_order_id for it in popped] == ["a", "b"] - assert state.has_queued_intent(instrument, "c") diff --git a/tests/semantics/state_transitions/test_new_to_working.py b/tests/semantics/state_transitions/test_new_to_working.py deleted file mode 100644 index 7adb2cd..0000000 --- a/tests/semantics/state_transitions/test_new_to_working.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Semantic test: NEW -> working. - -Invariant: -After a NEW intent has been sent (accepted_now), -the order must be present in the working orders state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_new_transitions_to_working() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Simulate that the order is acknowledged as working - state.orders[instrument] = { - client_order_id: { - "status": "working", - } - } - - assert instrument in state.orders - assert client_order_id in state.orders[instrument] - assert state.orders[instrument][client_order_id]["status"] == "working" diff --git a/tests/semantics/state_transitions/test_replace_to_replaced.py b/tests/semantics/state_transitions/test_replace_to_replaced.py deleted file mode 100644 index d00004d..0000000 --- a/tests/semantics/state_transitions/test_replace_to_replaced.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Semantic test: replace -> replaced. - -Invariant: -A replace operation must not result in duplicate working orders -for the same client_order_id. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_replace_transitions_to_replaced() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Existing working order - state.orders[instrument] = { - client_order_id: { - "status": "working", - "version": 1, - } - } - - # Simulate replace acknowledgment - state.orders[instrument][client_order_id] = { - "status": "working", - "version": 2, - } - - assert len(state.orders[instrument]) == 1 - assert state.orders[instrument][client_order_id]["version"] == 2 diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py deleted file mode 100644 index 6179981..0000000 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ /dev/null @@ -1,619 +0,0 @@ -""" -Characterization and semantic tests for submitted boundary behavior. - -This suite pins compatibility behavior while introducing an internal canonical -order lifecycle projection that begins at dispatch/submission. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.domain.types import ( - NewOrderIntent, - OrderStateEvent, - OrderSubmittedEvent, - Price, - Quantity, -) -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def _new_intent(instrument: str, client_order_id: str, *, ts_ns_local: int) -> NewOrderIntent: - return NewOrderIntent( - ts_ns_local=ts_ns_local, - instrument=instrument, - client_order_id=client_order_id, - intents_correlation_id=None, - side="buy", - order_type="limit", - intended_qty=Quantity(unit="contracts", value=1.0), - intended_price=Price(currency="USDC", value=100.0), - time_in_force="GTC", - ) - - -def _order_state_event( - instrument: str, - client_order_id: str, - *, - ts_ns_local: int, - ts_ns_exch: int, - state_type: str, - req: int = 0, -) -> OrderStateEvent: - return OrderStateEvent( - ts_ns_exch=ts_ns_exch, - ts_ns_local=ts_ns_local, - instrument=instrument, - client_order_id=client_order_id, - order_type="limit", - state_type=state_type, - side="buy", - intended_price=Price(currency="USDC", value=100.0), - filled_price=None, - intended_qty=Quantity(unit="contracts", value=1.0), - cum_filled_qty=None, - remaining_qty=None, - time_in_force="GTC", - reason=None, - raw={"req": req, "source": "snapshot"}, - ) - - -def _order_submitted_event( - instrument: str, - client_order_id: str, - *, - ts_ns_local_dispatch: int, -) -> OrderSubmittedEvent: - return OrderSubmittedEvent( - ts_ns_local_dispatch=ts_ns_local_dispatch, - instrument=instrument, - client_order_id=client_order_id, - side="buy", - order_type="limit", - intended_price=Price(currency="USDC", value=100.0), - intended_qty=Quantity(unit="contracts", value=1.0), - time_in_force="GTC", - intent_correlation_id="corr-1", - dispatch_attempt_id="attempt-1", - runtime_correlation={"engine": "backtest", "seq": 1}, - ) - - -def test_mark_intent_sent_new_preserves_inflight_compatibility_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(101) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - assert state.has_inflight(instrument, client_order_id) - assert state.inflight[instrument][client_order_id].action == "new" - assert state.inflight[instrument][client_order_id].ts_sent_ns_local == 101 - assert state.last_sent_intents[instrument][client_order_id] == (101, "new") - - -def test_mark_intent_sent_new_does_not_mutate_existing_strategy_state_orders_characterization() -> None: - instrument = "BTC-USDC-PERP" - existing_order_id = "existing-order-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_state_event( - _order_state_event( - instrument, - existing_order_id, - ts_ns_local=100, - ts_ns_exch=100, - state_type="working", - ) - ) - before = state.orders[instrument][existing_order_id] - - state.update_timestamp(150) - state.mark_intent_sent(instrument=instrument, client_order_id="new-order-1", intent_type="new") - - assert state.orders[instrument][existing_order_id] is before - assert state.orders[instrument][existing_order_id].state_type == "working" - - -def test_strategy_state_orders_remains_snapshot_driven_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-snapshot-driven-1" - state = StrategyState(event_bus=NullEventBus()) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[_new_intent(instrument, client_order_id, ts_ns_local=10)], - ) - state.update_timestamp(11) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - assert not state.has_working_order(instrument, client_order_id) - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=12, - ts_ns_exch=12, - state_type="working", - ) - ) - assert state.has_working_order(instrument, client_order_id) - - -def test_none_to_pending_new_compatibility_transition_remains_valid_characterization() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-pending-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=200, - ts_ns_exch=200, - state_type="pending_new", - req=1, - ) - ) - - assert state.orders[instrument][client_order_id].state_type == "pending_new" - - -def test_mark_intent_sent_new_creates_canonical_submitted_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(300) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.instrument == instrument - assert projection.client_order_id == client_order_id - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 300 - assert projection.updated_ts_ns_local == 300 - - -def test_mark_intent_sent_new_does_not_advance_processing_position_cursor() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-cursor-guard-1" - state = StrategyState(event_bus=NullEventBus()) - - assert state._last_processing_position_index is None - - state.update_timestamp(301) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - # mark_intent_sent sidecar behavior remains available without canonical entry metadata. - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - assert state.has_inflight(instrument, client_order_id) - assert state._last_processing_position_index is None - - -def test_order_submitted_event_creates_projection_without_mark_intent_sent() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-stream-submitted-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_submitted_event( - _order_submitted_event( - instrument, - client_order_id, - ts_ns_local_dispatch=305, - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.submitted_ts_ns_local == 305 - assert projection.updated_ts_ns_local == 305 - assert state.orders == {} - - -def test_mark_intent_sent_new_remains_unchanged_when_projection_preexists() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-coexistence-1" - state = StrategyState(event_bus=NullEventBus()) - - state.apply_order_submitted_event( - _order_submitted_event( - instrument, - client_order_id, - ts_ns_local_dispatch=310, - ) - ) - before = state.canonical_orders[(instrument, client_order_id)] - - state.update_timestamp(320) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - after = state.canonical_orders[(instrument, client_order_id)] - assert after.submitted_ts_ns_local == before.submitted_ts_ns_local - assert after.updated_ts_ns_local == before.updated_ts_ns_local - assert after.state == "submitted" - # Existing compatibility bookkeeping behavior remains intact. - assert state.has_inflight(instrument, client_order_id) - assert state.last_sent_intents[instrument][client_order_id] == (320, "new") - - -def test_queue_residency_alone_does_not_create_canonical_order() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-queued-only-1" - state = StrategyState(event_bus=NullEventBus()) - - state.merge_intents_into_queue( - instrument=instrument, - intents=[_new_intent(instrument, client_order_id, ts_ns_local=1)], - ) - - assert state.canonical_orders == {} - - -def test_mark_intent_sent_replace_and_cancel_do_not_create_canonical_submitted_order() -> None: - instrument = "BTC-USDC-PERP" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(400) - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="new") - state.apply_order_state_event( - _order_state_event( - instrument, - "existing-1", - ts_ns_local=410, - ts_ns_exch=410, - state_type="working", - ) - ) - - state.mark_intent_sent(instrument=instrument, client_order_id="replace-1", intent_type="replace") - state.mark_intent_sent(instrument=instrument, client_order_id="cancel-1", intent_type="cancel") - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="replace") - state.mark_intent_sent(instrument=instrument, client_order_id="existing-1", intent_type="cancel") - - assert (instrument, "replace-1") not in state.canonical_orders - assert (instrument, "cancel-1") not in state.canonical_orders - assert state.canonical_orders[(instrument, "existing-1")].state == "accepted" - - -def test_post_dispatch_feedback_advances_existing_canonical_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-advance-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(500) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=550, - ts_ns_exch=550, - state_type="working", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 550 - assert state.orders[instrument][client_order_id].state_type == "working" - - -def test_pending_new_does_not_advance_canonical_submitted_projection() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-pending-new-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(600) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - before = state.canonical_orders[(instrument, client_order_id)] - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=610, - ts_ns_exch=610, - state_type="pending_new", - req=1, - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.updated_ts_ns_local == before.updated_ts_ns_local - assert state.orders[instrument][client_order_id].state_type == "pending_new" - - -def test_accepted_advances_submitted_to_accepted() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-accepted-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(700) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=710, - ts_ns_exch=710, - state_type="accepted", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 710 - assert state.orders[instrument][client_order_id].state_type == "accepted" - - -def test_rejected_advances_submitted_to_rejected_terminal() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-rejected-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(800) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=810, - ts_ns_exch=810, - state_type="rejected", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "rejected" - assert projection.updated_ts_ns_local == 810 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_partially_filled_and_filled_canonical_progression() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-fill-progression-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(900) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=910, - ts_ns_exch=910, - state_type="working", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "accepted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=920, - ts_ns_exch=920, - state_type="partially_filled", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "partially_filled" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=930, - ts_ns_exch=930, - state_type="filled", - ) - ) - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "filled" - assert projection.updated_ts_ns_local == 930 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_partially_filled_to_canceled_canonical_progression() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-cancel-progression-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(950) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=960, - ts_ns_exch=960, - state_type="accepted", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=970, - ts_ns_exch=970, - state_type="partially_filled", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=980, - ts_ns_exch=980, - state_type="canceled", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "canceled" - assert projection.updated_ts_ns_local == 980 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_terminal_canonical_state_is_final_noop_on_later_updates() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-terminal-final-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1000) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1010, - ts_ns_exch=1010, - state_type="working", - ) - ) - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1020, - ts_ns_exch=1020, - state_type="filled", - ) - ) - assert state.canonical_orders[(instrument, client_order_id)].state == "filled" - assert state.canonical_orders[(instrument, client_order_id)].updated_ts_ns_local == 1020 - - # Invalid terminal transition should remain a no-op for canonical state. - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1030, - ts_ns_exch=1030, - state_type="canceled", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "filled" - assert projection.updated_ts_ns_local == 1020 - - -def test_replaced_does_not_advance_canonical_lifecycle() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-replaced-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1100) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1110, - ts_ns_exch=1110, - state_type="working", - ) - ) - before = state.canonical_orders[(instrument, client_order_id)] - assert before.state == "accepted" - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1120, - ts_ns_exch=1120, - state_type="replaced", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "accepted" - assert projection.updated_ts_ns_local == 1110 - - -def test_expired_does_not_introduce_canonical_expired_state() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-canonical-expired-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1200) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - state.apply_order_state_event( - _order_state_event( - instrument, - client_order_id, - ts_ns_local=1210, - ts_ns_exch=1210, - state_type="expired", - ) - ) - - projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "submitted" - assert projection.updated_ts_ns_local == 1200 - assert client_order_id not in state.orders.get(instrument, {}) - - -def test_snapshot_fill_progression_does_not_mutate_canonical_fill_reducer_buckets() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-snapshot-fill-guard-1" - state = StrategyState(event_bus=NullEventBus()) - - state.update_timestamp(1300) - state.mark_intent_sent(instrument=instrument, client_order_id=client_order_id, intent_type="new") - - first_partial = _order_state_event( - instrument, - client_order_id, - ts_ns_local=1310, - ts_ns_exch=1310, - state_type="partially_filled", - ).model_copy( - update={ - "filled_price": Price(currency="USDC", value=100.25), - "cum_filled_qty": Quantity(unit="contracts", value=0.25), - "remaining_qty": Quantity(unit="contracts", value=0.75), - } - ) - second_partial = _order_state_event( - instrument, - client_order_id, - ts_ns_local=1320, - ts_ns_exch=1320, - state_type="partially_filled", - ).model_copy( - update={ - "filled_price": Price(currency="USDC", value=100.50), - "cum_filled_qty": Quantity(unit="contracts", value=0.50), - "remaining_qty": Quantity(unit="contracts", value=0.50), - } - ) - - state.apply_order_state_event(first_partial) - state.apply_order_state_event(second_partial) - - # Compatibility snapshot/projection path remains active. - assert state.orders[instrument][client_order_id].state_type == "partially_filled" - assert state.orders[instrument][client_order_id].cum_filled_qty == 0.50 - assert state.canonical_orders[(instrument, client_order_id)].state == "submitted" - assert state.canonical_orders[(instrument, client_order_id)].updated_ts_ns_local == 1300 - - # Snapshot progression must not mutate canonical FillEvent reducer buckets. - assert state.fills == {} - assert state.fill_cum_qty == {} diff --git a/tests/semantics/state_transitions/test_terminal_clears_inflight.py b/tests/semantics/state_transitions/test_terminal_clears_inflight.py deleted file mode 100644 index 6bada2c..0000000 --- a/tests/semantics/state_transitions/test_terminal_clears_inflight.py +++ /dev/null @@ -1,26 +0,0 @@ -""" -Semantic test: terminal clears inflight. - -Invariant: -Any terminal order event must clear inflight state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_terminal_clears_inflight() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Precondition: order is inflight - state.inflight[instrument] = {client_order_id} - - # Simulate terminal event cleanup - state.inflight[instrument].discard(client_order_id) - - assert client_order_id not in state.inflight.get(instrument, set()) diff --git a/tests/semantics/state_transitions/test_working_to_filled.py b/tests/semantics/state_transitions/test_working_to_filled.py deleted file mode 100644 index de5a89f..0000000 --- a/tests/semantics/state_transitions/test_working_to_filled.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Semantic test: working -> filled. - -Invariant: -A filled order must be removed from the working orders state. -""" - -from __future__ import annotations - -from tradingchassis_core.core.domain.state import StrategyState -from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus - - -def test_working_transitions_to_filled() -> None: - instrument = "BTC-USDC-PERP" - client_order_id = "order-1" - - state = StrategyState(event_bus=NullEventBus()) - - # Precondition: order is working - state.orders[instrument] = { - client_order_id: { - "status": "working", - } - } - - # Simulate terminal fill - del state.orders[instrument][client_order_id] - - assert client_order_id not in state.orders.get(instrument, {}) diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py new file mode 100644 index 0000000..700f3ef --- /dev/null +++ b/tests/semantics/test_core_pipeline_clean.py @@ -0,0 +1,95 @@ +"""Clean CoreStep/CoreWakeupStep pipeline tests.""" + +from __future__ import annotations + +import tradingchassis_core as tc + + +class _OneIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def test_run_core_step_clean_pipeline_dispatchable() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=True, + ), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ("intent-1",) + assert tuple(intent.client_order_id for intent in result.candidate_intents) == ("intent-1",) + assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ("intent-1",) + assert result.core_step_decision is not None + assert not hasattr(result, "compat_gate_decision") + + +def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100), _control_entry(1, 101)), + strategy_evaluator=_OneIntentEvaluator(), + strategy_event_filter=lambda _event: True, + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.generated_intents) == 2 + assert len(result.candidate_intent_records) == 1 + assert len(result.dispatchable_intents) == 1 diff --git a/tests/semantics/test_no_runtime_dependencies.py b/tests/semantics/test_no_runtime_dependencies.py new file mode 100644 index 0000000..4c7a075 --- /dev/null +++ b/tests/semantics/test_no_runtime_dependencies.py @@ -0,0 +1,13 @@ +"""Guards that core package stays runtime-independent.""" + +from __future__ import annotations + +from pathlib import Path + + +def test_core_package_has_no_runtime_or_hftbacktest_imports() -> None: + package_root = Path(__file__).resolve().parents[2] / "tradingchassis_core" + for file_path in package_root.rglob("*.py"): + content = file_path.read_text(encoding="utf-8") + assert "hftbacktest" not in content + assert "core_runtime" not in content diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py new file mode 100644 index 0000000..e50ad61 --- /dev/null +++ b/tests/semantics/test_public_api_clean.py @@ -0,0 +1,30 @@ +"""Public API surface checks for clean Core exports.""" + +from __future__ import annotations + +import tradingchassis_core as tc + + +def test_public_api_exposes_clean_core_symbols() -> None: + for symbol in ( + "run_core_step", + "run_core_wakeup_step", + "CoreStepResult", + "CoreStepDecision", + "CorePolicyAdmissionContext", + "CoreExecutionControlApplyContext", + "ControlTimeEvent", + "MarketEvent", + "OrderSubmittedEvent", + "OrderExecutionFeedbackEvent", + "FillEvent", + "ExecutionControl", + "NullEventBus", + ): + assert hasattr(tc, symbol), symbol + + +def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: + assert not hasattr(tc, "GateDecision") + assert not hasattr(tc, "ControlTimeQueueReevaluationContext") + assert not hasattr(tc, "CoreDecisionContext") diff --git a/tests/semantics/test_risk_engine_policy_only.py b/tests/semantics/test_risk_engine_policy_only.py new file mode 100644 index 0000000..ce3a245 --- /dev/null +++ b/tests/semantics/test_risk_engine_policy_only.py @@ -0,0 +1,60 @@ +"""RiskEngine policy-only behavior tests.""" + +from __future__ import annotations + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import NotionalLimits + + +def _risk_config() -> tc.RiskConfig: + return tc.RiskConfig( + scope="test", + trading_enabled=True, + notional_limits=NotionalLimits( + currency="USDC", + max_gross_notional=1_000_000.0, + max_single_order_notional=1_000_000.0, + ), + position_limits=None, + quote_limits=None, + order_rate_limits=None, + max_loss=None, + ) + + +def _intent() -> tc.NewOrderIntent: + return tc.NewOrderIntent( + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="risk-intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + + +def test_risk_engine_evaluate_policy_intent_accepts_valid_intent() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + state.update_market( + instrument="BTC-USDC-PERP", + best_bid=99.0, + best_ask=101.0, + best_bid_qty=1.0, + best_ask_qty=1.0, + tick_size=0.1, + lot_size=0.01, + contract_size=1.0, + ts_ns_local=10, + ts_ns_exch=9, + ) + engine = tc.RiskEngine(_risk_config()) + accepted, reason = engine.evaluate_policy_intent( + intent=_intent(), + state=state, + now_ts_ns_local=10, + ) + assert accepted is True + assert reason is None diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 52a23e3..2f5fcf2 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -1,8 +1,4 @@ -"""Public API for the tradingchassis_core package. - -Only symbols imported here are considered part of the stable, -supported external interface. -""" +"""Public API for the tradingchassis_core package.""" from __future__ import annotations @@ -38,75 +34,54 @@ ProcessingPosition, ) from tradingchassis_core.core.domain.processing_step import ( - ControlTimeQueueReevaluationContext, - CoreDecisionContext, CoreExecutionControlApplyContext, CorePolicyAdmissionContext, + CoreStepStrategyContext, + CoreStepStrategyEvaluator, CoreWakeupReductionResult, run_core_step, run_core_wakeup_decision, run_core_wakeup_reduction, run_core_wakeup_step, ) - -# ---------------------------------------------------------------------- -# Backtest Engine API -# ---------------------------------------------------------------------- -# -# Backtest engine/runtime code is runtime-owned and has moved to the -# Core Runtime repository (import from `core_runtime.backtest.*`). -# -# This semantic-core package must remain importable without the runtime layer. from tradingchassis_core.core.domain.slots import ( SlotKey, stable_slot_order_id, ) - -# ---------------------------------------------------------------------- -# Domain Types (used by strategies) -# ---------------------------------------------------------------------- from tradingchassis_core.core.domain.state import StrategyState from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( + ControlTimeEvent, + FillEvent, MarketEvent, NewOrderIntent, + NotionalLimits, + OrderExecutionFeedbackEvent, OrderIntent, + OrderSubmittedEvent, Price, Quantity, ReplaceOrderIntent, RiskConstraints, ) -from tradingchassis_core.core.ports.engine_context import EngineContext - -# ---------------------------------------------------------------------- -# Config API (used by consumers) -# ---------------------------------------------------------------------- +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl from tradingchassis_core.core.risk.risk_config import RiskConfig -from tradingchassis_core.core.risk.risk_engine import GateDecision - -# ---------------------------------------------------------------------- -# Strategy Interface -# ---------------------------------------------------------------------- -from tradingchassis_core.strategies.base import Strategy -from tradingchassis_core.strategies.strategy_config import StrategyConfig - -# ---------------------------------------------------------------------- -# Public API definition -# ---------------------------------------------------------------------- +from tradingchassis_core.core.risk.risk_engine import RiskEngine __all__ = [ - # Config + "CoreConfiguration", "RiskConfig", - "StrategyConfig", - - # Strategy interface - "Strategy", - - # Strategy-facing domain API + "RiskEngine", "StrategyState", "MarketEvent", + "ControlTimeEvent", + "OrderSubmittedEvent", + "OrderExecutionFeedbackEvent", + "FillEvent", "RiskConstraints", + "NotionalLimits", "OrderIntent", "NewOrderIntent", "ReplaceOrderIntent", @@ -114,23 +89,21 @@ "Quantity", "SlotKey", "stable_slot_order_id", - "EngineContext", - "GateDecision", - "CoreConfiguration", "CandidateIntentOrigin", "CandidateIntentRecord", "ProcessingPosition", "EventStreamEntry", "process_event_entry", + "fold_event_stream_entries", "run_core_step", "run_core_wakeup_reduction", "run_core_wakeup_decision", "run_core_wakeup_step", - "CoreDecisionContext", + "CoreStepStrategyContext", + "CoreStepStrategyEvaluator", "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", "CoreWakeupReductionResult", - "ControlTimeQueueReevaluationContext", "ExecutionControlDecision", "ExecutionControlApplyContext", "ExecutionControlApplyResult", @@ -142,17 +115,12 @@ "PolicyRejectedCandidate", "PolicyAdmissionResult", "CoreStepDecision", - "fold_event_stream_entries", "CoreStepResult", - - # Version + "ExecutionControl", + "NullEventBus", "__version__", ] -# ---------------------------------------------------------------------- -# Package version -# ---------------------------------------------------------------------- - try: __version__ = version("tradingchassis-core") except PackageNotFoundError: diff --git a/tradingchassis_core/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py index 8cfff6a..9fbf55e 100644 --- a/tradingchassis_core/core/domain/__init__.py +++ b/tradingchassis_core/core/domain/__init__.py @@ -19,8 +19,6 @@ PolicyRiskDecision, ) from tradingchassis_core.core.domain.processing_step import ( - ControlTimeQueueReevaluationContext, - CoreDecisionContext, CoreExecutionControlApplyContext, CorePolicyAdmissionContext, CoreWakeupReductionResult, @@ -47,11 +45,9 @@ "PolicyAdmissionResult", "CoreStepDecision", "CoreStepResult", - "CoreDecisionContext", "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", "CoreWakeupReductionResult", - "ControlTimeQueueReevaluationContext", "run_core_wakeup_reduction", "run_core_wakeup_decision", "run_core_wakeup_step", diff --git a/tradingchassis_core/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py index 60fc60a..9258c95 100644 --- a/tradingchassis_core/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -1,12 +1,4 @@ -"""Docs-aligned event taxonomy markers for core. - -This module is intentionally lightweight. It defines semantic markers used to -disambiguate canonical Event Stream candidates from non-canonical artifacts in -the current core codebase. - -It does not implement Event Stream append semantics, Processing Order, replay, -or transport behavior. -""" +"""Canonical event taxonomy markers for core.""" from __future__ import annotations @@ -17,11 +9,9 @@ FillEvent, MarketEvent, OrderExecutionFeedbackEvent, - OrderStateEvent, OrderSubmittedEvent, ) from tradingchassis_core.core.events.events import ( - DerivedFillEvent, DerivedPnLEvent, ExposureDerivedEvent, OrderStateTransitionEvent, @@ -31,7 +21,7 @@ class CanonicalEventCategory(str, Enum): - """Canonical Event Stream categories from docs.""" + """Canonical Event Stream categories.""" MARKET = "market" INTENT_RELATED = "intent_related" @@ -43,10 +33,6 @@ class CanonicalEventCategory(str, Enum): category.value for category in CanonicalEventCategory ) - -# Canonical Event Stream candidates recognized in this slice. -# Note: FillEvent is tracked as a canonical execution-event candidate, but -# candidate status does not imply it is newly wired into runtime flow. CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE: dict[type[object], CanonicalEventCategory] = { MarketEvent: CanonicalEventCategory.MARKET, OrderSubmittedEvent: CanonicalEventCategory.INTENT_RELATED, @@ -55,8 +41,6 @@ class CanonicalEventCategory(str, Enum): ControlTimeEvent: CanonicalEventCategory.CONTROL, } - -# Non-canonical telemetry / observability records. TELEMETRY_EVENT_TYPES: frozenset[type[object]] = frozenset( { RiskDecisionEvent, @@ -66,17 +50,6 @@ class CanonicalEventCategory(str, Enum): } ) - -# Compatibility projection records (kept for current snapshot-driven flow). -COMPATIBILITY_PROJECTION_TYPES: frozenset[type[object]] = frozenset( - { - OrderStateEvent, - DerivedFillEvent, - } -) - - -# Non-canonical runtime-facing control helper. This is intentionally not an Event. NON_CANONICAL_CONTROL_HELPER_TYPES: frozenset[type[object]] = frozenset( {ControlSchedulingObligation} ) @@ -84,12 +57,9 @@ class CanonicalEventCategory(str, Enum): def canonical_category_for_type(record_type: type[object]) -> CanonicalEventCategory | None: """Return canonical category for recognized canonical stream candidates.""" - return CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE.get(record_type) def is_canonical_stream_candidate_type(record_type: type[object]) -> bool: """Return True when the type is marked as a canonical Event candidate.""" - return record_type in CANONICAL_STREAM_CANDIDATE_CATEGORY_BY_TYPE - diff --git a/tradingchassis_core/core/domain/execution_control_decision.py b/tradingchassis_core/core/domain/execution_control_decision.py index 1dcf8f8..13c9b33 100644 --- a/tradingchassis_core/core/domain/execution_control_decision.py +++ b/tradingchassis_core/core/domain/execution_control_decision.py @@ -1,4 +1,4 @@ -"""Core-owned execution-control decision scaffold and compatibility projection helpers.""" +"""Core-owned execution-control decision model.""" from __future__ import annotations @@ -6,12 +6,11 @@ from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision @dataclass(frozen=True, slots=True) class ExecutionControlDecision: - """Immutable non-canonical execution-control outcome projection.""" + """Immutable non-canonical execution-control outcome.""" queued_effective_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () @@ -37,18 +36,3 @@ def __post_init__(self) -> None: "execution_handled_intents", tuple(self.execution_handled_intents), ) - - -def map_compat_gate_decision_to_execution_control_decision( - decision: GateDecision, - *, - control_scheduling_obligation: ControlSchedulingObligation | None = None, -) -> ExecutionControlDecision: - """Project compatibility GateDecision into execution-control scaffold fields.""" - - return ExecutionControlDecision( - queued_effective_intents=tuple(decision.queued), - dispatchable_intents=tuple(decision.accepted_now), - execution_handled_intents=tuple(decision.handled_in_queue), - control_scheduling_obligation=control_scheduling_obligation, - ) diff --git a/tradingchassis_core/core/domain/order_lifecycle.py b/tradingchassis_core/core/domain/order_lifecycle.py deleted file mode 100644 index 9a2eb1b..0000000 --- a/tradingchassis_core/core/domain/order_lifecycle.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Canonical internal order lifecycle policy. - -This module defines a lightweight, internal-only lifecycle policy used by the -canonical order projection. Compatibility order states are normalized into -canonical lifecycle candidates before transition validation. -""" - -from __future__ import annotations - -CANONICAL_ORDER_STATES: frozenset[str] = frozenset( - { - "submitted", - "accepted", - "partially_filled", - "filled", - "canceled", - "rejected", - } -) - -CANONICAL_TERMINAL_ORDER_STATES: frozenset[str] = frozenset( - { - "filled", - "canceled", - "rejected", - } -) - -CANONICAL_ALLOWED_TRANSITIONS: dict[str, frozenset[str]] = { - "submitted": frozenset({"accepted", "rejected"}), - "accepted": frozenset({"partially_filled", "filled", "canceled"}), - "partially_filled": frozenset({"partially_filled", "filled", "canceled"}), - "filled": frozenset(), - "canceled": frozenset(), - "rejected": frozenset(), -} - -_COMPAT_TO_CANONICAL: dict[str, str | None] = { - "pending_new": None, - "accepted": "accepted", - "working": "accepted", - "partially_filled": "partially_filled", - "filled": "filled", - "canceled": "canceled", - "rejected": "rejected", - "replaced": None, - # Keep "expired" as compatibility/deferred for this slice. - "expired": None, -} - - -def normalize_compatibility_state_to_canonical(state_type: str) -> str | None: - """Map compatibility state values to canonical lifecycle candidates.""" - return _COMPAT_TO_CANONICAL.get(state_type) - - -def is_valid_canonical_order_transition(prev_state: str, next_state: str) -> bool: - """Return True when prev_state -> next_state is allowed canonically.""" - allowed = CANONICAL_ALLOWED_TRANSITIONS.get(prev_state) - if allowed is None: - return False - return next_state in allowed diff --git a/tradingchassis_core/core/domain/order_state_machine.py b/tradingchassis_core/core/domain/order_state_machine.py deleted file mode 100644 index e7463c2..0000000 --- a/tradingchassis_core/core/domain/order_state_machine.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Order lifecycle state machine definitions. - -This module defines the canonical order states and the allowed transitions -between them. It is intentionally passive and validation-only. - -The state machine is snapshot-driven and designed for observability, -debugging, and research instrumentation. It must NOT enforce behavior -or raise exceptions in production paths. -""" - -from __future__ import annotations - -# Terminal order states: once reached, the order is considered complete. -ORDER_TERMINAL_STATES: frozenset[str] = frozenset( - { - "filled", - "canceled", - "expired", - "rejected", - } -) - - -# Allowed order state transitions. -# -# Key : previous state (or None if order was not previously observed) -# Value : set of allowed next states -# -# Notes: -# - The state machine is best-effort and snapshot-driven. -# - Repeated states (e.g. partially_filled -> partially_filled) are allowed. -# - This definition is intentionally conservative and venue-agnostic. -ORDER_ALLOWED_TRANSITIONS: dict[str | None, frozenset[str]] = { - None: frozenset({"pending_new"}), - - "pending_new": frozenset( - { - "accepted", - "rejected", - } - ), - - "accepted": frozenset( - { - "working", - "canceled", - "rejected", - } - ), - - "working": frozenset( - { - "working", - "partially_filled", - "filled", - "canceled", - "replaced", - } - ), - - "partially_filled": frozenset( - { - "partially_filled", - "filled", - "canceled", - "replaced", - } - ), -} - - -def is_terminal_state(state: str) -> bool: - """Return True if the given state is terminal.""" - return state in ORDER_TERMINAL_STATES - - -def is_valid_transition(prev_state: str | None, next_state: str) -> bool: - """Return True if the transition prev_state -> next_state is allowed.""" - allowed = ORDER_ALLOWED_TRANSITIONS.get(prev_state) - if allowed is None: - return False - return next_state in allowed diff --git a/tradingchassis_core/core/domain/policy_risk_decision.py b/tradingchassis_core/core/domain/policy_risk_decision.py index afdd29d..19e364e 100644 --- a/tradingchassis_core/core/domain/policy_risk_decision.py +++ b/tradingchassis_core/core/domain/policy_risk_decision.py @@ -1,4 +1,4 @@ -"""Core-owned policy-risk decision scaffold and policy admission helpers.""" +"""Core-owned policy-risk decision model and policy admission helpers.""" from __future__ import annotations @@ -10,7 +10,6 @@ CandidateIntentRecord, ) from tradingchassis_core.core.domain.types import OrderIntent -from tradingchassis_core.core.risk.risk_engine import GateDecision if TYPE_CHECKING: from tradingchassis_core.core.domain.state import StrategyState @@ -38,17 +37,9 @@ class PolicyRiskDecision: def __post_init__(self) -> None: if not isinstance(self.accepted_intents, tuple): - object.__setattr__( - self, - "accepted_intents", - tuple(self.accepted_intents), - ) + object.__setattr__(self, "accepted_intents", tuple(self.accepted_intents)) if not isinstance(self.rejected_intents, tuple): - object.__setattr__( - self, - "rejected_intents", - tuple(self.rejected_intents), - ) + object.__setattr__(self, "rejected_intents", tuple(self.rejected_intents)) @dataclass(frozen=True, slots=True) @@ -70,23 +61,11 @@ class PolicyAdmissionResult: def __post_init__(self) -> None: if not isinstance(self.accepted_generated, tuple): - object.__setattr__( - self, - "accepted_generated", - tuple(self.accepted_generated), - ) + object.__setattr__(self, "accepted_generated", tuple(self.accepted_generated)) if not isinstance(self.rejected_generated, tuple): - object.__setattr__( - self, - "rejected_generated", - tuple(self.rejected_generated), - ) + object.__setattr__(self, "rejected_generated", tuple(self.rejected_generated)) if not isinstance(self.passthrough_queued, tuple): - object.__setattr__( - self, - "passthrough_queued", - tuple(self.passthrough_queued), - ) + object.__setattr__(self, "passthrough_queued", tuple(self.passthrough_queued)) def apply_policy_to_candidate_records( @@ -96,13 +75,7 @@ def apply_policy_to_candidate_records( now_ts_ns_local: int, policy_evaluator: PolicyIntentEvaluator, ) -> PolicyAdmissionResult: - """Apply policy admission to generated-origin candidates only. - - Side-effect contract: - - does not mutate candidate records; - - does not mutate queue/rate/inflight state by itself; - - does not emit events by itself. - """ + """Apply policy admission to generated-origin candidates only.""" accepted_generated: list[CandidateIntentRecord] = [] rejected_generated: list[PolicyRejectedCandidate] = [] @@ -145,21 +118,3 @@ def apply_policy_to_candidate_records( rejected_intents=tuple(rejected_intents), ), ) - - -def map_compat_gate_decision_to_policy_risk_decision( - decision: GateDecision, -) -> PolicyRiskDecision: - """Project compatibility GateDecision into policy-only scaffold fields. - - Notes: - - ``accepted_intents`` currently maps from ``accepted_now`` because the - compatibility gate does not expose a strict pre-execution-control - policy-accepted set. - - ``rejected_intents`` maps from the explicit rejected intent records. - """ - - return PolicyRiskDecision( - accepted_intents=tuple(decision.accepted_now), - rejected_intents=tuple(rejected.intent for rejected in decision.rejected), - ) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 3e5b0b8..afad7d2 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -1,9 +1,4 @@ -"""Higher-level Core step API skeleton. - -This module defines a transitional deterministic step entrypoint above the -canonical reducer boundary. In this phase, it delegates to process_event_entry -and returns an empty CoreStepResult contract value. -""" +"""Deterministic Core step orchestration over canonical reducer inputs.""" from __future__ import annotations @@ -16,7 +11,7 @@ apply_execution_control_plan, ) from tradingchassis_core.core.domain.execution_control_decision import ( - map_compat_gate_decision_to_execution_control_decision, + ExecutionControlDecision, ) from tradingchassis_core.core.domain.execution_control_plan import ( ExecutionControlCandidateInput, @@ -26,30 +21,24 @@ combine_candidate_intent_records, ) from tradingchassis_core.core.domain.policy_risk_decision import ( + PolicyAdmissionResult, PolicyIntentEvaluator, apply_policy_to_candidate_records, - map_compat_gate_decision_to_policy_risk_decision, ) 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_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult -from tradingchassis_core.core.domain.types import ControlTimeEvent, OrderIntent -from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.domain.types import OrderIntent if TYPE_CHECKING: from tradingchassis_core.core.execution_control.execution_control import ExecutionControl - from tradingchassis_core.core.risk.risk_engine import GateDecision, RiskEngine @dataclass(frozen=True, slots=True) class CoreStepStrategyContext: - """Deterministic strategy-evaluation context for one Core step. - - ``state`` is currently passed by reference for compatibility. Strategy - evaluators must treat it as read-only by contract in this scaffold slice. - """ + """Deterministic strategy-evaluation context for one Core step.""" state: StrategyState event: object @@ -64,31 +53,6 @@ def evaluate(self, context: CoreStepStrategyContext) -> Sequence[OrderIntent]: """Evaluate strategy once for the provided step context.""" -@dataclass(frozen=True, slots=True) -class ControlTimeQueueReevaluationContext: - """Deterministic context for control-time queue re-evaluation in Core.""" - - risk_engine: RiskEngine - instrument: str - now_ts_ns_local: int - - -@dataclass(frozen=True, slots=True) -class CoreDecisionContext: - """Optional deterministic context for candidate-intent decision capture. - - Notes: - - ``capture_only`` controls only result projection behavior. - - The compatibility RiskEngine path may still mutate queue/rate state. - """ - - risk_engine: RiskEngine - now_ts_ns_local: int - instrument: str | None = None - enable_candidate_intent_decision: bool = False - capture_only: bool = True - - @dataclass(frozen=True, slots=True) class CorePolicyAdmissionContext: """Optional side-effect-safe policy admission capture context.""" @@ -122,50 +86,28 @@ def __post_init__(self) -> None: object.__setattr__(self, "generated_intents", tuple(self.generated_intents)) -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 _resolve_candidate_instrument( - *, - entry: EventStreamEntry, - control_time_queue_context: ControlTimeQueueReevaluationContext | None, -) -> str | None: +def _resolve_candidate_instrument(*, entry: EventStreamEntry) -> str | None: event_instrument = getattr(entry.event, "instrument", None) if isinstance(event_instrument, str): return event_instrument - if control_time_queue_context is not None: - return control_time_queue_context.instrument return None -def _map_compat_gate_decision_to_core_step_decision( +def _to_core_step_decision( *, - decision: GateDecision, - control_scheduling_obligation: ControlSchedulingObligation | None, + policy_result: PolicyAdmissionResult, + execution_control_decision: ExecutionControlDecision, ) -> CoreStepDecision: return CoreStepDecision( - policy_rejected_intents=tuple(rejected.intent for rejected in decision.rejected), - policy_risk_decision=map_compat_gate_decision_to_policy_risk_decision(decision), - execution_control_decision=map_compat_gate_decision_to_execution_control_decision( - decision, - control_scheduling_obligation=control_scheduling_obligation, + policy_rejected_intents=tuple( + rejected.record.intent for rejected in policy_result.rejected_generated ), - queued_effective_intents=tuple(decision.queued), - dispatchable_intents=tuple(decision.accepted_now), - execution_handled_intents=tuple(decision.handled_in_queue), - control_scheduling_obligation=control_scheduling_obligation, + policy_risk_decision=policy_result.policy_risk_decision, + execution_control_decision=execution_control_decision, + queued_effective_intents=execution_control_decision.queued_effective_intents, + dispatchable_intents=execution_control_decision.dispatchable_intents, + execution_handled_intents=execution_control_decision.execution_handled_intents, + control_scheduling_obligation=execution_control_decision.control_scheduling_obligation, ) @@ -174,36 +116,15 @@ def run_core_step( entry: EventStreamEntry, *, configuration: CoreConfiguration | None = None, - control_time_queue_context: ControlTimeQueueReevaluationContext | None = None, policy_admission_context: CorePolicyAdmissionContext | None = None, execution_control_apply_context: CoreExecutionControlApplyContext | None = None, - core_decision_context: CoreDecisionContext | None = None, strategy_evaluator: CoreStepStrategyEvaluator | None = None, ) -> CoreStepResult: - """Run one transitional Core step. - - Behavior in this phase: - - delegates event processing to the canonical boundary via process_event_entry; - - computes generated/candidate intents deterministically; - - optionally captures compatibility decision projections via core_decision_context; - - preserves the existing control-time queue reevaluation compatibility path. - """ + """Run one deterministic Core step.""" if execution_control_apply_context is not None and policy_admission_context is None: raise ValueError( "execution_control_apply_context requires policy_admission_context" ) - if ( - isinstance(entry.event, ControlTimeEvent) - and control_time_queue_context is not None - and ( - policy_admission_context is not None - or execution_control_apply_context is not None - ) - ): - raise ValueError( - "control_time_queue_context cannot be combined with " - "policy_admission_context or execution_control_apply_context" - ) process_event_entry(state, entry, configuration=configuration) @@ -217,10 +138,7 @@ def run_core_step( ) generated_intents = tuple(strategy_evaluator.evaluate(strategy_context)) - snapshot_instrument = _resolve_candidate_instrument( - entry=entry, - control_time_queue_context=control_time_queue_context, - ) + snapshot_instrument = _resolve_candidate_instrument(entry=entry) queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) candidate_intent_records = combine_candidate_intent_records( generated_intents=generated_intents, @@ -228,156 +146,66 @@ def run_core_step( ) candidate_intents = tuple(record.intent for record in candidate_intent_records) - # Preserve the existing ControlTimeEvent compatibility path behavior. - if isinstance(entry.event, ControlTimeEvent) and control_time_queue_context is not None: - popped_intents = state.pop_queued_intents(control_time_queue_context.instrument) - if not popped_intents: - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - ) - - decision = control_time_queue_context.risk_engine.decide_intents( - raw_intents=popped_intents, - state=state, - now_ts_ns_local=control_time_queue_context.now_ts_ns_local, - ) - selected_obligation = _select_effective_control_scheduling_obligation(decision) - core_step_decision = _map_compat_gate_decision_to_core_step_decision( - decision=decision, - control_scheduling_obligation=selected_obligation, - ) + if policy_admission_context is None: return CoreStepResult( generated_intents=generated_intents, candidate_intent_records=candidate_intent_records, candidate_intents=candidate_intents, - dispatchable_intents=tuple(decision.accepted_now), - control_scheduling_obligation=selected_obligation, - core_step_decision=core_step_decision, - compat_gate_decision=decision, ) - if ( - policy_admission_context is not None - and core_decision_context is not None - and core_decision_context.enable_candidate_intent_decision - ): - raise ValueError( - "policy_admission_context cannot be combined with " - "core_decision_context.enable_candidate_intent_decision=True" - ) - if policy_admission_context is not None: - policy_result = apply_policy_to_candidate_records( - candidate_intent_records, - state=state, - now_ts_ns_local=policy_admission_context.now_ts_ns_local, - policy_evaluator=policy_admission_context.policy_evaluator, - ) - execution_control_plan = plan_execution_control_candidates( - ExecutionControlCandidateInput( - accepted_generated=policy_result.accepted_generated, - passthrough_queued=policy_result.passthrough_queued, - ) + policy_result = apply_policy_to_candidate_records( + candidate_intent_records, + state=state, + now_ts_ns_local=policy_admission_context.now_ts_ns_local, + policy_evaluator=policy_admission_context.policy_evaluator, + ) + execution_control_plan = plan_execution_control_candidates( + ExecutionControlCandidateInput( + accepted_generated=policy_result.accepted_generated, + passthrough_queued=policy_result.passthrough_queued, ) - apply_result = None - if execution_control_apply_context is not None: - apply_result = apply_execution_control_plan( - execution_control_plan, - ExecutionControlApplyContext( - state=state, - execution_control=execution_control_apply_context.execution_control, - now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, - max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, - max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, - ), - ) - core_step_decision = CoreStepDecision( - policy_rejected_intents=tuple( - rejected.record.intent for rejected in policy_result.rejected_generated - ), - policy_risk_decision=policy_result.policy_risk_decision, - execution_control_decision=( - execution_control_plan.execution_control_decision - if apply_result is None - else apply_result.execution_control_decision - ), - queued_effective_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.queued_effective_intents - ), - dispatchable_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.dispatchable_intents - ), - execution_handled_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.execution_handled_intents - ), - control_scheduling_obligation=( - None - if apply_result is None - else apply_result.control_scheduling_obligation + ) + + apply_result = None + if execution_control_apply_context is not None: + apply_result = apply_execution_control_plan( + execution_control_plan, + ExecutionControlApplyContext( + state=state, + execution_control=execution_control_apply_context.execution_control, + now_ts_ns_local=execution_control_apply_context.now_ts_ns_local, + max_orders_per_sec=execution_control_apply_context.max_orders_per_sec, + max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, ), ) - dispatchable_intents: tuple[OrderIntent, ...] = () - control_scheduling_obligation = None - if apply_result is not None: - control_scheduling_obligation = apply_result.control_scheduling_obligation - if execution_control_apply_context.activate_dispatchable_outputs: - dispatchable_intents = tuple( - record.record.intent for record in apply_result.dispatchable_records - ) - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - dispatchable_intents=dispatchable_intents, - control_scheduling_obligation=control_scheduling_obligation, - core_step_decision=core_step_decision, - ) - if not isinstance(entry.event, ControlTimeEvent): - if ( - core_decision_context is not None - and core_decision_context.enable_candidate_intent_decision - and candidate_intents - ): - if not core_decision_context.capture_only: - raise NotImplementedError( - "core_decision_context capture_only=False is not supported yet" - ) - decision = core_decision_context.risk_engine.decide_intents( - raw_intents=list(candidate_intents), - state=state, - now_ts_ns_local=core_decision_context.now_ts_ns_local, - ) - selected_obligation = _select_effective_control_scheduling_obligation(decision) - core_step_decision = _map_compat_gate_decision_to_core_step_decision( - decision=decision, - control_scheduling_obligation=selected_obligation, - ) - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - core_step_decision=core_step_decision, - compat_gate_decision=decision, + + effective_execution_control_decision = ( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ) + core_step_decision = _to_core_step_decision( + policy_result=policy_result, + execution_control_decision=effective_execution_control_decision, + ) + + dispatchable_intents: tuple[OrderIntent, ...] = () + control_scheduling_obligation = None + if apply_result is not None: + control_scheduling_obligation = apply_result.control_scheduling_obligation + if execution_control_apply_context.activate_dispatchable_outputs: + dispatchable_intents = tuple( + record.record.intent for record in apply_result.dispatchable_records ) - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - ) - if control_time_queue_context is None: - return CoreStepResult( - generated_intents=generated_intents, - candidate_intent_records=candidate_intent_records, - candidate_intents=candidate_intents, - ) + return CoreStepResult( + generated_intents=generated_intents, + candidate_intent_records=candidate_intent_records, + candidate_intents=candidate_intents, + dispatchable_intents=dispatchable_intents, + control_scheduling_obligation=control_scheduling_obligation, + core_step_decision=core_step_decision, + ) def run_core_wakeup_reduction( @@ -388,12 +216,7 @@ def run_core_wakeup_reduction( strategy_evaluator: CoreStepStrategyEvaluator | None = None, strategy_event_filter: Callable[[object], bool] | None = None, ) -> CoreWakeupReductionResult: - """Reduce multiple canonical entries and collect wakeup-level generated intents. - - This reducer phase intentionally performs no policy, no execution-control plan, - and no execution-control apply. - """ - + """Reduce multiple canonical entries and collect wakeup-level generated intents.""" entries_tuple = tuple(entries) generated_intents: list[OrderIntent] = [] for entry in entries_tuple: @@ -468,34 +291,14 @@ def run_core_wakeup_decision( max_cancels_per_sec=execution_control_apply_context.max_cancels_per_sec, ), ) - core_step_decision = CoreStepDecision( - policy_rejected_intents=tuple( - rejected.record.intent for rejected in policy_result.rejected_generated - ), - policy_risk_decision=policy_result.policy_risk_decision, - execution_control_decision=( - execution_control_plan.execution_control_decision - if apply_result is None - else apply_result.execution_control_decision - ), - queued_effective_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.queued_effective_intents - ), - dispatchable_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.dispatchable_intents - ), - execution_handled_intents=( - () - if apply_result is None - else apply_result.execution_control_decision.execution_handled_intents - ), - control_scheduling_obligation=( - None if apply_result is None else apply_result.control_scheduling_obligation - ), + effective_execution_control_decision = ( + execution_control_plan.execution_control_decision + if apply_result is None + else apply_result.execution_control_decision + ) + core_step_decision = _to_core_step_decision( + policy_result=policy_result, + execution_control_decision=effective_execution_control_decision, ) dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation = None diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 46a9d77..72b287a 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1,37 +1,17 @@ -"""Runtime strategy state management. +"""Deterministic Core strategy state. -This module maintains best-effort market, account, order, and queue state -derived from venue snapshots and events. Internal records in this module are -derived-state structures, not canonical Event Stream records. - -It is intentionally stateful and optimized for correctness and determinism -rather than minimal complexity. +This state container keeps canonical reducer-owned data and execution-control +supporting structures (queue + inflight tracking). It intentionally excludes +snapshot-era order lifecycle compatibility reducers. """ -# pylint: disable=line-too-long,too-many-instance-attributes,too-many-public-methods -# pylint: disable=missing-function-docstring,too-many-locals,too-many-arguments -# pylint: disable=too-many-positional-arguments,too-many-return-statements -# pylint: disable=too-many-boolean-expressions from __future__ import annotations from collections import deque from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Iterable +from typing import TYPE_CHECKING, Iterable -from tradingchassis_core.core.domain.order_lifecycle import ( - is_valid_canonical_order_transition, - normalize_compatibility_state_to_canonical, -) -from tradingchassis_core.core.domain.order_state_machine import is_valid_transition from tradingchassis_core.core.domain.processing_order import ProcessingPosition -from tradingchassis_core.core.domain.slots import SlotKey, stable_slot_order_id -from tradingchassis_core.core.domain.types import OrderStateEvent -from tradingchassis_core.core.events.events import ( - DerivedFillEvent, - DerivedPnLEvent, - ExposureDerivedEvent, - OrderStateTransitionEvent, -) if TYPE_CHECKING: from tradingchassis_core.core.domain.types import ( @@ -39,61 +19,15 @@ FillEvent, NewOrderIntent, OrderExecutionFeedbackEvent, - OrderExecutionFeedbackSnapshot, OrderIntent, OrderSubmittedEvent, ) from tradingchassis_core.core.events.event_bus import EventBus -# --------------------------------------------------------------------------- -# Internal state models -# -# These models are intentionally NOT part of the JSON-schema "source of truth". -# They exist to hold runtime state derived from adapter snapshots/events. -# --------------------------------------------------------------------------- - -TERMINAL_ORDER_STATES: set[str] = {"filled", "canceled", "expired", "rejected"} - -_UNKNOWN_CCY: str = "UNKNOWN" -_DEFAULT_QTY_UNIT: str = "contracts" - - -@dataclass(slots=True) -class OrderSnapshot: - """Best-effort compatibility order projection. - - This snapshot-facing structure supports compatibility ingestion/projection - flows and is not canonical lifecycle authority. - """ - - instrument: str - client_order_id: str - - ts_ns_exch: int - ts_ns_local: int - - order_type: str - time_in_force: str - state_type: str - - side: str - - intended_price: float - filled_price: float - - intended_qty: float - cum_filled_qty: float - remaining_qty: float - - # Best-effort request marker from runtime snapshots. - # Convention: 0 indicates no in-flight request. - req: int = 0 - - @dataclass(slots=True) class QueuedIntent: - """An intent stored for later sending (data only, no policy).""" + """An intent stored for later sending (data-only queue).""" intent: OrderIntent queued_at_ts_ns: int @@ -109,33 +43,17 @@ class InflightInfo: ts_sent_ns_local: int -@dataclass(slots=True) -class CanonicalOrderProjection: - """Internal canonical order lifecycle projection.""" - - instrument: str - client_order_id: str - state: str - submitted_ts_ns_local: int - updated_ts_ns_local: int - - @dataclass(slots=True) class MarketState: """Best-effort market snapshot needed for risk checks.""" - # Receipt (local) time is the strategy time axis. last_ts_ns_local: int = 0 - # Venue time is used as a tie-breaker for replacement-style updates. last_ts_ns_exch: int = 0 - best_bid: float = 0.0 best_ask: float = 0.0 mid: float = 0.0 - best_bid_qty: float = 0.0 best_ask_qty: float = 0.0 - tick_size: float = 0.0 lot_size: float = 0.0 contract_size: float = 1.0 @@ -143,7 +61,7 @@ class MarketState: @dataclass(slots=True) class AccountState: - """Best-effort account values from runtime account snapshots.""" + """Best-effort account values from canonical execution feedback.""" position: float = 0.0 balance: float = 0.0 @@ -151,58 +69,64 @@ class AccountState: trading_volume: float = 0.0 trading_value: float = 0.0 num_trades: int = 0 - equity: float = 0.0 initial_equity: float = 0.0 realized_pnl: float = 0.0 +@dataclass(slots=True) +class WorkingOrder: + """Canonical in-memory view of an active order.""" + + instrument: str + client_order_id: str + side: str + intended_price: float + intended_qty: float + cum_filled_qty: float + remaining_qty: float + state: str + submitted_ts_ns_local: int + updated_ts_ns_local: int + + +@dataclass(slots=True) +class CanonicalOrderProjection: + """Internal canonical order lifecycle projection.""" + + instrument: str + client_order_id: str + state: str + submitted_ts_ns_local: int + updated_ts_ns_local: int + side: str | None = None + intended_price: float | None = None + intended_qty: float | None = None + + class StrategyState: - """High-level strategy state keyed by instrument.""" + """High-level deterministic strategy state keyed by instrument.""" def __init__(self, event_bus: EventBus) -> None: self._event_bus = event_bus self.market: dict[str, MarketState] = {} self.account: dict[str, AccountState] = {} - self.orders: dict[str, dict[str, OrderSnapshot]] = {} - # Accumulates OrderStateEvents since the last consumer pop. - # Used to surface edge-events such as "replaced" to strategies. - self.order_events: dict[str, deque[OrderStateEvent]] = {} + self.orders: dict[str, dict[str, WorkingOrder]] = {} self.fills: dict[str, deque[FillEvent]] = {} - # Best-effort idempotence for fill deltas. - # Tracks last observed cumulative filled quantity per (instrument, client_order_id). self.fill_cum_qty: dict[str, dict[str, float]] = {} self.queued_intents: dict[str, deque[QueuedIntent]] = {} - self.inflight: dict[str, dict[str, InflightInfo]] = {} - # Internal canonical lifecycle projection keyed by (instrument, client_order_id). - # This projection is intentionally separate from compatibility snapshots. self.canonical_orders: dict[tuple[str, str], CanonicalOrderProjection] = {} - - # Best-effort tracking of last sent intent per (instrument, client_order_id). - # Mapping: instrument -> client_order_id -> (ts_ns_local, intent_type) self.last_sent_intents: dict[str, dict[str, tuple[int, str]]] = {} - - # Rolling equity series for rolling-loss checks. - # Stores (ts_ns_local, total_equity). self.rolling_equity: deque[tuple[int, float]] = deque() self._last_realized_pnl: dict[str, float] = {} self._last_exposure: dict[str, float] = {} - - # Canonical monotone simulation time (local/receipt axis). - # This is the single time reference used for gating and risk decisions. self.last_ts_ns_local: int = 0 - - # Migration-step Processing Order cursor metadata. - # Private: boundary-owned by process_canonical_event only. self._last_processing_position_index: int | None = None - # ---- Timestamp ---- def update_timestamp(self, ts_ns_local: int) -> None: - # Monotone simulation time: never regress. - # Using max() here makes the policy explicit. self.last_ts_ns_local = max(self.last_ts_ns_local, ts_ns_local) @property @@ -211,32 +135,17 @@ def sim_ts_ns_local(self) -> int: return self.last_ts_ns_local def _advance_processing_position(self, position: ProcessingPosition) -> None: - """Advance private Processing Order cursor for positioned canonical events.""" last = self._last_processing_position_index next_index = position.index - if last is not None and next_index <= last: raise ValueError( "Non-monotonic ProcessingPosition index: " f"received {next_index} after {last}." ) - self._last_processing_position_index = next_index def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: str) -> None: - """Record that an intent was sent to the execution layer. - - This is used for best-effort inflight handling. Backtest runtimes often provide snapshots - (status/req) rather than explicit ACK events, so inflight is cleared heuristically - as soon as subsequent snapshots indicate completion. - - Compatibility boundary: - - This mutates internal execution-control tracking only. - - For ``intent_type == "new"``, it seeds an internal canonical order - projection at ``submitted`` as sidecar projection state. - - It does not create a canonical Event Stream record. - """ - + """Record that an intent was sent to the execution layer.""" bucket = self.last_sent_intents.get(instrument) if bucket is None: bucket = {} @@ -249,7 +158,6 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s if inflight_bucket is None: inflight_bucket = {} self.inflight[instrument] = inflight_bucket - inflight_bucket[client_order_id] = InflightInfo(action=intent_type, ts_sent_ns_local=ts_now) if intent_type != "new": @@ -258,7 +166,6 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s key = (instrument, client_order_id) if key in self.canonical_orders: return - self.canonical_orders[key] = CanonicalOrderProjection( instrument=instrument, client_order_id=client_order_id, @@ -268,37 +175,46 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s ) def apply_order_submitted_event(self, event: OrderSubmittedEvent) -> None: - """Reduce canonical dispatch-time submitted entry into lifecycle projection. - - This reducer updates only internal canonical lifecycle projection state. - It intentionally does not mutate compatibility snapshot orders, inflight - bookkeeping, or last-sent tracking. - """ + """Reduce canonical submitted-entry into active-order projections.""" + self.update_timestamp(event.ts_ns_local_dispatch) key = (event.instrument, event.client_order_id) projection = self.canonical_orders.get(key) if projection is None: - self.canonical_orders[key] = CanonicalOrderProjection( + projection = CanonicalOrderProjection( instrument=event.instrument, client_order_id=event.client_order_id, state="submitted", submitted_ts_ns_local=event.ts_ns_local_dispatch, updated_ts_ns_local=event.ts_ns_local_dispatch, ) - return + self.canonical_orders[key] = projection - # Idempotent submitted-entry behavior: - # - If already submitted, keep existing projection unchanged. - # - If already beyond submitted, do not regress lifecycle state. - return + projection.state = "submitted" + projection.updated_ts_ns_local = max( + projection.updated_ts_ns_local, event.ts_ns_local_dispatch + ) + projection.side = event.side + projection.intended_price = event.intended_price.value + projection.intended_qty = event.intended_qty.value - def apply_control_time_event(self, event: ControlTimeEvent) -> None: - """Reduce canonical control-time event at boundary without state mutation. + order_bucket = self.orders.setdefault(event.instrument, {}) + order_bucket[event.client_order_id] = WorkingOrder( + instrument=event.instrument, + client_order_id=event.client_order_id, + side=event.side, + intended_price=event.intended_price.value, + intended_qty=event.intended_qty.value, + cum_filled_qty=0.0, + remaining_qty=event.intended_qty.value, + state="submitted", + submitted_ts_ns_local=event.ts_ns_local_dispatch, + updated_ts_ns_local=event.ts_ns_local_dispatch, + ) + self._clear_inflight(event.instrument, event.client_order_id) - This first core-only slice intentionally keeps ControlTimeEvent reduction - as a no-op for queue/rate/inflight and compatibility projections. - """ - _ = event - return + def apply_control_time_event(self, event: ControlTimeEvent) -> None: + """Reduce canonical control-time event without side effects.""" + self.update_timestamp(event.ts_ns_local_control) def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket = self.inflight.get(instrument) @@ -307,67 +223,11 @@ def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket.pop(client_order_id, None) def has_inflight(self, instrument: str, client_order_id: str) -> bool: - """Return True if an order id currently has an inflight marker.""" inflight_bucket = self.inflight.get(instrument) if inflight_bucket is None: return False return client_order_id in inflight_bucket - def _maybe_clear_inflight_from_snapshot(self, event: OrderStateEvent) -> None: - """Heuristically clear inflight markers based on snapshot state. - - This avoids leaking inflight entries when orders progress from pending to - working/terminal states. - """ - - # NOTE: - # Inflight clearing is heuristic because the venue provides snapshots, - # not explicit ACK / completion events. - # This is sufficient for backtest and research purposes, but does NOT - # guarantee exact ACK ordering as in a live FIX/WebSocket venue. - - inflight_bucket = self.inflight.get(event.instrument) - if inflight_bucket is None: - return - - info = inflight_bucket.get(event.client_order_id) - if info is None: - return - - if event.ts_ns_local < info.ts_sent_ns_local: - return - - req_val = 0 - if isinstance(event.raw, dict): - req_val = event.raw.get("req", 0) - - # If the snapshot indicates no active request, clear inflight as soon - # as the snapshot is at or after the send time. - if req_val == 0: - if event.state_type in TERMINAL_ORDER_STATES or event.state_type == "rejected": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if event.state_type in ("accepted", "working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - return - - if event.state_type in TERMINAL_ORDER_STATES or event.state_type == "rejected": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "new" and event.state_type in ("accepted", "working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "cancel" and event.state_type == "canceled": - self._clear_inflight(event.instrument, event.client_order_id) - return - - if info.action == "replace" and event.state_type in ("working", "partially_filled"): - self._clear_inflight(event.instrument, event.client_order_id) - - # ---- Market ---- def update_market( self, instrument: str, @@ -382,29 +242,16 @@ def update_market( ts_ns_local: int, ts_ns_exch: int, ) -> None: - """Low-level market reducer primitive. - - This method applies a market snapshot update directly to internal state. - It is intentionally preserved for compatibility and reducer-level tests. - For canonical candidates, prefer ``process_canonical_event`` as the - top-level canonical ingestion boundary. - """ m = self.market.get(instrument) if m is None: m = MarketState() self.market[instrument] = m - - # Replacement-style update policy: - # - primary sort key: receipt time (local) - # - tie-breaker: venue time (best-effort) if ts_ns_local < m.last_ts_ns_local: return if ts_ns_local == m.last_ts_ns_local and ts_ns_exch <= m.last_ts_ns_exch: return - m.last_ts_ns_local = ts_ns_local m.last_ts_ns_exch = ts_ns_exch - m.best_bid = best_bid m.best_ask = best_ask m.best_bid_qty = best_bid_qty @@ -412,11 +259,8 @@ def update_market( m.tick_size = tick_size m.lot_size = lot_size m.contract_size = contract_size - - if m.best_bid > 0.0 and m.best_ask > 0.0: - m.mid = 0.5 * (m.best_bid + m.best_ask) - else: - m.mid = 0.0 + m.mid = 0.5 * (m.best_bid + m.best_ask) if m.best_bid > 0.0 and m.best_ask > 0.0 else 0.0 + self.update_timestamp(ts_ns_local) def _update_market_from_positioned_canonical_event( self, @@ -432,20 +276,12 @@ def _update_market_from_positioned_canonical_event( ts_ns_local: int, ts_ns_exch: int, ) -> None: - """Apply market update for a positioned canonical event. - - This helper intentionally bypasses timestamp replacement no-op rules. - It is valid only when called after canonical boundary ProcessingPosition - monotonicity validation has accepted the event as the next causal input. - """ m = self.market.get(instrument) if m is None: m = MarketState() self.market[instrument] = m - m.last_ts_ns_local = ts_ns_local m.last_ts_ns_exch = ts_ns_exch - m.best_bid = best_bid m.best_ask = best_ask m.best_bid_qty = best_bid_qty @@ -453,11 +289,8 @@ def _update_market_from_positioned_canonical_event( m.tick_size = tick_size m.lot_size = lot_size m.contract_size = contract_size - - if m.best_bid > 0.0 and m.best_ask > 0.0: - m.mid = 0.5 * (m.best_bid + m.best_ask) - else: - m.mid = 0.0 + m.mid = 0.5 * (m.best_bid + m.best_ask) if m.best_bid > 0.0 and m.best_ask > 0.0 else 0.0 + self.update_timestamp(ts_ns_local) def get_mid(self, instrument: str) -> float: m = self.market.get(instrument) @@ -475,7 +308,6 @@ def get_lot_size(self, instrument: str) -> float: m = self.market.get(instrument) return 0.0 if m is None else m.lot_size - # ---- Account ---- def update_account( self, instrument: str, @@ -490,7 +322,6 @@ def update_account( if a is None: a = AccountState() self.account[instrument] = a - a.position = position a.balance = balance a.fee = fee @@ -500,67 +331,22 @@ def update_account( mid = self.get_mid(instrument) a.equity = a.balance + a.position * mid * self.get_contract_size(instrument) - if a.initial_equity == 0.0 and mid > 0.0: a.initial_equity = a.equity - a.realized_pnl = (a.equity - a.initial_equity) if a.initial_equity != 0.0 else 0.0 - self._update_rolling_equity(ts_ns_local=self.last_ts_ns_local) - # ---- Derived Realized PnL detection ---- - last = self._last_realized_pnl.get(instrument) - cur = a.realized_pnl - - if last is None: - # First observation: initialize baseline, no event - self._last_realized_pnl[instrument] = cur - elif cur != last: - self._event_bus.emit( - DerivedPnLEvent( - ts_ns_local=self.last_ts_ns_local, - instrument=instrument, - delta_pnl=cur - last, - cum_realized_pnl=cur, - ) - ) - self._last_realized_pnl[instrument] = cur - - # ---- Derived Exposure detection ---- - mid = self.get_mid(instrument) - contract_size = self.get_contract_size(instrument) - exposure = a.position * mid * contract_size - - last_exposure = self._last_exposure.get(instrument) - - if last_exposure is None: - # First observation establishes baseline - self._last_exposure[instrument] = exposure - elif exposure != last_exposure: - self._event_bus.emit( - ExposureDerivedEvent( - ts_ns_local=self.last_ts_ns_local, - instrument=instrument, - exposure=exposure, - delta_exposure=exposure - last_exposure, - ) - ) - self._last_exposure[instrument] = exposure - def _update_rolling_equity(self, *, ts_ns_local: int) -> None: if ts_ns_local <= 0: return - total_equity = sum(x.equity for x in self.account.values()) dq = self.rolling_equity - if dq: last_ts, last_eq = dq[-1] if ts_ns_local < last_ts: return if ts_ns_local == last_ts and total_equity == last_eq: return - dq.append((ts_ns_local, total_equity)) def get_total_equity(self) -> float: @@ -569,303 +355,71 @@ def get_total_equity(self) -> float: def get_rolling_loss(self, *, now_ts_ns_local: int, window_ns: int) -> float | None: if window_ns <= 0: return None - dq = self.rolling_equity if not dq: dq.append((now_ts_ns_local, self.get_total_equity())) return 0.0 - cutoff = now_ts_ns_local - window_ns while len(dq) > 1 and dq[0][0] < cutoff: dq.popleft() - start_ts, start_eq = dq[0] if start_ts > now_ts_ns_local: return None - cur_eq = self.get_total_equity() return float(cur_eq - start_eq) def get_total_pnl(self) -> float: return float(sum(a.realized_pnl for a in self.account.values())) - # ---- Orders ---- - @staticmethod - def _should_drop_transition_update(cur: OrderSnapshot, event: OrderStateEvent) -> bool: - """Return True if a late transition-style update should be ignored. - - Transition updates (live-style) must not be dropped purely because they - arrived late. They should only be dropped if they are idempotent and do - not advance the current snapshot. - - Accepted as progress: - - terminal state applied when current state is non-terminal - - increased cumulative filled quantity - - decreased remaining quantity - - clearing a request marker (req!=0 -> req==0) - """ - - if event.state_type in TERMINAL_ORDER_STATES and cur.state_type not in TERMINAL_ORDER_STATES: - return False - - event_cum = 0.0 - if event.cum_filled_qty is not None: - event_cum = event.cum_filled_qty.value - - if event_cum > cur.cum_filled_qty: - return False - - event_remaining = 0.0 - if event.remaining_qty is not None: - event_remaining = event.remaining_qty.value - else: - intended_qty = event.intended_qty.value - event_remaining = max(0.0, intended_qty - event_cum) - - if event_remaining < cur.remaining_qty: - return False - - current_req = 0 - if isinstance(event.raw, dict): - try: - current_req = event.raw["req"] # type: ignore[arg-type] - except KeyError: - current_req = 0 - - if cur.req != current_req and current_req == 0: - return False - - # Idempotent late update: ignore when it is strictly older. - if event.ts_ns_local < cur.ts_ns_local: - return True - if event.ts_ns_local == cur.ts_ns_local and event.ts_ns_exch < cur.ts_ns_exch: - return True - - # Equal/newer but not progressing: keep latest by timestamp. - return False - - def apply_order_state_event(self, event: OrderStateEvent) -> None: - """Reduce compatibility execution-feedback into snapshot-facing state. - - This is the compatibility reducer path for ``OrderStateEvent`` records. - It is not canonical Event Stream processing. Internal canonical-order - projection updates performed here are sidecar projection logic used to - keep compatibility pathways aligned with submitted-boundary semantics. - """ - self._advance_canonical_order_projection(event) - - events_bucket = self.order_events.setdefault(event.instrument, deque()) - bucket = self.orders.setdefault(event.instrument, {}) - cur = bucket.get(event.client_order_id) - - raw_dict: dict[str, object | None] = event.raw if isinstance(event.raw, dict) else None - - current_req = 0 - source = "transition" - if raw_dict is not None: - try: - current_req = raw_dict["req"] # type: ignore[arg-type] - except KeyError: - current_req = 0 - - try: - source = raw_dict["source"] # type: ignore[arg-type] - except KeyError: - source = "transition" - - prev_req = cur.req if cur is not None else 0 - - inflight_bucket = self.inflight.get(event.instrument) - inflight_info = None if inflight_bucket is None else inflight_bucket.get(event.client_order_id) - - if ( - inflight_info is not None - and inflight_info.action == "replace" - and prev_req != 0 - and current_req == 0 - and event.state_type not in TERMINAL_ORDER_STATES - and event.state_type != "rejected" - ): - replaced_event = event.model_copy(update={"state_type": "replaced"}) - events_bucket.append(replaced_event) - - if cur is not None: - # Late-update policy - # - # - Replacement-style events ("snapshot") may be safely treated as - # overwrites. Older snapshots must not overwrite newer snapshots. - # - Transition-style events ("transition") should not be dropped - # purely because they are late. A late terminal update or a late - # fill progression must still be applied. - is_snapshot = source == "snapshot" - - if is_snapshot: - if event.ts_ns_local < cur.ts_ns_local: - return - if event.ts_ns_local == cur.ts_ns_local and event.ts_ns_exch < cur.ts_ns_exch: - return - else: - if self._should_drop_transition_update(cur, event): - return - - # Treat 'replaced' as an edge event. The order identity remains and the - # snapshot should continue to exist in state. - if event.state_type == "replaced": - effective_state = "working" if cur is None else cur.state_type - event = event.model_copy(update={"state_type": effective_state}) - - # Order lifecycle state transition validation (observability only) - prev_state: str | None = None if cur is None else cur.state_type - next_state: str = event.state_type - - if not is_valid_transition(prev_state, next_state): - self._event_bus.emit( - OrderStateTransitionEvent( - ts_ns_local=event.ts_ns_local, - instrument=event.instrument, - client_order_id=event.client_order_id, - prev_state=prev_state, - next_state=next_state, - ) - ) - - # Derived Fill detection (snapshot-based) - if ( - cur is not None - and event.cum_filled_qty is not None - ): - prev_cum = cur.cum_filled_qty - new_cum = event.cum_filled_qty.value - - if new_cum > prev_cum: - self._event_bus.emit( - DerivedFillEvent( - ts_ns_local=event.ts_ns_local, - instrument=event.instrument, - client_order_id=event.client_order_id, - side=event.side, - delta_qty=new_cum - prev_cum, - cum_qty=new_cum, - price=( - event.filled_price.value - if event.filled_price is not None - else None - ), - ) - ) - - events_bucket.append(event) - - intended_price = event.intended_price.value - filled_price = event.filled_price.value if event.filled_price is not None else 0.0 - - intended_qty = event.intended_qty.value - cum_filled_qty = event.cum_filled_qty.value if event.cum_filled_qty is not None else 0.0 - remaining_qty = ( - event.remaining_qty.value - if event.remaining_qty is not None - else max(0.0, intended_qty - cum_filled_qty) - ) - - snap = OrderSnapshot( - instrument=event.instrument, - client_order_id=event.client_order_id, - ts_ns_exch=event.ts_ns_exch, - ts_ns_local=event.ts_ns_local, - order_type=event.order_type, - time_in_force=event.time_in_force, - state_type=event.state_type, - side=event.side, - intended_price=intended_price, - filled_price=filled_price, - intended_qty=intended_qty, - cum_filled_qty=cum_filled_qty, - remaining_qty=remaining_qty, - req=current_req, - ) - - # Clear inflight heuristically before any early return. - self._maybe_clear_inflight_from_snapshot(event) - - if snap.state_type in TERMINAL_ORDER_STATES: - bucket.pop(event.client_order_id, None) - self._clear_inflight(event.instrument, event.client_order_id) - last_bucket = self.last_sent_intents.get(event.instrument) - if last_bucket is not None: - last_bucket.pop(event.client_order_id, None) - return - - bucket[event.client_order_id] = snap - - def _advance_canonical_order_projection(self, event: OrderStateEvent) -> None: - key = (event.instrument, event.client_order_id) - projection = self.canonical_orders.get(key) - if projection is None: - return - - next_canonical_state = normalize_compatibility_state_to_canonical(event.state_type) - if next_canonical_state is None: - return - if event.ts_ns_local < projection.updated_ts_ns_local: - return - if not is_valid_canonical_order_transition(projection.state, next_canonical_state): - return - - projection.state = next_canonical_state - projection.updated_ts_ns_local = event.ts_ns_local - - # ---- Fills ---- - - # NOTE: - # Currently unused. - # Some backtest runtimes do not emit explicit FillEvent deltas; fills are inferred - # indirectly from order state snapshots instead. - # This method is reserved for event-driven backends or live trading venues - # that provide fill-level events. def apply_fill_event(self, event: FillEvent, *, max_keep: int = 10_000) -> None: - """Low-level fill reducer primitive. - - This method applies fill deltas directly to internal state and emits the - fill event on the bus. It remains available for compatibility and - reducer-level parity testing. For canonical candidates, prefer - ``process_canonical_event`` as the top-level canonical ingestion - boundary. - """ + """Reduce canonical fill deltas into fill and active-order projections.""" + self.update_timestamp(event.ts_ns_local) instrument = event.instrument client_order_id = event.client_order_id - bucket = self.fill_cum_qty[instrument] if instrument in self.fill_cum_qty else None - if bucket is None: - bucket = {} - self.fill_cum_qty[instrument] = bucket - + bucket = self.fill_cum_qty.setdefault(instrument, {}) cum_qty = event.cum_filled_qty.value - last_cum: float = bucket[client_order_id] if client_order_id in bucket else None - if last_cum is not None: - # Fill events are deltas. Duplicates commonly repeat the same cumulative filled. - # Late/out-of-order fills can arrive with a smaller cumulative filled. - # Both cases should be idempotent no-ops. - if cum_qty <= last_cum + 1e-12: - return - + last_cum = bucket.get(client_order_id) + if last_cum is not None and cum_qty <= last_cum + 1e-12: + return bucket[client_order_id] = cum_qty - dq = self.fills[instrument] if instrument in self.fills else None - if dq is None: - dq = deque() - self.fills[instrument] = dq - + dq = self.fills.setdefault(instrument, deque()) dq.append(event) while len(dq) > max_keep: dq.popleft() + order_bucket = self.orders.get(instrument) + if order_bucket is not None: + working = order_bucket.get(client_order_id) + if working is not None: + working.cum_filled_qty = cum_qty + if event.remaining_qty is not None: + working.remaining_qty = event.remaining_qty.value + else: + working.remaining_qty = max(0.0, working.intended_qty - cum_qty) + working.state = "filled" if working.remaining_qty <= 1e-12 else "partially_filled" + working.updated_ts_ns_local = event.ts_ns_local + if working.state == "filled": + order_bucket.pop(client_order_id, None) + self._clear_inflight(instrument, client_order_id) + + projection = self.canonical_orders.get((instrument, client_order_id)) + if projection is not None and event.ts_ns_local >= projection.updated_ts_ns_local: + if event.remaining_qty is not None and event.remaining_qty.value <= 1e-12: + projection.state = "filled" + else: + projection.state = "partially_filled" + projection.updated_ts_ns_local = event.ts_ns_local + self._event_bus.emit(event) def apply_order_execution_feedback_event( self, event: OrderExecutionFeedbackEvent, ) -> None: - """Reduce canonical order/account feedback into compatibility state reducers.""" + """Reduce canonical execution feedback into account state only.""" + self.update_timestamp(event.ts_ns_local_feedback) self.update_account( instrument=event.instrument, position=event.position, @@ -875,287 +429,20 @@ def apply_order_execution_feedback_event( trading_value=event.trading_value, num_trades=event.num_trades, ) - self.ingest_normalized_order_snapshots( - event.instrument, - event.order_snapshots, - ) - - def _map_snapshot_status( - self, - *, - instrument: str, - status: int, - req: int, - client_order_id: str, - ) -> str: - if status == 3: - return "filled" - if status == 4: - return "canceled" - if status == 5: - return "expired" - - if req != 0: - inflight_bucket = self.inflight.get(instrument) - inflight_info = ( - None if inflight_bucket is None else inflight_bucket.get(client_order_id) - ) - if inflight_info is not None and inflight_info.action == "new": - return "pending_new" - return "accepted" - - if status == 0: - return "accepted" - if status == 1: - return "working" - if status == 2: - return "partially_filled" - - return "rejected" - - def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable[object]) -> None: - """Ingest runtime order snapshots and reduce them into internal state. - - Snapshot-driven runtimes provide *snapshots* (not deltas). We translate each snapshot into - an OrderStateEvent (snapshot) and feed it into apply_order_state_event(). - - This is an adapter/materialization path for compatibility snapshot - ingestion. It is intentionally separate from canonical ingestion. - """ - - def map_status(status: int, req: int, client_order_id: str) -> str: - return self._map_snapshot_status( - instrument=instrument, - status=status, - req=req, - client_order_id=str(client_order_id), - ) - - # Some runtimes expose custom iterators with has_next/get semantics. - if hasattr(orders_snapshot_iter, "has_next") and hasattr(orders_snapshot_iter, "get"): - it = orders_snapshot_iter - - def _next() -> object | None: - return it.get() if it.has_next() else None - - while True: - o = _next() - if o is None: - break - self._ingest_one_snapshot_order(instrument, o, map_status) - return - - # Otherwise assume a normal Python iterable - for o in orders_snapshot_iter: - self._ingest_one_snapshot_order(instrument, o, map_status) - - def ingest_normalized_order_snapshots( - self, - instrument: str, - snapshots: Iterable[OrderExecutionFeedbackSnapshot], - ) -> None: - """Ingest normalized snapshot rows carried by canonical feedback events.""" - - def map_status(status: int, req: int, client_order_id: str) -> str: - return self._map_snapshot_status( - instrument=instrument, - status=status, - req=req, - client_order_id=str(client_order_id), - ) - - for snapshot in snapshots: - class _SnapshotRow: - __slots__ = ( - "order_id", - "order_type", - "side", - "time_in_force", - "status", - "req", - "price", - "qty", - "exec_price", - "exec_qty", - "leaves_qty", - "exch_timestamp", - "local_timestamp", - ) - - row = _SnapshotRow() - row.order_id = snapshot.order_id - row.order_type = snapshot.order_type - row.side = snapshot.side - row.time_in_force = snapshot.time_in_force - row.status = snapshot.status - row.req = snapshot.req - row.price = snapshot.price - row.qty = snapshot.qty - row.exec_price = snapshot.exec_price - row.exec_qty = snapshot.exec_qty - row.leaves_qty = snapshot.leaves_qty - row.exch_timestamp = snapshot.ts_ns_exch - row.local_timestamp = snapshot.ts_ns_local - self._ingest_one_snapshot_order(instrument, row, map_status) - - def _ingest_one_snapshot_order( - self, - instrument: str, - o: object, - map_status: Callable[[int, int, str], str], - ) -> None: - """Translate a single runtime order snapshot object into an OrderStateEvent.""" - - # --- Map primitive enums to your schema enums --- - order_type: str = "limit" if o.order_type == 0 else "market" - - # Snapshot adapters commonly use BUY=1, SELL=-1. - side: str = "buy" if o.side == 1 else "sell" - - tif: int = o.time_in_force - if tif == 0: - time_in_force = "GTC" - elif tif == 1: - time_in_force = "IOC" - elif tif == 2: - time_in_force = "FOK" - elif tif == 3: - time_in_force = "POST_ONLY" - else: - time_in_force = "GTC" - - req_val: int = o.req - - state_type: str = map_status(o.status, req_val, o.order_id) - - # --- Prices / quantities (schema requires structured objects) --- - intended_price: dict[str, str | float] = {"currency": _UNKNOWN_CCY, "value": o.price} - - exec_price: float = o.exec_price - filled_price: dict[str, str | float] = None if exec_price <= 0.0 else {"currency": _UNKNOWN_CCY, "value": exec_price} - - intended_qty: dict[str, str | float] = {"value": o.qty, "unit": _DEFAULT_QTY_UNIT} - - exec_qty: float = o.exec_qty - cum_filled_qty: dict[str, str | float] = None if exec_qty <= 0.0 else {"value": exec_qty, "unit": _DEFAULT_QTY_UNIT} - - leaves_qty: float = o.leaves_qty - remaining_qty: dict[str, str | float] = {"value": leaves_qty, "unit": _DEFAULT_QTY_UNIT} - - event = OrderStateEvent( - ts_ns_exch=o.exch_timestamp, - ts_ns_local=o.local_timestamp, - instrument=instrument, - client_order_id=str(o.order_id), - order_type=order_type, - state_type=state_type, - side=side, - intended_price=intended_price, - filled_price=filled_price, - intended_qty=intended_qty, - cum_filled_qty=cum_filled_qty, - remaining_qty=remaining_qty, - time_in_force=time_in_force, - reason=None, - raw={"status": o.status, "req": req_val, "source": "snapshot"}, - ) - - self.apply_order_state_event(event) - - def get_orders(self, instrument: str) -> dict[str, OrderSnapshot]: - """Return active order snapshots for an instrument (read-only view).""" - return self.orders.get(instrument, {}) - - # NOTE: - # Currently unused. - # OrderStateEvents are accumulated for observability (e.g. replaced, invalid - # transitions), but no strategy currently consumes edge-events explicitly. - # This hook is reserved for strategies that require order lifecycle events. - def pop_order_events(self, instrument: str) -> list[OrderStateEvent]: - """Return and clear accumulated OrderStateEvents for an instrument. - - The engine calls the strategy without passing a per-event stream. Strategies - that need edge-events (e.g. replaced) can consume them via this method. - """ - - dq = self.order_events.get(instrument) - if dq is None or not dq: - return [] - - out: list[OrderStateEvent] = list(dq) - dq.clear() - return out - - def get_working_order_snapshot(self, instrument: str, client_order_id: str) -> OrderSnapshot | None: - """Return an active order snapshot for an order id. - - Returns None if no active snapshot exists. - """ + def get_working_order_snapshot(self, instrument: str, client_order_id: str) -> WorkingOrder | None: bucket = self.orders.get(instrument) if bucket is None: return None return bucket.get(client_order_id) - # ---- Slot helpers (multi-level quoting) ---- - def slot_client_order_id(self, slot: SlotKey, namespace: str) -> str: - """Return the stable client_order_id for a slot.""" - return stable_slot_order_id(slot, namespace=namespace) - - def is_slot_busy(self, slot: SlotKey, namespace: str) -> bool: - """Return True if a slot is busy in queued ∪ working.""" - - client_order_id = self.slot_client_order_id(slot, namespace=namespace) - return self.is_order_id_busy(slot.instrument, client_order_id) - - # ---- Queue / existence helpers (C1) ---- - - # NOTE: - # Currently unused. - # SlotKey helpers are intended for slot-based / multi-level market making - # strategies with deterministic order identifiers. - # No slot-based strategy is implemented at this time. - def slot_key(self, instrument: str, side: str, level_index: int) -> SlotKey: - """Create a SlotKey for a given instrument/side/level.""" - - return SlotKey(instrument=str(instrument), side=str(side), level_index=int(level_index)) - - # NOTE: - # Currently unused. - # Deterministic slot-based order IDs are reserved for slot-driven quoting - # strategies. Current strategies generate order IDs explicitly. - def slot_order_id(self, slot: SlotKey, namespace: str) -> str: - """Return the deterministic client_order_id for a slot.""" - - return stable_slot_order_id(slot, namespace=namespace) - - def is_order_id_busy(self, instrument: str, client_order_id: str) -> bool: - """Return True if an order id exists in queued ∪ working.""" - - return bool( - self.has_working_order(instrument, client_order_id) - or self.has_queued_intent(instrument, client_order_id) - ) - - # NOTE: - # Currently unused. - # Slot occupancy checks are only relevant for slot-based strategies - # that enforce one active order per slot. - def is_slot_key_busy(self, slot: SlotKey, namespace: str) -> bool: - """Return True if a slot has a working order or queued intent.""" - - order_id = stable_slot_order_id(slot, namespace=namespace) - return self.is_order_id_busy(slot.instrument, order_id) - def has_working_order(self, instrument: str, client_order_id: str) -> bool: - """Check whether an active (non-terminal) order exists in working state.""" bucket = self.orders.get(instrument) if bucket is None: return False return client_order_id in bucket def has_queued_intent(self, instrument: str, client_order_id: str) -> bool: - """Check whether any queued intent exists for the given order id.""" q = self.queued_intents.get(instrument) if q is None: return False @@ -1163,39 +450,32 @@ def has_queued_intent(self, instrument: str, client_order_id: str) -> bool: return any(qi.logical_key == key for qi in q) def queued_intents_snapshot(self, instrument: str | None = None) -> tuple[OrderIntent, ...]: - """Return queued intents in deterministic stored order without mutation.""" if instrument is not None: q = self.queued_intents.get(instrument) if q is None: return () return tuple(qi.intent for qi in q) - snapshots: list[OrderIntent] = [] for instrument_key in sorted(self.queued_intents): snapshots.extend(qi.intent for qi in self.queued_intents[instrument_key]) return tuple(snapshots) def pop_queued_intents_for_order(self, instrument: str, client_order_id: str) -> list[QueuedIntent]: - """Remove and return all queued intents for the given order id.""" q = self.queued_intents.get(instrument) if q is None or not q: return [] - key = f"order:{client_order_id}" removed: list[QueuedIntent] = [] kept: deque[QueuedIntent] = deque() - for qi in q: if qi.logical_key == key: removed.append(qi) else: kept.append(qi) - self.queued_intents[instrument] = kept return removed def find_queued_new_intent(self, instrument: str, client_order_id: str) -> NewOrderIntent | None: - """Return the queued NEW intent for the given order id, if present.""" q = self.queued_intents.get(instrument) if q is None: return None @@ -1206,7 +486,6 @@ def find_queued_new_intent(self, instrument: str, client_order_id: str) -> NewOr return None def _intent_priority(self, intent: OrderIntent) -> int: - """Lower number means higher priority for flushing.""" if intent.intent_type == "cancel": return 0 if intent.intent_type == "replace": @@ -1216,12 +495,6 @@ def _intent_priority(self, intent: OrderIntent) -> int: return 9 def _compute_logical_key(self, intent: OrderIntent) -> str: - """Compute a stable key for queue replacement/deduplication. - - Contract: - - All order lifecycle operations are keyed by client_order_id. - - flags/target identifiers are intentionally not supported. - """ return f"order:{intent.client_order_id}" def merge_intents_into_queue( @@ -1229,45 +502,26 @@ def merge_intents_into_queue( instrument: str, intents: Iterable[OrderIntent], ) -> tuple[list[OrderIntent], list[tuple[OrderIntent, OrderIntent]], list[OrderIntent]]: - """Merge intents into the outbox queue with replacement semantics. - - This is OUTBOX DATA management only (no policy). The Risk/Gate decides what goes here. - - Replacement rules per logical_key: - - CANCEL dominates: - remove any queued NEW/REPLACE for that key and queue only CANCEL. - if CANCEL already queued, additional CANCEL replaces the older CANCEL (keep latest). - - REPLACE replaces queued NEW/REPLACE for that key. - if CANCEL is queued, the REPLACE is dropped (cancel dominates). - - NEW replaces queued NEW for that key. - if REPLACE or CANCEL is queued, the NEW is dropped (new is obsolete). - """ q: deque[QueuedIntent] = self.queued_intents.setdefault(instrument, deque()) - queued: list[OrderIntent] = [] replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] = [] dropped: list[OrderIntent] = [] - # Helper: find all queued entries matching key def _matching_entries(key: str) -> list[QueuedIntent]: return [qi for qi in q if qi.logical_key == key] for intent in intents: key = self._compute_logical_key(intent) prio = self._intent_priority(intent) - matches = _matching_entries(key) - has_cancel = any(qi.intent.intent_type == "cancel" for qi in matches) has_replace = any(qi.intent.intent_type == "replace" for qi in matches) has_new = any(qi.intent.intent_type == "new" for qi in matches) if intent.intent_type == "cancel": - # Remove all existing entries for the key (including older cancel) and keep latest cancel only. for qi in list(matches): q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1280,17 +534,13 @@ def _matching_entries(key: str) -> list[QueuedIntent]: continue if intent.intent_type == "replace": - # If a cancel is already queued, replace is obsolete. if has_cancel: dropped.append(intent) continue - - # Remove queued NEW/REPLACE for that key, keep only latest replace. for qi in list(matches): if qi.intent.intent_type in ("new", "replace"): q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1303,18 +553,14 @@ def _matching_entries(key: str) -> list[QueuedIntent]: continue if intent.intent_type == "new": - # If cancel or replace is already queued, new is obsolete. if has_cancel or has_replace: dropped.append(intent) continue - - # Replace only queued NEW for that key (keep latest new). if has_new: for qi in list(matches): if qi.intent.intent_type == "new": q.remove(qi) replaced_in_queue.append((qi.intent, intent)) - q.append( QueuedIntent( intent=intent, @@ -1326,7 +572,6 @@ def _matching_entries(key: str) -> list[QueuedIntent]: queued.append(intent) continue - # Unknown intent types are dropped to avoid silent weirdness. dropped.append(intent) return queued, replaced_in_queue, dropped @@ -1337,48 +582,29 @@ def pop_queued_intents( *, max_items: int | None = None, ) -> list[OrderIntent]: - """Pop flush candidates from the outbox queue. - - Ordering: - - priority (cancel -> replace -> new) - - FIFO by queued_at_ts_ns within same priority - - This function removes the selected items from the queue and returns their intents. - Gate may decide to re-queue them again if still rate-limited. - """ q: deque[QueuedIntent] = self.queued_intents.setdefault(instrument, deque()) if not q: return [] - items = list(q) items.sort(key=lambda qi: (qi.priority, qi.queued_at_ts_ns)) - # Inflight gating: do not emit intents for order ids that currently have - # an outbound request in flight. This keeps the queue stable and avoids - # sending replace storms while ACKs are pending. filtered: list[QueuedIntent] = [] for qi in items: if self.has_inflight(instrument, qi.intent.client_order_id): continue filtered.append(qi) - if max_items is None: - selected = filtered - else: - if max_items <= 0: - return [] - selected = filtered[:max_items] + selected = filtered if max_items is None else filtered[:max(0, max_items)] + if max_items is not None and max_items <= 0: + return [] selected_ids = {id(x) for x in selected} - out: list[OrderIntent] = [] new_q: deque[QueuedIntent] = deque() - for qi in q: if id(qi) in selected_ids: out.append(qi.intent) else: new_q.append(qi) - self.queued_intents[instrument] = new_q return out diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index b254aca..3b39d90 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -1,9 +1,4 @@ -"""Core step result contract model. - -This value object is the future return contract for a higher-level Core step. -It carries deterministic runtime-facing effects and optional compatibility -payloads without changing canonical reducer semantics. -""" +"""Core step result contract model.""" from __future__ import annotations @@ -13,12 +8,11 @@ from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.types import OrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation -from tradingchassis_core.core.risk.risk_engine import GateDecision @dataclass(frozen=True, slots=True) class CoreStepResult: - """Immutable result object for the future Core processing step API.""" + """Immutable result object for deterministic Core step APIs.""" generated_intents: tuple[OrderIntent, ...] = () candidate_intent_records: tuple[CandidateIntentRecord, ...] = () @@ -26,7 +20,6 @@ class CoreStepResult: dispatchable_intents: tuple[OrderIntent, ...] = () control_scheduling_obligation: ControlSchedulingObligation | None = None core_step_decision: CoreStepDecision | None = None - compat_gate_decision: GateDecision | None = None def __post_init__(self) -> None: if not isinstance(self.generated_intents, tuple): @@ -53,7 +46,6 @@ def __post_init__(self) -> None: "candidate_intents", tuple(record.intent for record in self.candidate_intent_records), ) - # Normalize sequence-like inputs to a tuple to keep deterministic value semantics. if not isinstance(self.dispatchable_intents, tuple): object.__setattr__( self, diff --git a/tradingchassis_core/core/domain/types.py b/tradingchassis_core/core/domain/types.py index 03d578d..972e600 100644 --- a/tradingchassis_core/core/domain/types.py +++ b/tradingchassis_core/core/domain/types.py @@ -1,62 +1,34 @@ -"""Core shared data models and schemas. - -This module defines Pydantic models used across the system for market data, -order intents, risk constraints, and execution feedback. - -Semantic notes for this refactor slice: -- ``MarketEvent`` is a canonical Market Event candidate. -- ``FillEvent`` is tracked as a canonical Execution Event candidate. -- ``OrderExecutionFeedbackEvent`` is a canonical execution-feedback event - candidate used for normalized rc3 runtime feedback migration. -- ``OrderStateEvent`` remains a compatibility execution-feedback / - snapshot-materialization record for now. - -These models are treated as schema definitions and intentionally prioritize -structural clarity over minimal class size. -""" +"""Core shared data models and schemas.""" # pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring from __future__ import annotations -from typing import Annotated, Any, Literal +from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, model_validator -# --------------------------------------------------------------------------- -# Common models -# --------------------------------------------------------------------------- - class Money(BaseModel): currency: str = Field(..., min_length=1) - amount: float = Field(...,) - + amount: float = Field(...) model_config = ConfigDict(extra="forbid") class Price(BaseModel): currency: str = Field(..., min_length=1) value: float = Field(..., ge=0) - model_config = ConfigDict(extra="forbid") class Quantity(BaseModel): value: float = Field(..., ge=0) - unit: str = Field(..., min_length=1) # e.g. "shares", "contracts", "BTC" - + unit: str = Field(..., min_length=1) model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# Market data models (MarketEvent + payloads) -# --------------------------------------------------------------------------- - - class BookLevel(BaseModel): price: Price quantity: Quantity - model_config = ConfigDict(extra="forbid") @@ -64,9 +36,7 @@ class BookPayload(BaseModel): book_type: Literal["snapshot", "delta"] bids: list[BookLevel] asks: list[BookLevel] - # depth is optional in the JSON schema, but must be >= 0 when present depth: int | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -75,29 +45,20 @@ class TradePayload(BaseModel): price: Price quantity: Quantity trade_id: str | None = Field(default=None, min_length=1) - model_config = ConfigDict(extra="forbid") class MarketEvent(BaseModel): ts_ns_exch: int = Field(..., gt=0) ts_ns_local: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) event_type: Literal["book", "trade"] - book: BookPayload | None = None trade: TradePayload | None = None - model_config = ConfigDict(extra="forbid") @model_validator(mode="after") def validate_payload_for_event_type(self) -> MarketEvent: - """ - Enforce the conditional requirements from the JSON schema: - - If event_type == "book": book must be present, trade must be None - - If event_type == "trade": trade must be present, book must be None - """ if self.event_type == "book": if self.book is None: raise ValueError("book payload is required when event_type is 'book'") @@ -117,146 +78,49 @@ def is_trade(self) -> bool: return self.event_type == "trade" -# --------------------------------------------------------------------------- -# Order intent models (discriminated union) -# --------------------------------------------------------------------------- - - TimeInForce = Literal["GTC", "IOC", "FOK", "POST_ONLY"] OrderType = Literal["limit", "market"] Side = Literal["buy", "sell"] class OrderIntentBase(BaseModel): - """ - Base fields shared by all order intents. - - Notes: - - client_order_id maps to the execution binding's order_id and is used for new/cancel/replace. - - intents_correlation_id is optional and can be used to link multiple intents together. - """ - - ts_ns_local: int = Field( - ..., - gt=0, - description="Local intent timestamp in nanoseconds since Unix epoch.", - ) - instrument: str = Field( - ..., - min_length=1, - description="Instrument identifier used for routing/execution binding (e.g., symbol, asset code).", - ) - client_order_id: str = Field( - ..., - min_length=1, - description=( - "Order identifier (maps to the execution binding's order_id). " - "Used for new/cancel/replace. Must be unique while an order with the same ID exists." - ), - ) - intents_correlation_id: str | None = Field( - default=None, - min_length=1, - description=( - "Optional correlation identifier to link multiple intents " - "(e.g., decision bundles) across the order lifecycle." - ), - ) - + ts_ns_local: int = Field(..., gt=0) + instrument: str = Field(..., min_length=1) + client_order_id: str = Field(..., min_length=1) + intents_correlation_id: str | None = Field(default=None, min_length=1) model_config = ConfigDict(extra="forbid") class NewOrderIntent(OrderIntentBase): - """ - Create a new order. - - Important: - - intended_price is required for both limit and market orders to match the execution binding signature. - """ - - intent_type: Literal["new"] = Field( - "new", - description="Intent type describing the order lifecycle action.", - ) - - side: Side = Field(..., description="Order side.") - order_type: OrderType = Field(..., description="Order type.") - intended_qty: Quantity = Field( - ..., - description="Intended total order quantity.", - ) - intended_price: Price = Field( - ..., - description=( - "Intended order price. Required for both limit and market orders " - "to match the execution binding signature." - ), - ) - time_in_force: TimeInForce = Field( - ..., - description="Time in force. Required for new intents.", - ) + intent_type: Literal["new"] = Field("new") + side: Side = Field(...) + order_type: OrderType = Field(...) + intended_qty: Quantity = Field(...) + intended_price: Price = Field(...) + time_in_force: TimeInForce = Field(...) class CancelOrderIntent(OrderIntentBase): - """ - Cancel an existing order identified by client_order_id. - - This intent deliberately forbids order-creation fields (side, order_type, qty, price, tif). - """ - - intent_type: Literal["cancel"] = Field( - "cancel", - description="Intent type describing the order lifecycle action.", - ) + intent_type: Literal["cancel"] = Field("cancel") class ReplaceOrderIntent(OrderIntentBase): - """ - Modify an existing order (limit-only). The order ID remains the same (client_order_id). - - Notes: - - order_type is constrained to 'limit'. - - time_in_force is intentionally not present here because the execution binding does not support modifying it. - - intended_qty is the new TOTAL quantity (not a delta). - """ - - intent_type: Literal["replace"] = Field( - "replace", - description="Intent type describing the order lifecycle action.", - ) - - side: Side = Field(..., description="Order side.") - order_type: Literal["limit"] = Field( - "limit", - description="Order type. For replace intents this must be 'limit'.", - ) - intended_qty: Quantity = Field( - ..., - description="Intended total order quantity (new total quantity, not a delta).", - ) - intended_price: Price = Field( - ..., - description="Intended order price.", - ) + intent_type: Literal["replace"] = Field("replace") + side: Side = Field(...) + order_type: Literal["limit"] = Field("limit") + intended_qty: Quantity = Field(...) + intended_price: Price = Field(...) -# Discriminated union: Pydantic will select the correct model based on intent_type. OrderIntent = Annotated[ NewOrderIntent | CancelOrderIntent | ReplaceOrderIntent, Field(discriminator="intent_type"), ] -# --------------------------------------------------------------------------- -# Risk constraints models -# --------------------------------------------------------------------------- - - class PositionLimits(BaseModel): currency: str = Field(..., min_length=1) max_position: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -264,7 +128,6 @@ class NotionalLimits(BaseModel): currency: str = Field(..., min_length=1) max_gross_notional: float | None = Field(default=None, ge=0) max_single_order_notional: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -273,14 +136,12 @@ class QuoteLimits(BaseModel): max_gross_quote_notional: float | None = Field(default=None, ge=0) max_net_quote_notional: float | None = None max_active_quotes: int | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") class OrderRateLimits(BaseModel): max_orders_per_second: float | None = Field(default=None, ge=0) max_cancels_per_second: float | None = Field(default=None, ge=0) - model_config = ConfigDict(extra="forbid") @@ -289,7 +150,6 @@ class MaxLoss(BaseModel): max_drawdown: float = Field(..., lt=0) rolling_loss: float | None = Field(default=None, lt=0) rolling_loss_window: float | None = Field(default=None, gt=0) - model_config = ConfigDict(extra="forbid") @@ -297,52 +157,32 @@ class RiskConstraints(BaseModel): ts_ns_local: int = Field(..., gt=0) scope: str = Field(..., min_length=1) trading_enabled: bool - position_limits: PositionLimits | None = None notional_limits: NotionalLimits | None = None quote_limits: QuoteLimits | None = None order_rate_limits: OrderRateLimits | None = None max_loss: MaxLoss | None = None - extra: dict[str, str | float | bool | None] = Field(default_factory=dict) - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# FillEvent model (delta event) -# --------------------------------------------------------------------------- - - class FillEvent(BaseModel): ts_ns_exch: int = Field(..., gt=0) ts_ns_local: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) client_order_id: str = Field(..., min_length=1) - side: Literal["buy", "sell"] intended_price: Price | None = None - filled_price: Price intended_qty: Quantity | None = None - cum_filled_qty: Quantity remaining_qty: Quantity | None = None - time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] liquidity_flag: Literal["maker", "taker", "unknown"] - fee: Money | None = None - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# OrderExecutionFeedbackEvent model (normalized rc3 feedback event) -# --------------------------------------------------------------------------- - - class OrderExecutionFeedbackSnapshot(BaseModel): order_id: str = Field(..., min_length=1) order_type: int @@ -350,79 +190,55 @@ class OrderExecutionFeedbackSnapshot(BaseModel): time_in_force: int status: int req: int - price: float qty: float = Field(..., ge=0) exec_price: float exec_qty: float = Field(..., ge=0) leaves_qty: float = Field(..., ge=0) - ts_ns_exch: int = Field(..., gt=0) ts_ns_local: int = Field(..., gt=0) - model_config = ConfigDict(extra="forbid") class OrderExecutionFeedbackEvent(BaseModel): ts_ns_local_feedback: int = Field(..., gt=0) instrument: str = Field(..., min_length=1) - position: float balance: float fee: float trading_volume: float trading_value: float num_trades: int - order_snapshots: tuple[OrderExecutionFeedbackSnapshot, ...] = Field( default_factory=tuple ) runtime_correlation: dict[str, str | int | float | bool | None] | None = None - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# OrderSubmittedEvent model (dispatch-time submitted boundary event) -# --------------------------------------------------------------------------- - - class OrderSubmittedEvent(BaseModel): ts_ns_local_dispatch: int = Field(..., gt=0) - instrument: str = Field(..., min_length=1) client_order_id: str = Field(..., min_length=1) - side: Literal["buy", "sell"] order_type: Literal["limit", "market"] - intended_price: Price intended_qty: Quantity time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] - intent_correlation_id: str | None = Field(default=None, min_length=1) dispatch_attempt_id: str | None = Field(default=None, min_length=1) runtime_correlation: dict[str, str | int | float | bool | None] | None = None - model_config = ConfigDict(extra="forbid") -# --------------------------------------------------------------------------- -# ControlTimeEvent model (runtime-realized control-time canonical event) -# --------------------------------------------------------------------------- - - class ControlTimeEvent(BaseModel): ts_ns_local_control: int = Field(..., gt=0) reason: str = Field(..., min_length=1) - due_ts_ns_local: int | None = Field(default=None, gt=0) realized_ts_ns_local: int | None = Field(default=None, gt=0) - obligation_reason: str | None = Field(default=None, min_length=1) obligation_due_ts_ns_local: int | None = Field(default=None, gt=0) runtime_correlation: dict[str, str | int | float | bool | None] | None = None - model_config = ConfigDict(extra="forbid") @model_validator(mode="after") @@ -432,51 +248,3 @@ def validate_due_or_realized_present(self) -> ControlTimeEvent: "at least one of due_ts_ns_local or realized_ts_ns_local is required" ) return self - - -# --------------------------------------------------------------------------- -# OrderStateEvent model (snapshot event) -# --------------------------------------------------------------------------- - - -class OrderStateEvent(BaseModel): - """Compatibility execution-feedback / snapshot-materialization record. - - ``OrderStateEvent`` remains non-canonical in this slice. It exists for - compatibility ingestion/projection flows and must not be interpreted as a - canonical Event Stream record. - """ - ts_ns_exch: int = Field(..., gt=0) - ts_ns_local: int = Field(..., gt=0) - - instrument: str = Field(..., min_length=1) - client_order_id: str = Field(..., min_length=1) - - order_type: Literal["limit", "market"] - state_type: Literal[ - "pending_new", - "accepted", - "working", - "partially_filled", - "filled", - "canceled", - "expired", - "rejected", - "replaced", - ] - - side: Literal["buy", "sell"] - intended_price: Price - - filled_price: Price | None = None - intended_qty: Quantity - - cum_filled_qty: Quantity | None = None - remaining_qty: Quantity | None = None - - time_in_force: Literal["GTC", "IOC", "FOK", "POST_ONLY"] - - reason: str | None = Field(default=None, min_length=1) - raw: dict[str, Any | None] = None - - model_config = ConfigDict(extra="forbid") diff --git a/tradingchassis_core/core/events/events.py b/tradingchassis_core/core/events/events.py index 4255fa5..36d8155 100644 --- a/tradingchassis_core/core/events/events.py +++ b/tradingchassis_core/core/events/events.py @@ -1,14 +1,5 @@ -"""Non-canonical telemetry and compatibility event records. +"""Non-canonical telemetry records.""" -This module is intentionally separate from canonical Event Stream candidates. -Records defined here are used for observability and compatibility projections: - -- telemetry / observability records (e.g. risk summaries, derived metrics) -- compatibility projection artifacts (e.g. inferred fill deltas) - -These records are transport payloads for local sinks and must not be interpreted -as canonical Event Stream semantics by default. -""" from __future__ import annotations from dataclasses import dataclass @@ -16,10 +7,8 @@ @dataclass(slots=True) class OrderStateTransitionEvent: - """Observability payload for invalid/edge order-state transitions. + """Observability payload for unexpected order-state transitions.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str client_order_id: str @@ -27,62 +16,31 @@ class OrderStateTransitionEvent: next_state: str -@dataclass(slots=True) -class DerivedFillEvent: - """Inferred compatibility projection artifact. - - This record is derived from snapshot progression and is not a canonical - ``FillEvent`` or canonical Event Stream record. - """ - ts_ns_local: int - instrument: str - client_order_id: str - - side: str - - delta_qty: float - cum_qty: float - - price: float | None - - @dataclass(slots=True) class DerivedPnLEvent: - """Observability payload for derived realized-PnL changes. + """Observability payload for derived realized-PnL changes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str - delta_pnl: float cum_realized_pnl: float @dataclass(slots=True) class ExposureDerivedEvent: - """Observability payload for derived exposure changes. + """Observability payload for derived exposure changes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int instrument: str - exposure: float delta_exposure: float @dataclass(slots=True) class RiskDecisionEvent: - """Observability payload summarizing risk/gate outcomes. + """Observability payload summarizing policy-risk outcomes.""" - Telemetry only; not a canonical Event Stream record. - """ ts_ns_local: int - accepted: int - queued: int rejected: int - handled: int - reject_reasons: dict[str, int] diff --git a/tradingchassis_core/core/ports/engine_context.py b/tradingchassis_core/core/ports/engine_context.py deleted file mode 100644 index 57d0b83..0000000 --- a/tradingchassis_core/core/ports/engine_context.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from typing import Protocol - - -class EngineContext(Protocol): - """Read-only execution context exposed to strategies. - - This context intentionally hides engine / runtime specifics. - """ - - @property - def tick_size(self) -> float: - """Minimum price increment.""" diff --git a/tradingchassis_core/core/ports/venue_adapter.py b/tradingchassis_core/core/ports/venue_adapter.py deleted file mode 100644 index 2bdf756..0000000 --- a/tradingchassis_core/core/ports/venue_adapter.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Venue adapter protocol for strategy execution. - -This module defines the abstract venue-facing boundary used by the strategy -loop. Concrete implementations adapt specific backtest or live venues to -this protocol. -""" - -from __future__ import annotations - -from typing import Any, Protocol - - -class VenueAdapter(Protocol): - """Venue-facing feed boundary. - - The strategy loop must not depend on venue-specific APIs. - - Snapshot objects are intentionally typed as Any: they are only consumed by - venue-specific translation code, not by strategy/risk/state layers. - """ - - def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: - """Block until the next wakeup, returning a venue-defined rc code.""" - - def current_timestamp_ns(self) -> int: - """Return the venue local/receipt timestamp axis in ns.""" - - def read_market_snapshot(self) -> Any: - """Return the current market snapshot object (venue-specific).""" - - def read_orders_snapshot(self) -> tuple[Any, Any]: - """Return a tuple (state_values, orders) (venue-specific).""" - - def record(self, recorder: Any) -> None: - """Record the current venue state into the recorder (if supported).""" diff --git a/tradingchassis_core/core/risk/risk_config.py b/tradingchassis_core/core/risk/risk_config.py index 7d8ac54..4b2c3f3 100644 --- a/tradingchassis_core/core/risk/risk_config.py +++ b/tradingchassis_core/core/risk/risk_config.py @@ -1,4 +1,4 @@ -"""Risk configuration model for backtest and live trading engines.""" +"""Typed deterministic risk configuration model.""" from __future__ import annotations @@ -16,56 +16,21 @@ class RiskConfig(BaseModel): - """Structured-only risk configuration.""" + """Structured risk configuration used by Core policy evaluation.""" scope: str = Field(..., min_length=1) trading_enabled: bool = True - - # Mirrors types.py RiskConstraints fields (types.py is the source of truth) position_limits: PositionLimits | None = None notional_limits: NotionalLimits | None = None quote_limits: QuoteLimits | None = None order_rate_limits: OrderRateLimits | None = None max_loss: MaxLoss | None = None - - # Optional additional config fields (kept separate from RiskConstraints.extra) extra: dict[str, Any] = Field(default_factory=dict) model_config = ConfigDict(extra="forbid") - @classmethod - def from_json_obj(cls, risk_obj: dict[str, Any]) -> RiskConfig: - """Create a RiskConfig instance from a JSON-compatible object.""" - return cls.model_validate(risk_obj) - @model_validator(mode="after") def validate_consistency(self) -> RiskConfig: - """Validate internal consistency of the risk configuration.""" if self.notional_limits is None: raise ValueError("notional_limits is required") return self - - @property - def params(self) -> dict[str, Any]: - """Return engine-compatible flat risk parameters.""" - return self.to_engine_params() - - def to_engine_params(self) -> dict[str, Any]: - """Convert the structured configuration into flat engine parameters.""" - params: dict[str, Any] = {} - - if self.position_limits is not None: - params["position_limits"] = self.position_limits - if self.notional_limits is not None: - params["notional_limits"] = self.notional_limits - if self.quote_limits is not None: - params["quote_limits"] = self.quote_limits - if self.order_rate_limits is not None: - params["order_rate_limits"] = self.order_rate_limits - if self.max_loss is not None: - params["max_loss"] = self.max_loss - - if self.extra: - params.update(self.extra) - - return params diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 3750c21..983eb9d 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -1,100 +1,36 @@ -"""Risk engine implementing hard risk checks and intent gating.""" +"""Policy-risk evaluator used by deterministic Core policy admission.""" from __future__ import annotations -from collections import defaultdict -from dataclasses import dataclass from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason from tradingchassis_core.core.domain.types import OrderIntent, RiskConstraints -from tradingchassis_core.core.events.events import RiskDecisionEvent -from tradingchassis_core.core.execution_control import ExecutionControl -from tradingchassis_core.core.execution_control.types import ( - ControlSchedulingObligation, -) from tradingchassis_core.core.ports.venue_policy import VenuePolicy from tradingchassis_core.core.risk.risk_policy import RiskPolicy if TYPE_CHECKING: - from risk.risk_config import RiskConfig - from tradingchassis_core.core.domain.state import StrategyState - from tradingchassis_core.core.events.event_bus import EventBus - - -# --------------------------------------------------------------------------- -# Gate decision models (internal, not part of JSON schema) -# --------------------------------------------------------------------------- - - -@dataclass(slots=True) -class RejectedIntent: - intent: OrderIntent - reason: str - - -@dataclass(slots=True) -class GateDecision: - """Result of the hard risk/gate layer. - - Compatibility decision contract consumed by strategy/runtime orchestration. - This is not an Event and not a canonical Event Stream record. - - - accepted_now: intents that may be sent immediately - - queued: intents that were enqueued into StrategyState.queue (data-only) - - rejected: hard rejects with reasons - - replaced_in_queue: (old, new) pairs when queue replacement happened - - dropped_in_queue: intents that were dropped during queue merge (e.g. superseded) - - handled_in_queue: intents that were fully handled locally in the queue layer - (e.g. cancel/replace acting only on queued state) and must not be sent - - next_send_ts_ns_local: earliest local timestamp where it makes sense to wake up - to try flushing the queue (best-effort) - """ - - ts_ns_local: int - accepted_now: list[OrderIntent] - queued: list[OrderIntent] - rejected: list[RejectedIntent] - replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] - dropped_in_queue: list[OrderIntent] - handled_in_queue: list[OrderIntent] - # Populated by the runner after outbound execution. - execution_rejected: list[RejectedIntent] - next_send_ts_ns_local: int | None - control_scheduling_obligations: tuple[ControlSchedulingObligation, ...] = () + from tradingchassis_core.core.risk.risk_config import RiskConfig class RiskEngine: - """Hard risk and intent gating engine.""" - - # pylint: disable=too-many-instance-attributes - """Hard risk + gate layer. + """Policy-only evaluator. - This layer is allowed to: - - hard reject invalid / risk-breaching intents - - queue intents that should be sent later (rate limits, budgets) - - accept intents for immediate sending - - It must NOT submit orders itself. + This component is intentionally side-effect-free for the CoreStep policy phase: + it does not mutate queue/rate/inflight state and does not perform execution-control. """ - def __init__(self, risk_cfg: RiskConfig, event_bus: EventBus) -> None: + def __init__(self, risk_cfg: RiskConfig) -> None: self.risk_cfg = risk_cfg - self._event_bus = event_bus venue_policy_cfg = self._parse_venue_policy_config(risk_cfg) self._venue_policy = VenuePolicy( min_order_notional=venue_policy_cfg["min_order_notional"], post_only_mode=venue_policy_cfg["post_only_mode"], ) - self._risk_policy = RiskPolicy(venue_policy=self._venue_policy) - # Internal execution-control component owns rate state and queue admission logic. - # RiskEngine must own a single instance to preserve state lifetime semantics. - self._execution_control = ExecutionControl() - @staticmethod def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: cfg: dict[str, object] = { @@ -106,9 +42,6 @@ def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: if not isinstance(extra, dict): return cfg - # Preferred form: extra["venue_policy"] is a nested dict. - # RiskConstraints.extra requires flat scalar values, therefore - # nested config must be normalized before being exposed as constraints. vp = extra["venue_policy"] if "venue_policy" in extra else None if isinstance(vp, dict): if "min_order_notional" in vp: @@ -116,20 +49,15 @@ def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: cfg["min_order_notional"] = float(vp["min_order_notional"]) except (TypeError, ValueError): pass - if "post_only_mode" in vp: mode = str(vp["post_only_mode"]) if mode in {"reject", "drop"}: cfg["post_only_mode"] = mode - return cfg - # Backwards/alternative form: flattened keys. if "venue_policy_min_order_notional" in extra: try: - cfg["min_order_notional"] = float( - extra["venue_policy_min_order_notional"] - ) + cfg["min_order_notional"] = float(extra["venue_policy_min_order_notional"]) except (TypeError, ValueError): pass @@ -142,29 +70,18 @@ def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: @staticmethod def _constraints_extra(extra: object) -> dict[str, object]: - """Normalize RiskConfig.extra for RiskConstraints. - - RiskConstraints.extra is defined as a flat mapping of scalar values - (str/float/bool/None). Nested dicts are not allowed. - - The normalization keeps the original mapping intact on RiskConfig, - but produces a flattened mapping for strategy constraints. - """ - if not isinstance(extra, dict): return {} normalized: dict[str, object] = {} for key, value in extra.items(): if key == "venue_policy" and isinstance(value, dict): - # Flatten nested venue policy config. if "min_order_notional" in value: normalized["venue_policy_min_order_notional"] = value["min_order_notional"] if "post_only_mode" in value: normalized["venue_policy_post_only_mode"] = value["post_only_mode"] continue - # Only keep scalar values to match the RiskConstraints schema. if value is None or isinstance(value, (str, float, bool)): normalized[key] = value elif isinstance(value, int): @@ -172,27 +89,8 @@ def _constraints_extra(extra: object) -> dict[str, object]: return normalized - @staticmethod - def _float_equal(a: float, b: float) -> bool: - """Best-effort float equality for normalized values.""" - return abs(a - b) <= 1e-12 - - @property - def execution_control(self) -> ExecutionControl: - """Expose the owned stateful execution-control instance. - - This accessor intentionally returns the existing instance to preserve - queue/inflight/rate continuity across compatibility and Core step paths. - It must not allocate a new ExecutionControl. - """ - return self._execution_control - - # --------------------------------------------------------------------- - # Soft constraints for strategy - # --------------------------------------------------------------------- - def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: - """Build RiskConstraints handed to the strategy.""" + """Build RiskConstraints handed to strategy evaluation.""" extra = self._constraints_extra(self.risk_cfg.extra) return RiskConstraints( ts_ns_local=current_timestamp_ns_local, @@ -206,10 +104,6 @@ def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: extra=extra, ) - # --------------------------------------------------------------------- - # Hard gate decision - # --------------------------------------------------------------------- - def evaluate_policy_intent( self, *, @@ -217,13 +111,7 @@ def evaluate_policy_intent( state: StrategyState, now_ts_ns_local: int, ) -> tuple[bool, str | None]: - """Evaluate one intent with policy-only checks and no side effects. - - Side-effect contract: - - does not call execution-control helpers; - - does not mutate queue/rate/inflight state; - - does not emit EventBus events. - """ + """Evaluate one intent with policy-only checks and no side effects.""" raw_intents = [intent] @@ -291,254 +179,3 @@ def evaluate_policy_intent( if not ok: return False, reason return True, None - - # pylint: disable=too-many-locals,too-many-branches,too-many-statements - def decide_intents( - self, - raw_intents: list[OrderIntent], - state: StrategyState, - now_ts_ns_local: int, - ) -> GateDecision: - """Hard gate decision. - - - Hard rejects: never send (risk breach / invalid) - - Queue: send later (rate limits / local budgets) - - Accept: send now - - NOTE: This implementation *does* enqueue queued intents into StrategyState - (data-only queue) by calling state.merge_intents_into_queue(). - """ - - accepted_now: list[OrderIntent] = [] - to_queue_by_instr: defaultdict[str, list[OrderIntent]] = defaultdict(list) - rejected: list[RejectedIntent] = [] - replaced_in_queue: list[tuple[OrderIntent, OrderIntent]] = [] - dropped_in_queue: list[OrderIntent] = [] - handled_in_queue: list[OrderIntent] = [] - - # Intents that ended up queued due to rate limits or due to queue-only handling. - queued: list[OrderIntent] = [] - next_send_ts: int | None = None - control_scheduling_obligations: list[ControlSchedulingObligation] = [] - - # counters for RiskDecisionEvent - reject_counts: dict[str, int] = {} - def _count_reject(reason: str) -> None: - reject_counts[reason] = reject_counts.get(reason, 0) + 1 - - # --- Trading enabled gate --- - triggered, policy_accepted, policy_rejected = self._risk_policy.trading_enabled_gate( - trading_enabled=self.risk_cfg.trading_enabled, - raw_intents=raw_intents, - ) - if triggered: - accepted_now.extend(policy_accepted) - for it, reason in policy_rejected: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=[], - rejected=rejected, - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - # emit summary - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=0, - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) - ) - - return decision - - # --- Max loss (portfolio drawdown kill-switch) --- - triggered, policy_accepted, policy_rejected = self._risk_policy.max_loss_gate( - max_loss_cfg=self.risk_cfg.max_loss, - raw_intents=raw_intents, - state=state, - now_ts_ns_local=now_ts_ns_local, - ) - if triggered: - accepted_now.extend(policy_accepted) - for it, reason in policy_rejected: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=[], - rejected=rejected, - replaced_in_queue=[], - dropped_in_queue=[], - handled_in_queue=[], - execution_rejected=[], - next_send_ts_ns_local=None, - control_scheduling_obligations=(), - ) - - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=0, - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) - ) - - return decision - - # --- Rate limits (per second, local time) --- - rate_cfg = self.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 - - # --- Position / notional limits --- - pos_cfg = self.risk_cfg.position_limits - max_pos = None if (pos_cfg is None or pos_cfg.max_position is None) else pos_cfg.max_position - - notional_cfg = self.risk_cfg.notional_limits - max_gross_notional = notional_cfg.max_gross_notional - max_single_order_notional = notional_cfg.max_single_order_notional - - quote_cfg = self.risk_cfg.quote_limits - - quote_book = None - if quote_cfg is not None: - quote_book = self._risk_policy.quote_book_global(state) - - # Base portfolio gross notional (best-effort) - base_gross_notional = self._risk_policy.portfolio_gross_notional(state) - - # ----------------------------------------------------------------- - # Per-intent decision - # ----------------------------------------------------------------- - for it in raw_intents: - norm = self._risk_policy.normalize_intent(it, state) - if norm.reject_reason is not None: - rejected.append(RejectedIntent(it, norm.reject_reason)) - _count_reject(norm.reject_reason) - continue - if norm.dropped: - handled_in_queue.append(it) - continue - if norm.normalized is None: - rejected.append(RejectedIntent(it, RejectReason.INVALID_QTY)) - _count_reject(RejectReason.INVALID_QTY) - continue - - it = norm.normalized - # 0) Pre-submission lifecycle / identity / inflight routing compatibility handling. - continue_to_policy, lifecycle_reject_reason = ( - self._execution_control.route_pre_submission_lifecycle_and_inflight( - it, - state=state, - to_queue_by_instr=to_queue_by_instr, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - queued=queued, - handled_in_queue=handled_in_queue, - float_equal=self._float_equal, - ) - ) - if not continue_to_policy: - if lifecycle_reject_reason is not None: - rejected.append(RejectedIntent(it, lifecycle_reject_reason)) - _count_reject(lifecycle_reject_reason) - continue - - # 1) Outbound hygiene validation (hard reject) - ok, reason = self._risk_policy.validate_intent(it, state) - if not ok: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - continue - - # 2) Hard risk checks (hard reject) - ok, reason = self._risk_policy.hard_checks( - it, - state, - max_pos=max_pos, - max_single_order_notional=max_single_order_notional, - max_gross_notional=max_gross_notional, - base_gross_notional=base_gross_notional, - quote_cfg=quote_cfg, - quote_book=quote_book, - ) - if not ok: - rejected.append(RejectedIntent(it, reason)) - _count_reject(reason) - continue - - # 3) Rate limiting -> queue (soft, not reject) - rate_result = self._execution_control.route_after_policy_rate_limit( - it, - now_ts_ns_local=now_ts_ns_local, - max_orders_per_sec=max_orders_per_sec, - max_cancels_per_sec=max_cancels_per_sec, - ) - if rate_result.stage_to_queue: - to_queue_by_instr[it.instrument].append(it) - obligation = rate_result.scheduling_obligation - if obligation is not None: - control_scheduling_obligations.append(obligation) - next_send_ts = ( - obligation.due_ts_ns_local - if next_send_ts is None - else min(next_send_ts, obligation.due_ts_ns_local) - ) - continue - - accepted_now.append(it) - - # ----------------------------------------------------------------- - # Queue merge per instrument (replacement rules live in StrategyState) - # ----------------------------------------------------------------- - self._execution_control.merge_to_queue_per_instrument( - state=state, - to_queue_by_instr=to_queue_by_instr, - queued=queued, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - ) - - decision = GateDecision( - ts_ns_local=now_ts_ns_local, - accepted_now=accepted_now, - queued=queued, - rejected=rejected, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - handled_in_queue=handled_in_queue, - execution_rejected=[], - next_send_ts_ns_local=next_send_ts, - control_scheduling_obligations=tuple(control_scheduling_obligations), - ) - - self._event_bus.emit( - RiskDecisionEvent( - ts_ns_local=now_ts_ns_local, - accepted=len(accepted_now), - queued=len(queued), - rejected=len(rejected), - handled=len(handled_in_queue), - reject_reasons=reject_counts, - ) - ) - - return decision diff --git a/tradingchassis_core/core/schemas/order_state_event.schema.json b/tradingchassis_core/core/schemas/order_state_event.schema.json deleted file mode 100644 index 41149e1..0000000 --- a/tradingchassis_core/core/schemas/order_state_event.schema.json +++ /dev/null @@ -1,100 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/order_state_event.schema.json", - "title": "OrderStateEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "client_order_id", - "order_type", - "state_type", - "side", - "intended_price", - "intended_qty", - "time_in_force" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue state event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local state event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier.", - "type": "string", - "minLength": 1 - }, - "client_order_id": { - "description": "Client-assigned order ID (if used).", - "type": "string", - "minLength": 1 - }, - "order_type": { - "description": "Order type indicating how the order was intended to be executed.", - "type": "string", - "enum": ["limit", "market"] - }, - "state_type": { - "description": "Lifecycle state of the order.", - "type": "string", - "enum": [ - "pending_new", - "accepted", - "working", - "partially_filled", - "filled", - "canceled", - "expired", - "rejected", - "replaced" - ] - }, - "side": { - "description": "Side of the filled order from client perspective.", - "type": "string", - "enum": ["buy", "sell"] - }, - "intended_price": { - "description": "Intended price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "filled_price": { - "description": "Filled price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended quantity of this order in underlying units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "cum_filled_qty": { - "description": "Cumulative filled quantity of this order in underlying units at this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "remaining_qty": { - "description": "Remaining open quantity after this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "description": "Time-in-force instruction (venue-specific mapping).", - "type": "string", - "enum": ["GTC", "IOC", "FOK", "POST_ONLY"] - }, - "reason": { - "description": "Optional reason or error code for this state change.", - "type": "string", - "minLength": 1 - }, - "raw": { - "description": "Optional raw payload from venue or OMS for debugging.", - "type": "object" - } - }, - "additionalProperties": false -} diff --git a/tradingchassis_core/strategies/base.py b/tradingchassis_core/strategies/base.py deleted file mode 100644 index e6389e8..0000000 --- a/tradingchassis_core/strategies/base.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Base strategy interface. - -This module defines the Strategy protocol used by the backtest and -live execution engines. Concrete strategies implement this interface and are -driven exclusively by venue wakeups and risk engine decisions. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from tradingchassis_core.core.domain.state import StrategyState - from tradingchassis_core.core.domain.types import MarketEvent, OrderIntent, RiskConstraints - from tradingchassis_core.core.ports.engine_context import EngineContext - from tradingchassis_core.core.risk.risk_engine import GateDecision - - -class Strategy(ABC): - """Strategy protocol implemented by all concrete strategies. - - The strategy is triggered by two event sources: - - Feed events (rc=2): market data changes such as book/trade updates. - - Order updates (rc=3): order responses / fills / cancels reflected in state snapshots. - - The strategy must NOT assume that created intents are already live in the market. - Live state must be derived from StrategyState order snapshots. - """ - - @abstractmethod - def on_feed( - self, - state: StrategyState, - event: MarketEvent, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Handle a feed wakeup (rc=2) and produce zero or more raw OrderIntents.""" - - @abstractmethod - def on_order_update( - self, - state: StrategyState, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Handle an order update wakeup (rc=3) and produce zero or more raw OrderIntents. - - This hook is used for live-like behavior, e.g. reacting to fills, rejects, - or cancels without waiting for the next market data tick. - """ - - @abstractmethod - def on_risk_decision(self, decision: GateDecision) -> None: - """Receive GateDecision feedback (accepted, queued, rejected with reasons).""" diff --git a/tradingchassis_core/strategies/strategy_config.py b/tradingchassis_core/strategies/strategy_config.py deleted file mode 100644 index 3fe9e62..0000000 --- a/tradingchassis_core/strategies/strategy_config.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Strategy configuration model. - -This module defines the StrategyConfig schema used to parse and normalize -strategy-related configuration from JSON into engine-consumable parameters. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field, model_validator - - -class StrategyConfig(BaseModel): - """Strategy config that collects arbitrary extra keys into ``params``. - - JSON example: - "strategy": { - "class_path": "my_strategies.debug:DebugStrategy", - "spread": 50.0, - "size": 0.0001 - } - - Result: - class_path="my_strategies.debug:DebugStrategy" - params={"spread": 50.0, "size": 0.0001} - """ - - class_path: str = Field(..., min_length=1) - - params: dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(extra="allow") - - @model_validator(mode="before") - @classmethod - def _collect_extras_into_params(cls, data: Any) -> Any: - """Collect unknown top-level keys into the ``params`` mapping. - - This allows flat JSON strategy configuration without requiring - a nested "params" object. - """ - if not isinstance(data, dict): - return data - - d = dict(data) - - explicit_params = d.pop("params", None) - - reserved = {"class_path"} - - extras = {k: v for k, v in d.items() if k not in reserved} - - for k in extras.keys(): - d.pop(k, None) - - merged: dict[str, Any] = {} - if isinstance(explicit_params, dict): - merged.update(explicit_params) - merged.update(extras) - - d["params"] = merged - return d - - def to_engine_params(self) -> dict[str, Any]: - """Return a shallow copy of strategy parameters. - - The engine must not mutate configuration state. - """ - return dict(self.params) From 1238563f0edc4f779369dc79ae43e51e1e6e30c7 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 15:00:07 +0000 Subject: [PATCH 32/53] refactor(core): polish clean pipeline contracts and tests --- CHANGELOG.md | 9 +- README.md | 1 + docs/README.md | 5 + docs/reference/public-api.md | 7 +- pyproject.toml | 21 +- tests/semantics/test_core_pipeline_clean.py | 114 ++++++++++- tests/semantics/test_public_api_clean.py | 10 +- .../core/domain/execution_control_apply.py | 12 +- .../core/domain/processing_step.py | 12 +- tradingchassis_core/core/domain/state.py | 4 +- tradingchassis_core/core/domain/types.py | 25 +-- .../execution_constraints_policy.py} | 88 ++------- tradingchassis_core/core/risk/risk_engine.py | 54 +++-- tradingchassis_core/core/risk/risk_policy.py | 11 +- .../core/schemas/common.schema.json | 52 ----- .../core/schemas/fill_event.schema.json | 79 -------- .../core/schemas/market_event.schema.json | 121 ------------ .../core/schemas/order_intent.schema.json | 187 ------------------ .../core/schemas/risk_constraints.schema.json | 166 ---------------- 19 files changed, 205 insertions(+), 773 deletions(-) rename tradingchassis_core/core/{ports/venue_policy.py => risk/execution_constraints_policy.py} (74%) delete mode 100644 tradingchassis_core/core/schemas/common.schema.json delete mode 100644 tradingchassis_core/core/schemas/fill_event.schema.json delete mode 100644 tradingchassis_core/core/schemas/market_event.schema.json delete mode 100644 tradingchassis_core/core/schemas/order_intent.schema.json delete mode 100644 tradingchassis_core/core/schemas/risk_constraints.schema.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 12ef9ed..4cdfcba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,14 @@ ### Changed - Phase R1 clean cut: - - removed `GateDecision` compatibility contract from Core APIs - - removed compatibility decision contexts from `run_core_step` - - removed snapshot-era `OrderStateEvent` model and reducers + - removed compatibility gate and snapshot lifecycle contracts from Core APIs - made `RiskEngine` policy-only (`evaluate_policy_intent`, constraints build) - simplified docs/tests to one clean CoreStep/CoreWakeupStep architecture +- Phase R2 polish: + - removed JSON schema files in favor of Pydantic contract source of truth + - removed snapshot-shaped execution feedback payload rows + - renamed constraint normalizer to `ExecutionConstraintsPolicy` + - hardened clean-pipeline semantics tests (reconciliation, rejection, deferral) ### Added diff --git a/README.md b/README.md index 8c12870..6b71165 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ It owns one architecture: Core owns: - canonical event models (`MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, `FillEvent`) +- Pydantic contract models as the schema source of truth - deterministic state reduction - strategy evaluator protocol - candidate intent combination + provenance diff --git a/docs/README.md b/docs/README.md index d10993e..4e403a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,3 +17,8 @@ The only supported processing architecture is: 5. policy admission 6. execution-control planning/apply 7. `CoreStepResult` outputs for runtime dispatch/scheduling + +## Contract source of truth + +Core contract models are defined in Pydantic classes under +`tradingchassis_core/core/domain/types.py`. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index c49e6ca..6fd118e 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -43,9 +43,4 @@ The package export boundary is `tradingchassis_core`. - `NullEventBus` - `RiskEngine` (policy-only evaluator) -## Removed compatibility contracts - -- `GateDecision` -- `CoreStepResult.compat_gate_decision` -- `ControlTimeQueueReevaluationContext` -- `CoreDecisionContext` +Compatibility bridge contracts are intentionally absent from the public API. diff --git a/pyproject.toml b/pyproject.toml index e0fa628..970b557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "tradingchassis-core" version = "0.1.0" -description = "Deterministic, event-driven core, with explicit risk management, order state machines, queue semantics, and research orchestration." +description = "Deterministic event-driven core package for intent policy and execution control." readme = "README.md" requires-python = ">=3.11" authors = [{ name = "tradingeng@protonmail.com" }] @@ -28,29 +28,18 @@ dev = [ "import-linter>=1.11,<2", "ruff>=0.4,<1", "mypy>=1.9,<2", - "jsonschema>=4,<5", "matplotlib>=3,<4", "numpy>=2.0,<2.3", - "referencing>=0.37,<1", ] -# -------------------------------------------------- -# Explicit package discovery -# -------------------------------------------------- [tool.setuptools.packages.find] include = ["tradingchassis_core*"] -# -------------------------------------------------- -# Pytest -# -------------------------------------------------- [tool.pytest.ini_options] minversion = "9.0" addopts = "-ra" testpaths = ["tests"] -# -------------------------------------------------- -# Ruff -# -------------------------------------------------- [tool.ruff] target-version = "py311" line-length = 100 @@ -59,9 +48,6 @@ line-length = 100 select = ["E", "F", "I"] ignore = ["E501"] -# -------------------------------------------------- -# MyPy (static typing) -# -------------------------------------------------- [tool.mypy] python_version = "3.11" warn_unused_configs = true @@ -70,14 +56,10 @@ pretty = true show_error_codes = true ignore_errors = true -# -------------------------------------------------- -# Import Linter -# -------------------------------------------------- [tool.importlinter] root_package = "tradingchassis_core" include_external_packages = true -# Core stays pure [[tool.importlinter.contracts]] name = "Core must be pure" type = "forbidden" @@ -85,4 +67,3 @@ source_modules = ["tradingchassis_core.core"] forbidden_modules = [ "tradingchassis_core.strategies" ] - diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py index 700f3ef..175bab6 100644 --- a/tests/semantics/test_core_pipeline_clean.py +++ b/tests/semantics/test_core_pipeline_clean.py @@ -35,6 +35,46 @@ def evaluate_policy_intent( return True, None +class _RejectAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return False, "blocked_for_test" + + +class _DuplicateIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + first = tc.NewOrderIntent( + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="dup-intent", + intents_correlation_id="corr-a", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + second = tc.NewOrderIntent( + ts_ns_local=11, + instrument="BTC-USDC-PERP", + client_order_id="dup-intent", + intents_correlation_id="corr-b", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=2.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=101.0), + time_in_force="GTC", + ) + return [first, second] + + def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), @@ -70,7 +110,22 @@ def test_run_core_step_clean_pipeline_dispatchable() -> None: assert tuple(intent.client_order_id for intent in result.candidate_intents) == ("intent-1",) assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ("intent-1",) assert result.core_step_decision is not None - assert not hasattr(result, "compat_gate_decision") + + +def test_run_core_step_processes_entry_before_strategy_evaluation() -> None: + class _ChecksReducedStateEvaluator: + def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.OrderIntent]: + # ControlTimeEvent reduction updates monotone timestamp before evaluation. + assert context.state.sim_ts_ns_local == 100 + return [] + + state = tc.StrategyState(event_bus=tc.NullEventBus()) + _ = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_ChecksReducedStateEvaluator(), + ) + assert state._last_processing_position_index == 0 def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: @@ -93,3 +148,60 @@ def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: assert len(result.generated_intents) == 2 assert len(result.candidate_intent_records) == 1 assert len(result.dispatchable_intents) == 1 + + +def test_candidate_reconciliation_prefers_latest_same_key_generated_intent() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_DuplicateIntentEvaluator(), + ) + assert len(result.generated_intents) == 2 + assert len(result.candidate_intent_records) == 1 + winner = result.candidate_intent_records[0].intent + assert winner.client_order_id == "dup-intent" + assert winner.intended_qty.value == 2.0 + + +def test_policy_rejection_prevents_dispatchable_intents() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_RejectAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + activate_dispatchable_outputs=True, + ), + ) + assert result.dispatchable_intents == () + assert result.core_step_decision is not None + assert len(result.core_step_decision.policy_rejected_intents) == 1 + + +def test_execution_control_deferral_returns_scheduling_obligation() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=100, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=100, + max_orders_per_sec=0.0, + activate_dispatchable_outputs=True, + ), + ) + assert result.dispatchable_intents == () + assert result.control_scheduling_obligation is not None + assert result.control_scheduling_obligation.reason == "rate_limit" diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py index e50ad61..7b657c4 100644 --- a/tests/semantics/test_public_api_clean.py +++ b/tests/semantics/test_public_api_clean.py @@ -25,6 +25,10 @@ def test_public_api_exposes_clean_core_symbols() -> None: def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: - assert not hasattr(tc, "GateDecision") - assert not hasattr(tc, "ControlTimeQueueReevaluationContext") - assert not hasattr(tc, "CoreDecisionContext") + removed = ( + "".join(["Gate", "Decision"]), + "".join(["ControlTimeQueue", "ReevaluationContext"]), + "".join(["Core", "DecisionContext"]), + ) + for symbol in removed: + assert not hasattr(tc, symbol) diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py index d0bbcc0..2bb52ab 100644 --- a/tradingchassis_core/core/domain/execution_control_apply.py +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -22,11 +22,7 @@ @dataclass(frozen=True, slots=True) class ExecutionControlApplyContext: - """Mutable apply inputs for one deterministic apply operation. - - The context keeps references to mutable runtime objects (state and execution - control) while the dataclass itself stays immutable as a value container. - """ + """Mutable apply inputs for one deterministic apply operation.""" state: StrategyState execution_control: ExecutionControl @@ -61,7 +57,7 @@ class ExecutionControlHandledRecord: @dataclass(frozen=True, slots=True) class ExecutionControlApplyResult: - """Result of mutable execution-control apply over one plan snapshot.""" + """Result of mutable execution-control apply over one plan state.""" queued_effective_records: tuple[CandidateIntentRecord, ...] = () dispatchable_records: tuple[ExecutionControlDispatchableRecord, ...] = () @@ -137,8 +133,8 @@ def apply_execution_control_plan( """Apply mutable execution-control semantics over planned active records. This function mutates only StrategyState queue data and ExecutionControl - rate state. It does not call RiskEngine.decide_intents, does not perform - venue dispatch, and does not emit canonical events. + rate state. It does not perform venue dispatch and does not emit canonical + events. """ state = context.state diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index afad7d2..cf57999 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -138,8 +138,8 @@ def run_core_step( ) generated_intents = tuple(strategy_evaluator.evaluate(strategy_context)) - snapshot_instrument = _resolve_candidate_instrument(entry=entry) - queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) + queued_instrument = _resolve_candidate_instrument(entry=entry) + queued_snapshot = state.queued_intents_snapshot(queued_instrument) candidate_intent_records = combine_candidate_intent_records( generated_intents=generated_intents, queued_intents=queued_snapshot, @@ -242,7 +242,7 @@ def run_core_wakeup_decision( state: StrategyState, reduction: CoreWakeupReductionResult, *, - snapshot_instrument: str | None = None, + queued_instrument: str | None = None, policy_admission_context: CorePolicyAdmissionContext | None = None, execution_control_apply_context: CoreExecutionControlApplyContext | None = None, ) -> CoreStepResult: @@ -253,7 +253,7 @@ def run_core_wakeup_decision( "execution_control_apply_context requires policy_admission_context" ) - queued_snapshot = state.queued_intents_snapshot(snapshot_instrument) + queued_snapshot = state.queued_intents_snapshot(queued_instrument) candidate_intent_records = combine_candidate_intent_records( generated_intents=reduction.generated_intents, queued_intents=queued_snapshot, @@ -325,7 +325,7 @@ def run_core_wakeup_step( configuration: CoreConfiguration | None = None, strategy_evaluator: CoreStepStrategyEvaluator | None = None, strategy_event_filter: Callable[[object], bool] | None = None, - snapshot_instrument: str | None = None, + queued_instrument: str | None = None, policy_admission_context: CorePolicyAdmissionContext | None = None, execution_control_apply_context: CoreExecutionControlApplyContext | None = None, ) -> CoreStepResult: @@ -341,7 +341,7 @@ def run_core_wakeup_step( return run_core_wakeup_decision( state, reduction, - snapshot_instrument=snapshot_instrument, + queued_instrument=queued_instrument, policy_admission_context=policy_admission_context, execution_control_apply_context=execution_control_apply_context, ) diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 72b287a..ea061eb 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1,8 +1,8 @@ """Deterministic Core strategy state. This state container keeps canonical reducer-owned data and execution-control -supporting structures (queue + inflight tracking). It intentionally excludes -snapshot-era order lifecycle compatibility reducers. +supporting structures (queue + inflight tracking). Runtime snapshot parsing and +venue lifecycle adaptation are intentionally out of scope for Core. """ from __future__ import annotations diff --git a/tradingchassis_core/core/domain/types.py b/tradingchassis_core/core/domain/types.py index 972e600..f3261cc 100644 --- a/tradingchassis_core/core/domain/types.py +++ b/tradingchassis_core/core/domain/types.py @@ -1,4 +1,7 @@ -"""Core shared data models and schemas.""" +"""Core shared data models. + +Pydantic models in this module are the source of truth for Core contracts. +""" # pylint: disable=line-too-long,missing-class-docstring,missing-function-docstring from __future__ import annotations @@ -183,23 +186,6 @@ class FillEvent(BaseModel): model_config = ConfigDict(extra="forbid") -class OrderExecutionFeedbackSnapshot(BaseModel): - order_id: str = Field(..., min_length=1) - order_type: int - side: int - time_in_force: int - status: int - req: int - price: float - qty: float = Field(..., ge=0) - exec_price: float - exec_qty: float = Field(..., ge=0) - leaves_qty: float = Field(..., ge=0) - ts_ns_exch: int = Field(..., gt=0) - ts_ns_local: int = Field(..., gt=0) - model_config = ConfigDict(extra="forbid") - - class OrderExecutionFeedbackEvent(BaseModel): ts_ns_local_feedback: int = Field(..., gt=0) instrument: str = Field(..., min_length=1) @@ -209,9 +195,6 @@ class OrderExecutionFeedbackEvent(BaseModel): trading_volume: float trading_value: float num_trades: int - order_snapshots: tuple[OrderExecutionFeedbackSnapshot, ...] = Field( - default_factory=tuple - ) runtime_correlation: dict[str, str | int | float | bool | None] | None = None model_config = ConfigDict(extra="forbid") diff --git a/tradingchassis_core/core/ports/venue_policy.py b/tradingchassis_core/core/risk/execution_constraints_policy.py similarity index 74% rename from tradingchassis_core/core/ports/venue_policy.py rename to tradingchassis_core/core/risk/execution_constraints_policy.py index 1640b98..7bebb5d 100644 --- a/tradingchassis_core/core/ports/venue_policy.py +++ b/tradingchassis_core/core/risk/execution_constraints_policy.py @@ -1,9 +1,7 @@ -"""Venue policy normalization and validation logic. +"""Execution-constraint normalization and validation logic. -This module applies minimal, venue-agnostic constraints to order intents, -such as tick/lot rounding, post-only enforcement, and minimum notional checks. -The logic is intentionally explicit and branch-heavy to preserve correctness -and debuggability. +This module applies instrument/execution constraints to order intents, such as +tick/lot rounding, post-only enforcement, and minimum notional checks. """ from __future__ import annotations @@ -21,27 +19,15 @@ @dataclass(slots=True) class NormalizationOutcome: - """Result of venue policy normalization.""" + """Result of execution-constraint normalization.""" normalized: OrderIntent | None reject_reason: str | None dropped: bool -class VenuePolicy: - """Minimal venue policy layer. - - Scope (kept intentionally small): - - tick rounding for limit prices - - lot rounding for intended quantity - - Venue-specific constraints can be enabled in a minimal form: - - post-only crossing checks (best-effort using top-of-book from state) - - min-notional checks - - The policy remains best-effort and intentionally avoids venue-specific - edge cases (self-trade prevention, reduce-only, advanced price sliding). - """ +class ExecutionConstraintsPolicy: + """Minimal instrument/execution constraints layer.""" def __init__( self, @@ -50,29 +36,17 @@ def __init__( post_only_mode: str = "reject", ) -> None: self._min_order_notional = float(min_order_notional) - mode = str(post_only_mode) if mode not in {"reject", "drop"}: raise ValueError(f"Invalid post_only_mode: {mode}") self._post_only_mode = mode - # pylint: disable=too-many-return-statements def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> NormalizationOutcome: - """Normalize an intent according to venue constraints. - - Returns: - NormalizationOutcome with one of: - - normalized != None: normalized intent - - reject_reason != None: hard reject - - dropped == True: no-op intent (e.g. qty rounds to 0) - """ - if intent.intent_type == "cancel": return NormalizationOutcome(normalized=intent, reject_reason=None, dropped=False) tick_size = state.get_tick_size(intent.instrument) lot_size = state.get_lot_size(intent.instrument) - qty = 0.0 if intent.intended_qty is None else float(intent.intended_qty.value) qty_norm = self._round_qty(qty, lot_size) if qty_norm <= 0.0: @@ -86,16 +60,15 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali reject_reason=RejectReason.INVALID_LIMIT_PRICE, dropped=False, ) - - px = float(intent.intended_price.value) - px_norm = self._round_price(px, tick_size, side=intent.side) + px_norm = self._round_price( + float(intent.intended_price.value), tick_size, side=intent.side + ) if px_norm is None or px_norm <= 0.0: return NormalizationOutcome( normalized=None, reject_reason=RejectReason.INVALID_LIMIT_PRICE, dropped=False, ) - post_only_outcome = self._enforce_post_only(intent, state, px_norm) if post_only_outcome is not None: return post_only_outcome @@ -110,47 +83,32 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali reject_reason=None, dropped=False, ) - - # replace return NormalizationOutcome( normalized=self._clone_replace(intent, qty_norm, px_norm), reject_reason=None, dropped=False, ) - # pylint: disable=too-many-return-statements def _enforce_post_only( self, intent: OrderIntent, state: StrategyState, px_norm: float, ) -> NormalizationOutcome | None: - if intent.intent_type != "new": + if intent.intent_type != "new" or intent.time_in_force != "POST_ONLY": return None - if intent.time_in_force != "POST_ONLY": - return None - market = state.market[intent.instrument] if intent.instrument in state.market else None if market is None: return None - best_bid = float(market.best_bid) best_ask = float(market.best_ask) if best_bid <= 0.0 or best_ask <= 0.0: return None - - would_cross = False - if intent.side == "buy": - would_cross = px_norm >= best_ask - else: - would_cross = px_norm <= best_bid - + would_cross = px_norm >= best_ask if intent.side == "buy" else px_norm <= best_bid if not would_cross: return None - if self._post_only_mode == "drop": return NormalizationOutcome(normalized=None, reject_reason=None, dropped=True) - return NormalizationOutcome( normalized=None, reject_reason=RejectReason.POST_ONLY_WOULD_TRADE, @@ -166,23 +124,18 @@ def _enforce_min_notional( ) -> NormalizationOutcome | None: if self._min_order_notional <= 0.0: return None - price = px_norm if intent.order_type == "market": mid = float(state.get_mid(intent.instrument)) if mid <= 0.0: return None price = mid - if price is None or price <= 0.0: return None - contract_size = float(state.get_contract_size(intent.instrument)) notional = float(price) * float(qty_norm) * contract_size - if notional + 1e-12 >= self._min_order_notional: return None - return NormalizationOutcome( normalized=None, reject_reason=RejectReason.MIN_NOTIONAL, @@ -203,19 +156,14 @@ def _round_price(price: float, tick_size: float, *, side: str) -> float | None: return None if tick_size <= 0.0: return float(price) - ticks = price / tick_size - if side == "buy": - rounded = math.floor(ticks) * tick_size - else: - rounded = math.ceil(ticks) * tick_size + rounded = math.floor(ticks) * tick_size if side == "buy" else math.ceil(ticks) * tick_size return float(rounded) @staticmethod def _clone_new(intent: OrderIntent, qty: float, px: float | None) -> NewOrderIntent: qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency - return NewOrderIntent( ts_ns_local=intent.ts_ns_local, instrument=intent.instrument, @@ -224,23 +172,15 @@ def _clone_new(intent: OrderIntent, qty: float, px: float | None) -> NewOrderInt side=intent.side, order_type=intent.order_type, intended_qty={"unit": qty_unit, "value": qty}, - intended_price=None - if px is None - else {"currency": price_ccy, "value": px}, + intended_price=None if px is None else {"currency": price_ccy, "value": px}, time_in_force=intent.time_in_force, ) @staticmethod def _clone_replace(intent: OrderIntent, qty: float, px: float | None) -> OrderIntent: - # ReplaceOrderIntent shares the same field names as OrderIntent for the used fields. payload = intent.model_dump() qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit payload["intended_qty"] = {"unit": qty_unit, "value": qty} - price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency - payload["intended_price"] = ( - None - if px is None - else {"currency": price_ccy, "value": px} - ) + payload["intended_price"] = None if px is None else {"currency": price_ccy, "value": px} return type(intent).model_validate(payload) diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 983eb9d..c0eb81b 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -6,7 +6,9 @@ from tradingchassis_core.core.domain.reject_reasons import RejectReason from tradingchassis_core.core.domain.types import OrderIntent, RiskConstraints -from tradingchassis_core.core.ports.venue_policy import VenuePolicy +from tradingchassis_core.core.risk.execution_constraints_policy import ( + ExecutionConstraintsPolicy, +) from tradingchassis_core.core.risk.risk_policy import RiskPolicy if TYPE_CHECKING: @@ -24,15 +26,15 @@ class RiskEngine: def __init__(self, risk_cfg: RiskConfig) -> None: self.risk_cfg = risk_cfg - venue_policy_cfg = self._parse_venue_policy_config(risk_cfg) - self._venue_policy = VenuePolicy( - min_order_notional=venue_policy_cfg["min_order_notional"], - post_only_mode=venue_policy_cfg["post_only_mode"], + constraints_cfg = self._parse_execution_constraints_config(risk_cfg) + self._constraints_policy = ExecutionConstraintsPolicy( + min_order_notional=constraints_cfg["min_order_notional"], + post_only_mode=constraints_cfg["post_only_mode"], ) - self._risk_policy = RiskPolicy(venue_policy=self._venue_policy) + self._risk_policy = RiskPolicy(constraints_policy=self._constraints_policy) @staticmethod - def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: + def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> dict[str, object]: cfg: dict[str, object] = { "min_order_notional": 0.0, "post_only_mode": "reject", @@ -42,27 +44,35 @@ def _parse_venue_policy_config(risk_cfg: RiskConfig) -> dict[str, object]: if not isinstance(extra, dict): return cfg - vp = extra["venue_policy"] if "venue_policy" in extra else None - if isinstance(vp, dict): - if "min_order_notional" in vp: + constraints = ( + extra["execution_constraints"] + if "execution_constraints" in extra + else None + ) + if isinstance(constraints, dict): + if "min_order_notional" in constraints: try: - cfg["min_order_notional"] = float(vp["min_order_notional"]) + cfg["min_order_notional"] = float( + constraints["min_order_notional"] + ) except (TypeError, ValueError): pass - if "post_only_mode" in vp: - mode = str(vp["post_only_mode"]) + if "post_only_mode" in constraints: + mode = str(constraints["post_only_mode"]) if mode in {"reject", "drop"}: cfg["post_only_mode"] = mode return cfg - if "venue_policy_min_order_notional" in extra: + if "execution_constraints_min_order_notional" in extra: try: - cfg["min_order_notional"] = float(extra["venue_policy_min_order_notional"]) + cfg["min_order_notional"] = float( + extra["execution_constraints_min_order_notional"] + ) except (TypeError, ValueError): pass - if "venue_policy_post_only_mode" in extra: - mode = str(extra["venue_policy_post_only_mode"]) + if "execution_constraints_post_only_mode" in extra: + mode = str(extra["execution_constraints_post_only_mode"]) if mode in {"reject", "drop"}: cfg["post_only_mode"] = mode @@ -75,11 +85,15 @@ def _constraints_extra(extra: object) -> dict[str, object]: normalized: dict[str, object] = {} for key, value in extra.items(): - if key == "venue_policy" and isinstance(value, dict): + if key == "execution_constraints" and isinstance(value, dict): if "min_order_notional" in value: - normalized["venue_policy_min_order_notional"] = value["min_order_notional"] + normalized["execution_constraints_min_order_notional"] = value[ + "min_order_notional" + ] if "post_only_mode" in value: - normalized["venue_policy_post_only_mode"] = value["post_only_mode"] + normalized["execution_constraints_post_only_mode"] = value[ + "post_only_mode" + ] continue if value is None or isinstance(value, (str, float, bool)): diff --git a/tradingchassis_core/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py index 4e17f31..8420b80 100644 --- a/tradingchassis_core/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -11,7 +11,10 @@ from tradingchassis_core.core.domain.reject_reasons import RejectReason from tradingchassis_core.core.domain.types import OrderIntent -from tradingchassis_core.core.ports.venue_policy import NormalizationOutcome, VenuePolicy +from tradingchassis_core.core.risk.execution_constraints_policy import ( + ExecutionConstraintsPolicy, + NormalizationOutcome, +) if TYPE_CHECKING: from tradingchassis_core.core.domain.state import StrategyState @@ -21,8 +24,8 @@ class RiskPolicy: """Pure policy layer used by RiskEngine.""" - def __init__(self, *, venue_policy: VenuePolicy) -> None: - self._venue_policy = venue_policy + def __init__(self, *, constraints_policy: ExecutionConstraintsPolicy) -> None: + self._constraints_policy = constraints_policy def trading_enabled_gate( self, @@ -101,7 +104,7 @@ def _accept_cancels_reject_others( return accepted_now, rejected def normalize_intent(self, it: OrderIntent, state: StrategyState) -> NormalizationOutcome: - return self._venue_policy.normalize_intent(it, state) + return self._constraints_policy.normalize_intent(it, state) def validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: """Outbound intent sanity. diff --git a/tradingchassis_core/core/schemas/common.schema.json b/tradingchassis_core/core/schemas/common.schema.json deleted file mode 100644 index ca8539e..0000000 --- a/tradingchassis_core/core/schemas/common.schema.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://schemas.example.com/common.schema.json", - "definitions": { - "Money": { - "type": "object", - "description": "Monetary amount with currency.", - "required": ["currency", "amount"], - "properties": { - "currency": { - "type": "string", - "minLength": 1, - "description": "Currency code (e.g. USD, EUR)." - }, - "amount": { - "type": "number", - "description": "Positive or negative monetary amount." - } - }, - "additionalProperties": false - }, - - "Price": { - "type": "object", - "description": "Price or rate with currency (e.g. price per unit).", - "required": ["currency", "value"], - "properties": { - "currency": { - "type": "string", - "minLength": 1, - "description": "Currency code (e.g. USD, EUR)." - }, - "value": { - "type": "number", - "minimum": 0, - "description": "Price value in quote currency. Price can be 0 as a placeholder." - } - }, - "additionalProperties": false - }, - - "Quantity": { - "type": "object", - "required": ["value", "unit"], - "properties": { - "value": { "type": "number", "minimum": 0, "description": "Value can be 0 for remaining quantity." }, - "unit": { "type": "string", "minLength": 1 } - }, - "additionalProperties": false - } - } -} diff --git a/tradingchassis_core/core/schemas/fill_event.schema.json b/tradingchassis_core/core/schemas/fill_event.schema.json deleted file mode 100644 index dd444cf..0000000 --- a/tradingchassis_core/core/schemas/fill_event.schema.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/fill_event.schema.json", - "title": "FillEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "client_order_id", - "side", - "filled_price", - "cum_filled_qty", - "time_in_force", - "liquidity_flag" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue fill event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local fill event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier.", - "type": "string", - "minLength": 1 - }, - "client_order_id": { - "description": "Client-assigned order ID (if used).", - "type": "string", - "minLength": 1 - }, - "side": { - "description": "Side of the filled order from client perspective.", - "type": "string", - "enum": ["buy", "sell"] - }, - "intended_price": { - "description": "Intended price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "filled_price": { - "description": "Filled price for the order.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended quantity of this order in underlying units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "cum_filled_qty": { - "description": "Cumulative filled quantity of this order in underlying units at this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "remaining_qty": { - "description": "Remaining open quantity after this state.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "description": "Time-in-force instruction (venue-specific mapping).", - "type": "string", - "enum": ["GTC", "IOC", "FOK", "POST_ONLY"] - }, - "liquidity_flag": { - "description": "Indicates whether the fill was maker or taker.", - "type": "string", - "enum": ["maker", "taker", "unknown"] - }, - "fee": { - "description": "Transaction fee or rebate. Negative = fee paid, positive = rebate.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Money" - } - }, - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/market_event.schema.json b/tradingchassis_core/core/schemas/market_event.schema.json deleted file mode 100644 index 6b74db0..0000000 --- a/tradingchassis_core/core/schemas/market_event.schema.json +++ /dev/null @@ -1,121 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/market_event.schema.json", - "title": "MarketEvent", - "type": "object", - "required": [ - "ts_ns_exch", - "ts_ns_local", - "instrument", - "event_type" - ], - "properties": { - "ts_ns_exch": { - "description": "Venue event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "ts_ns_local": { - "description": "Local event timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "instrument": { - "description": "Instrument identifier, venue-specific.", - "type": "string", - "minLength": 1 - }, - "event_type": { - "description": "Type of market event.", - "type": "string", - "enum": ["book", "trade"] - }, - "book": { - "description": "Payload for order book events.", - "type": "object", - "required": ["book_type", "bids", "asks"], - "properties": { - "book_type": { - "description": "Whether this is a full snapshot or an incremental update.", - "type": "string", - "enum": ["snapshot", "delta"] - }, - "bids": { - "description": "Bid levels, highest price first.", - "type": "array", - "items": { - "type": "object", - "required": ["price", "quantity"], - "properties": { - "price": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" }, - "quantity": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" } - }, - "additionalProperties": false - } - }, - "asks": { - "description": "Ask levels, lowest price first.", - "type": "array", - "items": { - "type": "object", - "required": ["price", "quantity"], - "properties": { - "price": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" }, - "quantity": { "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" } - }, - "additionalProperties": false - } - }, - "depth": { - "description": "Depth of the book represented by this event (number of levels).", - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false - }, - "trade": { - "description": "Payload for trade events.", - "type": "object", - "required": ["side", "price", "quantity"], - "properties": { - "side": { - "description": "Taker side of the trade.", - "type": "string", - "enum": ["buy", "sell"] - }, - "price": { - "description": "Trade price.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "quantity": { - "description": "Trade quantity in contract units.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "trade_id": { - "description": "Venue-provided trade identifier, if available.", - "type": "string", - "minLength": 1 - } - }, - "additionalProperties": false - } - }, - "allOf": [ - { - "if": { "properties": { "event_type": { "const": "book" } } }, - "then": { - "required": ["book"], - "not": { "required": ["trade"] } - } - }, - { - "if": { "properties": { "event_type": { "const": "trade" } } }, - "then": { - "required": ["trade"], - "not": { "required": ["book"] } - } - } - ], - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/order_intent.schema.json b/tradingchassis_core/core/schemas/order_intent.schema.json deleted file mode 100644 index 6fffd37..0000000 --- a/tradingchassis_core/core/schemas/order_intent.schema.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/order_intent.schema.json", - "title": "OrderIntent", - "type": "object", - "description": "Schema for an order intent used as a single source of truth across strategy, validation, and (backtest) execution bindings. The schema models intent-specific requirements via oneOf (new/cancel/replace).", - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type" - ], - "properties": { - "ts_ns_local": { - "type": "integer", - "description": "Local intent timestamp in nanoseconds since Unix epoch.", - "exclusiveMinimum": 0 - }, - "instrument": { - "type": "string", - "description": "Instrument identifier used for routing and execution binding (e.g., symbol, asset code). Required for all intents, including cancel.", - "minLength": 1 - }, - "client_order_id": { - "type": "string", - "description": "Order identifier (maps to runtime adapter order IDs). Used for new/cancel/replace. Must be unique while an order with the same ID exists.", - "minLength": 1 - }, - "intent_type": { - "type": "string", - "enum": [ - "new", - "cancel", - "replace" - ], - "description": "Intent type describing the order lifecycle action." - }, - "intents_correlation_id": { - "type": "string", - "description": "Optional correlation identifier to link multiple intents (e.g., decision bundles) across the order lifecycle.", - "minLength": 1 - }, - "order_type": { - "type": "string", - "enum": [ - "limit", - "market" - ], - "description": "Order type. For replace intents this must be 'limit'." - }, - "side": { - "type": "string", - "enum": [ - "buy", - "sell" - ], - "description": "Order side." - }, - "intended_price": { - "description": "Intended order price. Required for both limit and market orders to match the execution binding signature.", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Price" - }, - "intended_qty": { - "description": "Intended total order quantity. For replace intents, this is the new total quantity (not a delta).", - "$ref": "https://schemas.example.com/common.schema.json#/definitions/Quantity" - }, - "time_in_force": { - "type": "string", - "enum": [ - "GTC", - "IOC", - "FOK", - "POST_ONLY" - ], - "description": "Time in force. Required for new intents. Not allowed for replace intents because the binding does not support modifying it." - } - }, - "oneOf": [ - { - "title": "NewOrderIntent", - "description": "Create a new order.", - "type": "object", - "properties": { - "intent_type": { - "const": "new" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type", - "side", - "order_type", - "intended_qty", - "intended_price", - "time_in_force" - ] - }, - { - "title": "CancelOrderIntent", - "description": "Cancel an existing order identified by client_order_id.", - "type": "object", - "properties": { - "intent_type": { - "const": "cancel" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type" - ], - "allOf": [ - { - "not": { - "required": [ - "side" - ] - } - }, - { - "not": { - "required": [ - "order_type" - ] - } - }, - { - "not": { - "required": [ - "intended_qty" - ] - } - }, - { - "not": { - "required": [ - "intended_price" - ] - } - }, - { - "not": { - "required": [ - "time_in_force" - ] - } - } - ] - }, - { - "title": "ReplaceOrderIntent", - "description": "Modify an existing order (limit-only). The order ID remains the same (client_order_id). Time-in-force cannot be modified.", - "type": "object", - "properties": { - "intent_type": { - "const": "replace" - }, - "order_type": { - "const": "limit" - } - }, - "required": [ - "ts_ns_local", - "instrument", - "client_order_id", - "intent_type", - "side", - "order_type", - "intended_qty", - "intended_price" - ], - "allOf": [ - { - "not": { - "required": [ - "time_in_force" - ] - } - } - ] - } - ], - "additionalProperties": false -} diff --git a/tradingchassis_core/core/schemas/risk_constraints.schema.json b/tradingchassis_core/core/schemas/risk_constraints.schema.json deleted file mode 100644 index 8336204..0000000 --- a/tradingchassis_core/core/schemas/risk_constraints.schema.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://example.com/schemas/risk_constraints.schema.json", - "title": "RiskConstraints", - "type": "object", - "required": [ - "ts_ns_local", - "scope", - "trading_enabled" - ], - "properties": { - "ts_ns_local": { - "description": "Local risk state timestamp in nanoseconds since Unix epoch.", - "type": "integer", - "exclusiveMinimum": 0 - }, - "scope": { - "description": "Scope for which these constraints apply (e.g. strategy name, account).", - "type": "string", - "minLength": 1 - }, - "trading_enabled": { - "description": "Global on/off flag. If false, no new risk-increasing orders should be sent.", - "type": "boolean" - }, - - "position_limits": { - "description": "Limits expressed in underlying units (e.g. shares, contracts). Position value is interpreted in the given currency context.", - "type": "object", - "required": ["currency"], - "properties": { - "currency": { - "description": "Currency context for evaluating position limits (e.g. 'USD', 'EUR')", - "type": "string", - "minLength": 1 - }, - "max_position": { - "description": "Maximum allowed absolute position in underlying units (applies to the net position if the venue auto-nets).", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "notional_limits": { - "description": "Limits expressed in monetary notional (exposure) in a given currency.", - "type": "object", - "required": [ - "currency" - ], - "properties": { - "currency": { - "description": "Currency identifier used for notional limits (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_gross_notional": { - "description": "Maximum allowed gross notional exposure (sum of absolute notionals across instruments) in the given currency.", - "type": "number", - "minimum": 0 - }, - "max_single_order_notional": { - "description": "Maximum allowed notional for a single new order in the given currency.", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "quote_limits": { - "description": "Limits on outstanding quote exposure, expressed in a monetary currency.", - "type": "object", - "required": [ - "currency" - ], - "properties": { - "currency": { - "description": "Currency identifier used for quote-notional limits (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_gross_quote_notional": { - "description": "Maximum gross notional of all active quotes (sum of absolute notional of all bid/ask quotes) in the given currency.", - "type": "number", - "minimum": 0 - }, - "max_net_quote_notional": { - "description": "Maximum net notional of active quotes (bid notional - ask notional).", - "type": "number" - }, - "max_active_quotes": { - "description": "Maximum number of active quotes across all instruments.", - "type": "integer", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "order_rate_limits": { - "description": "Rate limits for order and cancel activity.", - "type": "object", - "properties": { - "max_orders_per_second": { - "description": "Maximum number of new orders per second.", - "type": "number", - "minimum": 0 - }, - "max_cancels_per_second": { - "description": "Maximum number of cancel requests per second.", - "type": "number", - "minimum": 0 - } - }, - "additionalProperties": false - }, - - "max_loss": { - "description": "Maximum allowed realized + unrealized losses.", - "type": "object", - "required": [ - "currency", - "max_drawdown" - ], - "properties": { - "currency": { - "description": "Currency identifier used for the daily loss limit (e.g. 'USD', 'EUR', 'SimDollar'). No specific format enforced.", - "type": "string", - "minLength": 1 - }, - "max_drawdown": { - "description": "Maximum permitted portfolio loss measured from the most recent equity peak (peak-to-trough drawdown limit).", - "type": "number", - "exclusiveMaximum": 0 - }, - "rolling_loss": { - "description": "Maximum permitted loss within a rolling time window; used as an additional regime/bug protection alongside the max_drawdown limit.", - "type": "number", - "exclusiveMaximum": 0 - }, - "rolling_loss_window": { - "description": "Size of rolling loss measurement window in minutes, defining how far back losses are accumulated for the rolling_loss check.", - "type": "number", - "exclusiveMinimum": 0 - } - }, - "additionalProperties": false - }, - - "extra": { - "description": "Extension point for additional risk parameters.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "boolean", - "null" - ] - } - } - }, - "additionalProperties": false -} From 746b5d7ab12d4193c9b3e16dff3a0561c9a49e84 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 15:29:10 +0000 Subject: [PATCH 33/53] chore(core): harden standalone package baseline --- CHANGELOG.md | 29 +++-- CONTRIBUTING.md | 75 +++++++++-- README.md | 120 +++++++++++++++--- SECURITY.md | 45 ++++--- docs/README.md | 45 +++++-- docs/code-map/core-pipeline-map.md | 30 +++++ docs/code-map/repository-map.md | 38 ++++++ docs/how-to/add-canonical-event.md | 16 +++ docs/how-to/update-core-step-pipeline.md | 24 ++++ .../update-policy-and-execution-control.md | 33 +++++ docs/reference/events-reference.md | 43 +++++++ docs/reference/public-api.md | 26 +++- pyproject.toml | 32 +++-- tests/semantics/test_core_pipeline_clean.py | 38 ++++++ tests/semantics/test_public_api_clean.py | 26 ++++ .../semantics/test_risk_engine_policy_only.py | 1 + tradingchassis_core/__init__.py | 6 + .../core/domain/processing_step.py | 10 +- .../execution_control/execution_control.py | 13 +- tradingchassis_core/core/ports/__init__.py | 0 .../core/risk/execution_constraints_policy.py | 62 ++++++--- tradingchassis_core/core/risk/risk_engine.py | 42 +++--- tradingchassis_core/core/risk/risk_policy.py | 12 +- tradingchassis_core/strategies/__init__.py | 0 24 files changed, 630 insertions(+), 136 deletions(-) create mode 100644 docs/code-map/core-pipeline-map.md create mode 100644 docs/code-map/repository-map.md create mode 100644 docs/how-to/add-canonical-event.md create mode 100644 docs/how-to/update-core-step-pipeline.md create mode 100644 docs/how-to/update-policy-and-execution-control.md create mode 100644 docs/reference/events-reference.md delete mode 100644 tradingchassis_core/core/ports/__init__.py delete mode 100644 tradingchassis_core/strategies/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdfcba..39e2bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,23 @@ # Changelog -## [Unreleased] +This changelog starts from the clean Core package baseline. + +## [0.1.0] - 2026-05-14 + +### Added + +- Deterministic `run_core_step` and `run_core_wakeup_step` architecture. +- Canonical event input models and `EventStreamEntry`/`ProcessingPosition`. +- Intent candidate record pipeline with dominance/reconciliation. +- Policy-only risk admission and execution-control plan/apply integration. +- `CoreStepResult.dispatchable_intents` and `ControlSchedulingObligation` outputs. +- Core-only quickstart example and focused semantics test coverage. ### Changed -- Phase R1 clean cut: - - removed compatibility gate and snapshot lifecycle contracts from Core APIs - - made `RiskEngine` policy-only (`evaluate_policy_intent`, constraints build) - - simplified docs/tests to one clean CoreStep/CoreWakeupStep architecture -- Phase R2 polish: - - removed JSON schema files in favor of Pydantic contract source of truth - - removed snapshot-shaped execution feedback payload rows - - renamed constraint normalizer to `ExecutionConstraintsPolicy` - - hardened clean-pipeline semantics tests (reconciliation, rejection, deferral) +- Package metadata, exports, and docs reset for standalone Core library identity. +- Pydantic models established as contract source of truth across public API docs. -### Added +### Removed -- clean public exports for canonical events, `ExecutionControl`, and `NullEventBus` -- focused semantics tests for clean Core pipeline and API boundary +- Legacy compatibility-first contracts and references not part of the clean baseline. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 214e4a1..1fe855e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,23 +1,80 @@ # Contributing to TradingChassis Core -Keep contributions aligned to deterministic Core ownership. +Contributions should preserve TradingChassis Core as a deterministic, +runtime-agnostic library. -## Scope rules +## Package Scope -- Keep Core focused on canonical models, reduction, policy admission, and execution-control semantics. -- Do not add runtime adapters, venue I/O integrations, dispatch implementations, or `hftbacktest` dependencies. -- Keep Core outputs deterministic and runtime-agnostic (`CoreStepResult`). +- Core owns canonical events, state reduction, strategy evaluation boundary, + candidate reconciliation, policy admission, execution-control plan/apply, + and `CoreStepResult`. +- Core does not own runtime orchestration, venue adapters, dispatch lifecycle, + `hftbacktest`, or deployment/config wiring. -## Local validation +## Development Setup From `core`: ```bash python -m pip install -e ".[dev]" +``` + +## Validation Commands + +Run before opening a PR: + +```bash python examples/core_step_quickstart.py -python -m pytest -q tests/semantics +python -m pytest -q +python -m build ``` -## Documentation +## Architecture Rules + +- Core accepts canonical events through `EventStreamEntry` and + `process_event_entry` / `process_canonical_event`. +- Core returns deterministic `CoreStepResult`; runtime dispatch happens later. +- Do not introduce runtime/backtest imports (`core_runtime`, `hftbacktest`). +- Do not restore `GateDecision`, snapshot lifecycle compatibility, or + runtime-owned decision contracts. +- Pydantic models are the source of truth for contract structure. + +## Changing Core Behavior + +### Canonical events + +- Add event models in `tradingchassis_core/core/domain/types.py`. +- Register canonical category handling in `core/domain/event_model.py`. +- Update canonical reduction behavior in `core/domain/processing.py`. + +### CoreStep/CoreWakeupStep pipeline + +- Update `core/domain/processing_step.py` for deterministic flow changes. +- Keep reconciliation/policy/apply transitions explicit and side-effect-safe. + +### Policy and risk behavior + +- Implement policy checks in `core/risk/` and wire through + `evaluate_policy_intent`. +- Keep risk admission as policy-only; no dispatch/runtime side effects. + +### Execution-control behavior + +- Update plan/apply stages in `core/domain/execution_control_plan.py` and + `core/domain/execution_control_apply.py`. +- Preserve `ControlSchedulingObligation` as non-canonical output. + +### Public API exports and docs + +- Update `tradingchassis_core/__init__.py` for intentional public exports only. +- Sync docs in `README.md` and `docs/reference/public-api.md`. + +## Pull Request Checklist + +- [ ] Package remains Core-only and deterministic. +- [ ] Public API changes are intentional and tested. +- [ ] Quickstart still runs via public imports. +- [ ] `python -m pytest -q` passes. +- [ ] `python -m build` succeeds. +- [ ] README/docs/changelog updated to match behavior. -Update `README.md` and `docs/reference/public-api.md` when public API changes. diff --git a/README.md b/README.md index 6b71165..8ec2a47 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,120 @@ # TradingChassis Core -`tradingchassis_core` is a deterministic Core package. +`tradingchassis_core` is a deterministic trading decision engine core library. -It owns one architecture: +It provides a clean Core-only package baseline centered on `run_core_step` and +`run_core_wakeup_step`, with Pydantic contracts as the source of truth. -`EventStreamEntry -> run_core_step/run_core_wakeup_step -> candidate intents -> policy admission -> execution-control apply -> CoreStepResult` +## What TradingChassis Core Is -## Scope +- Canonical event models and event taxonomy for Core reduction +- Processing-order contracts: `EventStreamEntry` and `ProcessingPosition` +- Deterministic state reduction and strategy evaluator boundary +- Intent models, candidate records, and dominance/reconciliation +- Policy admission (`PolicyRiskDecision`) and execution-control plan/apply +- `CoreStepResult` outputs for runtime dispatch and control scheduling -Core owns: +## What TradingChassis Core Is Not -- canonical event models (`MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, `FillEvent`) -- Pydantic contract models as the schema source of truth -- deterministic state reduction -- strategy evaluator protocol -- candidate intent combination + provenance -- policy admission semantics -- execution-control semantics (queue/rate/inflight/sendability) -- `CoreStepResult` outputs (`dispatchable_intents`, `control_scheduling_obligation`) +- Runtime orchestration or runtime order lifecycle management +- Venue adapters, backtest/live I/O, or dispatch implementations +- Deployment ownership or runtime-owned entrypoints -Core does not own: +## Clean Core Pipeline -- venue/backtest/live I/O -- runtime dispatch and runtime execution errors -- adapter integrations or `hftbacktest` -- runtime config file loading and deployment wiring +The clean deterministic pipeline is: -## Quickstart +`EventStreamEntry` +`-> process_event_entry / process_canonical_event` +`-> strategy evaluator` +`-> generated intents` +`-> candidate intent records` +`-> dominance / reconciliation` +`-> policy admission` +`-> execution-control plan/apply` +`-> CoreStepResult.dispatchable_intents` +`-> runtime dispatches later` + +## Installation From the `core` directory: ```bash python -m pip install -e ".[dev]" +``` + +## Quickstart + +```bash python examples/core_step_quickstart.py -python -m pytest -q tests/semantics/examples/test_core_step_quickstart.py ``` -## Docs +The quickstart demonstrates canonical input, `run_core_step`, generated intents, +candidate intent records, and dispatchable outputs after policy/admission apply. + +Minimal shape: + +```python +import tradingchassis_core as tc + +state = tc.StrategyState(event_bus=tc.NullEventBus()) +result = tc.run_core_step( + state, + tc.EventStreamEntry( + position=tc.ProcessingPosition(index=0), + event=tc.ControlTimeEvent( + ts_ns_local_control=1_000, + reason="scheduled_control_recheck", + due_ts_ns_local=1_000, + realized_ts_ns_local=1_000, + ), + ), +) +print(result.generated_intents, result.dispatchable_intents) +``` + +See `examples/core_step_quickstart.py` for a complete runnable example. + +## Public API Overview + +Main exported categories from `tradingchassis_core`: + +- Canonical events: `MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, + `OrderExecutionFeedbackEvent`, `FillEvent` +- Pipeline models/APIs: `EventStreamEntry`, `ProcessingPosition`, + `process_canonical_event`, `process_event_entry`, `run_core_step`, + `run_core_wakeup_reduction`, `run_core_wakeup_decision`, + `run_core_wakeup_step` +- Decision/output contracts: `CoreStepDecision`, `CoreStepResult`, + `PolicyRiskDecision`, `ExecutionControlDecision`, + `ControlSchedulingObligation` +- Intents and candidates: `OrderIntent`, `NewOrderIntent`, + `CancelOrderIntent`, `ReplaceOrderIntent`, `CandidateIntentRecord`, + `CandidateIntentOrigin` +- Core state/config helpers: `StrategyState`, `CoreConfiguration`, + `ExecutionControl`, `RiskEngine`, `RiskConfig`, `NullEventBus` + +## Testing + +From the `core` directory: + +```bash +python -m pytest -q +``` + +## Documentation - `docs/README.md` - `docs/reference/public-api.md` +- `docs/reference/events-reference.md` +- `docs/code-map/core-pipeline-map.md` +- `docs/code-map/repository-map.md` +- `docs/how-to/add-canonical-event.md` +- `docs/how-to/update-core-step-pipeline.md` +- `docs/how-to/update-policy-and-execution-control.md` + +## Project References + +- Changelog: `CHANGELOG.md` +- Contributing: `CONTRIBUTING.md` +- Security: `SECURITY.md` diff --git a/SECURITY.md b/SECURITY.md index 4678273..b53ffb4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,18 +1,18 @@ # Security Policy -## Supported Versions and Status +## Supported Baseline -The active supported line is the current `main` branch and accepted MVP baseline for this Core -repository. +The supported baseline is the clean standalone Core package line (`0.1.x` and +forward on the active mainline branch). -Older commits may not receive security fixes. +Older historical commits may not receive fixes. ## Reporting a Vulnerability Do not report vulnerabilities in public issues. -Use a private security advisory workflow if available for this repository, or contact project -maintainers through the project's configured private channel. +Use a private security advisory workflow if available for this repository, or +contact project maintainers through the configured private channel. Include: @@ -24,21 +24,34 @@ Include: This policy covers the Core package in this repository, including: -- semantic event-processing contracts -- state and decision model handling -- package integrity and dependency usage in Core +- canonical event and intent contracts +- deterministic CoreStep/CoreWakeupStep decision pipeline +- package integrity and dependency usage in `tradingchassis_core` -## Out of Scope and Disclaimers +## Secrets and Credentials Policy -- No financial or trading performance guarantee is provided -- Safe live trading operation is not guaranteed without runtime/venue-specific validation - -## Secrets and Credentials - -Never commit live secrets to this repository, including: +Never commit live secrets or account-sensitive data, including: - API keys and venue credentials - account identifiers tied to real accounts - private trading data dumps Tests and documentation examples must use synthetic or non-sensitive data only. + +## Runtime and Trading Caveat + +- TradingChassis Core is a library and does not guarantee safe live trading by + itself. +- Runtime orchestration, venue behavior, and deployment hardening remain outside + this package scope and require separate validation. + +## No Financial Performance Guarantee + +This package provides deterministic software behavior, not financial advice or +performance guarantees. + +## Dependency Vulnerability Handling + +- Keep dependencies minimal and pinned by compatible ranges. +- Review dependency advisories and patch vulnerable versions promptly. +- Prefer removing unused dependencies over adding new tooling. diff --git a/docs/README.md b/docs/README.md index 4e403a2..e420b24 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,24 +1,43 @@ # TradingChassis Core Docs -This documentation set describes the clean Core package only. +This documentation set describes the standalone clean Core package baseline. ## Contents -- `reference/public-api.md`: supported root exports and step contracts +- `reference/public-api.md`: supported root exports and package boundary +- `reference/events-reference.md`: canonical events and intent contracts +- `code-map/core-pipeline-map.md`: deterministic pipeline walkthrough +- `code-map/repository-map.md`: package layout and ownership map +- `how-to/add-canonical-event.md`: extending canonical event contracts +- `how-to/update-core-step-pipeline.md`: changing CoreStep/CoreWakeupStep behavior +- `how-to/update-policy-and-execution-control.md`: changing policy/apply behavior -## Architectural baseline +## Package Purpose -The only supported processing architecture is: +TradingChassis Core is a deterministic trading decision engine library. It owns +canonical contracts, state reduction, and step-level decision outputs. -1. canonical `EventStreamEntry` ingestion -2. deterministic state reduction -3. strategy evaluation -4. candidate intent combination -5. policy admission -6. execution-control planning/apply -7. `CoreStepResult` outputs for runtime dispatch/scheduling +## Clean Core Pipeline + +1. `EventStreamEntry` +2. `process_event_entry` / `process_canonical_event` +3. strategy evaluator +4. generated intents +5. candidate records + dominance/reconciliation +6. policy admission +7. execution-control plan/apply +8. `CoreStepResult` outputs (`dispatchable_intents`, + `control_scheduling_obligation`) +9. runtime dispatch happens later ## Contract source of truth -Core contract models are defined in Pydantic classes under -`tradingchassis_core/core/domain/types.py`. +Pydantic contract models in `tradingchassis_core/core/domain/types.py` are the +source of truth for canonical event/intent schemas. + +## Out of Scope + +- runtime orchestration and order lifecycle ownership +- venue adapters, live/backtest I/O, external dispatch +- `hftbacktest` and `core_runtime` dependencies +- legacy GateDecision and snapshot lifecycle compatibility layers diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md new file mode 100644 index 0000000..8535dc6 --- /dev/null +++ b/docs/code-map/core-pipeline-map.md @@ -0,0 +1,30 @@ +# Core Pipeline Map + +This map captures the only supported deterministic decision pipeline for +TradingChassis Core. + +## Step-by-step flow + +1. `EventStreamEntry` arrives with `ProcessingPosition`. +2. `process_event_entry` forwards to `process_canonical_event`. +3. Canonical reducer mutates `StrategyState` deterministically. +4. Strategy evaluator produces generated intents. +5. Candidate records are built and reconciled/dominated. +6. Policy admission accepts/rejects generated candidates. +7. Execution-control plan/apply computes queue/dispatch/scheduling outputs. +8. `CoreStepResult` returns `dispatchable_intents` and optional + `control_scheduling_obligation`. +9. Runtime can dispatch later; Core does not dispatch. + +## Core APIs + +- Single-entry flow: `run_core_step` +- Wakeup reduction: `run_core_wakeup_reduction` +- Wakeup decision/apply: `run_core_wakeup_decision` +- Wakeup convenience wrapper: `run_core_wakeup_step` + +## Determinism notes + +- Processing order monotonicity is enforced by `ProcessingPosition`. +- Core logic is side-effect-safe apart from deterministic state mutation. +- Runtime adapters and external dispatch concerns are outside Core. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md new file mode 100644 index 0000000..f891db6 --- /dev/null +++ b/docs/code-map/repository-map.md @@ -0,0 +1,38 @@ +# Repository Map + +High-level map for the standalone Core package. + +## Package layout + +- `tradingchassis_core/__init__.py`: public package boundary exports +- `tradingchassis_core/core/domain/`: canonical contracts and deterministic + pipeline orchestration +- `tradingchassis_core/core/risk/`: policy-only risk evaluator/config +- `tradingchassis_core/core/execution_control/`: execution-control primitives +- `tradingchassis_core/core/events/`: internal event bus/sink utilities + +## Tests and examples + +- `tests/semantics/`: focused contract and deterministic behavior tests +- `examples/core_step_quickstart.py`: public-import quickstart + +## Top-level package docs and metadata + +- `README.md`: package front door +- `CHANGELOG.md`: clean baseline changelog +- `CONTRIBUTING.md`: development and architecture rules +- `SECURITY.md`: vulnerability handling and scope policy +- `pyproject.toml`: build and tooling configuration + +## Boundary matrix + +Core owns: + +- canonical events and processing-order contracts +- deterministic reduction and step decisions +- intent candidate, policy admission, execution-control outputs + +Core does not own: + +- runtime orchestration, adapters, I/O, deployment +- dispatch lifecycle beyond `CoreStepResult` outputs diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md new file mode 100644 index 0000000..06e340b --- /dev/null +++ b/docs/how-to/add-canonical-event.md @@ -0,0 +1,16 @@ +# How To Add a Canonical Event + +1. Add or update the Pydantic event model in + `tradingchassis_core/core/domain/types.py`. +2. Register the event in `core/domain/event_model.py` canonical category mapping. +3. Add reducer handling in `core/domain/processing.py` within + `process_canonical_event`. +4. Add/update semantics tests under `tests/semantics/`. +5. Export the model from `tradingchassis_core/__init__.py` if part of public API. +6. Update `docs/reference/events-reference.md` and `docs/reference/public-api.md`. + +Rules: + +- Keep canonical processing deterministic. +- Do not introduce runtime adapter or dispatch logic in reducers. +- Keep Pydantic contracts as source of truth. diff --git a/docs/how-to/update-core-step-pipeline.md b/docs/how-to/update-core-step-pipeline.md new file mode 100644 index 0000000..0fd7cca --- /dev/null +++ b/docs/how-to/update-core-step-pipeline.md @@ -0,0 +1,24 @@ +# How To Update CoreStep Pipeline Behavior + +Core step orchestration lives in +`tradingchassis_core/core/domain/processing_step.py`. + +Recommended workflow: + +1. Start from `run_core_step` and identify which phase changes: + reduction, strategy evaluation, reconciliation, policy, or apply. +2. Keep stage boundaries explicit: + - reduction first + - strategy generation second + - candidate reconciliation third + - policy admission fourth + - execution-control plan/apply fifth +3. Preserve `CoreStepResult` as the public output contract. +4. Add or update tests in `tests/semantics/test_core_pipeline_clean.py`. +5. Confirm quickstart behavior still reflects the public contract. + +Guardrails: + +- No runtime dispatch logic in Core pipeline code. +- No legacy compatibility contract restoration. +- Keep deterministic behavior and public API coherence. diff --git a/docs/how-to/update-policy-and-execution-control.md b/docs/how-to/update-policy-and-execution-control.md new file mode 100644 index 0000000..2e06285 --- /dev/null +++ b/docs/how-to/update-policy-and-execution-control.md @@ -0,0 +1,33 @@ +# How To Update Policy and Execution Control + +Policy admission and execution-control are separate deterministic phases. + +## Policy updates + +- Policy contract entrypoint: + `PolicyIntentEvaluator.evaluate_policy_intent(...)` +- Core integration: + `core/domain/policy_risk_decision.py` and `run_core_step` policy phase +- Built-in policy-only evaluator: + `core/risk/risk_engine.py` + +When updating policy: + +1. Keep evaluation side-effect-free. +2. Return explicit accept/reject with reason. +3. Validate behavior with semantics tests. + +## Execution-control updates + +- Planning model: + `core/domain/execution_control_plan.py` +- Apply stage: + `core/domain/execution_control_apply.py` +- Runtime-facing non-canonical output: + `ControlSchedulingObligation` + +When updating execution-control: + +1. Keep queue/dispatchability decisions deterministic. +2. Preserve `CoreStepResult.dispatchable_intents` contract. +3. Use `ControlSchedulingObligation` for deferred control signals. diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md new file mode 100644 index 0000000..d0384b9 --- /dev/null +++ b/docs/reference/events-reference.md @@ -0,0 +1,43 @@ +# Events and Intents Reference + +TradingChassis Core accepts canonical event contracts and produces intent/decision +contracts. Pydantic models are the schema source of truth. + +## Canonical Event Models + +- `MarketEvent`: book/trade market data input for state reduction +- `ControlTimeEvent`: control-time wakeup and scheduling context +- `OrderSubmittedEvent`: canonical submitted-order acknowledgement +- `OrderExecutionFeedbackEvent`: canonical account/execution feedback +- `FillEvent`: canonical fill lifecycle update + +Canonical ingestion boundary: + +- `process_canonical_event(state, event, ...)` +- `process_event_entry(state, EventStreamEntry(...), ...)` + +## Processing Order Models + +- `ProcessingPosition` +- `EventStreamEntry` + +These models provide deterministic ordering metadata without implementing a full +stream storage/replay subsystem. + +## Intent Models + +- `OrderIntent` (discriminated union) +- `NewOrderIntent` +- `CancelOrderIntent` +- `ReplaceOrderIntent` +- `Price` +- `Quantity` + +## Non-canonical Output Models + +- `CandidateIntentRecord` with `CandidateIntentOrigin` +- `PolicyRiskDecision` +- `ExecutionControlDecision` +- `CoreStepDecision` +- `CoreStepResult` +- `ControlSchedulingObligation` diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index 6fd118e..e99a66f 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -1,6 +1,6 @@ # Public API Reference -The package export boundary is `tradingchassis_core`. +The public package boundary is the `tradingchassis_core` root import. ## Canonical events @@ -12,6 +12,8 @@ The package export boundary is `tradingchassis_core`. ## Step APIs +- `process_canonical_event` +- `process_event_entry` - `run_core_step` - `run_core_wakeup_reduction` - `run_core_wakeup_decision` @@ -38,9 +40,27 @@ The package export boundary is `tradingchassis_core`. - `ExecutionControl` - `ControlSchedulingObligation` -## Utility exports +## Intents and numeric models + +- `OrderIntent` +- `NewOrderIntent` +- `CancelOrderIntent` +- `ReplaceOrderIntent` +- `Price` +- `Quantity` + +## Runtime-safe utilities - `NullEventBus` - `RiskEngine` (policy-only evaluator) +- `RiskConfig` + +## Publicly absent by design -Compatibility bridge contracts are intentionally absent from the public API. +- `GateDecision` +- `compat_gate_decision` +- `ControlTimeQueueReevaluationContext` +- `CoreDecisionContext` +- `OrderStateEvent` +- `DerivedFillEvent` +- `VenueAdapter` / `VenuePolicy` diff --git a/pyproject.toml b/pyproject.toml index 970b557..af2a72f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,21 @@ build-backend = "setuptools.build_meta" [project] name = "tradingchassis-core" version = "0.1.0" -description = "Deterministic event-driven core package for intent policy and execution control." +description = "Deterministic trading decision engine core library." readme = "README.md" requires-python = ">=3.11" -authors = [{ name = "tradingeng@protonmail.com" }] -license = { text = "MIT" } +authors = [{ name = "TradingChassis Core Contributors" }] +maintainers = [{ name = "TradingChassis Core Maintainers" }] +license = "MIT" +keywords = ["trading", "deterministic", "pydantic", "event-driven", "strategy"] classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Topic :: Office/Business :: Financial :: Investment", + "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] @@ -28,10 +33,16 @@ dev = [ "import-linter>=1.11,<2", "ruff>=0.4,<1", "mypy>=1.9,<2", - "matplotlib>=3,<4", - "numpy>=2.0,<2.3", + "build>=1,<2", ] +[project.urls] +Homepage = "https://github.com/tradingchassis/tradingchassis" +Repository = "https://github.com/tradingchassis/tradingchassis" +Documentation = "https://github.com/tradingchassis/tradingchassis/tree/main/core/docs" +Changelog = "https://github.com/tradingchassis/tradingchassis/blob/main/core/CHANGELOG.md" +Issues = "https://github.com/tradingchassis/tradingchassis/issues" + [tool.setuptools.packages.find] include = ["tradingchassis_core*"] @@ -39,6 +50,7 @@ include = ["tradingchassis_core*"] minversion = "9.0" addopts = "-ra" testpaths = ["tests"] +pythonpath = ["."] [tool.ruff] target-version = "py311" @@ -54,16 +66,16 @@ warn_unused_configs = true ignore_missing_imports = true pretty = true show_error_codes = true -ignore_errors = true [tool.importlinter] root_package = "tradingchassis_core" include_external_packages = true [[tool.importlinter.contracts]] -name = "Core must be pure" +name = "Core stays runtime-independent" type = "forbidden" -source_modules = ["tradingchassis_core.core"] +source_modules = ["tradingchassis_core"] forbidden_modules = [ - "tradingchassis_core.strategies" + "core_runtime", + "hftbacktest", ] diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py index 175bab6..e0d20f7 100644 --- a/tests/semantics/test_core_pipeline_clean.py +++ b/tests/semantics/test_core_pipeline_clean.py @@ -3,6 +3,7 @@ from __future__ import annotations import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import BookLevel, BookPayload class _OneIntentEvaluator: @@ -10,6 +11,7 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: _ = context return [ tc.NewOrderIntent( + intent_type="new", ts_ns_local=10, instrument="BTC-USDC-PERP", client_order_id="intent-1", @@ -51,6 +53,7 @@ class _DuplicateIntentEvaluator: def evaluate(self, context: object) -> list[tc.NewOrderIntent]: _ = context first = tc.NewOrderIntent( + intent_type="new", ts_ns_local=10, instrument="BTC-USDC-PERP", client_order_id="dup-intent", @@ -62,6 +65,7 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: time_in_force="GTC", ) second = tc.NewOrderIntent( + intent_type="new", ts_ns_local=11, instrument="BTC-USDC-PERP", client_order_id="dup-intent", @@ -160,6 +164,7 @@ def test_candidate_reconciliation_prefers_latest_same_key_generated_intent() -> assert len(result.generated_intents) == 2 assert len(result.candidate_intent_records) == 1 winner = result.candidate_intent_records[0].intent + assert isinstance(winner, tc.NewOrderIntent) assert winner.client_order_id == "dup-intent" assert winner.intended_qty.value == 2.0 @@ -205,3 +210,36 @@ def test_execution_control_deferral_returns_scheduling_obligation() -> None: assert result.dispatchable_intents == () assert result.control_scheduling_obligation is not None assert result.control_scheduling_obligation.reason == "rate_limit" + + +def test_process_canonical_event_reduces_market_event() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_canonical_event( + state, + tc.MarketEvent( + ts_ns_exch=200, + ts_ns_local=201, + instrument="BTC-USDC-PERP", + event_type="book", + book=BookPayload( + book_type="snapshot", + bids=[ + BookLevel( + price=tc.Price(currency="USDC", value=99.0), + quantity=tc.Quantity(value=1.0, unit="contracts"), + ) + ], + asks=[ + BookLevel( + price=tc.Price(currency="USDC", value=101.0), + quantity=tc.Quantity(value=2.0, unit="contracts"), + ) + ], + depth=1, + ), + ), + ) + assert state.sim_ts_ns_local == 201 + market = state.market["BTC-USDC-PERP"] + assert market.best_bid == 99.0 + assert market.best_ask == 101.0 diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py index 7b657c4..2aa7fb6 100644 --- a/tests/semantics/test_public_api_clean.py +++ b/tests/semantics/test_public_api_clean.py @@ -7,10 +7,20 @@ def test_public_api_exposes_clean_core_symbols() -> None: for symbol in ( + "EventStreamEntry", + "ProcessingPosition", + "process_canonical_event", + "process_event_entry", "run_core_step", + "run_core_wakeup_reduction", + "run_core_wakeup_decision", "run_core_wakeup_step", "CoreStepResult", "CoreStepDecision", + "PolicyRiskDecision", + "ExecutionControlDecision", + "CandidateIntentRecord", + "CandidateIntentOrigin", "CorePolicyAdmissionContext", "CoreExecutionControlApplyContext", "ControlTimeEvent", @@ -18,8 +28,19 @@ def test_public_api_exposes_clean_core_symbols() -> None: "OrderSubmittedEvent", "OrderExecutionFeedbackEvent", "FillEvent", + "OrderIntent", + "NewOrderIntent", + "CancelOrderIntent", + "ReplaceOrderIntent", + "Price", + "Quantity", + "CoreConfiguration", + "StrategyState", "ExecutionControl", + "ControlSchedulingObligation", "NullEventBus", + "RiskEngine", + "RiskConfig", ): assert hasattr(tc, symbol), symbol @@ -27,8 +48,13 @@ def test_public_api_exposes_clean_core_symbols() -> None: def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: removed = ( "".join(["Gate", "Decision"]), + "".join(["compat_", "gate_decision"]), "".join(["ControlTimeQueue", "ReevaluationContext"]), "".join(["Core", "DecisionContext"]), + "".join(["OrderState", "Event"]), + "".join(["Derived", "FillEvent"]), + "".join(["Venue", "Adapter"]), + "".join(["Venue", "Policy"]), ) for symbol in removed: assert not hasattr(tc, symbol) diff --git a/tests/semantics/test_risk_engine_policy_only.py b/tests/semantics/test_risk_engine_policy_only.py index ce3a245..9fd7b98 100644 --- a/tests/semantics/test_risk_engine_policy_only.py +++ b/tests/semantics/test_risk_engine_policy_only.py @@ -24,6 +24,7 @@ def _risk_config() -> tc.RiskConfig: def _intent() -> tc.NewOrderIntent: return tc.NewOrderIntent( + intent_type="new", ts_ns_local=10, instrument="BTC-USDC-PERP", client_order_id="risk-intent-1", diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 2f5fcf2..13a1fda 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -27,6 +27,7 @@ ) from tradingchassis_core.core.domain.processing import ( fold_event_stream_entries, + process_canonical_event, process_event_entry, ) from tradingchassis_core.core.domain.processing_order import ( @@ -52,6 +53,7 @@ from tradingchassis_core.core.domain.step_decision import CoreStepDecision from tradingchassis_core.core.domain.step_result import CoreStepResult from tradingchassis_core.core.domain.types import ( + CancelOrderIntent, ControlTimeEvent, FillEvent, MarketEvent, @@ -67,6 +69,7 @@ ) from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus from tradingchassis_core.core.execution_control.execution_control import ExecutionControl +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 RiskEngine @@ -84,6 +87,7 @@ "NotionalLimits", "OrderIntent", "NewOrderIntent", + "CancelOrderIntent", "ReplaceOrderIntent", "Price", "Quantity", @@ -93,6 +97,7 @@ "CandidateIntentRecord", "ProcessingPosition", "EventStreamEntry", + "process_canonical_event", "process_event_entry", "fold_event_stream_entries", "run_core_step", @@ -117,6 +122,7 @@ "CoreStepDecision", "CoreStepResult", "ExecutionControl", + "ControlSchedulingObligation", "NullEventBus", "__version__", ] diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index cf57999..908bf45 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -193,7 +193,10 @@ def run_core_step( control_scheduling_obligation = None if apply_result is not None: control_scheduling_obligation = apply_result.control_scheduling_obligation - if execution_control_apply_context.activate_dispatchable_outputs: + if ( + execution_control_apply_context is not None + and execution_control_apply_context.activate_dispatchable_outputs + ): dispatchable_intents = tuple( record.record.intent for record in apply_result.dispatchable_records ) @@ -304,7 +307,10 @@ def run_core_wakeup_decision( control_scheduling_obligation = None if apply_result is not None: control_scheduling_obligation = apply_result.control_scheduling_obligation - if execution_control_apply_context.activate_dispatchable_outputs: + if ( + execution_control_apply_context is not None + and execution_control_apply_context.activate_dispatchable_outputs + ): dispatchable_intents = tuple( record.record.intent for record in apply_result.dispatchable_records ) diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index dc9cdb3..48708a8 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -15,7 +15,7 @@ from typing import TYPE_CHECKING, Callable from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent, ReplaceOrderIntent from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: @@ -169,6 +169,8 @@ def route_pre_submission_lifecycle_and_inflight( has_queued = state.has_queued_intent(it.instrument, it.client_order_id) if it.intent_type == "replace": + if not isinstance(it, ReplaceOrderIntent): + return False, RejectReason.INVALID_QTY if has_working: working = state.get_working_order_snapshot(it.instrument, it.client_order_id) if working is not None: @@ -210,6 +212,8 @@ def route_pre_submission_lifecycle_and_inflight( return False, RejectReason.ORDER_NOT_FOUND if it.intent_type == "replace": + if not isinstance(it, ReplaceOrderIntent): + return False, RejectReason.INVALID_QTY if not has_working: queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) if queued_new is None: @@ -255,7 +259,7 @@ def handle_cancel_against_queued_only_state( def handle_replace_against_queued_new( self, - it: OrderIntent, + it: ReplaceOrderIntent, *, state: StrategyState, queued_new: NewOrderIntent, @@ -270,6 +274,7 @@ def handle_replace_against_queued_new( replaced_in_queue.append((qi.intent, it)) updated_new = NewOrderIntent( + intent_type="new", ts_ns_local=it.ts_ns_local, instrument=it.instrument, client_order_id=it.client_order_id, @@ -294,7 +299,7 @@ def handle_replace_against_queued_new( @staticmethod def is_replace_noop_against_working( *, - replace_intent: OrderIntent, + replace_intent: ReplaceOrderIntent, working_intended_price: float, working_intended_qty: float, float_equal: Callable[[float, float], bool], @@ -306,7 +311,7 @@ def is_replace_noop_against_working( @staticmethod def is_replace_noop_against_queued_new( *, - replace_intent: OrderIntent, + replace_intent: ReplaceOrderIntent, queued_new: NewOrderIntent, float_equal: Callable[[float, float], bool], ) -> bool: diff --git a/tradingchassis_core/core/ports/__init__.py b/tradingchassis_core/core/ports/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tradingchassis_core/core/risk/execution_constraints_policy.py b/tradingchassis_core/core/risk/execution_constraints_policy.py index 7bebb5d..5a08003 100644 --- a/tradingchassis_core/core/risk/execution_constraints_policy.py +++ b/tradingchassis_core/core/risk/execution_constraints_policy.py @@ -11,7 +11,13 @@ from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.domain.types import ( + NewOrderIntent, + OrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) if TYPE_CHECKING: from tradingchassis_core.core.domain.state import StrategyState @@ -45,9 +51,16 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali if intent.intent_type == "cancel": return NormalizationOutcome(normalized=intent, reject_reason=None, dropped=False) + if not isinstance(intent, (NewOrderIntent, ReplaceOrderIntent)): + return NormalizationOutcome( + normalized=None, + reject_reason=RejectReason.INVALID_QTY, + dropped=False, + ) + tick_size = state.get_tick_size(intent.instrument) lot_size = state.get_lot_size(intent.instrument) - qty = 0.0 if intent.intended_qty is None else float(intent.intended_qty.value) + qty = float(intent.intended_qty.value) qty_norm = self._round_qty(qty, lot_size) if qty_norm <= 0.0: return NormalizationOutcome(normalized=None, reject_reason=None, dropped=True) @@ -69,9 +82,10 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali reject_reason=RejectReason.INVALID_LIMIT_PRICE, dropped=False, ) - post_only_outcome = self._enforce_post_only(intent, state, px_norm) - if post_only_outcome is not None: - return post_only_outcome + if isinstance(intent, NewOrderIntent): + post_only_outcome = self._enforce_post_only(intent, state, px_norm) + if post_only_outcome is not None: + return post_only_outcome min_notional_outcome = self._enforce_min_notional(intent, state, qty_norm, px_norm) if min_notional_outcome is not None: @@ -91,11 +105,11 @@ def normalize_intent(self, intent: OrderIntent, state: StrategyState) -> Normali def _enforce_post_only( self, - intent: OrderIntent, + intent: NewOrderIntent, state: StrategyState, px_norm: float, ) -> NormalizationOutcome | None: - if intent.intent_type != "new" or intent.time_in_force != "POST_ONLY": + if intent.time_in_force != "POST_ONLY": return None market = state.market[intent.instrument] if intent.instrument in state.market else None if market is None: @@ -117,7 +131,7 @@ def _enforce_post_only( def _enforce_min_notional( self, - intent: OrderIntent, + intent: NewOrderIntent | ReplaceOrderIntent, state: StrategyState, qty_norm: float, px_norm: float | None, @@ -161,26 +175,34 @@ def _round_price(price: float, tick_size: float, *, side: str) -> float | None: return float(rounded) @staticmethod - def _clone_new(intent: OrderIntent, qty: float, px: float | None) -> NewOrderIntent: - qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit - price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency + def _clone_new(intent: NewOrderIntent, qty: float, px: float | None) -> NewOrderIntent: + price = intent.intended_price.value if px is None else px return NewOrderIntent( + intent_type="new", ts_ns_local=intent.ts_ns_local, instrument=intent.instrument, client_order_id=intent.client_order_id, intents_correlation_id=intent.intents_correlation_id, side=intent.side, order_type=intent.order_type, - intended_qty={"unit": qty_unit, "value": qty}, - intended_price=None if px is None else {"currency": price_ccy, "value": px}, + intended_qty=Quantity(unit=intent.intended_qty.unit, value=qty), + intended_price=Price(currency=intent.intended_price.currency, value=price), time_in_force=intent.time_in_force, ) @staticmethod - def _clone_replace(intent: OrderIntent, qty: float, px: float | None) -> OrderIntent: - payload = intent.model_dump() - qty_unit = "contracts" if intent.intended_qty is None else intent.intended_qty.unit - payload["intended_qty"] = {"unit": qty_unit, "value": qty} - price_ccy = "UNKNOWN" if intent.intended_price is None else intent.intended_price.currency - payload["intended_price"] = None if px is None else {"currency": price_ccy, "value": px} - return type(intent).model_validate(payload) + def _clone_replace( + intent: ReplaceOrderIntent, qty: float, px: float | None + ) -> ReplaceOrderIntent: + price = intent.intended_price.value if px is None else px + return ReplaceOrderIntent( + intent_type="replace", + ts_ns_local=intent.ts_ns_local, + instrument=intent.instrument, + client_order_id=intent.client_order_id, + intents_correlation_id=intent.intents_correlation_id, + side=intent.side, + order_type=intent.order_type, + intended_qty=Quantity(unit=intent.intended_qty.unit, value=qty), + intended_price=Price(currency=intent.intended_price.currency, value=price), + ) diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index c0eb81b..18e4727 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -26,23 +26,21 @@ class RiskEngine: def __init__(self, risk_cfg: RiskConfig) -> None: self.risk_cfg = risk_cfg - constraints_cfg = self._parse_execution_constraints_config(risk_cfg) + min_order_notional, post_only_mode = self._parse_execution_constraints_config(risk_cfg) self._constraints_policy = ExecutionConstraintsPolicy( - min_order_notional=constraints_cfg["min_order_notional"], - post_only_mode=constraints_cfg["post_only_mode"], + min_order_notional=min_order_notional, + post_only_mode=post_only_mode, ) self._risk_policy = RiskPolicy(constraints_policy=self._constraints_policy) @staticmethod - def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> dict[str, object]: - cfg: dict[str, object] = { - "min_order_notional": 0.0, - "post_only_mode": "reject", - } + def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> tuple[float, str]: + min_order_notional = 0.0 + post_only_mode = "reject" extra = risk_cfg.extra if not isinstance(extra, dict): - return cfg + return min_order_notional, post_only_mode constraints = ( extra["execution_constraints"] @@ -52,20 +50,18 @@ def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> dict[str, objec if isinstance(constraints, dict): if "min_order_notional" in constraints: try: - cfg["min_order_notional"] = float( - constraints["min_order_notional"] - ) + min_order_notional = float(constraints["min_order_notional"]) except (TypeError, ValueError): pass if "post_only_mode" in constraints: mode = str(constraints["post_only_mode"]) if mode in {"reject", "drop"}: - cfg["post_only_mode"] = mode - return cfg + post_only_mode = mode + return min_order_notional, post_only_mode if "execution_constraints_min_order_notional" in extra: try: - cfg["min_order_notional"] = float( + min_order_notional = float( extra["execution_constraints_min_order_notional"] ) except (TypeError, ValueError): @@ -74,16 +70,16 @@ def _parse_execution_constraints_config(risk_cfg: RiskConfig) -> dict[str, objec if "execution_constraints_post_only_mode" in extra: mode = str(extra["execution_constraints_post_only_mode"]) if mode in {"reject", "drop"}: - cfg["post_only_mode"] = mode + post_only_mode = mode - return cfg + return min_order_notional, post_only_mode @staticmethod - def _constraints_extra(extra: object) -> dict[str, object]: + def _constraints_extra(extra: object) -> dict[str, str | float | bool | None]: if not isinstance(extra, dict): return {} - normalized: dict[str, object] = {} + normalized: dict[str, str | float | bool | None] = {} for key, value in extra.items(): if key == "execution_constraints" and isinstance(value, dict): if "min_order_notional" in value: @@ -171,8 +167,12 @@ def evaluate_policy_intent( max_pos = None if (pos_cfg is None or pos_cfg.max_position is None) else pos_cfg.max_position notional_cfg = self.risk_cfg.notional_limits - max_gross_notional = notional_cfg.max_gross_notional - max_single_order_notional = notional_cfg.max_single_order_notional + max_gross_notional = ( + None if notional_cfg is None else notional_cfg.max_gross_notional + ) + max_single_order_notional = ( + None if notional_cfg is None else notional_cfg.max_single_order_notional + ) quote_cfg = self.risk_cfg.quote_limits quote_book = None diff --git a/tradingchassis_core/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py index 8420b80..13efe92 100644 --- a/tradingchassis_core/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING from tradingchassis_core.core.domain.reject_reasons import RejectReason -from tradingchassis_core.core.domain.types import OrderIntent +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent, ReplaceOrderIntent from tradingchassis_core.core.risk.execution_constraints_policy import ( ExecutionConstraintsPolicy, NormalizationOutcome, @@ -69,10 +69,11 @@ def max_loss_gate( pnl = state.get_total_pnl() if pnl <= max_loss_cfg.max_drawdown: - return True, self._accept_cancels_reject_others( + accepted_now, rejected = self._accept_cancels_reject_others( raw_intents, RejectReason.MAX_LOSS_DRAWDOWN, ) + return True, accepted_now, rejected # Rolling loss kill-switch (equity change over a fixed window) if max_loss_cfg.rolling_loss is not None and max_loss_cfg.rolling_loss_window is not None: @@ -82,10 +83,11 @@ def max_loss_gate( window_ns=window_ns, ) if rolling is not None and rolling <= max_loss_cfg.rolling_loss: - return True, self._accept_cancels_reject_others( + accepted_now, rejected = self._accept_cancels_reject_others( raw_intents, RejectReason.MAX_LOSS_ROLLING, ) + return True, accepted_now, rejected return False, [], [] @@ -145,7 +147,7 @@ def hard_checks( max_gross_notional: float | None, base_gross_notional: float | None, quote_cfg: QuoteLimits | None, - quote_book: dict[tuple[str, str | None, tuple[float, float]]], + quote_book: dict[tuple[str, str], tuple[float, float]] | None, ) -> tuple[bool, str]: """Apply hard risk checks. Returns (ok, reason).""" @@ -210,6 +212,8 @@ def hard_checks( return True, "OK" def intent_price(self, it: OrderIntent, state: StrategyState) -> float | None: + if not isinstance(it, (NewOrderIntent, ReplaceOrderIntent)): + return None if it.order_type == "limit": return None if it.intended_price is None else it.intended_price.value mid = state.get_mid(it.instrument) diff --git a/tradingchassis_core/strategies/__init__.py b/tradingchassis_core/strategies/__init__.py deleted file mode 100644 index e69de29..0000000 From a40e23895c224a198ec9fbd9abe60e4f10af777e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 15:55:29 +0000 Subject: [PATCH 34/53] test(core): harden pipeline coverage and quickstart checks --- README.md | 123 +++++++++--------- examples/core_step_quickstart.py | 17 ++- .../examples/test_core_step_quickstart.py | 22 ++++ tests/semantics/test_core_pipeline_clean.py | 94 +++++++++++++ tests/semantics/test_public_api_clean.py | 1 + 5 files changed, 197 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 8ec2a47..9e83ae4 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,53 @@ # TradingChassis Core -`tradingchassis_core` is a deterministic trading decision engine core library. +`tradingchassis_core` is a deterministic step engine for trading decisions. -It provides a clean Core-only package baseline centered on `run_core_step` and -`run_core_wakeup_step`, with Pydantic contracts as the source of truth. +It turns ordered canonical events into Core decisions/intents. It does not +perform venue I/O or external order dispatch. -## What TradingChassis Core Is +## Core In One Picture -- Canonical event models and event taxonomy for Core reduction -- Processing-order contracts: `EventStreamEntry` and `ProcessingPosition` -- Deterministic state reduction and strategy evaluator boundary -- Intent models, candidate records, and dominance/reconciliation -- Policy admission (`PolicyRiskDecision`) and execution-control plan/apply -- `CoreStepResult` outputs for runtime dispatch and control scheduling - -## What TradingChassis Core Is Not - -- Runtime orchestration or runtime order lifecycle management -- Venue adapters, backtest/live I/O, or dispatch implementations -- Deployment ownership or runtime-owned entrypoints - -## Clean Core Pipeline - -The clean deterministic pipeline is: - -`EventStreamEntry` -`-> process_event_entry / process_canonical_event` -`-> strategy evaluator` -`-> generated intents` -`-> candidate intent records` -`-> dominance / reconciliation` -`-> policy admission` -`-> execution-control plan/apply` -`-> CoreStepResult.dispatchable_intents` -`-> runtime dispatches later` +```mermaid +flowchart LR + Runtime["Runtime / Adapter"] --> Entry["EventStreamEntry
Canonical Event + ProcessingPosition"] + Entry --> Core["tradingchassis_core
CoreStep / CoreWakeupStep"] + Core --> Result["CoreStepResult
dispatchable_intents
control_scheduling_obligation"] + Result --> RuntimeDispatch["Runtime dispatches later"] +``` -## Installation +## Pipeline + +```text +EventStreamEntry + -> process_event_entry / process_canonical_event + -> strategy evaluator + -> generated intents + -> candidate records + -> dominance / reconciliation + -> policy admission + -> execution-control plan/apply + -> CoreStepResult.dispatchable_intents + -> Runtime dispatches later +``` -From the `core` directory: +## Input / Core / Output / Not Owned By Core -```bash -python -m pip install -e ".[dev]" -``` +- **Input:** `EventStreamEntry` values with canonical events and stream position. +- **Core does:** deterministic reduction, strategy evaluation boundary, candidate + merge/dominance, policy admission, execution-control planning/apply. +- **Output:** `CoreStepResult` with generated/candidate intents, optional + `dispatchable_intents`, and optional `control_scheduling_obligation`. +- **Not owned by Core:** raw market/feed I/O, venue adapters, external dispatch, + credentials/environment wiring, runtime orchestration, Kubernetes/deployment. ## Quickstart +Run the Core-only quickstart from `core`: + ```bash python examples/core_step_quickstart.py ``` -The quickstart demonstrates canonical input, `run_core_step`, generated intents, -candidate intent records, and dispatchable outputs after policy/admission apply. - Minimal shape: ```python @@ -67,42 +63,53 @@ result = tc.run_core_step( reason="scheduled_control_recheck", due_ts_ns_local=1_000, realized_ts_ns_local=1_000, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=1_000, + runtime_correlation=None, ), ), ) print(result.generated_intents, result.dispatchable_intents) ``` -See `examples/core_step_quickstart.py` for a complete runnable example. +See `examples/core_step_quickstart.py` for the full runnable walkthrough. + +## Public Entrypoints -## Public API Overview +| Entrypoint | Purpose | +| --- | --- | +| `run_core_step` | One-entry deterministic reduce/evaluate/decide/apply step | +| `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup | +| `run_core_wakeup_decision` | Wakeup-level candidate/policy/execution decision phase | +| `run_core_wakeup_step` | Convenience wrapper for reduction + decision | +| `process_event_entry` | Reduce one `EventStreamEntry` into `StrategyState` | +| `process_canonical_event` | Reduce one canonical event into `StrategyState` | -Main exported categories from `tradingchassis_core`: +## Ownership Boundary -- Canonical events: `MarketEvent`, `ControlTimeEvent`, `OrderSubmittedEvent`, - `OrderExecutionFeedbackEvent`, `FillEvent` -- Pipeline models/APIs: `EventStreamEntry`, `ProcessingPosition`, - `process_canonical_event`, `process_event_entry`, `run_core_step`, - `run_core_wakeup_reduction`, `run_core_wakeup_decision`, - `run_core_wakeup_step` -- Decision/output contracts: `CoreStepDecision`, `CoreStepResult`, - `PolicyRiskDecision`, `ExecutionControlDecision`, - `ControlSchedulingObligation` -- Intents and candidates: `OrderIntent`, `NewOrderIntent`, - `CancelOrderIntent`, `ReplaceOrderIntent`, `CandidateIntentRecord`, - `CandidateIntentOrigin` -- Core state/config helpers: `StrategyState`, `CoreConfiguration`, - `ExecutionControl`, `RiskEngine`, `RiskConfig`, `NullEventBus` +| Core owns | Runtime owns | +| --- | --- | +| canonical models/contracts | raw I/O and feed adapters | +| state reduction and ordering | venue adapters and transport | +| strategy evaluator boundary | external dispatch execution | +| candidate intents and reconciliation | credentials/env wiring | +| policy admission | live/backtest orchestration | +| execution control | Kubernetes/deployment | +| `CoreStepResult` decision contract | runtime lifecycle glue | -## Testing +## Developer Commands From the `core` directory: ```bash +python -m pip install -e ".[dev]" +python examples/core_step_quickstart.py python -m pytest -q +python -m mypy tradingchassis_core tests +python -m build ``` -## Documentation +## Docs - `docs/README.md` - `docs/reference/public-api.md` diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 1ffe4fd..7b71b8b 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -53,6 +53,8 @@ def evaluate_policy_intent( def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: + # EventStreamEntry is the ordered Core input unit: a canonical event plus + # ProcessingPosition telling Core where this event sits in the stream. return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), event=tc.ControlTimeEvent( @@ -68,6 +70,9 @@ def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: + # v1 shows the minimum deterministic step: Core reduces one canonical event + # and strategy evaluation emits generated intents. No policy/apply contexts + # are provided yet, so Core returns zero dispatchable intents by design. result = tc.run_core_step( state, _control_time_entry(index=0, ts_ns_local=1_000), @@ -82,6 +87,8 @@ def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: + # v2 adds policy admission and execution-control apply. With dispatchable + # outputs activated, Core exposes intents that Runtime can dispatch later. result = tc.run_core_step( state, _control_time_entry(index=1, ts_ns_local=1_001), @@ -102,19 +109,25 @@ def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: def main() -> None: + # StrategyState holds deterministic Core memory across steps + # (market snapshots, queued intents, monotone timestamps, etc.). state = tc.StrategyState(event_bus=tc.NullEventBus()) + + # Core consumes canonical events. Here we use ControlTimeEvent as a simple + # canonical trigger event to drive the deterministic step pipeline. result_v1 = run_v1_generated_only(state) result_v2 = run_v2_with_policy_and_apply(state) - print("CoreStep quickstart") + print("CoreStep quickstart (Core-only deterministic engine)") print("v1 generated:", [intent.client_order_id for intent in result_v1.generated_intents]) print( "v1 candidate origins:", [record.origin.value for record in result_v1.candidate_intent_records], ) - print("v1 dispatchable:", [intent.client_order_id for intent in result_v1.dispatchable_intents]) + print("v1 dispatchable: [] (Core does not dispatch externally)") print("v2 dispatchable:", [intent.client_order_id for intent in result_v2.dispatchable_intents]) print("v2 obligation:", result_v2.control_scheduling_obligation) + print("Runtime dispatches these later; Core only returns decisions/intents.") if __name__ == "__main__": diff --git a/tests/semantics/examples/test_core_step_quickstart.py b/tests/semantics/examples/test_core_step_quickstart.py index c75337a..e281152 100644 --- a/tests/semantics/examples/test_core_step_quickstart.py +++ b/tests/semantics/examples/test_core_step_quickstart.py @@ -5,6 +5,8 @@ import importlib.util from pathlib import Path +import pytest + import tradingchassis_core as tc _MODULE_PATH = Path(__file__).resolve().parents[3] / "examples" / "core_step_quickstart.py" @@ -15,6 +17,14 @@ _SPEC.loader.exec_module(_MODULE) +def test_core_step_quickstart_is_core_only() -> None: + source = _MODULE_PATH.read_text(encoding="utf-8") + assert "import tradingchassis_core as tc" in source + assert "from tradingchassis_core.core" not in source + assert "hftbacktest" not in source + assert "core_runtime" not in source + + def test_core_step_quickstart_v1_generated_and_candidates() -> None: state = tc.StrategyState(event_bus=tc.NullEventBus()) result = _MODULE.run_v1_generated_only(state) @@ -41,3 +51,15 @@ def test_core_step_quickstart_v2_dispatchable_output() -> None: assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ( _MODULE.INTENT_ID_V2, ) + + +def test_core_step_quickstart_main_prints_core_boundary( + capsys: pytest.CaptureFixture[str], +) -> None: + _MODULE.main() + captured = capsys.readouterr() + output = captured.out + assert "CoreStep quickstart (Core-only deterministic engine)" in output + assert "v1 generated" in output + assert "v1 dispatchable: [] (Core does not dispatch externally)" in output + assert "Runtime dispatches these later" in output diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py index e0d20f7..eb84a22 100644 --- a/tests/semantics/test_core_pipeline_clean.py +++ b/tests/semantics/test_core_pipeline_clean.py @@ -79,6 +79,25 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: return [first, second] +class _IndexedIntentEvaluator: + def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.NewOrderIntent]: + idx = context.position.index + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=100 + idx, + instrument="BTC-USDC-PERP", + client_order_id=f"wake-{idx}", + intents_correlation_id=f"wake-corr-{idx}", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0 + idx), + time_in_force="GTC", + ) + ] + + def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), @@ -112,6 +131,9 @@ def test_run_core_step_clean_pipeline_dispatchable() -> None: ) assert tuple(intent.client_order_id for intent in result.generated_intents) == ("intent-1",) assert tuple(intent.client_order_id for intent in result.candidate_intents) == ("intent-1",) + assert tuple(record.origin for record in result.candidate_intent_records) == ( + tc.CandidateIntentOrigin.GENERATED, + ) assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ("intent-1",) assert result.core_step_decision is not None @@ -121,6 +143,8 @@ class _ChecksReducedStateEvaluator: def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.OrderIntent]: # ControlTimeEvent reduction updates monotone timestamp before evaluation. assert context.state.sim_ts_ns_local == 100 + assert isinstance(context.event, tc.ControlTimeEvent) + assert context.position.index == 0 return [] state = tc.StrategyState(event_bus=tc.NullEventBus()) @@ -154,6 +178,61 @@ def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: assert len(result.dispatchable_intents) == 1 +def test_run_core_wakeup_step_matches_reduction_then_decision_path() -> None: + entries = (_control_entry(0, 100), _control_entry(1, 101)) + reduction_state = tc.StrategyState(event_bus=tc.NullEventBus()) + reduction = tc.run_core_wakeup_reduction( + reduction_state, + entries, + strategy_evaluator=_IndexedIntentEvaluator(), + strategy_event_filter=lambda _event: True, + ) + decision_result = tc.run_core_wakeup_decision( + reduction_state, + reduction, + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + step_state = tc.StrategyState(event_bus=tc.NullEventBus()) + step_result = tc.run_core_wakeup_step( + step_state, + entries, + strategy_evaluator=_IndexedIntentEvaluator(), + strategy_event_filter=lambda _event: True, + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + assert tuple(intent.client_order_id for intent in decision_result.generated_intents) == ( + "wake-0", + "wake-1", + ) + assert tuple(intent.client_order_id for intent in step_result.generated_intents) == ( + "wake-0", + "wake-1", + ) + assert tuple(intent.client_order_id for intent in step_result.dispatchable_intents) == ( + "wake-0", + "wake-1", + ) + assert decision_result == step_result + + def test_candidate_reconciliation_prefers_latest_same_key_generated_intent() -> None: state = tc.StrategyState(event_bus=tc.NullEventBus()) result = tc.run_core_step( @@ -188,6 +267,9 @@ def test_policy_rejection_prevents_dispatchable_intents() -> None: assert result.dispatchable_intents == () assert result.core_step_decision is not None assert len(result.core_step_decision.policy_rejected_intents) == 1 + assert result.core_step_decision.policy_risk_decision is not None + assert len(result.core_step_decision.policy_risk_decision.accepted_intents) == 0 + assert len(result.core_step_decision.policy_risk_decision.rejected_intents) == 1 def test_execution_control_deferral_returns_scheduling_obligation() -> None: @@ -212,6 +294,18 @@ def test_execution_control_deferral_returns_scheduling_obligation() -> None: assert result.control_scheduling_obligation.reason == "rate_limit" +def test_core_step_generated_only_mode_never_dispatches_externally() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_step( + state, + _control_entry(0, 100), + strategy_evaluator=_OneIntentEvaluator(), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ("intent-1",) + assert result.core_step_decision is None + assert result.dispatchable_intents == () + + def test_process_canonical_event_reduces_market_event() -> None: state = tc.StrategyState(event_bus=tc.NullEventBus()) tc.process_canonical_event( diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py index 2aa7fb6..9c6a5f9 100644 --- a/tests/semantics/test_public_api_clean.py +++ b/tests/semantics/test_public_api_clean.py @@ -53,6 +53,7 @@ def test_public_api_does_not_expose_removed_compatibility_symbols() -> None: "".join(["Core", "DecisionContext"]), "".join(["OrderState", "Event"]), "".join(["Derived", "FillEvent"]), + "".join(["decide_", "intents"]), "".join(["Venue", "Adapter"]), "".join(["Venue", "Policy"]), ) From 8cae0ff392b3e7d2a7ba16d26ece01e48a317b62 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 16:45:18 +0000 Subject: [PATCH 35/53] docs(core): explain decision kernel motivation and parity --- README.md | 128 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9e83ae4..d85c951 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,104 @@ # TradingChassis Core -`tradingchassis_core` is a deterministic step engine for trading decisions. +`tradingchassis_core` is the stable **deterministic trading decision kernel** +for TradingChassis: an event-step engine that applies ordered **canonical Events +(the Event Stream under Processing Order and Configuration) +and produces **`CoreStepResult`** outputs—including strategy-generated and +candidate **Intents**, optional **`dispatchable_intents`**, and optional +**Control Scheduling Obligations**. It does not perform **Venue** I/O, +**Execution** (adapter-side dispatch), or Runtime orchestration. + +> **Terminology:** Definitions for **Core**, **Runtime**, **Event Stream**, +> **Processing Order**, **Intent**, **Risk Engine**, **Execution Control**, +> **Queue**, **Backtesting**, **Live**, and related terms match the [canonical +> glossary](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). + +## Why this exists + +Trading systems often drift when backtest logic, live logic, policy limits, and +execution throttling are implemented in different places. TradingChassis Core +centralizes deterministic decision semantics—state reduction, **Strategy** +evaluation, **Risk Engine** (policy) admission, and **Execution Control** +(planning apply over reconciled **Intents**)—in one library. **Runtime** +environments (**Backtesting**, **Live**, research tooling, Kubernetes-backed +deployments, different **Venue Adapters**) may change; **Core** should not. + +## What this gives you + +| What you get | Why it matters | +| --- | --- | +| One deterministic **Core** pipeline | Same event-step path for reduction → evaluation → candidates → policy → execution-control apply | +| Canonical **Event** input model (`EventStreamEntry`) | Aligns with **Event Stream** + **Processing Order**; **State** is `f(Event Stream, Configuration)` | +| **Strategy** output as **Intents** | Internal, order/venue-agnostic commands before adapter-specific shapes | +| **Risk Engine** separated from **Execution Control** | Policy admission vs **Queue** / scheduling / rate-aware presentation split, as in the intent pipeline (**Strategy → Risk → Queue → Adapter**) | +| **`dispatchable_intents`** + optional **Control Scheduling Obligation** | Runtime performs **Execution** and injects **Control-Time Events** when obligations are realized | +| Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | +| Shared kernel across environments | Serious **Backtesting-Live** parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution-Control code elsewhere | + +## Why this matters for trading + +The gap between tested behavior and live behavior can dominate outcomes. +**Backtesting** is useful when the **same Core semantics**—given comparable +**Event Stream** and **Configuration**—drive decisions in research and +production. Deterministic, canonical-event-driven **Core** logic makes strategy, +policy, and Execution Control behavior reproducible and unit-testable. +Wall-clock scheduling, **Venue** behavior, adapter mapping, and infrastructure +stay in the **Runtime** and **Venue Adapter**, not in **Core**. + +## How it fits into a full system + +Runtimes normalize inputs +into canonical **Events** and feed the **Core**. The **Core** always returns the same +result type (**`CoreStepResult`**) for a given step; each **Runtime** handles +environment-specific **Execution**, scheduling glue, and **Control-Time Event** +injection when a **Control Scheduling Obligation** is realized. + +```mermaid +flowchart TB + Backtesting["Backtesting Runtime"] --> Canonical["Canonical Events"] + Live["Live Runtime"] --> Canonical + Canonical --> Core["TradingChassis Core
deterministic decision kernel"] + Core --> Result["CoreStepResult
dispatchable intents + scheduling obligation"] + Result --> Runtime["Runtime dispatch / scheduling / I/O"] +``` + +## Backtesting-Live parity + +**Core** is designed to reduce **decision-logic drift** between **Backtesting** +and **Live**: the same canonical **Event** + **`run_core_step`** / reduction APIs +can drive both worlds when Runtimes construct comparable **`EventStreamEntry`** +sequences under the same **Configuration**. Normalizing feeds, timestamps, and +control semantics before they enter **Core** narrows unnecessary divergence. + +**Core** does **not** remove every simulation-vs-production gap. **Venue** +behavior, latency, fills and liquidity, market-data quality, **Venue Adapter** +behavior, **Runtime** scheduling, and infrastructure failure modes can still +differ and must be modeled outside **Core**. What **Core** removes is a major +source of mismatch—duplicating or subtly diverging the Strategy/Risk Engine/ +Execution Control itself. + +## When to use this package -It turns ordered canonical events into Core decisions/intents. It does not -perform venue I/O or external order dispatch. +- Building an internal trading system where **Backtesting** and **Live** should share decision semantics. +- Wanting a deterministic **Strategy** / **Risk Engine** / **Execution Control** kernel. +- Separating trading semantics from adapters, I/O, and Kubernetes wiring. +- Testing decisions and **Intents** without **Runtime** harnesses. +- Sharing one decision path across simulation and production. + +## When not to use this package + +- You only need a one-off notebook **Backtesting** experiment. +- You want a complete **Venue** connector or turnkey **Live** execution stack (**Execution** is adapter + **Runtime** responsibility). +- You expect this repo to ship a full Kubernetes **Runtime**, deployment manifests, or operational stack. +- You expect **Core** to execute orders, talk to **Venues**, or replace adapters / **Runtime** dispatch. ## Core In One Picture +The diagram shows one step: how **`EventStreamEntry`** flows through +**Core** and hands outcomes back to the **Runtime**. + ```mermaid -flowchart LR +flowchart TB Runtime["Runtime / Adapter"] --> Entry["EventStreamEntry
Canonical Event + ProcessingPosition"] Entry --> Core["tradingchassis_core
CoreStep / CoreWakeupStep"] Core --> Result["CoreStepResult
dispatchable_intents
control_scheduling_obligation"] @@ -19,22 +109,23 @@ flowchart LR ```text EventStreamEntry + ---> Runtime reduces to canonical Events -> process_event_entry / process_canonical_event - -> strategy evaluator - -> generated intents + -> Strategy evaluator + -> generated Intents -> candidate records -> dominance / reconciliation -> policy admission - -> execution-control plan/apply + -> Execution Control plan/apply -> CoreStepResult.dispatchable_intents - -> Runtime dispatches later + ---> Runtime dispatches Intents into Orders ``` ## Input / Core / Output / Not Owned By Core - **Input:** `EventStreamEntry` values with canonical events and stream position. - **Core does:** deterministic reduction, strategy evaluation boundary, candidate - merge/dominance, policy admission, execution-control planning/apply. + merge/dominance, policy admission, Execution Control planning/apply. - **Output:** `CoreStepResult` with generated/candidate intents, optional `dispatchable_intents`, and optional `control_scheduling_obligation`. - **Not owned by Core:** raw market/feed I/O, venue adapters, external dispatch, @@ -94,7 +185,7 @@ See `examples/core_step_quickstart.py` for the full runnable walkthrough. | strategy evaluator boundary | external dispatch execution | | candidate intents and reconciliation | credentials/env wiring | | policy admission | live/backtest orchestration | -| execution control | Kubernetes/deployment | +| Execution Control | Kubernetes/deployment | | `CoreStepResult` decision contract | runtime lifecycle glue | ## Developer Commands @@ -108,20 +199,3 @@ python -m pytest -q python -m mypy tradingchassis_core tests python -m build ``` - -## Docs - -- `docs/README.md` -- `docs/reference/public-api.md` -- `docs/reference/events-reference.md` -- `docs/code-map/core-pipeline-map.md` -- `docs/code-map/repository-map.md` -- `docs/how-to/add-canonical-event.md` -- `docs/how-to/update-core-step-pipeline.md` -- `docs/how-to/update-policy-and-execution-control.md` - -## Project References - -- Changelog: `CHANGELOG.md` -- Contributing: `CONTRIBUTING.md` -- Security: `SECURITY.md` From dd17b2341abeab8ddb7c2fa8abceb54bf3d15d32 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 16:47:23 +0000 Subject: [PATCH 36/53] docs(core): explain decision kernel motivation and parity --- README.md | 104 +++++++++++++++++++++++++++--------------------------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index d85c951..17a74c5 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,57 @@ # TradingChassis Core -`tradingchassis_core` is the stable **deterministic trading decision kernel** -for TradingChassis: an event-step engine that applies ordered **canonical Events +`tradingchassis_core` is the stable deterministic trading decision kernel +for TradingChassis: an event-step engine that applies ordered canonical Events (the Event Stream under Processing Order and Configuration) -and produces **`CoreStepResult`** outputs—including strategy-generated and -candidate **Intents**, optional **`dispatchable_intents`**, and optional -**Control Scheduling Obligations**. It does not perform **Venue** I/O, -**Execution** (adapter-side dispatch), or Runtime orchestration. - -> **Terminology:** Definitions for **Core**, **Runtime**, **Event Stream**, -> **Processing Order**, **Intent**, **Risk Engine**, **Execution Control**, -> **Queue**, **Backtesting**, **Live**, and related terms match the [canonical +and produces `CoreStepResult` outputs—including strategy-generated and +candidate Intents, optional `dispatchable_intents`, and optional +Control Scheduling Obligations. It does not perform Venue I/O, +Execution (adapter-side dispatch), or Runtime orchestration. + +> Terminology: Definitions for Core, Runtime, Event Stream, +> Processing Order, Intent, Risk Engine, Execution Control, +> Queue, Backtesting, Live, and related terms match the [canonical > glossary](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). ## Why this exists Trading systems often drift when backtest logic, live logic, policy limits, and execution throttling are implemented in different places. TradingChassis Core -centralizes deterministic decision semantics—state reduction, **Strategy** -evaluation, **Risk Engine** (policy) admission, and **Execution Control** -(planning apply over reconciled **Intents**)—in one library. **Runtime** -environments (**Backtesting**, **Live**, research tooling, Kubernetes-backed -deployments, different **Venue Adapters**) may change; **Core** should not. +centralizes deterministic decision semantics—state reduction, Strategy +evaluation, Risk Engine (policy) admission, and Execution Control +(planning apply over reconciled Intents)—in one library. Runtime +environments (Backtesting, Live, research tooling, Kubernetes-backed +deployments, different Venue Adapters) may change; Core should not. ## What this gives you | What you get | Why it matters | | --- | --- | -| One deterministic **Core** pipeline | Same event-step path for reduction → evaluation → candidates → policy → execution-control apply | -| Canonical **Event** input model (`EventStreamEntry`) | Aligns with **Event Stream** + **Processing Order**; **State** is `f(Event Stream, Configuration)` | -| **Strategy** output as **Intents** | Internal, order/venue-agnostic commands before adapter-specific shapes | -| **Risk Engine** separated from **Execution Control** | Policy admission vs **Queue** / scheduling / rate-aware presentation split, as in the intent pipeline (**Strategy → Risk → Queue → Adapter**) | -| **`dispatchable_intents`** + optional **Control Scheduling Obligation** | Runtime performs **Execution** and injects **Control-Time Events** when obligations are realized | +| One deterministic Core pipeline | Same event-step path for reduction → evaluation → candidates → policy → execution-control apply | +| Canonical Event input model (`EventStreamEntry`) | Aligns with Event Stream + Processing Order; State is `f(Event Stream, Configuration)` | +| Strategy output as Intents | Internal, order/venue-agnostic commands before adapter-specific shapes | +| Risk Engine separated from Execution Control | Policy admission vs Queue / scheduling / rate-aware presentation split, as in the intent pipeline (Strategy → Risk → Queue → Adapter) | +| `dispatchable_intents` + optional Control Scheduling Obligation | Runtime performs Execution and injects Control-Time Events when obligations are realized | | Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | -| Shared kernel across environments | Serious **Backtesting-Live** parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution-Control code elsewhere | +| Shared kernel across environments | Serious Backtesting-Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution-Control code elsewhere | ## Why this matters for trading The gap between tested behavior and live behavior can dominate outcomes. -**Backtesting** is useful when the **same Core semantics**—given comparable -**Event Stream** and **Configuration**—drive decisions in research and -production. Deterministic, canonical-event-driven **Core** logic makes strategy, +Backtesting is useful when the same Core semantics—given comparable +Event Stream and Configuration—drive decisions in research and +production. Deterministic, canonical-event-driven Core logic makes strategy, policy, and Execution Control behavior reproducible and unit-testable. -Wall-clock scheduling, **Venue** behavior, adapter mapping, and infrastructure -stay in the **Runtime** and **Venue Adapter**, not in **Core**. +Wall-clock scheduling, Venue behavior, adapter mapping, and infrastructure +stay in the Runtime and Venue Adapter, not in Core. ## How it fits into a full system Runtimes normalize inputs -into canonical **Events** and feed the **Core**. The **Core** always returns the same -result type (**`CoreStepResult`**) for a given step; each **Runtime** handles -environment-specific **Execution**, scheduling glue, and **Control-Time Event** -injection when a **Control Scheduling Obligation** is realized. +into canonical Events and feed the Core. The Core always returns the same +result type (`CoreStepResult`) for a given step; each Runtime handles +environment-specific Execution, scheduling glue, and Control-Time Event +injection when a Control Scheduling Obligation is realized. ```mermaid flowchart TB @@ -64,38 +64,38 @@ flowchart TB ## Backtesting-Live parity -**Core** is designed to reduce **decision-logic drift** between **Backtesting** -and **Live**: the same canonical **Event** + **`run_core_step`** / reduction APIs -can drive both worlds when Runtimes construct comparable **`EventStreamEntry`** -sequences under the same **Configuration**. Normalizing feeds, timestamps, and -control semantics before they enter **Core** narrows unnecessary divergence. +Core is designed to reduce decision-logic drift between Backtesting +and Live: the same canonical Event + `run_core_step` / reduction APIs +can drive both worlds when Runtimes construct comparable `EventStreamEntry` +sequences under the same Configuration. Normalizing feeds, timestamps, and +control semantics before they enter Core narrows unnecessary divergence. -**Core** does **not** remove every simulation-vs-production gap. **Venue** -behavior, latency, fills and liquidity, market-data quality, **Venue Adapter** -behavior, **Runtime** scheduling, and infrastructure failure modes can still -differ and must be modeled outside **Core**. What **Core** removes is a major +Core does not remove every simulation-vs-production gap. Venue +behavior, latency, fills and liquidity, market-data quality, Venue Adapter +behavior, Runtime scheduling, and infrastructure failure modes can still +differ and must be modeled outside Core. What Core removes is a major source of mismatch—duplicating or subtly diverging the Strategy/Risk Engine/ Execution Control itself. ## When to use this package -- Building an internal trading system where **Backtesting** and **Live** should share decision semantics. -- Wanting a deterministic **Strategy** / **Risk Engine** / **Execution Control** kernel. +- Building an internal trading system where Backtesting and Live should share decision semantics. +- Wanting a deterministic Strategy / Risk Engine / Execution Control kernel. - Separating trading semantics from adapters, I/O, and Kubernetes wiring. -- Testing decisions and **Intents** without **Runtime** harnesses. +- Testing decisions and Intents without Runtime harnesses. - Sharing one decision path across simulation and production. ## When not to use this package -- You only need a one-off notebook **Backtesting** experiment. -- You want a complete **Venue** connector or turnkey **Live** execution stack (**Execution** is adapter + **Runtime** responsibility). -- You expect this repo to ship a full Kubernetes **Runtime**, deployment manifests, or operational stack. -- You expect **Core** to execute orders, talk to **Venues**, or replace adapters / **Runtime** dispatch. +- You only need a one-off notebook Backtesting experiment. +- You want a complete Venue connector or turnkey Live execution stack (Execution is adapter + Runtime responsibility). +- You expect this repo to ship a full Kubernetes Runtime, deployment manifests, or operational stack. +- You expect Core to execute orders, talk to Venues, or replace adapters / Runtime dispatch. ## Core In One Picture -The diagram shows one step: how **`EventStreamEntry`** flows through -**Core** and hands outcomes back to the **Runtime**. +The diagram shows one step: how `EventStreamEntry` flows through +Core and hands outcomes back to the Runtime. ```mermaid flowchart TB @@ -123,12 +123,12 @@ EventStreamEntry ## Input / Core / Output / Not Owned By Core -- **Input:** `EventStreamEntry` values with canonical events and stream position. -- **Core does:** deterministic reduction, strategy evaluation boundary, candidate +- Input: `EventStreamEntry` values with canonical events and stream position. +- Core does: deterministic reduction, strategy evaluation boundary, candidate merge/dominance, policy admission, Execution Control planning/apply. -- **Output:** `CoreStepResult` with generated/candidate intents, optional +- Output: `CoreStepResult` with generated/candidate intents, optional `dispatchable_intents`, and optional `control_scheduling_obligation`. -- **Not owned by Core:** raw market/feed I/O, venue adapters, external dispatch, +- Not owned by Core: raw market/feed I/O, venue adapters, external dispatch, credentials/environment wiring, runtime orchestration, Kubernetes/deployment. ## Quickstart From 39aa265280768e70a58dceb2034fb9d2a7c29278 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 16:54:55 +0000 Subject: [PATCH 37/53] docs(core): clarify motivation and backtest live parity --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 17a74c5..a11f38f 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,12 @@ Execution (adapter-side dispatch), or Runtime orchestration. > Terminology: Definitions for Core, Runtime, Event Stream, > Processing Order, Intent, Risk Engine, Execution Control, > Queue, Backtesting, Live, and related terms match the [canonical -> glossary](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). +> terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). ## Why this exists -Trading systems often drift when backtest logic, live logic, policy limits, and -execution throttling are implemented in different places. TradingChassis Core +Trading systems often drift when Backtesting logic, Live logic, risk (policy) limits, and +Strategy throttling are implemented in different places. TradingChassis Core centralizes deterministic decision semantics—state reduction, Strategy evaluation, Risk Engine (policy) admission, and Execution Control (planning apply over reconciled Intents)—in one library. Runtime From cad33f7a43ca498e82a6d2ca6f112680d0f91622 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 17:12:41 +0000 Subject: [PATCH 38/53] docs(core): clarify motivation and backtest live parity --- README.md | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index a11f38f..7ec9609 100644 --- a/README.md +++ b/README.md @@ -37,20 +37,19 @@ deployments, different Venue Adapters) may change; Core should not. ## Why this matters for trading -The gap between tested behavior and live behavior can dominate outcomes. +The gap between tested behavior and real behavior can dominate outcomes. Backtesting is useful when the same Core semantics—given comparable -Event Stream and Configuration—drive decisions in research and -production. Deterministic, canonical-event-driven Core logic makes strategy, +Event Stream and Configuration—drive decisions in simulation and +production. Deterministic, canonical-event-driven Core logic makes Strategy, policy, and Execution Control behavior reproducible and unit-testable. Wall-clock scheduling, Venue behavior, adapter mapping, and infrastructure stay in the Runtime and Venue Adapter, not in Core. ## How it fits into a full system -Runtimes normalize inputs -into canonical Events and feed the Core. The Core always returns the same -result type (`CoreStepResult`) for a given step; each Runtime handles -environment-specific Execution, scheduling glue, and Control-Time Event +Runtimes normalize inputs into canonical Events and feed the Core. +The Core always returns the same result type (`CoreStepResult`) for a given step; +each Runtime handles environment-specific Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. ```mermaid @@ -70,11 +69,11 @@ can drive both worlds when Runtimes construct comparable `EventStreamEntry` sequences under the same Configuration. Normalizing feeds, timestamps, and control semantics before they enter Core narrows unnecessary divergence. -Core does not remove every simulation-vs-production gap. Venue +Core does not remove every simulation-vs-production gap. Individual Venue behavior, latency, fills and liquidity, market-data quality, Venue Adapter behavior, Runtime scheduling, and infrastructure failure modes can still differ and must be modeled outside Core. What Core removes is a major -source of mismatch—duplicating or subtly diverging the Strategy/Risk Engine/ +source of mismatch—duplicating and subtly diverging Strategy/Risk Engine/ Execution Control itself. ## When to use this package @@ -88,8 +87,8 @@ Execution Control itself. ## When not to use this package - You only need a one-off notebook Backtesting experiment. -- You want a complete Venue connector or turnkey Live execution stack (Execution is adapter + Runtime responsibility). -- You expect this repo to ship a full Kubernetes Runtime, deployment manifests, or operational stack. +- You want a complete Venue connector or turnkey Live implementation. +- You expect this to ship a full Kubernetes Runtime, deployment manifests, or operations. - You expect Core to execute orders, talk to Venues, or replace adapters / Runtime dispatch. ## Core In One Picture @@ -123,23 +122,23 @@ EventStreamEntry ## Input / Core / Output / Not Owned By Core -- Input: `EventStreamEntry` values with canonical events and stream position. -- Core does: deterministic reduction, strategy evaluation boundary, candidate +- Input: `EventStreamEntry` values with canonical Events and Event Stream position. +- Core does: deterministic reduction, Strategy evaluation boundary, candidate merge/dominance, policy admission, Execution Control planning/apply. -- Output: `CoreStepResult` with generated/candidate intents, optional +- Output: `CoreStepResult` with generated/candidate Intents, optional `dispatchable_intents`, and optional `control_scheduling_obligation`. -- Not owned by Core: raw market/feed I/O, venue adapters, external dispatch, - credentials/environment wiring, runtime orchestration, Kubernetes/deployment. +- Not owned by Core: raw market/feed I/O, Venue Adapters, external dispatch, + credentials/environment wiring, Runtime orchestration, Kubernetes/deployment. ## Quickstart -Run the Core-only quickstart from `core`: +Run the quickstart ```bash python examples/core_step_quickstart.py ``` -Minimal shape: +or minimal shape: ```python import tradingchassis_core as tc @@ -161,9 +160,10 @@ result = tc.run_core_step( ), ) print(result.generated_intents, result.dispatchable_intents) +# Expected: () () — no strategy or policy/EC path in this snippet. ``` -See `examples/core_step_quickstart.py` for the full runnable walkthrough. +See `examples/core_step_quickstart.py` for a full runnable walkthrough. ## Public Entrypoints @@ -171,26 +171,26 @@ See `examples/core_step_quickstart.py` for the full runnable walkthrough. | --- | --- | | `run_core_step` | One-entry deterministic reduce/evaluate/decide/apply step | | `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup | -| `run_core_wakeup_decision` | Wakeup-level candidate/policy/execution decision phase | +| `run_core_wakeup_decision` | Wakeup-level candidate/policy/Execution Control decision phase | | `run_core_wakeup_step` | Convenience wrapper for reduction + decision | | `process_event_entry` | Reduce one `EventStreamEntry` into `StrategyState` | -| `process_canonical_event` | Reduce one canonical event into `StrategyState` | +| `process_canonical_event` | Reduce one canonical Event into `StrategyState` | ## Ownership Boundary | Core owns | Runtime owns | | --- | --- | | canonical models/contracts | raw I/O and feed adapters | -| state reduction and ordering | venue adapters and transport | -| strategy evaluator boundary | external dispatch execution | +| state reduction and ordering | Venue Adapters and transport | +| strategy evaluator boundary | external dispatch Execution | | candidate intents and reconciliation | credentials/env wiring | -| policy admission | live/backtest orchestration | +| policy admission | Backtesting/Live orchestration | | Execution Control | Kubernetes/deployment | -| `CoreStepResult` decision contract | runtime lifecycle glue | +| `CoreStepResult` decision contract | Runtime lifecycle glue | ## Developer Commands -From the `core` directory: +From root directory: ```bash python -m pip install -e ".[dev]" From 78ce0207eaf53dfe3a4ef3d62823ff0293d4f61c Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 17:47:09 +0000 Subject: [PATCH 39/53] docs(core): clarify motivation and backtest live parity --- README.md | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7ec9609..9d6a6dd 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Execution (adapter-side dispatch), or Runtime orchestration. ## Why this exists -Trading systems often drift when Backtesting logic, Live logic, risk (policy) limits, and +Trading systems often drift when Backtesting logic, Live logic, policy limits, and Strategy throttling are implemented in different places. TradingChassis Core centralizes deterministic decision semantics—state reduction, Strategy evaluation, Risk Engine (policy) admission, and Execution Control @@ -54,11 +54,10 @@ injection when a Control Scheduling Obligation is realized. ```mermaid flowchart TB - Backtesting["Backtesting Runtime"] --> Canonical["Canonical Events"] - Live["Live Runtime"] --> Canonical - Canonical --> Core["TradingChassis Core
deterministic decision kernel"] - Core --> Result["CoreStepResult
dispatchable intents + scheduling obligation"] - Result --> Runtime["Runtime dispatch / scheduling / I/O"] + R1["Runtime:
canonical Event"] --> Entry["EventStreamEntry:
canonical Event + ProcessingPosition"] + Entry --> Core["TradingChassis Core:
CoreStep / CoreWakeupStep"] + Core --> Result["CoreStepResult:
dispatchable Intents + scheduling obligation"] + Result --> R2["Runtime:
dispatch / scheduling / I/O"] ``` ## Backtesting-Live parity @@ -91,20 +90,7 @@ Execution Control itself. - You expect this to ship a full Kubernetes Runtime, deployment manifests, or operations. - You expect Core to execute orders, talk to Venues, or replace adapters / Runtime dispatch. -## Core In One Picture - -The diagram shows one step: how `EventStreamEntry` flows through -Core and hands outcomes back to the Runtime. - -```mermaid -flowchart TB - Runtime["Runtime / Adapter"] --> Entry["EventStreamEntry
Canonical Event + ProcessingPosition"] - Entry --> Core["tradingchassis_core
CoreStep / CoreWakeupStep"] - Core --> Result["CoreStepResult
dispatchable_intents
control_scheduling_obligation"] - Result --> RuntimeDispatch["Runtime dispatches later"] -``` - -## Pipeline +## Full pipeline ```text EventStreamEntry @@ -181,10 +167,10 @@ See `examples/core_step_quickstart.py` for a full runnable walkthrough. | Core owns | Runtime owns | | --- | --- | | canonical models/contracts | raw I/O and feed adapters | -| state reduction and ordering | Venue Adapters and transport | -| strategy evaluator boundary | external dispatch Execution | -| candidate intents and reconciliation | credentials/env wiring | -| policy admission | Backtesting/Live orchestration | +| State reduction and ordering | Venue Adapters and transport | +| Strategy evaluator boundary | external dispatch Execution | +| candidate Intents and reconciliation | credentials/env wiring | +| risk admission | Backtesting/Live orchestration | | Execution Control | Kubernetes/deployment | | `CoreStepResult` decision contract | Runtime lifecycle glue | From 3eb412d3a414dd175a956a3fbfe4ebcfdb2015ac Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 17:51:55 +0000 Subject: [PATCH 40/53] docs(core): clarify motivation and backtest live parity --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9d6a6dd..eec397d 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Execution Control itself. ```text EventStreamEntry - ---> Runtime reduces to canonical Events +---> Runtime reduces to canonical Events -> process_event_entry / process_canonical_event -> Strategy evaluator -> generated Intents @@ -103,7 +103,7 @@ EventStreamEntry -> policy admission -> Execution Control plan/apply -> CoreStepResult.dispatchable_intents - ---> Runtime dispatches Intents into Orders +---> Runtime dispatches Intents into Orders ``` ## Input / Core / Output / Not Owned By Core @@ -176,7 +176,7 @@ See `examples/core_step_quickstart.py` for a full runnable walkthrough. ## Developer Commands -From root directory: +From root: ```bash python -m pip install -e ".[dev]" From b880c40b1ec3683706e9f81684629ddcf4d44c27 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 17:54:10 +0000 Subject: [PATCH 41/53] docs(core): clarify motivation and backtest live parity --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eec397d..0385d11 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Execution Control itself. ```text EventStreamEntry ----> Runtime reduces to canonical Events +> Runtime reduces to canonical Events -> process_event_entry / process_canonical_event -> Strategy evaluator -> generated Intents @@ -103,7 +103,7 @@ EventStreamEntry -> policy admission -> Execution Control plan/apply -> CoreStepResult.dispatchable_intents ----> Runtime dispatches Intents into Orders +> Runtime dispatches Intents into Orders ``` ## Input / Core / Output / Not Owned By Core From 464f0126426c2b52f68443e59e84440e04717638 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 17:55:59 +0000 Subject: [PATCH 42/53] docs(core): clarify motivation and backtest live parity --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0385d11..36cc0f3 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ Execution Control itself. ## Full pipeline ```text -EventStreamEntry -> Runtime reduces to canonical Events +Runtime reduces to canonical Events + -> process_event_entry / process_canonical_event -> Strategy evaluator -> generated Intents @@ -103,7 +103,8 @@ EventStreamEntry -> policy admission -> Execution Control plan/apply -> CoreStepResult.dispatchable_intents -> Runtime dispatches Intents into Orders + +Runtime dispatches Intents into Orders ``` ## Input / Core / Output / Not Owned By Core From f686dc350bdcc66a2a16551e561aed2900afdd67 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:16:00 +0000 Subject: [PATCH 43/53] docs(core): clarify motivation and backtest live parity --- CHANGELOG.md | 6 +- CONTRIBUTING.md | 28 ++++---- README.md | 6 +- docs/README.md | 4 +- examples/core_step_quickstart.py | 34 +++++----- pyproject.toml | 19 +++--- .../examples/test_core_step_quickstart.py | 65 ------------------- .../semantics/test_no_runtime_dependencies.py | 13 ---- 8 files changed, 45 insertions(+), 130 deletions(-) delete mode 100644 tests/semantics/examples/test_core_step_quickstart.py delete mode 100644 tests/semantics/test_no_runtime_dependencies.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e2bd2..d0481e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,14 @@ This changelog starts from the clean Core package baseline. -## [0.1.0] - 2026-05-14 +## [Unreleased] ### Added - Deterministic `run_core_step` and `run_core_wakeup_step` architecture. -- Canonical event input models and `EventStreamEntry`/`ProcessingPosition`. +- Canonical Event input models and `EventStreamEntry`/`ProcessingPosition`. - Intent candidate record pipeline with dominance/reconciliation. -- Policy-only risk admission and execution-control plan/apply integration. +- Policy-only risk admission and Execution Control plan/apply integration. - `CoreStepResult.dispatchable_intents` and `ControlSchedulingObligation` outputs. - Core-only quickstart example and focused semantics test coverage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fe855e..58f2184 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,13 +3,16 @@ Contributions should preserve TradingChassis Core as a deterministic, runtime-agnostic library. +> Terminology: Definitions and related terms match the [canonical +> terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). + ## Package Scope -- Core owns canonical events, state reduction, strategy evaluation boundary, - candidate reconciliation, policy admission, execution-control plan/apply, +- Core owns canonical Events, State reduction, Strategy evaluation boundary, + candidate reconciliation, policy admission, Execution Control plan/apply, and `CoreStepResult`. -- Core does not own runtime orchestration, venue adapters, dispatch lifecycle, - `hftbacktest`, or deployment/config wiring. +- Core does not own Runtime orchestration, Venue Adapters, dispatch lifecycle, + or deployment/config wiring. ## Development Setup @@ -31,19 +34,17 @@ python -m build ## Architecture Rules -- Core accepts canonical events through `EventStreamEntry` and +- Core accepts canonical Events through `EventStreamEntry` and `process_event_entry` / `process_canonical_event`. -- Core returns deterministic `CoreStepResult`; runtime dispatch happens later. -- Do not introduce runtime/backtest imports (`core_runtime`, `hftbacktest`). -- Do not restore `GateDecision`, snapshot lifecycle compatibility, or - runtime-owned decision contracts. +- Core returns deterministic `CoreStepResult`; Runtime dispatches. +- Do not introduce Runtime imports. - Pydantic models are the source of truth for contract structure. ## Changing Core Behavior -### Canonical events +### Canonical Events -- Add event models in `tradingchassis_core/core/domain/types.py`. +- Add Event models in `tradingchassis_core/core/domain/types.py`. - Register canonical category handling in `core/domain/event_model.py`. - Update canonical reduction behavior in `core/domain/processing.py`. @@ -56,9 +57,9 @@ python -m build - Implement policy checks in `core/risk/` and wire through `evaluate_policy_intent`. -- Keep risk admission as policy-only; no dispatch/runtime side effects. +- Keep risk admission as policy-only; no dispatch/Runtime side effects. -### Execution-control behavior +### Execution Control behavior - Update plan/apply stages in `core/domain/execution_control_plan.py` and `core/domain/execution_control_apply.py`. @@ -77,4 +78,3 @@ python -m build - [ ] `python -m pytest -q` passes. - [ ] `python -m build` succeeds. - [ ] README/docs/changelog updated to match behavior. - diff --git a/README.md b/README.md index 36cc0f3..841a83b 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,7 @@ candidate Intents, optional `dispatchable_intents`, and optional Control Scheduling Obligations. It does not perform Venue I/O, Execution (adapter-side dispatch), or Runtime orchestration. -> Terminology: Definitions for Core, Runtime, Event Stream, -> Processing Order, Intent, Risk Engine, Execution Control, -> Queue, Backtesting, Live, and related terms match the [canonical +> Terminology: Definitions and related terms match the [canonical > terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). ## Why this exists @@ -147,7 +145,7 @@ result = tc.run_core_step( ), ) print(result.generated_intents, result.dispatchable_intents) -# Expected: () () — no strategy or policy/EC path in this snippet. +# Expected: () () — no Strategy or policy/EC path in this snippet. ``` See `examples/core_step_quickstart.py` for a full runnable walkthrough. diff --git a/docs/README.md b/docs/README.md index e420b24..ef9f89b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,4 @@ source of truth for canonical event/intent schemas. ## Out of Scope - runtime orchestration and order lifecycle ownership -- venue adapters, live/backtest I/O, external dispatch -- `hftbacktest` and `core_runtime` dependencies -- legacy GateDecision and snapshot lifecycle compatibility layers +- Venue Adapters, Backtesting/Live I/O, external dispatch diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 7b71b8b..3922df5 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -16,7 +16,7 @@ class OneIntentEvaluator: - """Small evaluator that emits one deterministic new-order intent.""" + """Small evaluator that emits one deterministic new-order Intent.""" def __init__(self, client_order_id: str) -> None: self._client_order_id = client_order_id @@ -39,25 +39,25 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: class AllowAllPolicy: - """Policy evaluator that admits every generated candidate intent.""" + """Policy evaluator that admits every generated candidate Intent.""" def evaluate_policy_intent( self, *, - intent: tc.OrderIntent, + Intent: tc.OrderIntent, state: tc.StrategyState, now_ts_ns_local: int, ) -> tuple[bool, str | None]: - _ = (intent, state, now_ts_ns_local) + _ = (Intent, state, now_ts_ns_local) return True, None def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: - # EventStreamEntry is the ordered Core input unit: a canonical event plus - # ProcessingPosition telling Core where this event sits in the stream. + # EventStreamEntry is the ordered Core input unit: a canonical Event plus + # ProcessingPosition telling Core where this Event sits in the Event Stream. return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), - event=tc.ControlTimeEvent( + Event=tc.ControlTimeEvent( ts_ns_local_control=ts_ns_local, reason="scheduled_control_recheck", due_ts_ns_local=ts_ns_local, @@ -70,9 +70,9 @@ def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: - # v1 shows the minimum deterministic step: Core reduces one canonical event - # and strategy evaluation emits generated intents. No policy/apply contexts - # are provided yet, so Core returns zero dispatchable intents by design. + # v1 shows the minimum deterministic step: Core reduces one canonical Event + # and strategy evaluation emits generated Intents. No policy/apply contexts + # are provided yet, so Core returns zero dispatchable Intents by design. result = tc.run_core_step( state, _control_time_entry(index=0, ts_ns_local=1_000), @@ -88,7 +88,7 @@ def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: # v2 adds policy admission and execution-control apply. With dispatchable - # outputs activated, Core exposes intents that Runtime can dispatch later. + # outputs activated, Core exposes Intents that Runtime can dispatch. result = tc.run_core_step( state, _control_time_entry(index=1, ts_ns_local=1_001), @@ -110,24 +110,24 @@ def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: def main() -> None: # StrategyState holds deterministic Core memory across steps - # (market snapshots, queued intents, monotone timestamps, etc.). + # (market snapshots, queued Intents, monotone timestamps, etc.). state = tc.StrategyState(event_bus=tc.NullEventBus()) - # Core consumes canonical events. Here we use ControlTimeEvent as a simple - # canonical trigger event to drive the deterministic step pipeline. + # Core consumes canonical Events. Here we use ControlTimeEvent as a simple + # canonical trigger Event to drive the deterministic step pipeline. result_v1 = run_v1_generated_only(state) result_v2 = run_v2_with_policy_and_apply(state) print("CoreStep quickstart (Core-only deterministic engine)") - print("v1 generated:", [intent.client_order_id for intent in result_v1.generated_intents]) + print("v1 generated:", [Intent.client_order_id for Intent in result_v1.generated_intents]) print( "v1 candidate origins:", [record.origin.value for record in result_v1.candidate_intent_records], ) print("v1 dispatchable: [] (Core does not dispatch externally)") - print("v2 dispatchable:", [intent.client_order_id for intent in result_v2.dispatchable_intents]) + print("v2 dispatchable:", [Intent.client_order_id for Intent in result_v2.dispatchable_intents]) print("v2 obligation:", result_v2.control_scheduling_obligation) - print("Runtime dispatches these later; Core only returns decisions/intents.") + print("Runtime dispatches these; Core only returns decisions/Intents.") if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index af2a72f..c7b6052 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "tradingchassis-core" +name = "TradingChassis-core" version = "0.1.0" -description = "Deterministic trading decision engine core library." +description = "Deterministic trading decision kernel." readme = "README.md" requires-python = ">=3.11" authors = [{ name = "TradingChassis Core Contributors" }] @@ -37,11 +37,11 @@ dev = [ ] [project.urls] -Homepage = "https://github.com/tradingchassis/tradingchassis" -Repository = "https://github.com/tradingchassis/tradingchassis" -Documentation = "https://github.com/tradingchassis/tradingchassis/tree/main/core/docs" -Changelog = "https://github.com/tradingchassis/tradingchassis/blob/main/core/CHANGELOG.md" -Issues = "https://github.com/tradingchassis/tradingchassis/issues" +Homepage = "https://github.com/TradingChassis" +Repository = "https://github.com/TradingChassis/core" +Documentation = "https://github.com/TradingChassis/core/tree/main/core/docs" +Changelog = "https://github.com/TradingChassis/core/blob/main/core/CHANGELOG.md" +Issues = "https://github.com/TradingChassis/core/issues" [tool.setuptools.packages.find] include = ["tradingchassis_core*"] @@ -75,7 +75,4 @@ include_external_packages = true name = "Core stays runtime-independent" type = "forbidden" source_modules = ["tradingchassis_core"] -forbidden_modules = [ - "core_runtime", - "hftbacktest", -] +forbidden_modules = [] diff --git a/tests/semantics/examples/test_core_step_quickstart.py b/tests/semantics/examples/test_core_step_quickstart.py deleted file mode 100644 index e281152..0000000 --- a/tests/semantics/examples/test_core_step_quickstart.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Semantics coverage for the Core-only CoreStep quickstart example.""" - -from __future__ import annotations - -import importlib.util -from pathlib import Path - -import pytest - -import tradingchassis_core as tc - -_MODULE_PATH = Path(__file__).resolve().parents[3] / "examples" / "core_step_quickstart.py" -_SPEC = importlib.util.spec_from_file_location("core_step_quickstart_example", _MODULE_PATH) -assert _SPEC is not None -assert _SPEC.loader is not None -_MODULE = importlib.util.module_from_spec(_SPEC) -_SPEC.loader.exec_module(_MODULE) - - -def test_core_step_quickstart_is_core_only() -> None: - source = _MODULE_PATH.read_text(encoding="utf-8") - assert "import tradingchassis_core as tc" in source - assert "from tradingchassis_core.core" not in source - assert "hftbacktest" not in source - assert "core_runtime" not in source - - -def test_core_step_quickstart_v1_generated_and_candidates() -> None: - state = tc.StrategyState(event_bus=tc.NullEventBus()) - result = _MODULE.run_v1_generated_only(state) - - assert isinstance(result, tc.CoreStepResult) - assert tuple(intent.client_order_id for intent in result.generated_intents) == ( - _MODULE.INTENT_ID_V1, - ) - assert tuple(record.origin for record in result.candidate_intent_records) == ( - tc.CandidateIntentOrigin.GENERATED, - ) - assert tuple(intent.client_order_id for intent in result.candidate_intents) == ( - _MODULE.INTENT_ID_V1, - ) - assert result.dispatchable_intents == () - - -def test_core_step_quickstart_v2_dispatchable_output() -> None: - state = tc.StrategyState(event_bus=tc.NullEventBus()) - _ = _MODULE.run_v1_generated_only(state) - result = _MODULE.run_v2_with_policy_and_apply(state) - - assert isinstance(result, tc.CoreStepResult) - assert tuple(intent.client_order_id for intent in result.dispatchable_intents) == ( - _MODULE.INTENT_ID_V2, - ) - - -def test_core_step_quickstart_main_prints_core_boundary( - capsys: pytest.CaptureFixture[str], -) -> None: - _MODULE.main() - captured = capsys.readouterr() - output = captured.out - assert "CoreStep quickstart (Core-only deterministic engine)" in output - assert "v1 generated" in output - assert "v1 dispatchable: [] (Core does not dispatch externally)" in output - assert "Runtime dispatches these later" in output diff --git a/tests/semantics/test_no_runtime_dependencies.py b/tests/semantics/test_no_runtime_dependencies.py deleted file mode 100644 index 4c7a075..0000000 --- a/tests/semantics/test_no_runtime_dependencies.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Guards that core package stays runtime-independent.""" - -from __future__ import annotations - -from pathlib import Path - - -def test_core_package_has_no_runtime_or_hftbacktest_imports() -> None: - package_root = Path(__file__).resolve().parents[2] / "tradingchassis_core" - for file_path in package_root.rglob("*.py"): - content = file_path.read_text(encoding="utf-8") - assert "hftbacktest" not in content - assert "core_runtime" not in content From a998185bf2e3515166a89c56c991cf679f78749e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:23:32 +0000 Subject: [PATCH 44/53] docs(core): clarify motivation and backtest live parity --- CHANGELOG.md | 2 +- CONTRIBUTING.md | 8 +-- README.md | 53 +++++++++---------- SECURITY.md | 6 +-- docs/README.md | 22 ++++---- docs/code-map/core-pipeline-map.md | 8 +-- docs/code-map/repository-map.md | 10 ++-- docs/how-to/add-canonical-event.md | 2 +- docs/how-to/update-core-step-pipeline.md | 10 ++-- .../update-policy-and-execution-control.md | 14 ++--- docs/reference/events-reference.md | 4 +- docs/reference/public-api.md | 4 +- 12 files changed, 71 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0481e1..f57db22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ This changelog starts from the clean Core package baseline. - Deterministic `run_core_step` and `run_core_wakeup_step` architecture. - Canonical Event input models and `EventStreamEntry`/`ProcessingPosition`. - Intent candidate record pipeline with dominance/reconciliation. -- Policy-only risk admission and Execution Control plan/apply integration. +- Risk Engine (policy-only) admission and Execution Control plan/apply integration. - `CoreStepResult.dispatchable_intents` and `ControlSchedulingObligation` outputs. - Core-only quickstart example and focused semantics test coverage. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 58f2184..1f984aa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Contributing to TradingChassis Core Contributions should preserve TradingChassis Core as a deterministic, -runtime-agnostic library. +Runtime-agnostic library. > Terminology: Definitions and related terms match the [canonical > terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). @@ -9,7 +9,7 @@ runtime-agnostic library. ## Package Scope - Core owns canonical Events, State reduction, Strategy evaluation boundary, - candidate reconciliation, policy admission, Execution Control plan/apply, + candidate reconciliation, Risk Engine (policy), Execution Control plan/apply, and `CoreStepResult`. - Core does not own Runtime orchestration, Venue Adapters, dispatch lifecycle, or deployment/config wiring. @@ -53,11 +53,11 @@ python -m build - Update `core/domain/processing_step.py` for deterministic flow changes. - Keep reconciliation/policy/apply transitions explicit and side-effect-safe. -### Policy and risk behavior +### Risk Engine (policy) behavior - Implement policy checks in `core/risk/` and wire through `evaluate_policy_intent`. -- Keep risk admission as policy-only; no dispatch/Runtime side effects. +- Keep Risk Engine admission as policy-only; no dispatch/Runtime side effects. ### Execution Control behavior diff --git a/README.md b/README.md index 841a83b..52d7e91 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ `tradingchassis_core` is the stable deterministic trading decision kernel for TradingChassis: an event-step engine that applies ordered canonical Events (the Event Stream under Processing Order and Configuration) -and produces `CoreStepResult` outputs—including strategy-generated and +and produces `CoreStepResult` outputs—including Strategy-generated and candidate Intents, optional `dispatchable_intents`, and optional -Control Scheduling Obligations. It does not perform Venue I/O, +Control Scheduling Obligation output. It does not perform Venue I/O, Execution (adapter-side dispatch), or Runtime orchestration. > Terminology: Definitions and related terms match the [canonical @@ -15,37 +15,37 @@ Execution (adapter-side dispatch), or Runtime orchestration. Trading systems often drift when Backtesting logic, Live logic, policy limits, and Strategy throttling are implemented in different places. TradingChassis Core -centralizes deterministic decision semantics—state reduction, Strategy +centralizes deterministic decision semantics—State reduction, Strategy evaluation, Risk Engine (policy) admission, and Execution Control (planning apply over reconciled Intents)—in one library. Runtime -environments (Backtesting, Live, research tooling, Kubernetes-backed +environments (Backtesting, Live, Research tooling, Kubernetes-backed deployments, different Venue Adapters) may change; Core should not. ## What this gives you | What you get | Why it matters | | --- | --- | -| One deterministic Core pipeline | Same event-step path for reduction → evaluation → candidates → policy → execution-control apply | +| One deterministic Core pipeline | Same Event-step path for reduction → evaluation → candidates → Risk Engine → Execution Control apply | | Canonical Event input model (`EventStreamEntry`) | Aligns with Event Stream + Processing Order; State is `f(Event Stream, Configuration)` | -| Strategy output as Intents | Internal, order/venue-agnostic commands before adapter-specific shapes | -| Risk Engine separated from Execution Control | Policy admission vs Queue / scheduling / rate-aware presentation split, as in the intent pipeline (Strategy → Risk → Queue → Adapter) | +| Strategy output as Intents | Internal, order/Venue-agnostic commands before Venue Adapter-specific shapes | +| Risk Engine separated from Execution Control | Risk Engine (policy) vs Queue / scheduling / rate-aware presentation split, as in the intent pipeline (Strategy → Risk → Queue → Adapter) | | `dispatchable_intents` + optional Control Scheduling Obligation | Runtime performs Execution and injects Control-Time Events when obligations are realized | | Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | -| Shared kernel across environments | Serious Backtesting-Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution-Control code elsewhere | +| Shared kernel across environments | Serious Backtesting and Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution Control code elsewhere | ## Why this matters for trading The gap between tested behavior and real behavior can dominate outcomes. Backtesting is useful when the same Core semantics—given comparable Event Stream and Configuration—drive decisions in simulation and -production. Deterministic, canonical-event-driven Core logic makes Strategy, -policy, and Execution Control behavior reproducible and unit-testable. -Wall-clock scheduling, Venue behavior, adapter mapping, and infrastructure +production. Deterministic Core logic driven by canonical Events makes Strategy, +Risk Engine, and Execution Control behavior reproducible and unit-testable. +Wall-clock scheduling, Venue behavior, Venue Adapter mapping, and infrastructure stay in the Runtime and Venue Adapter, not in Core. ## How it fits into a full system -Runtimes normalize inputs into canonical Events and feed the Core. +Each Runtime normalizes inputs into canonical Events and feeds the Core. The Core always returns the same result type (`CoreStepResult`) for a given step; each Runtime handles environment-specific Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. @@ -54,15 +54,15 @@ injection when a Control Scheduling Obligation is realized. flowchart TB R1["Runtime:
canonical Event"] --> Entry["EventStreamEntry:
canonical Event + ProcessingPosition"] Entry --> Core["TradingChassis Core:
CoreStep / CoreWakeupStep"] - Core --> Result["CoreStepResult:
dispatchable Intents + scheduling obligation"] + Core --> Result["CoreStepResult:
dispatchable Intents + Control Scheduling Obligation"] Result --> R2["Runtime:
dispatch / scheduling / I/O"] ``` -## Backtesting-Live parity +## Backtesting and Live parity Core is designed to reduce decision-logic drift between Backtesting and Live: the same canonical Event + `run_core_step` / reduction APIs -can drive both worlds when Runtimes construct comparable `EventStreamEntry` +can drive both worlds when each Runtime constructs comparable `EventStreamEntry` sequences under the same Configuration. Normalizing feeds, timestamps, and control semantics before they enter Core narrows unnecessary divergence. @@ -77,16 +77,16 @@ Execution Control itself. - Building an internal trading system where Backtesting and Live should share decision semantics. - Wanting a deterministic Strategy / Risk Engine / Execution Control kernel. -- Separating trading semantics from adapters, I/O, and Kubernetes wiring. +- Separating trading semantics from Venue Adapters, I/O, and Kubernetes wiring. - Testing decisions and Intents without Runtime harnesses. - Sharing one decision path across simulation and production. ## When not to use this package -- You only need a one-off notebook Backtesting experiment. +- You only need a one-off Backtesting notebook experiment. - You want a complete Venue connector or turnkey Live implementation. - You expect this to ship a full Kubernetes Runtime, deployment manifests, or operations. -- You expect Core to execute orders, talk to Venues, or replace adapters / Runtime dispatch. +- You expect Core to execute orders, talk to Venues, or replace Venue Adapters / Runtime dispatch. ## Full pipeline @@ -94,11 +94,11 @@ Execution Control itself. Runtime reduces to canonical Events -> process_event_entry / process_canonical_event - -> Strategy evaluator + -> Strategy evaluation -> generated Intents -> candidate records -> dominance / reconciliation - -> policy admission + -> Risk Engine (policy) -> Execution Control plan/apply -> CoreStepResult.dispatchable_intents @@ -109,7 +109,7 @@ Runtime dispatches Intents into Orders - Input: `EventStreamEntry` values with canonical Events and Event Stream position. - Core does: deterministic reduction, Strategy evaluation boundary, candidate - merge/dominance, policy admission, Execution Control planning/apply. + merge/dominance, Risk Engine (policy), Execution Control planning/apply. - Output: `CoreStepResult` with generated/candidate Intents, optional `dispatchable_intents`, and optional `control_scheduling_obligation`. - Not owned by Core: raw market/feed I/O, Venue Adapters, external dispatch, @@ -145,7 +145,7 @@ result = tc.run_core_step( ), ) print(result.generated_intents, result.dispatchable_intents) -# Expected: () () — no Strategy or policy/EC path in this snippet. +# Expected: () () — no Strategy or Risk Engine/Execution Control path in this snippet. ``` See `examples/core_step_quickstart.py` for a full runnable walkthrough. @@ -156,7 +156,7 @@ See `examples/core_step_quickstart.py` for a full runnable walkthrough. | --- | --- | | `run_core_step` | One-entry deterministic reduce/evaluate/decide/apply step | | `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup | -| `run_core_wakeup_decision` | Wakeup-level candidate/policy/Execution Control decision phase | +| `run_core_wakeup_decision` | Wakeup-level candidate/Risk Engine/Execution Control decision phase | | `run_core_wakeup_step` | Convenience wrapper for reduction + decision | | `process_event_entry` | Reduce one `EventStreamEntry` into `StrategyState` | | `process_canonical_event` | Reduce one canonical Event into `StrategyState` | @@ -167,9 +167,9 @@ See `examples/core_step_quickstart.py` for a full runnable walkthrough. | --- | --- | | canonical models/contracts | raw I/O and feed adapters | | State reduction and ordering | Venue Adapters and transport | -| Strategy evaluator boundary | external dispatch Execution | +| Strategy evaluation boundary | adapter-side Execution | | candidate Intents and reconciliation | credentials/env wiring | -| risk admission | Backtesting/Live orchestration | +| Risk Engine (policy) | Backtesting/Live orchestration | | Execution Control | Kubernetes/deployment | | `CoreStepResult` decision contract | Runtime lifecycle glue | @@ -180,7 +180,6 @@ From root: ```bash python -m pip install -e ".[dev]" python examples/core_step_quickstart.py -python -m pytest -q -python -m mypy tradingchassis_core tests +./scripts/check.sh python -m build ``` diff --git a/SECURITY.md b/SECURITY.md index b53ffb4..5228104 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,7 +24,7 @@ Include: This policy covers the Core package in this repository, including: -- canonical event and intent contracts +- canonical Event and Intent contracts - deterministic CoreStep/CoreWakeupStep decision pipeline - package integrity and dependency usage in `tradingchassis_core` @@ -32,7 +32,7 @@ This policy covers the Core package in this repository, including: Never commit live secrets or account-sensitive data, including: -- API keys and venue credentials +- API keys and Venue credentials - account identifiers tied to real accounts - private trading data dumps @@ -42,7 +42,7 @@ Tests and documentation examples must use synthetic or non-sensitive data only. - TradingChassis Core is a library and does not guarantee safe live trading by itself. -- Runtime orchestration, venue behavior, and deployment hardening remain outside +- Runtime orchestration, Venue behavior, and deployment hardening remain outside this package scope and require separate validation. ## No Financial Performance Guarantee diff --git a/docs/README.md b/docs/README.md index ef9f89b..e4d9145 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,37 +5,37 @@ This documentation set describes the standalone clean Core package baseline. ## Contents - `reference/public-api.md`: supported root exports and package boundary -- `reference/events-reference.md`: canonical events and intent contracts +- `reference/events-reference.md`: canonical Events and Intent contracts - `code-map/core-pipeline-map.md`: deterministic pipeline walkthrough - `code-map/repository-map.md`: package layout and ownership map -- `how-to/add-canonical-event.md`: extending canonical event contracts +- `how-to/add-canonical-event.md`: extending canonical Event contracts - `how-to/update-core-step-pipeline.md`: changing CoreStep/CoreWakeupStep behavior -- `how-to/update-policy-and-execution-control.md`: changing policy/apply behavior +- `how-to/update-policy-and-execution-control.md`: changing Risk Engine / Execution Control behavior ## Package Purpose TradingChassis Core is a deterministic trading decision engine library. It owns -canonical contracts, state reduction, and step-level decision outputs. +canonical contracts, State reduction, and step-level decision outputs. ## Clean Core Pipeline 1. `EventStreamEntry` 2. `process_event_entry` / `process_canonical_event` -3. strategy evaluator -4. generated intents +3. Strategy evaluation +4. generated Intents 5. candidate records + dominance/reconciliation -6. policy admission -7. execution-control plan/apply +6. Risk Engine (policy) +7. Execution Control plan/apply 8. `CoreStepResult` outputs (`dispatchable_intents`, `control_scheduling_obligation`) -9. runtime dispatch happens later +9. Runtime dispatch happens later ## Contract source of truth Pydantic contract models in `tradingchassis_core/core/domain/types.py` are the -source of truth for canonical event/intent schemas. +source of truth for canonical Event/Intent schemas. ## Out of Scope -- runtime orchestration and order lifecycle ownership +- Runtime orchestration and Order lifecycle ownership - Venue Adapters, Backtesting/Live I/O, external dispatch diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index 8535dc6..5117ab9 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -8,10 +8,10 @@ TradingChassis Core. 1. `EventStreamEntry` arrives with `ProcessingPosition`. 2. `process_event_entry` forwards to `process_canonical_event`. 3. Canonical reducer mutates `StrategyState` deterministically. -4. Strategy evaluator produces generated intents. +4. Strategy evaluation produces generated Intents. 5. Candidate records are built and reconciled/dominated. -6. Policy admission accepts/rejects generated candidates. -7. Execution-control plan/apply computes queue/dispatch/scheduling outputs. +6. Risk Engine (policy) accepts/rejects generated candidates. +7. Execution Control plan/apply computes Queue/dispatch/scheduling outputs. 8. `CoreStepResult` returns `dispatchable_intents` and optional `control_scheduling_obligation`. 9. Runtime can dispatch later; Core does not dispatch. @@ -25,6 +25,6 @@ TradingChassis Core. ## Determinism notes -- Processing order monotonicity is enforced by `ProcessingPosition`. +- Processing Order monotonicity is enforced by `ProcessingPosition`. - Core logic is side-effect-safe apart from deterministic state mutation. - Runtime adapters and external dispatch concerns are outside Core. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md index f891db6..2fa428a 100644 --- a/docs/code-map/repository-map.md +++ b/docs/code-map/repository-map.md @@ -7,8 +7,8 @@ High-level map for the standalone Core package. - `tradingchassis_core/__init__.py`: public package boundary exports - `tradingchassis_core/core/domain/`: canonical contracts and deterministic pipeline orchestration -- `tradingchassis_core/core/risk/`: policy-only risk evaluator/config -- `tradingchassis_core/core/execution_control/`: execution-control primitives +- `tradingchassis_core/core/risk/`: policy-only Risk Engine evaluator/config +- `tradingchassis_core/core/execution_control/`: Execution Control primitives - `tradingchassis_core/core/events/`: internal event bus/sink utilities ## Tests and examples @@ -28,11 +28,11 @@ High-level map for the standalone Core package. Core owns: -- canonical events and processing-order contracts +- canonical Events and Processing Order contracts - deterministic reduction and step decisions -- intent candidate, policy admission, execution-control outputs +- Intent candidate, Risk Engine (policy), Execution Control outputs Core does not own: -- runtime orchestration, adapters, I/O, deployment +- Runtime orchestration, Venue Adapters, I/O, deployment - dispatch lifecycle beyond `CoreStepResult` outputs diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md index 06e340b..0f66d67 100644 --- a/docs/how-to/add-canonical-event.md +++ b/docs/how-to/add-canonical-event.md @@ -12,5 +12,5 @@ Rules: - Keep canonical processing deterministic. -- Do not introduce runtime adapter or dispatch logic in reducers. +- Do not introduce Venue Adapter or dispatch logic in reducers. - Keep Pydantic contracts as source of truth. diff --git a/docs/how-to/update-core-step-pipeline.md b/docs/how-to/update-core-step-pipeline.md index 0fd7cca..1e67f00 100644 --- a/docs/how-to/update-core-step-pipeline.md +++ b/docs/how-to/update-core-step-pipeline.md @@ -6,19 +6,19 @@ Core step orchestration lives in Recommended workflow: 1. Start from `run_core_step` and identify which phase changes: - reduction, strategy evaluation, reconciliation, policy, or apply. + reduction, Strategy evaluation, reconciliation, Risk Engine (policy), or apply. 2. Keep stage boundaries explicit: - reduction first - - strategy generation second + - Strategy generation second - candidate reconciliation third - - policy admission fourth - - execution-control plan/apply fifth + - Risk Engine (policy) fourth + - Execution Control plan/apply fifth 3. Preserve `CoreStepResult` as the public output contract. 4. Add or update tests in `tests/semantics/test_core_pipeline_clean.py`. 5. Confirm quickstart behavior still reflects the public contract. Guardrails: -- No runtime dispatch logic in Core pipeline code. +- No Runtime dispatch logic in Core pipeline code. - No legacy compatibility contract restoration. - Keep deterministic behavior and public API coherence. diff --git a/docs/how-to/update-policy-and-execution-control.md b/docs/how-to/update-policy-and-execution-control.md index 2e06285..3a825be 100644 --- a/docs/how-to/update-policy-and-execution-control.md +++ b/docs/how-to/update-policy-and-execution-control.md @@ -1,8 +1,8 @@ -# How To Update Policy and Execution Control +# How To Update Risk Engine and Execution Control -Policy admission and execution-control are separate deterministic phases. +The Risk Engine (policy) and Execution Control are separate deterministic phases. -## Policy updates +## Risk Engine updates - Policy contract entrypoint: `PolicyIntentEvaluator.evaluate_policy_intent(...)` @@ -11,13 +11,13 @@ Policy admission and execution-control are separate deterministic phases. - Built-in policy-only evaluator: `core/risk/risk_engine.py` -When updating policy: +When updating Risk Engine policy behavior: 1. Keep evaluation side-effect-free. 2. Return explicit accept/reject with reason. 3. Validate behavior with semantics tests. -## Execution-control updates +## Execution Control updates - Planning model: `core/domain/execution_control_plan.py` @@ -26,8 +26,8 @@ When updating policy: - Runtime-facing non-canonical output: `ControlSchedulingObligation` -When updating execution-control: +When updating Execution Control: -1. Keep queue/dispatchability decisions deterministic. +1. Keep Queue/dispatchability decisions deterministic. 2. Preserve `CoreStepResult.dispatchable_intents` contract. 3. Use `ControlSchedulingObligation` for deferred control signals. diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md index d0384b9..24c6013 100644 --- a/docs/reference/events-reference.md +++ b/docs/reference/events-reference.md @@ -1,12 +1,12 @@ # Events and Intents Reference -TradingChassis Core accepts canonical event contracts and produces intent/decision +TradingChassis Core accepts canonical Event contracts and produces Intent/decision contracts. Pydantic models are the schema source of truth. ## Canonical Event Models - `MarketEvent`: book/trade market data input for state reduction -- `ControlTimeEvent`: control-time wakeup and scheduling context +- `ControlTimeEvent`: Control-Time Event wakeup and scheduling context - `OrderSubmittedEvent`: canonical submitted-order acknowledgement - `OrderExecutionFeedbackEvent`: canonical account/execution feedback - `FillEvent`: canonical fill lifecycle update diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index e99a66f..a7cc201 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -2,7 +2,7 @@ The public package boundary is the `tradingchassis_core` root import. -## Canonical events +## Canonical Events - `MarketEvent` - `ControlTimeEvent` @@ -52,7 +52,7 @@ The public package boundary is the `tradingchassis_core` root import. ## Runtime-safe utilities - `NullEventBus` -- `RiskEngine` (policy-only evaluator) +- `RiskEngine` (Risk Engine; policy-only) - `RiskConfig` ## Publicly absent by design From e3dcb22266c7a9555ba09deaf8b868d1cc584ab4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:39:09 +0000 Subject: [PATCH 45/53] docs(core): clarify motivation and backtest live parity --- README.md | 80 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 52d7e91..4c947c9 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,17 @@ candidate Intents, optional `dispatchable_intents`, and optional Control Scheduling Obligation output. It does not perform Venue I/O, Execution (adapter-side dispatch), or Runtime orchestration. -> Terminology: Definitions and related terms match the [canonical -> terminology](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). +**What it is:** a shared library for the decision path only—canonical Event in, +deterministic Strategy / Risk Engine / Execution Control processing, Intents and +Execution Control outputs out. **What it is not:** a one-off Backtesting script, a Venue +connector, a Live or Kubernetes Runtime, or anything that performs external +dispatch. The same Core is meant to stay stable while local Research, Backtesting, +simulation, Live trading, Venue Adapters, and infrastructure around you change. + +> Terminology: Definitions and related terms match the +> [`canonical terminology`](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). +> In-repo pointers: [`core/docs/README.md`](docs/README.md) and +> [`core/docs/code-map/core-pipeline-map.md`](docs/code-map/core-pipeline-map.md). ## Why this exists @@ -21,6 +30,15 @@ evaluation, Risk Engine (policy) admission, and Execution Control environments (Backtesting, Live, Research tooling, Kubernetes-backed deployments, different Venue Adapters) may change; Core should not. +A typical notebook or one-off Backtesting script inlines feed handling, Strategy rules, +Risk Engine (policy) checks, and how orders are sent in one place. That is fast to sketch but +tends to fork: the Live path reimplements similar ideas with different bugs and +timing. Core keeps the decision kernel in one place: Runtimes normalize inputs into +canonical Events, invoke Core, and perform Execution and dispatch outside Core using +`CoreStepResult`; Strategy, Risk Engine, +and Execution Control semantics stay identical across those Runtimes when the +Event Stream and Configuration match. + ## What this gives you | What you get | Why it matters | @@ -33,31 +51,51 @@ deployments, different Venue Adapters) may change; Core should not. | Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | | Shared kernel across environments | Serious Backtesting and Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution Control code elsewhere | +In short: one pipeline, canonical Events, Intents inside Core, policy vs Execution +Control split, dispatchable Intents plus optional Control Scheduling Obligation for +the Runtime, and a boundary that makes parity and testing practical—not a second +copy of decision logic per environment. + ## Why this matters for trading -The gap between tested behavior and real behavior can dominate outcomes. -Backtesting is useful when the same Core semantics—given comparable -Event Stream and Configuration—drive decisions in simulation and -production. Deterministic Core logic driven by canonical Events makes Strategy, -Risk Engine, and Execution Control behavior reproducible and unit-testable. -Wall-clock scheduling, Venue behavior, Venue Adapter mapping, and infrastructure -stay in the Runtime and Venue Adapter, not in Core. +The gap between tested behavior and Live trading behavior can dominate outcomes. **Backtesting** +is only a reliable guide if the **same** Core decision logic—Strategy, +Risk Engine, Execution Control—can drive Live when the Event Stream and +Configuration are comparable. Deterministic Core logic driven by canonical Events +makes that logic reproducible and unit-testable without duplicating it in each +Runtime. + +This package does **not** guarantee profitable trading, perfect Backtesting/Live +equality, or identical fills. It **does** remove a major class of drift: the +decision engine itself. Wall-clock scheduling, Venue behavior, Venue Adapter +mapping, latency, liquidity, market-data quality, and infrastructure failure modes +stay in the Runtime, Venue Adapter, and Venue—not in Core. ## How it fits into a full system -Each Runtime normalizes inputs into canonical Events and feeds the Core. -The Core always returns the same result type (`CoreStepResult`) for a given step; -each Runtime handles environment-specific Execution, scheduling glue, and Control-Time Event -injection when a Control Scheduling Obligation is realized. +Backtesting Runtimes, Live Runtimes, and local Research or simulation harnesses can +all feed the **same** Core: they normalize feeds, timestamps, and control semantics +into canonical Events, build `EventStreamEntry` sequences, and call the same +`run_core_step` / reduction APIs. Core always returns the same contract +(`CoreStepResult`) for a given step; each Runtime owns environment-specific +Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. ```mermaid -flowchart TB - R1["Runtime:
canonical Event"] --> Entry["EventStreamEntry:
canonical Event + ProcessingPosition"] - Entry --> Core["TradingChassis Core:
CoreStep / CoreWakeupStep"] - Core --> Result["CoreStepResult:
dispatchable Intents + Control Scheduling Obligation"] - Result --> R2["Runtime:
dispatch / scheduling / I/O"] +flowchart LR + BT["Backtesting Runtime"] --> CE["Canonical Events"] + LV["Live Runtime"] --> CE + RS["Research / local"] --> CE + CE --> CORE["TradingChassis Core
deterministic decision kernel"] + CORE --> RES["CoreStepResult
dispatchable Intents + Control Scheduling Obligation"] + RES --> RT["Runtime
dispatch / scheduling / I/O"] ``` +Core never replaces the Runtime: the Runtime is responsible for feeding canonical +Events and for turning `dispatchable_intents` into Venue traffic (and for everything +Kubernetes, credentials, and operations-related). What stays stable is the Core +pipeline and contracts; what varies by design is Runtime choice, Venue Adapter, +Venue, and deployment. + ## Backtesting and Live parity Core is designed to reduce decision-logic drift between Backtesting @@ -78,15 +116,15 @@ Execution Control itself. - Building an internal trading system where Backtesting and Live should share decision semantics. - Wanting a deterministic Strategy / Risk Engine / Execution Control kernel. - Separating trading semantics from Venue Adapters, I/O, and Kubernetes wiring. -- Testing decisions and Intents without Runtime harnesses. +- Testing decisions and Intents without full Backtesting or Live machinery. - Sharing one decision path across simulation and production. ## When not to use this package - You only need a one-off Backtesting notebook experiment. - You want a complete Venue connector or turnkey Live implementation. -- You expect this to ship a full Kubernetes Runtime, deployment manifests, or operations. -- You expect Core to execute orders, talk to Venues, or replace Venue Adapters / Runtime dispatch. +- You expect this package to ship a full Kubernetes Runtime, deployment manifests, or production operations. +- You expect Core to execute orders, talk to Venues, replace Venue Adapters, or perform external dispatch. ## Full pipeline From 259bc15d40ecbcaabb5cd95b21afc2053b7ce467 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:45:40 +0000 Subject: [PATCH 46/53] docs(core): clarify motivation and backtest live parity --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4c947c9..1f561dd 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Execution (adapter-side dispatch), or Runtime orchestration. **What it is:** a shared library for the decision path only—canonical Event in, deterministic Strategy / Risk Engine / Execution Control processing, Intents and -Execution Control outputs out. **What it is not:** a one-off Backtesting script, a Venue +Execution Control outputs out. + +**What it is not:** a one-off Backtesting script, a Venue connector, a Live or Kubernetes Runtime, or anything that performs external dispatch. The same Core is meant to stay stable while local Research, Backtesting, simulation, Live trading, Venue Adapters, and infrastructure around you change. @@ -20,7 +22,7 @@ simulation, Live trading, Venue Adapters, and infrastructure around you change. > In-repo pointers: [`core/docs/README.md`](docs/README.md) and > [`core/docs/code-map/core-pipeline-map.md`](docs/code-map/core-pipeline-map.md). -## Why this exists +## Why this is relevant Trading systems often drift when Backtesting logic, Live logic, policy limits, and Strategy throttling are implemented in different places. TradingChassis Core @@ -81,7 +83,7 @@ into canonical Events, build `EventStreamEntry` sequences, and call the same Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. ```mermaid -flowchart LR +flowchart TB BT["Backtesting Runtime"] --> CE["Canonical Events"] LV["Live Runtime"] --> CE RS["Research / local"] --> CE @@ -128,6 +130,8 @@ Execution Control itself. ## Full pipeline +Internal processing pipeline, in sequential order: + ```text Runtime reduces to canonical Events From 95e79794627ce55a7714911e9e19efe4f0b11612 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:48:01 +0000 Subject: [PATCH 47/53] docs(core): clarify motivation and backtest live parity --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1f561dd..92b20fa 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ dispatch. The same Core is meant to stay stable while local Research, Backtestin simulation, Live trading, Venue Adapters, and infrastructure around you change. > Terminology: Definitions and related terms match the -> [`canonical terminology`](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). +> [`canonical terminology`](https://tradingchassis.github.io/docs/latest/00-guides/terminology/). > In-repo pointers: [`core/docs/README.md`](docs/README.md) and > [`core/docs/code-map/core-pipeline-map.md`](docs/code-map/core-pipeline-map.md). @@ -83,7 +83,7 @@ into canonical Events, build `EventStreamEntry` sequences, and call the same Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. ```mermaid -flowchart TB +flowchart LR BT["Backtesting Runtime"] --> CE["Canonical Events"] LV["Live Runtime"] --> CE RS["Research / local"] --> CE From b230a355fab07ff7623e129780064c66117eee04 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:48:54 +0000 Subject: [PATCH 48/53] docs(core): clarify motivation and backtest live parity --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 92b20fa..3f83f91 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,11 @@ into canonical Events, build `EventStreamEntry` sequences, and call the same Execution, scheduling glue, and Control-Time Event injection when a Control Scheduling Obligation is realized. ```mermaid -flowchart LR - BT["Backtesting Runtime"] --> CE["Canonical Events"] - LV["Live Runtime"] --> CE - RS["Research / local"] --> CE - CE --> CORE["TradingChassis Core
deterministic decision kernel"] - CORE --> RES["CoreStepResult
dispatchable Intents + Control Scheduling Obligation"] - RES --> RT["Runtime
dispatch / scheduling / I/O"] +flowchart TB + R1["Runtime:
canonical Event"] --> Entry["EventStreamEntry:
canonical Event + ProcessingPosition"] + Entry --> Core["TradingChassis Core:
CoreStep / CoreWakeupStep"] + Core --> Result["CoreStepResult:
dispatchable Intents + Control Scheduling Obligation"] + Result --> R2["Runtime:
dispatch / scheduling / I/O"] ``` Core never replaces the Runtime: the Runtime is responsible for feeding canonical From 42716f6e223510f8591c27fd454f07b7243790c5 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 18:51:33 +0000 Subject: [PATCH 49/53] docs(core): clarify motivation and backtest live parity --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3f83f91..d421cdb 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ simulation, Live trading, Venue Adapters, and infrastructure around you change. > In-repo pointers: [`core/docs/README.md`](docs/README.md) and > [`core/docs/code-map/core-pipeline-map.md`](docs/code-map/core-pipeline-map.md). -## Why this is relevant +## Why it is relevant Trading systems often drift when Backtesting logic, Live logic, policy limits, and Strategy throttling are implemented in different places. TradingChassis Core @@ -41,7 +41,7 @@ canonical Events, invoke Core, and perform Execution and dispatch outside Core u and Execution Control semantics stay identical across those Runtimes when the Event Stream and Configuration match. -## What this gives you +## What it gives you | What you get | Why it matters | | --- | --- | @@ -58,7 +58,7 @@ Control split, dispatchable Intents plus optional Control Scheduling Obligation the Runtime, and a boundary that makes parity and testing practical—not a second copy of decision logic per environment. -## Why this matters for trading +## Why it matters for trading The gap between tested behavior and Live trading behavior can dominate outcomes. **Backtesting** is only a reliable guide if the **same** Core decision logic—Strategy, @@ -111,7 +111,7 @@ differ and must be modeled outside Core. What Core removes is a major source of mismatch—duplicating and subtly diverging Strategy/Risk Engine/ Execution Control itself. -## When to use this package +## When to use `tradingchassis_core` - Building an internal trading system where Backtesting and Live should share decision semantics. - Wanting a deterministic Strategy / Risk Engine / Execution Control kernel. @@ -119,7 +119,7 @@ Execution Control itself. - Testing decisions and Intents without full Backtesting or Live machinery. - Sharing one decision path across simulation and production. -## When not to use this package +## When not to use `tradingchassis_core` - You only need a one-off Backtesting notebook experiment. - You want a complete Venue connector or turnkey Live implementation. From 5c7b6d40be79d59e71dccaa41e0b2ab26c1dba8b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 14 May 2026 19:48:05 +0000 Subject: [PATCH 50/53] test(core): cover control-time scheduling semantics --- README.md | 11 +- docs/README.md | 7 +- docs/code-map/core-pipeline-map.md | 7 +- docs/flows/control-time-and-scheduling.md | 68 ++++++ docs/reference/events-reference.md | 8 +- docs/reference/public-api.md | 3 +- examples/core_step_quickstart.py | 8 +- .../test_control_time_scheduling_semantics.py | 202 ++++++++++++++++++ .../core/domain/execution_control_apply.py | 5 + .../core/domain/step_result.py | 8 +- .../core/execution_control/__init__.py | 10 +- .../execution_control/execution_control.py | 6 +- 12 files changed, 326 insertions(+), 17 deletions(-) create mode 100644 docs/flows/control-time-and-scheduling.md create mode 100644 tests/semantics/test_control_time_scheduling_semantics.py diff --git a/README.md b/README.md index d421cdb..652a172 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,16 @@ Event Stream and Configuration match. | Canonical Event input model (`EventStreamEntry`) | Aligns with Event Stream + Processing Order; State is `f(Event Stream, Configuration)` | | Strategy output as Intents | Internal, order/Venue-agnostic commands before Venue Adapter-specific shapes | | Risk Engine separated from Execution Control | Risk Engine (policy) vs Queue / scheduling / rate-aware presentation split, as in the intent pipeline (Strategy → Risk → Queue → Adapter) | -| `dispatchable_intents` + optional Control Scheduling Obligation | Runtime performs Execution and injects Control-Time Events when obligations are realized | +| `dispatchable_intents` + optional Control Scheduling Obligation | Runtime performs Execution and may inject canonical `ControlTimeEvent` when a **rate-limit** obligation is realized ([`docs/flows/control-time-and-scheduling.md`](docs/flows/control-time-and-scheduling.md)); inflight deferral does not emit that obligation by default | + +## Control time and scheduling (Core) + +`ControlSchedulingObligation` is a **non-canonical**, time-dependent hint produced +when execution-control **apply** defers for **rate limits**. **Inflight** gating is +**feedback-dependent** and does not, by default, produce this obligation; queued +work is reconsidered after canonical execution Events update state. Runtimes must +not flush Core queues outside the normal `run_core_step` / execution-control apply +path. See [`docs/flows/control-time-and-scheduling.md`](docs/flows/control-time-and-scheduling.md). | Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | | Shared kernel across environments | Serious Backtesting and Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution Control code elsewhere | diff --git a/docs/README.md b/docs/README.md index e4d9145..433938a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ This documentation set describes the standalone clean Core package baseline. - `reference/public-api.md`: supported root exports and package boundary - `reference/events-reference.md`: canonical Events and Intent contracts +- `flows/control-time-and-scheduling.md`: rate-limit vs inflight deferral and obligations - `code-map/core-pipeline-map.md`: deterministic pipeline walkthrough - `code-map/repository-map.md`: package layout and ownership map - `how-to/add-canonical-event.md`: extending canonical Event contracts @@ -27,8 +28,10 @@ canonical contracts, State reduction, and step-level decision outputs. 6. Risk Engine (policy) 7. Execution Control plan/apply 8. `CoreStepResult` outputs (`dispatchable_intents`, - `control_scheduling_obligation`) -9. Runtime dispatch happens later + optional `control_scheduling_obligation` for **rate-limit** deferral only—see + `flows/control-time-and-scheduling.md`) +9. Runtime dispatch happens later; Runtime injects canonical Events (including + optional `ControlTimeEvent` when an obligation is realized) ## Contract source of truth diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index 5117ab9..deaa776 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -13,8 +13,11 @@ TradingChassis Core. 6. Risk Engine (policy) accepts/rejects generated candidates. 7. Execution Control plan/apply computes Queue/dispatch/scheduling outputs. 8. `CoreStepResult` returns `dispatchable_intents` and optional - `control_scheduling_obligation`. -9. Runtime can dispatch later; Core does not dispatch. + `control_scheduling_obligation` (non-canonical; **rate-limit** deferral only + in the current slice—see `../flows/control-time-and-scheduling.md`). +9. Runtime can dispatch later and inject further canonical Events (including + `ControlTimeEvent` when an obligation is realized); Core does not perform + external dispatch or mutate queues outside this pipeline. ## Core APIs diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md new file mode 100644 index 0000000..6d3d8c8 --- /dev/null +++ b/docs/flows/control-time-and-scheduling.md @@ -0,0 +1,68 @@ +# Control time and scheduling + +This note is the **Core package** source of truth for how non-canonical +`ControlSchedulingObligation` relates to canonical `ControlTimeEvent` input and +to execution-control deferral. + +## Terms + +- **ControlSchedulingObligation** — Non-canonical Core output: a structured hint + that a **time-dependent** recheck may be useful. It is **not** part of the + canonical Event Stream and does not mutate `StrategyState`. +- **ControlTimeEvent** — Canonical **control** category Event. It becomes part of + deterministic history only after the **Runtime** injects it as + `EventStreamEntry` input (same ingestion path as other canonical Events). +- **Inflight** — Core-side **intent-operation** gating: a sendability / operation + slot (for example keyed by `client_order_id`) is occupied because an earlier + intent operation is still awaiting **canonical execution feedback**. This is + not the same as venue-side “order ownership”; Core models sendability for the + decision pipeline. +- **Rate-limit deferral** — Execution control blocks dispatch because the + configured **token / time budget** for orders or cancels is not yet available at + the apply clock (`now_ts_ns_local` in `CoreExecutionControlApplyContext`). +- **Inflight deferral** — Dispatch is blocked because **inflight** gating applies, + not because a rate-limit wake time is known ahead of time. + +## What Core emits today + +| Deferral kind | Time-dependent? | `ControlSchedulingObligation` by default? | Expected resolution | +| --- | --- | --- | --- | +| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → strategy → … → execution-control apply. | +| Inflight | No (feedback-dependent) | **No** | Later canonical **execution / lifecycle** Events (for example `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, or `FillEvent`, depending on lifecycle) update `StrategyState` so a subsequent step can reconsider queued work. | + +**Not in scope for the current contract:** inflight timeout, wall-clock recovery, +or “synthetic” obligations for inflight-only waits. + +**Not implied:** every queued intent produces a scheduling obligation or a future +`ControlTimeEvent`. Obligations are for **rate-limit** rechecks in the current +Core slice. + +## Clean Core pipeline (unchanged) + +1. `EventStreamEntry` +2. `process_event_entry` / `process_canonical_event` +3. Strategy evaluator +4. generated intents +5. candidate records + dominance / reconciliation +6. policy admission +7. execution-control plan / **apply** +8. `CoreStepResult.dispatchable_intents` and optional `control_scheduling_obligation` +9. Runtime performs venue dispatch and **injects** further canonical Events (including + any `ControlTimeEvent` realized from an obligation). + +Pure planning (`plan_execution_control_candidates`) does **not** emit obligations; +they are selected only in the mutable **apply** stage (`apply_execution_control_plan`). + +## Runtime ownership + +- Runtimes **must not** mutate Core queues (`StrategyState.queued_intents`, etc.) + directly outside the normal Core step / execution-control apply path. +- Queue flush / sendability decisions remain **ExecutionControl-owned** inside + Core when `CoreExecutionControlApplyContext` is supplied to `run_core_step` / + wakeup APIs. + +## Further reading + +- [`reference/events-reference.md`](../reference/events-reference.md) +- [`code-map/core-pipeline-map.md`](../code-map/core-pipeline-map.md) +- Tests: `tests/semantics/test_control_time_scheduling_semantics.py` diff --git a/docs/reference/events-reference.md b/docs/reference/events-reference.md index 24c6013..e1e79e4 100644 --- a/docs/reference/events-reference.md +++ b/docs/reference/events-reference.md @@ -6,7 +6,10 @@ contracts. Pydantic models are the schema source of truth. ## Canonical Event Models - `MarketEvent`: book/trade market data input for state reduction -- `ControlTimeEvent`: Control-Time Event wakeup and scheduling context +- `ControlTimeEvent`: canonical **control** wakeup; becomes stream history only + after Runtime injection. Reducer updates monotone time (and processing cursor + when positioned). Scheduling **obligations** are a separate non-canonical output; + see `../flows/control-time-and-scheduling.md`. - `OrderSubmittedEvent`: canonical submitted-order acknowledgement - `OrderExecutionFeedbackEvent`: canonical account/execution feedback - `FillEvent`: canonical fill lifecycle update @@ -40,4 +43,5 @@ stream storage/replay subsystem. - `ExecutionControlDecision` - `CoreStepDecision` - `CoreStepResult` -- `ControlSchedulingObligation` +- `ControlSchedulingObligation` (time-dependent **rate-limit** recheck hint; not + emitted for **inflight-only** deferral by default—see `../flows/control-time-and-scheduling.md`) diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a7cc201..a1fce41 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -38,7 +38,8 @@ The public package boundary is the `tradingchassis_core` root import. - `PolicyRiskDecision` - `ExecutionControlDecision` - `ExecutionControl` -- `ControlSchedulingObligation` +- `ControlSchedulingObligation` (non-canonical; **rate-limit** recheck hint in the + current slice—see `../flows/control-time-and-scheduling.md`) ## Intents and numeric models diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 3922df5..1129b3d 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -44,20 +44,22 @@ class AllowAllPolicy: def evaluate_policy_intent( self, *, - Intent: tc.OrderIntent, + intent: tc.OrderIntent, state: tc.StrategyState, now_ts_ns_local: int, ) -> tuple[bool, str | None]: - _ = (Intent, state, now_ts_ns_local) + _ = (intent, state, now_ts_ns_local) return True, None def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: # EventStreamEntry is the ordered Core input unit: a canonical Event plus # ProcessingPosition telling Core where this Event sits in the Event Stream. + # ControlTimeEvent here is only a driver Event; scheduling obligations come + # from execution-control apply (e.g. rate-limit deferral), not from every step. return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), - Event=tc.ControlTimeEvent( + event=tc.ControlTimeEvent( ts_ns_local_control=ts_ns_local, reason="scheduled_control_recheck", due_ts_ns_local=ts_ns_local, diff --git a/tests/semantics/test_control_time_scheduling_semantics.py b/tests/semantics/test_control_time_scheduling_semantics.py new file mode 100644 index 0000000..fa134f7 --- /dev/null +++ b/tests/semantics/test_control_time_scheduling_semantics.py @@ -0,0 +1,202 @@ +"""Control scheduling obligation semantics: rate-limit vs inflight deferral. + +See ``docs/flows/control-time-and-scheduling.md`` for the normative description. +""" + +from __future__ import annotations + +import tradingchassis_core as tc + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def _order_submitted_entry( + index: int, + ts_dispatch: int, + *, + client_order_id: str = "order-a", + price: float = 100.0, +) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderSubmittedEvent( + ts_ns_local_dispatch=ts_dispatch, + instrument="BTC-USDC-PERP", + client_order_id=client_order_id, + side="buy", + order_type="limit", + intended_price=tc.Price(currency="USDC", value=price), + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + time_in_force="GTC", + intent_correlation_id=None, + dispatch_attempt_id=None, + runtime_correlation=None, + ), + ) + + +class _NewIntentEvaluator: + def evaluate(self, context: object) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument="BTC-USDC-PERP", + client_order_id="intent-1", + intents_correlation_id="corr-1", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _ReplaceIntentEvaluator: + """Emits a single replace against ``order-a`` (requires a working order).""" + + def evaluate(self, context: object) -> list[tc.ReplaceOrderIntent]: + _ = context + return [ + tc.ReplaceOrderIntent( + intent_type="replace", + ts_ns_local=100, + instrument="BTC-USDC-PERP", + client_order_id="order-a", + intents_correlation_id="corr-repl", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=99.0), + ) + ] + + +def _policy_and_apply( + *, + now_ts: int, + max_orders_per_sec: float | None = None, +) -> tuple[tc.CorePolicyAdmissionContext, tc.CoreExecutionControlApplyContext]: + return ( + tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=now_ts, + ), + tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=now_ts, + max_orders_per_sec=max_orders_per_sec, + activate_dispatchable_outputs=True, + ), + ) + + +def test_rate_limit_deferral_emits_control_scheduling_obligation() -> None: + """Time-dependent rate limiting produces a non-canonical scheduling obligation.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=0.0) + + result = tc.run_core_step( + state, + _control_entry(0, now_ts), + strategy_evaluator=_NewIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + + assert result.dispatchable_intents == () + obl = result.control_scheduling_obligation + assert obl is not None + assert obl.reason == "rate_limit" + assert obl.source == "execution_control_rate_limit" + assert obl.due_ts_ns_local >= now_ts + assert obl.scope_key == "instrument:BTC-USDC-PERP" + + +def test_inflight_deferral_does_not_emit_control_scheduling_obligation() -> None: + """Inflight gating is feedback-dependent; Core does not emit a wake obligation.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _order_submitted_entry(0, now_ts)) + assert state.has_working_order("BTC-USDC-PERP", "order-a") + + state.mark_intent_sent("BTC-USDC-PERP", "order-a", "replace") + assert state.has_inflight("BTC-USDC-PERP", "order-a") + + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=None) + + result = tc.run_core_step( + state, + _control_entry(1, now_ts), + strategy_evaluator=_ReplaceIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + + assert result.control_scheduling_obligation is None + assert result.dispatchable_intents == () + assert result.core_step_decision is not None + assert len(result.core_step_decision.queued_effective_intents) >= 1 + queued = state.queued_intents.get("BTC-USDC-PERP") + assert queued is not None and len(queued) == 1 + assert queued[0].intent.intent_type == "replace" + + +def test_inflight_queued_replace_reprocessed_after_order_submitted_feedback() -> None: + """Canonical OrderSubmittedEvent clears inflight so a later step can dispatch queue.""" + now_ts = 100 + state = tc.StrategyState(event_bus=tc.NullEventBus()) + tc.process_event_entry(state, _order_submitted_entry(0, now_ts)) + state.mark_intent_sent("BTC-USDC-PERP", "order-a", "replace") + + policy_ctx, apply_ctx = _policy_and_apply(now_ts=now_ts, max_orders_per_sec=None) + blocked = tc.run_core_step( + state, + _control_entry(1, now_ts), + strategy_evaluator=_ReplaceIntentEvaluator(), + policy_admission_context=policy_ctx, + execution_control_apply_context=apply_ctx, + ) + assert blocked.dispatchable_intents == () + assert blocked.control_scheduling_obligation is None + + policy_ctx2, apply_ctx2 = _policy_and_apply(now_ts=now_ts + 1, max_orders_per_sec=None) + cleared = tc.run_core_step( + state, + _order_submitted_entry(2, now_ts + 1, price=99.0), + policy_admission_context=policy_ctx2, + execution_control_apply_context=apply_ctx2, + ) + + assert not state.has_inflight("BTC-USDC-PERP", "order-a") + assert len(cleared.dispatchable_intents) == 1 + assert cleared.dispatchable_intents[0].intent_type == "replace" + assert cleared.control_scheduling_obligation is None diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py index 2bb52ab..59255e8 100644 --- a/tradingchassis_core/core/domain/execution_control_apply.py +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -135,6 +135,11 @@ def apply_execution_control_plan( This function mutates only StrategyState queue data and ExecutionControl rate state. It does not perform venue dispatch and does not emit canonical events. + + ``control_scheduling_obligation`` is selected only from **rate-limit** + deferrals (time-dependent). **Inflight** gating queues or blocks work without + adding a scheduling obligation; that case is resolved when later canonical + events update sendability (not via a Core-derived wake time in this slice). """ state = context.state diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index 3b39d90..853b488 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -12,7 +12,13 @@ @dataclass(frozen=True, slots=True) class CoreStepResult: - """Immutable result object for deterministic Core step APIs.""" + """Immutable result object for deterministic Core step APIs. + + ``control_scheduling_obligation`` is set only when execution-control apply + defers for **rate limits** (time-dependent). It is ``None`` for inflight-only + deferral and other cases without a Core-derived wake time. Only injected + ``ControlTimeEvent`` values are canonical stream input for control time. + """ generated_intents: tuple[OrderIntent, ...] = () candidate_intent_records: tuple[CandidateIntentRecord, ...] = () diff --git a/tradingchassis_core/core/execution_control/__init__.py b/tradingchassis_core/core/execution_control/__init__.py index 1a3be4a..1b8338f 100644 --- a/tradingchassis_core/core/execution_control/__init__.py +++ b/tradingchassis_core/core/execution_control/__init__.py @@ -1,8 +1,12 @@ """Execution control (internal). -This package intentionally hosts internal components that govern queue admission, -inflight gating, and timing/rate limiting, while keeping RiskEngine focused on -policy decisions. +This package hosts internal components that govern queue admission, inflight +gating, and rate limiting. Policy admission stays in the Risk Engine / domain +layer. + +``ControlSchedulingObligation`` (in ``types``) is a non-canonical scheduling hint +for **rate-limit** deferral only in the current Core slice; **inflight** deferral +does not emit that obligation by default. See ``docs/flows/control-time-and-scheduling.md``. """ from tradingchassis_core.core.execution_control.execution_control import ExecutionControl diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index 48708a8..645f68f 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -1,8 +1,10 @@ """Execution control (internal extraction from RiskEngine). Owns: -- token bucket rate limiting state & math -- inflight gating that routes NEW/REPLACE to queue +- token bucket rate limiting state & math (time-dependent deferral may surface + a ``ControlSchedulingObligation`` from the apply stage; see docs on control time) +- inflight gating that routes NEW/REPLACE to queue (feedback-dependent; no + scheduling obligation by default) - queue admission via StrategyState.merge_intents_into_queue(...) - queue-only local handling for certain CANCEL/REPLACE cases """ From ef09b35576e006dfbebadf44c32128f8d264f002 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 15 May 2026 11:09:36 +0000 Subject: [PATCH 51/53] feat(core): evaluate wakeup strategy after full batch reduction --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- CHANGELOG.md | 1 + README.md | 21 +- docs/code-map/core-pipeline-map.md | 22 ++ docs/code-map/repository-map.md | 2 +- docs/flows/control-time-and-scheduling.md | 2 +- docs/how-to/add-canonical-event.md | 4 +- docs/how-to/update-core-step-pipeline.md | 11 + docs/reference/public-api.md | 4 +- examples/core_step_quickstart.py | 7 +- tests/semantics/test_core_pipeline_clean.py | 79 +---- .../semantics/test_core_wakeup_final_state.py | 298 ++++++++++++++++++ tests/semantics/test_public_api_clean.py | 2 + tradingchassis_core/__init__.py | 4 + .../core/domain/event_model.py | 2 +- tradingchassis_core/core/domain/processing.py | 10 +- .../core/domain/processing_order.py | 2 +- .../core/domain/processing_step.py | 56 ++-- tradingchassis_core/core/domain/state.py | 6 +- tradingchassis_core/core/events/event_bus.py | 2 +- tradingchassis_core/core/events/event_sink.py | 2 +- tradingchassis_core/core/risk/risk_engine.py | 2 +- 22 files changed, 419 insertions(+), 122 deletions(-) create mode 100644 tests/semantics/test_core_wakeup_final_state.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 4b5f5ed..f9bf041 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,4 +27,4 @@ What should happen? ## Logs / Output -Paste relevant logs, stack traces, or event output here. +Paste relevant logs, traces, or outputs here. diff --git a/CHANGELOG.md b/CHANGELOG.md index f57db22..97050db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This changelog starts from the clean Core package baseline. ### Added - Deterministic `run_core_step` and `run_core_wakeup_step` architecture. +- CoreWakeupStep final-state Strategy evaluation: reduce all entries, then `CoreWakeupStrategyEvaluator` once. - Canonical Event input models and `EventStreamEntry`/`ProcessingPosition`. - Intent candidate record pipeline with dominance/reconciliation. - Risk Engine (policy-only) admission and Execution Control plan/apply integration. diff --git a/README.md b/README.md index 652a172..49f4667 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # TradingChassis Core `tradingchassis_core` is the stable deterministic trading decision kernel -for TradingChassis: an event-step engine that applies ordered canonical Events +for TradingChassis: an Event-step engine that applies ordered canonical Events (the Event Stream under Processing Order and Configuration) and produces `CoreStepResult` outputs—including Strategy-generated and candidate Intents, optional `dispatchable_intents`, and optional @@ -204,12 +204,27 @@ See `examples/core_step_quickstart.py` for a full runnable walkthrough. | Entrypoint | Purpose | | --- | --- | | `run_core_step` | One-entry deterministic reduce/evaluate/decide/apply step | -| `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup | +| `run_core_wakeup_reduction` | Multi-entry reduction phase for one wakeup (no per-entry Strategy) | | `run_core_wakeup_decision` | Wakeup-level candidate/Risk Engine/Execution Control decision phase | -| `run_core_wakeup_step` | Convenience wrapper for reduction + decision | +| `run_core_wakeup_step` | Reduce all entries, evaluate Strategy once, then one decision pass | | `process_event_entry` | Reduce one `EventStreamEntry` into `StrategyState` | | `process_canonical_event` | Reduce one canonical Event into `StrategyState` | + + +## CoreWakeupStep semantics + +CoreWakeupStep is not "parallel Event processing". +It is deterministic batch processing: the Runtime gives Core an ordered sequence of +canonical entries, and Core reduces them in that order before making one decision. + +- `run_core_step` handles one `EventStreamEntry`. +- `run_core_wakeup_step` handles an ordered batch of `EventStreamEntry` values. +- Runtime is responsible for normalizing and ordering simultaneous raw inputs. +- Core reduces all wakeup entries in order, evaluates Strategy once on the final state, + then runs Policy Admission and ExecutionControl once. +- Runtime dispatches after the returned `CoreStepResult`. + ## Ownership Boundary | Core owns | Runtime owns | diff --git a/docs/code-map/core-pipeline-map.md b/docs/code-map/core-pipeline-map.md index deaa776..cfd4ed3 100644 --- a/docs/code-map/core-pipeline-map.md +++ b/docs/code-map/core-pipeline-map.md @@ -31,3 +31,25 @@ TradingChassis Core. - Processing Order monotonicity is enforced by `ProcessingPosition`. - Core logic is side-effect-safe apart from deterministic state mutation. - Runtime adapters and external dispatch concerns are outside Core. + + +## CoreWakeupStep batch semantics + +`CoreWakeupStep` is not "parallel Event processing". +It is deterministic batch processing: the Runtime gives Core an ordered sequence of +canonical `EventStreamEntry` values, and Core reduces them in that order before making +one decision. + +Wakeup flow: + +1. Runtime supplies an ordered batch of `EventStreamEntry` values. +2. `run_core_wakeup_reduction` calls `process_event_entry` for each entry in order. +3. `CoreWakeupStrategyEvaluator.evaluate` runs **once** on the fully reduced state + (`CoreWakeupStrategyContext` carries all entries). +4. `run_core_wakeup_decision` snapshots queued intents once, combines generated + queued + once, applies dominance/reconciliation once, Policy Admission once, and + ExecutionControl plan/apply once. +5. `CoreStepResult.dispatchable_intents` is returned; Runtime dispatches later. + +`run_core_step` remains single-entry: one reduction, one step-level Strategy evaluation, +one decision pass. diff --git a/docs/code-map/repository-map.md b/docs/code-map/repository-map.md index 2fa428a..d050461 100644 --- a/docs/code-map/repository-map.md +++ b/docs/code-map/repository-map.md @@ -9,7 +9,7 @@ High-level map for the standalone Core package. pipeline orchestration - `tradingchassis_core/core/risk/`: policy-only Risk Engine evaluator/config - `tradingchassis_core/core/execution_control/`: Execution Control primitives -- `tradingchassis_core/core/events/`: internal event bus/sink utilities +- `tradingchassis_core/core/events/`: internal Event bus/sink utilities ## Tests and examples diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md index 6d3d8c8..3f1b28f 100644 --- a/docs/flows/control-time-and-scheduling.md +++ b/docs/flows/control-time-and-scheduling.md @@ -27,7 +27,7 @@ to execution-control deferral. | Deferral kind | Time-dependent? | `ControlSchedulingObligation` by default? | Expected resolution | | --- | --- | --- | --- | -| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → strategy → … → execution-control apply. | +| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → Strategy → … → execution-control apply. | | Inflight | No (feedback-dependent) | **No** | Later canonical **execution / lifecycle** Events (for example `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, or `FillEvent`, depending on lifecycle) update `StrategyState` so a subsequent step can reconsider queued work. | **Not in scope for the current contract:** inflight timeout, wall-clock recovery, diff --git a/docs/how-to/add-canonical-event.md b/docs/how-to/add-canonical-event.md index 0f66d67..86f86b8 100644 --- a/docs/how-to/add-canonical-event.md +++ b/docs/how-to/add-canonical-event.md @@ -1,8 +1,8 @@ # How To Add a Canonical Event -1. Add or update the Pydantic event model in +1. Add or update the Pydantic Event model in `tradingchassis_core/core/domain/types.py`. -2. Register the event in `core/domain/event_model.py` canonical category mapping. +2. Register the Event in `core/domain/event_model.py` canonical category mapping. 3. Add reducer handling in `core/domain/processing.py` within `process_canonical_event`. 4. Add/update semantics tests under `tests/semantics/`. diff --git a/docs/how-to/update-core-step-pipeline.md b/docs/how-to/update-core-step-pipeline.md index 1e67f00..aed51cb 100644 --- a/docs/how-to/update-core-step-pipeline.md +++ b/docs/how-to/update-core-step-pipeline.md @@ -22,3 +22,14 @@ Guardrails: - No Runtime dispatch logic in Core pipeline code. - No legacy compatibility contract restoration. - Keep deterministic behavior and public API coherence. + + +## CoreWakeupStep changes + +When updating wakeup behavior: + +1. Keep `run_core_wakeup_reduction` as reduction-only (no per-entry Strategy calls). +2. Use `CoreWakeupStrategyEvaluator` and `wakeup_strategy_evaluator=` for batch evaluation. +3. Preserve one Policy Admission and one ExecutionControl apply per wakeup in + `run_core_wakeup_decision`. +4. Add tests in `tests/semantics/test_core_wakeup_final_state.py`. diff --git a/docs/reference/public-api.md b/docs/reference/public-api.md index a1fce41..ff7a77f 100644 --- a/docs/reference/public-api.md +++ b/docs/reference/public-api.md @@ -17,7 +17,7 @@ The public package boundary is the `tradingchassis_core` root import. - `run_core_step` - `run_core_wakeup_reduction` - `run_core_wakeup_decision` -- `run_core_wakeup_step` +- `run_core_wakeup_step` (ordered batch: reduce all entries, then evaluate Strategy once) ## Step inputs/outputs @@ -28,6 +28,8 @@ The public package boundary is the `tradingchassis_core` root import. - `CoreStepDecision` - `CoreStepResult` - `CoreWakeupReductionResult` +- `CoreWakeupStrategyContext` +- `CoreWakeupStrategyEvaluator` ## Supporting deterministic models diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 1129b3d..9374f0b 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -1,4 +1,7 @@ -"""Core-only CoreStep quickstart example.""" +"""Core-only CoreStep quickstart example. + +For ordered multi-entry wakeup batches see run_core_wakeup_step in the docs. +""" from __future__ import annotations @@ -73,7 +76,7 @@ def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: # v1 shows the minimum deterministic step: Core reduces one canonical Event - # and strategy evaluation emits generated Intents. No policy/apply contexts + # and Strategy evaluation emits generated Intents. No policy/apply contexts # are provided yet, so Core returns zero dispatchable Intents by design. result = tc.run_core_step( state, diff --git a/tests/semantics/test_core_pipeline_clean.py b/tests/semantics/test_core_pipeline_clean.py index eb84a22..7daae42 100644 --- a/tests/semantics/test_core_pipeline_clean.py +++ b/tests/semantics/test_core_pipeline_clean.py @@ -79,25 +79,6 @@ def evaluate(self, context: object) -> list[tc.NewOrderIntent]: return [first, second] -class _IndexedIntentEvaluator: - def evaluate(self, context: tc.CoreStepStrategyContext) -> list[tc.NewOrderIntent]: - idx = context.position.index - return [ - tc.NewOrderIntent( - intent_type="new", - ts_ns_local=100 + idx, - instrument="BTC-USDC-PERP", - client_order_id=f"wake-{idx}", - intents_correlation_id=f"wake-corr-{idx}", - side="buy", - order_type="limit", - intended_qty=tc.Quantity(value=1.0, unit="contracts"), - intended_price=tc.Price(currency="USDC", value=100.0 + idx), - time_in_force="GTC", - ) - ] - - def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), @@ -161,8 +142,7 @@ def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: result = tc.run_core_wakeup_step( state, (_control_entry(0, 100), _control_entry(1, 101)), - strategy_evaluator=_OneIntentEvaluator(), - strategy_event_filter=lambda _event: True, + wakeup_strategy_evaluator=_OneIntentEvaluator(), policy_admission_context=tc.CorePolicyAdmissionContext( policy_evaluator=_AllowAllPolicy(), now_ts_ns_local=101, @@ -173,66 +153,11 @@ def test_run_core_wakeup_step_clean_pipeline_dispatchable() -> None: activate_dispatchable_outputs=True, ), ) - assert len(result.generated_intents) == 2 + assert len(result.generated_intents) == 1 assert len(result.candidate_intent_records) == 1 assert len(result.dispatchable_intents) == 1 -def test_run_core_wakeup_step_matches_reduction_then_decision_path() -> None: - entries = (_control_entry(0, 100), _control_entry(1, 101)) - reduction_state = tc.StrategyState(event_bus=tc.NullEventBus()) - reduction = tc.run_core_wakeup_reduction( - reduction_state, - entries, - strategy_evaluator=_IndexedIntentEvaluator(), - strategy_event_filter=lambda _event: True, - ) - decision_result = tc.run_core_wakeup_decision( - reduction_state, - reduction, - policy_admission_context=tc.CorePolicyAdmissionContext( - policy_evaluator=_AllowAllPolicy(), - now_ts_ns_local=101, - ), - execution_control_apply_context=tc.CoreExecutionControlApplyContext( - execution_control=tc.ExecutionControl(), - now_ts_ns_local=101, - activate_dispatchable_outputs=True, - ), - ) - - step_state = tc.StrategyState(event_bus=tc.NullEventBus()) - step_result = tc.run_core_wakeup_step( - step_state, - entries, - strategy_evaluator=_IndexedIntentEvaluator(), - strategy_event_filter=lambda _event: True, - policy_admission_context=tc.CorePolicyAdmissionContext( - policy_evaluator=_AllowAllPolicy(), - now_ts_ns_local=101, - ), - execution_control_apply_context=tc.CoreExecutionControlApplyContext( - execution_control=tc.ExecutionControl(), - now_ts_ns_local=101, - activate_dispatchable_outputs=True, - ), - ) - - assert tuple(intent.client_order_id for intent in decision_result.generated_intents) == ( - "wake-0", - "wake-1", - ) - assert tuple(intent.client_order_id for intent in step_result.generated_intents) == ( - "wake-0", - "wake-1", - ) - assert tuple(intent.client_order_id for intent in step_result.dispatchable_intents) == ( - "wake-0", - "wake-1", - ) - assert decision_result == step_result - - def test_candidate_reconciliation_prefers_latest_same_key_generated_intent() -> None: state = tc.StrategyState(event_bus=tc.NullEventBus()) result = tc.run_core_step( diff --git a/tests/semantics/test_core_wakeup_final_state.py b/tests/semantics/test_core_wakeup_final_state.py new file mode 100644 index 0000000..67b1dab --- /dev/null +++ b/tests/semantics/test_core_wakeup_final_state.py @@ -0,0 +1,298 @@ +"""Final-state CoreWakeupStep Strategy evaluation semantics (Phase WU2).""" + +from __future__ import annotations + +import tradingchassis_core as tc +from tradingchassis_core.core.domain.types import BookLevel, BookPayload + +INSTRUMENT = "BTC-USDC-PERP" + +_TEST_CONFIGURATION = tc.CoreConfiguration( + version="test-v1", + payload={ + "market": { + "instruments": { + INSTRUMENT: { + "tick_size": 0.01, + "lot_size": 0.001, + "contract_size": 1.0, + } + } + } + }, +) + + + +class _OneIntentEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.NewOrderIntent]: + _ = context + return [ + tc.NewOrderIntent( + intent_type="new", + ts_ns_local=10, + instrument=INSTRUMENT, + client_order_id="wake-generated", + intents_correlation_id="corr-wake", + side="buy", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=100.0), + time_in_force="GTC", + ) + ] + + +class _CountingWakeupEvaluator: + def __init__(self) -> None: + self.call_count = 0 + self.last_context: tc.CoreWakeupStrategyContext | None = None + + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + self.call_count += 1 + self.last_context = context + return [] + + +class _FinalStateAwareEvaluator: + def evaluate(self, context: tc.CoreWakeupStrategyContext) -> list[tc.OrderIntent]: + assert context.state.sim_ts_ns_local == 203 + market = context.state.market[INSTRUMENT] + assert market.best_bid == 99.0 + assert market.best_ask == 101.0 + account = context.state.account[INSTRUMENT] + assert account.position == 2.5 + assert len(context.entries) == 3 + assert isinstance(context.entries[0].event, tc.OrderExecutionFeedbackEvent) + assert isinstance(context.entries[1].event, tc.MarketEvent) + assert isinstance(context.entries[2].event, tc.ControlTimeEvent) + assert context.last_position is not None + assert context.last_position.index == 2 + return [] + + +class _AllowAllPolicy: + def evaluate_policy_intent( + self, + *, + intent: tc.OrderIntent, + state: tc.StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, str | None]: + _ = (intent, state, now_ts_ns_local) + return True, None + + +def _control_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.ControlTimeEvent( + ts_ns_local_control=ts, + reason="scheduled_control_recheck", + due_ts_ns_local=ts, + realized_ts_ns_local=ts, + obligation_reason="rate_limit", + obligation_due_ts_ns_local=ts, + runtime_correlation=None, + ), + ) + + +def _market_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.MarketEvent( + ts_ns_exch=ts - 1, + ts_ns_local=ts, + instrument=INSTRUMENT, + event_type="book", + book=BookPayload( + book_type="snapshot", + bids=[ + BookLevel( + price=tc.Price(currency="USDC", value=99.0), + quantity=tc.Quantity(value=1.0, unit="contracts"), + ) + ], + asks=[ + BookLevel( + price=tc.Price(currency="USDC", value=101.0), + quantity=tc.Quantity(value=2.0, unit="contracts"), + ) + ], + depth=1, + ), + ), + ) + + +def _execution_feedback_entry(index: int, ts: int) -> tc.EventStreamEntry: + return tc.EventStreamEntry( + position=tc.ProcessingPosition(index=index), + event=tc.OrderExecutionFeedbackEvent( + ts_ns_local_feedback=ts, + instrument=INSTRUMENT, + position=2.5, + balance=10_000.0, + fee=0.1, + trading_volume=1.0, + trading_value=100.0, + num_trades=1, + runtime_correlation=None, + ), + ) + + +def _queued_intent(client_order_id: str) -> tc.NewOrderIntent: + return tc.NewOrderIntent( + intent_type="new", + ts_ns_local=50, + instrument=INSTRUMENT, + client_order_id=client_order_id, + intents_correlation_id="queued-corr", + side="sell", + order_type="limit", + intended_qty=tc.Quantity(value=1.0, unit="contracts"), + intended_price=tc.Price(currency="USDC", value=99.5), + time_in_force="GTC", + ) + + +def test_wakeup_reduces_all_entries_before_strategy_evaluation() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + entries = ( + _execution_feedback_entry(0, 200), + _market_entry(1, 201), + _control_entry(2, 203), + ) + _ = tc.run_core_wakeup_step( + state, + entries, + configuration=_TEST_CONFIGURATION, + wakeup_strategy_evaluator=_FinalStateAwareEvaluator(), + ) + + +def test_wakeup_strategy_evaluator_called_exactly_once() -> None: + entries = (_control_entry(0, 100), _control_entry(1, 101)) + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + _ = tc.run_core_wakeup_step(state, entries, wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + assert evaluator.last_context is not None + assert evaluator.last_context.entries == entries + assert evaluator.last_context.state.sim_ts_ns_local == 101 + assert evaluator.last_context.last_position == entries[-1].position + + +def test_wakeup_generated_intents_combined_once_with_queued_intents() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + state.merge_intents_into_queue(INSTRUMENT, [_queued_intent("queued-1")]) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100),), + wakeup_strategy_evaluator=_OneIntentEvaluator(), + ) + assert tuple(intent.client_order_id for intent in result.generated_intents) == ( + "wake-generated", + ) + origins = tuple(record.origin for record in result.candidate_intent_records) + assert tc.CandidateIntentOrigin.GENERATED in origins + assert tc.CandidateIntentOrigin.QUEUED in origins + client_ids = {record.intent.client_order_id for record in result.candidate_intent_records} + assert client_ids == {"wake-generated", "queued-1"} + + +def test_wakeup_policy_and_execution_control_apply_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + result = tc.run_core_wakeup_step( + state, + (_control_entry(0, 100), _control_entry(1, 101)), + wakeup_strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + assert len(result.generated_intents) == 1 + assert len(result.dispatchable_intents) == 1 + assert result.core_step_decision is not None + policy_decision = result.core_step_decision.policy_risk_decision + assert policy_decision is not None + assert len(policy_decision.accepted_intents) == 1 + + +def test_empty_wakeup_batch_is_valid_without_evaluator() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + reduction = tc.run_core_wakeup_reduction(state, ()) + assert reduction.entries == () + assert reduction.generated_intents == () + result = tc.run_core_wakeup_decision(state, reduction) + assert result.generated_intents == () + assert result.candidate_intent_records == () + + +def test_empty_wakeup_batch_with_evaluator_runs_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + reduction = tc.run_core_wakeup_reduction(state, (), wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + assert reduction.generated_intents == () + assert evaluator.last_context is not None + assert evaluator.last_context.entries == () + assert evaluator.last_context.last_position is None + + +def test_single_entry_wakeup_evaluates_once() -> None: + state = tc.StrategyState(event_bus=tc.NullEventBus()) + evaluator = _CountingWakeupEvaluator() + _ = tc.run_core_wakeup_step(state, (_control_entry(0, 100),), wakeup_strategy_evaluator=evaluator) + assert evaluator.call_count == 1 + + +def test_run_core_wakeup_step_matches_reduction_then_decision_path() -> None: + entries = (_control_entry(0, 100), _control_entry(1, 101)) + reduction_state = tc.StrategyState(event_bus=tc.NullEventBus()) + reduction = tc.run_core_wakeup_reduction( + reduction_state, + entries, + wakeup_strategy_evaluator=_OneIntentEvaluator(), + ) + decision_result = tc.run_core_wakeup_decision( + reduction_state, + reduction, + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + step_state = tc.StrategyState(event_bus=tc.NullEventBus()) + step_result = tc.run_core_wakeup_step( + step_state, + entries, + wakeup_strategy_evaluator=_OneIntentEvaluator(), + policy_admission_context=tc.CorePolicyAdmissionContext( + policy_evaluator=_AllowAllPolicy(), + now_ts_ns_local=101, + ), + execution_control_apply_context=tc.CoreExecutionControlApplyContext( + execution_control=tc.ExecutionControl(), + now_ts_ns_local=101, + activate_dispatchable_outputs=True, + ), + ) + + assert decision_result == step_result + assert len(step_result.generated_intents) == 1 + assert len(step_result.dispatchable_intents) == 1 diff --git a/tests/semantics/test_public_api_clean.py b/tests/semantics/test_public_api_clean.py index 9c6a5f9..6eecc4b 100644 --- a/tests/semantics/test_public_api_clean.py +++ b/tests/semantics/test_public_api_clean.py @@ -15,6 +15,8 @@ def test_public_api_exposes_clean_core_symbols() -> None: "run_core_wakeup_reduction", "run_core_wakeup_decision", "run_core_wakeup_step", + "CoreWakeupStrategyContext", + "CoreWakeupStrategyEvaluator", "CoreStepResult", "CoreStepDecision", "PolicyRiskDecision", diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 13a1fda..4f5f241 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -40,6 +40,8 @@ CoreStepStrategyContext, CoreStepStrategyEvaluator, CoreWakeupReductionResult, + CoreWakeupStrategyContext, + CoreWakeupStrategyEvaluator, run_core_step, run_core_wakeup_decision, run_core_wakeup_reduction, @@ -106,6 +108,8 @@ "run_core_wakeup_step", "CoreStepStrategyContext", "CoreStepStrategyEvaluator", + "CoreWakeupStrategyContext", + "CoreWakeupStrategyEvaluator", "CoreExecutionControlApplyContext", "CorePolicyAdmissionContext", "CoreWakeupReductionResult", diff --git a/tradingchassis_core/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py index 9258c95..9f51443 100644 --- a/tradingchassis_core/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -1,4 +1,4 @@ -"""Canonical event taxonomy markers for core.""" +"""Canonical Event taxonomy markers for core.""" from __future__ import annotations diff --git a/tradingchassis_core/core/domain/processing.py b/tradingchassis_core/core/domain/processing.py index 6e2a560..d15c8a0 100644 --- a/tradingchassis_core/core/domain/processing.py +++ b/tradingchassis_core/core/domain/processing.py @@ -1,7 +1,7 @@ -"""Minimal canonical event processing boundary for core. +"""Minimal canonical Event processing boundary for core. This module introduces a narrow, docs-aligned processing boundary for current -canonical event candidates. For these candidates, ``process_canonical_event`` +canonical Event candidates. For these candidates, ``process_canonical_event`` is the preferred top-level canonical state-advance entrypoint in core. This module is intentionally small: @@ -100,7 +100,7 @@ def process_canonical_event( position: ProcessingPosition | None = None, configuration: CoreConfiguration | None = None, ) -> None: - """Process a canonical event candidate via existing state reducers. + """Process a canonical Event candidate via existing state reducers. Preferred usage for the current slice: - use this function as the top-level canonical ingestion boundary for @@ -123,7 +123,7 @@ def process_canonical_event( """ record_type = type(event) if not is_canonical_stream_candidate_type(record_type): - raise TypeError(f"Unsupported non-canonical event type: {record_type.__name__}") + raise TypeError(f"Unsupported non-canonical Event type: {record_type.__name__}") category = canonical_category_for_type(record_type) @@ -206,7 +206,7 @@ def process_canonical_event( return raise TypeError( - "Unsupported canonical event candidate for this processing boundary: " + "Unsupported canonical Event candidate for this processing boundary: " f"{record_type.__name__}" ) diff --git a/tradingchassis_core/core/domain/processing_order.py b/tradingchassis_core/core/domain/processing_order.py index dad7cc1..8872b8b 100644 --- a/tradingchassis_core/core/domain/processing_order.py +++ b/tradingchassis_core/core/domain/processing_order.py @@ -22,7 +22,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class EventStreamEntry: - """Minimal envelope for canonical event processing-order input. + """Minimal envelope for canonical Event processing-order input. This value object intentionally carries only: - the causal processing-order position; and diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index 908bf45..d2fad12 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Callable, Protocol, Sequence +from typing import TYPE_CHECKING, Protocol, Sequence from tradingchassis_core.core.domain.configuration import CoreConfiguration from tradingchassis_core.core.domain.execution_control_apply import ( @@ -38,7 +38,7 @@ @dataclass(frozen=True, slots=True) class CoreStepStrategyContext: - """Deterministic strategy-evaluation context for one Core step.""" + """Deterministic Strategy-evaluation context for one Core step.""" state: StrategyState event: object @@ -47,10 +47,27 @@ class CoreStepStrategyContext: class CoreStepStrategyEvaluator(Protocol): - """Core-owned strategy evaluation protocol for unified step semantics.""" + """Core-owned Strategy evaluation protocol for unified step semantics.""" def evaluate(self, context: CoreStepStrategyContext) -> Sequence[OrderIntent]: - """Evaluate strategy once for the provided step context.""" + """Evaluate Strategy once for the provided step context.""" + + +@dataclass(frozen=True, slots=True) +class CoreWakeupStrategyContext: + """Deterministic Strategy-evaluation context for one Core wakeup batch.""" + + state: StrategyState + entries: tuple[EventStreamEntry, ...] + configuration: CoreConfiguration | None = None + last_position: ProcessingPosition | None = None + + +class CoreWakeupStrategyEvaluator(Protocol): + """Core-owned Strategy evaluation protocol for one wakeup batch.""" + + def evaluate(self, context: CoreWakeupStrategyContext) -> Sequence[OrderIntent]: + """Evaluate Strategy once after all wakeup entries are reduced.""" @dataclass(frozen=True, slots=True) @@ -216,28 +233,27 @@ def run_core_wakeup_reduction( entries: Sequence[EventStreamEntry], *, configuration: CoreConfiguration | None = None, - strategy_evaluator: CoreStepStrategyEvaluator | None = None, - strategy_event_filter: Callable[[object], bool] | None = None, + wakeup_strategy_evaluator: CoreWakeupStrategyEvaluator | None = None, ) -> CoreWakeupReductionResult: - """Reduce multiple canonical entries and collect wakeup-level generated intents.""" + """Reduce multiple canonical entries in order for one runtime wakeup.""" entries_tuple = tuple(entries) - generated_intents: list[OrderIntent] = [] for entry in entries_tuple: process_event_entry(state, entry, configuration=configuration) - if strategy_evaluator is None: - continue - if strategy_event_filter is None or not strategy_event_filter(entry.event): - continue - strategy_context = CoreStepStrategyContext( + + generated_intents: tuple[OrderIntent, ...] = () + if wakeup_strategy_evaluator is not None: + last_position = entries_tuple[-1].position if entries_tuple else None + wakeup_context = CoreWakeupStrategyContext( state=state, - event=entry.event, - position=entry.position, + entries=entries_tuple, configuration=configuration, + last_position=last_position, ) - generated_intents.extend(strategy_evaluator.evaluate(strategy_context)) + generated_intents = tuple(wakeup_strategy_evaluator.evaluate(wakeup_context)) + return CoreWakeupReductionResult( entries=entries_tuple, - generated_intents=tuple(generated_intents), + generated_intents=generated_intents, ) @@ -329,8 +345,7 @@ def run_core_wakeup_step( entries: Sequence[EventStreamEntry], *, configuration: CoreConfiguration | None = None, - strategy_evaluator: CoreStepStrategyEvaluator | None = None, - strategy_event_filter: Callable[[object], bool] | None = None, + wakeup_strategy_evaluator: CoreWakeupStrategyEvaluator | None = None, queued_instrument: str | None = None, policy_admission_context: CorePolicyAdmissionContext | None = None, execution_control_apply_context: CoreExecutionControlApplyContext | None = None, @@ -341,8 +356,7 @@ def run_core_wakeup_step( state, entries, configuration=configuration, - strategy_evaluator=strategy_evaluator, - strategy_event_filter=strategy_event_filter, + wakeup_strategy_evaluator=wakeup_strategy_evaluator, ) return run_core_wakeup_decision( state, diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index ea061eb..68b2222 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1,4 +1,4 @@ -"""Deterministic Core strategy state. +"""Deterministic Core Strategy state. This state container keeps canonical reducer-owned data and execution-control supporting structures (queue + inflight tracking). Runtime snapshot parsing and @@ -105,7 +105,7 @@ class CanonicalOrderProjection: class StrategyState: - """High-level deterministic strategy state keyed by instrument.""" + """High-level deterministic Strategy state keyed by instrument.""" def __init__(self, event_bus: EventBus) -> None: self._event_bus = event_bus @@ -213,7 +213,7 @@ def apply_order_submitted_event(self, event: OrderSubmittedEvent) -> None: self._clear_inflight(event.instrument, event.client_order_id) def apply_control_time_event(self, event: ControlTimeEvent) -> None: - """Reduce canonical control-time event without side effects.""" + """Reduce canonical control-time Event without side effects.""" self.update_timestamp(event.ts_ns_local_control) def _clear_inflight(self, instrument: str, client_order_id: str) -> None: diff --git a/tradingchassis_core/core/events/event_bus.py b/tradingchassis_core/core/events/event_bus.py index 5cf493d..59eb396 100644 --- a/tradingchassis_core/core/events/event_bus.py +++ b/tradingchassis_core/core/events/event_bus.py @@ -25,7 +25,7 @@ def register(self, sink: EventSink) -> None: self._sinks.append(sink) def emit(self, event: Any) -> None: - """Emit an event to all sinks.""" + """Emit an Event to all sinks.""" for sink in self._sinks: sink.on_event(event) diff --git a/tradingchassis_core/core/events/event_sink.py b/tradingchassis_core/core/events/event_sink.py index 49a8b3d..3040040 100644 --- a/tradingchassis_core/core/events/event_sink.py +++ b/tradingchassis_core/core/events/event_sink.py @@ -10,4 +10,4 @@ class EventSink(Protocol): def on_event(self, event: Any) -> None: - """Consume a domain event.""" + """Consume a domain Event.""" diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index 18e4727..b1a03d8 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -100,7 +100,7 @@ def _constraints_extra(extra: object) -> dict[str, str | float | bool | None]: return normalized def build_constraints(self, current_timestamp_ns_local: int) -> RiskConstraints: - """Build RiskConstraints handed to strategy evaluation.""" + """Build RiskConstraints handed to Strategy evaluation.""" extra = self._constraints_extra(self.risk_cfg.extra) return RiskConstraints( ts_ns_local=current_timestamp_ns_local, From c67047c93e899a0dad342ea14e3273a7c078d2e7 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 15 May 2026 11:30:17 +0000 Subject: [PATCH 52/53] chore(.github): normalize template filenames --- .github/ISSUE_TEMPLATE/{bug_report.md => bug-report.md} | 0 .github/ISSUE_TEMPLATE/{feature_request.md => feature-request.md} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename .github/ISSUE_TEMPLATE/{bug_report.md => bug-report.md} (100%) rename .github/ISSUE_TEMPLATE/{feature_request.md => feature-request.md} (100%) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug-report.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature-request.md similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature-request.md From f0cb7fa6838fc3fefa890f26ab8849128814d12e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 15 May 2026 12:06:18 +0000 Subject: [PATCH 53/53] docs: improve language and use consistent terminology --- README.md | 4 ++-- docs/flows/control-time-and-scheduling.md | 8 ++++---- docs/{README.md => index.md} | 0 examples/core_step_quickstart.py | 6 +++--- tests/semantics/test_control_time_scheduling_semantics.py | 2 +- .../core/domain/execution_control_apply.py | 8 ++++---- .../core/domain/execution_control_decision.py | 4 ++-- tradingchassis_core/core/domain/execution_control_plan.py | 8 ++++---- tradingchassis_core/core/domain/processing_step.py | 4 ++-- tradingchassis_core/core/domain/state.py | 6 +++--- tradingchassis_core/core/domain/step_result.py | 2 +- tradingchassis_core/core/execution_control/__init__.py | 2 +- .../core/execution_control/execution_control.py | 8 ++++---- tradingchassis_core/core/risk/risk_engine.py | 2 +- tradingchassis_core/core/risk/risk_policy.py | 2 +- 15 files changed, 33 insertions(+), 33 deletions(-) rename docs/{README.md => index.md} (100%) diff --git a/README.md b/README.md index 49f4667..3428744 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,10 @@ Event Stream and Configuration match. ## Control time and scheduling (Core) `ControlSchedulingObligation` is a **non-canonical**, time-dependent hint produced -when execution-control **apply** defers for **rate limits**. **Inflight** gating is +when Execution Control **apply** defers for **rate limits**. **Inflight** gating is **feedback-dependent** and does not, by default, produce this obligation; queued work is reconsidered after canonical execution Events update state. Runtimes must -not flush Core queues outside the normal `run_core_step` / execution-control apply +not flush Core queues outside the normal `run_core_step` / Execution Control apply path. See [`docs/flows/control-time-and-scheduling.md`](docs/flows/control-time-and-scheduling.md). | Runtime-independent package | Test trading semantics without production I/O; explicit ownership boundary | | Shared kernel across environments | Serious Backtesting and Live parity for the decision engine—no secondary copy of Strategy/Risk Engine/Execution Control code elsewhere | diff --git a/docs/flows/control-time-and-scheduling.md b/docs/flows/control-time-and-scheduling.md index 3f1b28f..1257ce3 100644 --- a/docs/flows/control-time-and-scheduling.md +++ b/docs/flows/control-time-and-scheduling.md @@ -2,7 +2,7 @@ This note is the **Core package** source of truth for how non-canonical `ControlSchedulingObligation` relates to canonical `ControlTimeEvent` input and -to execution-control deferral. +to Execution Control deferral. ## Terms @@ -27,7 +27,7 @@ to execution-control deferral. | Deferral kind | Time-dependent? | `ControlSchedulingObligation` by default? | Expected resolution | | --- | --- | --- | --- | -| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → Strategy → … → execution-control apply. | +| Rate limit | Yes | **Yes** (reason such as `rate_limit`) | Runtime may realize the obligation and inject `ControlTimeEvent`; the next `run_core_step` re-runs reduction → Strategy → … → Execution Control apply. | | Inflight | No (feedback-dependent) | **No** | Later canonical **execution / lifecycle** Events (for example `OrderSubmittedEvent`, `OrderExecutionFeedbackEvent`, or `FillEvent`, depending on lifecycle) update `StrategyState` so a subsequent step can reconsider queued work. | **Not in scope for the current contract:** inflight timeout, wall-clock recovery, @@ -45,7 +45,7 @@ Core slice. 4. generated intents 5. candidate records + dominance / reconciliation 6. policy admission -7. execution-control plan / **apply** +7. Execution Control plan / apply 8. `CoreStepResult.dispatchable_intents` and optional `control_scheduling_obligation` 9. Runtime performs venue dispatch and **injects** further canonical Events (including any `ControlTimeEvent` realized from an obligation). @@ -56,7 +56,7 @@ they are selected only in the mutable **apply** stage (`apply_execution_control_ ## Runtime ownership - Runtimes **must not** mutate Core queues (`StrategyState.queued_intents`, etc.) - directly outside the normal Core step / execution-control apply path. + directly outside the normal Core step / Execution Control apply path. - Queue flush / sendability decisions remain **ExecutionControl-owned** inside Core when `CoreExecutionControlApplyContext` is supplied to `run_core_step` / wakeup APIs. diff --git a/docs/README.md b/docs/index.md similarity index 100% rename from docs/README.md rename to docs/index.md diff --git a/examples/core_step_quickstart.py b/examples/core_step_quickstart.py index 9374f0b..c7187de 100644 --- a/examples/core_step_quickstart.py +++ b/examples/core_step_quickstart.py @@ -1,6 +1,6 @@ """Core-only CoreStep quickstart example. -For ordered multi-entry wakeup batches see run_core_wakeup_step in the docs. +For ordered multi-entry wakeup batches see run_core_wakeup_step in the README. """ from __future__ import annotations @@ -59,7 +59,7 @@ def _control_time_entry(*, index: int, ts_ns_local: int) -> tc.EventStreamEntry: # EventStreamEntry is the ordered Core input unit: a canonical Event plus # ProcessingPosition telling Core where this Event sits in the Event Stream. # ControlTimeEvent here is only a driver Event; scheduling obligations come - # from execution-control apply (e.g. rate-limit deferral), not from every step. + # from Execution Control apply (e.g. rate-limit deferral), not from every step. return tc.EventStreamEntry( position=tc.ProcessingPosition(index=index), event=tc.ControlTimeEvent( @@ -92,7 +92,7 @@ def run_v1_generated_only(state: tc.StrategyState) -> tc.CoreStepResult: def run_v2_with_policy_and_apply(state: tc.StrategyState) -> tc.CoreStepResult: - # v2 adds policy admission and execution-control apply. With dispatchable + # v2 adds policy admission and Execution Control apply. With dispatchable # outputs activated, Core exposes Intents that Runtime can dispatch. result = tc.run_core_step( state, diff --git a/tests/semantics/test_control_time_scheduling_semantics.py b/tests/semantics/test_control_time_scheduling_semantics.py index fa134f7..8b19f95 100644 --- a/tests/semantics/test_control_time_scheduling_semantics.py +++ b/tests/semantics/test_control_time_scheduling_semantics.py @@ -171,7 +171,7 @@ def test_inflight_deferral_does_not_emit_control_scheduling_obligation() -> None def test_inflight_queued_replace_reprocessed_after_order_submitted_feedback() -> None: - """Canonical OrderSubmittedEvent clears inflight so a later step can dispatch queue.""" + """Canonical OrderSubmittedEvent clears inflight so a later step can dispatch Queue.""" now_ts = 100 state = tc.StrategyState(event_bus=tc.NullEventBus()) tc.process_event_entry(state, _order_submitted_entry(0, now_ts)) diff --git a/tradingchassis_core/core/domain/execution_control_apply.py b/tradingchassis_core/core/domain/execution_control_apply.py index 59255e8..ff614b0 100644 --- a/tradingchassis_core/core/domain/execution_control_apply.py +++ b/tradingchassis_core/core/domain/execution_control_apply.py @@ -1,4 +1,4 @@ -"""Mutable execution-control apply stage over a pure execution-control plan.""" +"""Mutable Execution Control apply stage over a pure Execution Control plan.""" from __future__ import annotations @@ -57,7 +57,7 @@ class ExecutionControlHandledRecord: @dataclass(frozen=True, slots=True) class ExecutionControlApplyResult: - """Result of mutable execution-control apply over one plan state.""" + """Result of mutable Execution Control apply over one plan state.""" queued_effective_records: tuple[CandidateIntentRecord, ...] = () dispatchable_records: tuple[ExecutionControlDispatchableRecord, ...] = () @@ -130,9 +130,9 @@ def apply_execution_control_plan( plan: ExecutionControlPlan, context: ExecutionControlApplyContext, ) -> ExecutionControlApplyResult: - """Apply mutable execution-control semantics over planned active records. + """Apply mutable Execution Control semantics over planned active records. - This function mutates only StrategyState queue data and ExecutionControl + This function mutates only StrategyState Queue data and ExecutionControl rate state. It does not perform venue dispatch and does not emit canonical events. diff --git a/tradingchassis_core/core/domain/execution_control_decision.py b/tradingchassis_core/core/domain/execution_control_decision.py index 13c9b33..e40ff8e 100644 --- a/tradingchassis_core/core/domain/execution_control_decision.py +++ b/tradingchassis_core/core/domain/execution_control_decision.py @@ -1,4 +1,4 @@ -"""Core-owned execution-control decision model.""" +"""Core-owned Execution Control decision model.""" from __future__ import annotations @@ -10,7 +10,7 @@ @dataclass(frozen=True, slots=True) class ExecutionControlDecision: - """Immutable non-canonical execution-control outcome.""" + """Immutable non-canonical Execution Control outcome.""" queued_effective_intents: tuple[OrderIntent, ...] = () dispatchable_intents: tuple[OrderIntent, ...] = () diff --git a/tradingchassis_core/core/domain/execution_control_plan.py b/tradingchassis_core/core/domain/execution_control_plan.py index 40c1468..4b000d4 100644 --- a/tradingchassis_core/core/domain/execution_control_plan.py +++ b/tradingchassis_core/core/domain/execution_control_plan.py @@ -1,4 +1,4 @@ -"""Pure, non-canonical execution-control candidate planning scaffolds.""" +"""Pure, non-canonical Execution Control candidate planning scaffolds.""" from __future__ import annotations @@ -12,7 +12,7 @@ @dataclass(frozen=True, slots=True) class ExecutionControlCandidateInput: - """Policy-admitted candidate records for capture-only execution-control planning.""" + """Policy-admitted candidate records for capture-only Execution Control planning.""" accepted_generated: tuple[CandidateIntentRecord, ...] = () passthrough_queued: tuple[CandidateIntentRecord, ...] = () @@ -34,7 +34,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True, slots=True) class ExecutionControlPlan: - """Capture-only execution-control candidate planning result.""" + """Capture-only Execution Control candidate planning result.""" active_records: tuple[CandidateIntentRecord, ...] = () queued_effective_records: tuple[CandidateIntentRecord, ...] = () @@ -70,7 +70,7 @@ def __post_init__(self) -> None: def plan_execution_control_candidates( planning_input: ExecutionControlCandidateInput, ) -> ExecutionControlPlan: - """Build a deterministic, side-effect-free execution-control plan projection.""" + """Build a deterministic, side-effect-free Execution Control plan projection.""" active_records = ( tuple(planning_input.accepted_generated) diff --git a/tradingchassis_core/core/domain/processing_step.py b/tradingchassis_core/core/domain/processing_step.py index d2fad12..711c957 100644 --- a/tradingchassis_core/core/domain/processing_step.py +++ b/tradingchassis_core/core/domain/processing_step.py @@ -80,7 +80,7 @@ class CorePolicyAdmissionContext: @dataclass(frozen=True, slots=True) class CoreExecutionControlApplyContext: - """Optional mutable execution-control apply context for one Core step.""" + """Optional mutable Execution Control apply context for one Core step.""" execution_control: ExecutionControl now_ts_ns_local: int @@ -265,7 +265,7 @@ def run_core_wakeup_decision( policy_admission_context: CorePolicyAdmissionContext | None = None, execution_control_apply_context: CoreExecutionControlApplyContext | None = None, ) -> CoreStepResult: - """Run one wakeup-level candidate/policy/execution-control decision phase.""" + """Run one wakeup-level candidate/policy/Execution Control decision phase.""" if execution_control_apply_context is not None and policy_admission_context is None: raise ValueError( diff --git a/tradingchassis_core/core/domain/state.py b/tradingchassis_core/core/domain/state.py index 68b2222..6aab540 100644 --- a/tradingchassis_core/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -1,7 +1,7 @@ """Deterministic Core Strategy state. -This state container keeps canonical reducer-owned data and execution-control -supporting structures (queue + inflight tracking). Runtime snapshot parsing and +This state container keeps canonical reducer-owned data and Execution Control +supporting structures (Queue + inflight tracking). Runtime snapshot parsing and venue lifecycle adaptation are intentionally out of scope for Core. """ @@ -27,7 +27,7 @@ @dataclass(slots=True) class QueuedIntent: - """An intent stored for later sending (data-only queue).""" + """An intent stored for later sending (data-only Queue).""" intent: OrderIntent queued_at_ts_ns: int diff --git a/tradingchassis_core/core/domain/step_result.py b/tradingchassis_core/core/domain/step_result.py index 853b488..1d1e1d4 100644 --- a/tradingchassis_core/core/domain/step_result.py +++ b/tradingchassis_core/core/domain/step_result.py @@ -14,7 +14,7 @@ class CoreStepResult: """Immutable result object for deterministic Core step APIs. - ``control_scheduling_obligation`` is set only when execution-control apply + ``control_scheduling_obligation`` is set only when Execution Control apply defers for **rate limits** (time-dependent). It is ``None`` for inflight-only deferral and other cases without a Core-derived wake time. Only injected ``ControlTimeEvent`` values are canonical stream input for control time. diff --git a/tradingchassis_core/core/execution_control/__init__.py b/tradingchassis_core/core/execution_control/__init__.py index 1b8338f..7f123df 100644 --- a/tradingchassis_core/core/execution_control/__init__.py +++ b/tradingchassis_core/core/execution_control/__init__.py @@ -1,6 +1,6 @@ """Execution control (internal). -This package hosts internal components that govern queue admission, inflight +This package hosts internal components that govern Queue admission, inflight gating, and rate limiting. Policy admission stays in the Risk Engine / domain layer. diff --git a/tradingchassis_core/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py index 645f68f..b7de265 100644 --- a/tradingchassis_core/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -3,10 +3,10 @@ Owns: - token bucket rate limiting state & math (time-dependent deferral may surface a ``ControlSchedulingObligation`` from the apply stage; see docs on control time) -- inflight gating that routes NEW/REPLACE to queue (feedback-dependent; no +- inflight gating that routes NEW/REPLACE to Queue (feedback-dependent; no scheduling obligation by default) -- queue admission via StrategyState.merge_intents_into_queue(...) -- queue-only local handling for certain CANCEL/REPLACE cases +- Queue admission via StrategyState.merge_intents_into_queue(...) +- Queue-only local handling for certain CANCEL/REPLACE cases """ from __future__ import annotations @@ -270,7 +270,7 @@ def handle_replace_against_queued_new( queued: list[OrderIntent], handled_in_queue: list[OrderIntent], ) -> None: - """REPLACE acting on queued NEW: transform into updated NEW in the queue.""" + """REPLACE acting on queued NEW: transform into updated NEW in the Queue.""" removed = state.pop_queued_intents_for_order(it.instrument, it.client_order_id) for qi in removed: replaced_in_queue.append((qi.intent, it)) diff --git a/tradingchassis_core/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py index b1a03d8..7d7f7da 100644 --- a/tradingchassis_core/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -20,7 +20,7 @@ class RiskEngine: """Policy-only evaluator. This component is intentionally side-effect-free for the CoreStep policy phase: - it does not mutate queue/rate/inflight state and does not perform execution-control. + it does not mutate Queue/rate/inflight state and does not perform Execution Control. """ def __init__(self, risk_cfg: RiskConfig) -> None: diff --git a/tradingchassis_core/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py index 13efe92..0356d6e 100644 --- a/tradingchassis_core/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -2,7 +2,7 @@ This module is intentionally internal and behavior-preserving: - It contains only policy checks (validation, kill-switches, hard limits). -- It does not perform queue admission, rate limiting, or inflight gating. +- It does not perform Queue admission, rate limiting, or inflight gating. """ from __future__ import annotations