From e35e13d437fb1fbfecb5f26c31ba6fdd804c8adb Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 14:23:16 +0200 Subject: [PATCH 01/61] fix --- .devcontainer/devcontainer.json | 21 ++++++++++----------- README.md | 11 +++++------ examples/local/local.json | 10 +++++----- 3 files changed, 20 insertions(+), 22 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c2abb25..cd762cd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,18 +1,17 @@ { - "name": "Trading Framework Dev", + "name": "TradingChassis Core Dev", "build": { "dockerfile": "Dockerfile", "context": ".." }, - "postCreateCommand": "pip install -e .[dev]", + "workspaceFolder": "/workspaces/core", + "remoteUser": "root", + "runArgs": ["--security-opt=label=disable"], + "containerEnv": { + "SHELL": "/bin/bash" + }, + + "postCreateCommand": "pip install -e .[dev]" - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.debugpy" - ] - } - } -} \ No newline at end of file +} diff --git a/README.md b/README.md index ebcabd9..2dc5bd5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Trading Framework +# TradingChassis – Core ![CI](https://github.com/trading-engineering/trading-framework/actions/workflows/tests.yaml/badge.svg) ![Python](https://img.shields.io/badge/python-3.11+-blue) @@ -41,7 +41,7 @@ Backtesting setups tend to: - Lack deterministic event modeling - Do not scale to research workflows -This framework solves those problems by introducing: +This Core solves those problems by introducing: - Clear domain boundaries - Explicit state transitions @@ -91,7 +91,7 @@ Minimal local example: `examples/local/backtest.py`\ Runs entirely locally using bundled or synthetic example data.\ No S3, cloud storage or live connectivity required. -### Option 1 --- Recommended: Dev Container +### Option 1 – Recommended: Dev Container A reproducible development environment is provided via a dev container. @@ -100,8 +100,7 @@ git clone https://github.com/trading-engineering/trading-framework cd trading-framework ``` -Open in an IDE supporting Dev Containers (e.g. VS Code), reopen in -container, then: +Open in an IDE supporting Dev Containers, reopen in container, then: ```bash python examples/local/backtest.py --config examples/local/local.json @@ -109,7 +108,7 @@ python examples/local/backtest.py --config examples/local/local.json No manual `pip install` required inside the container. -### Option 2 --- Local Python Environment +### Option 2 – Local Python Environment Python 3.11.x is required. diff --git a/examples/local/local.json b/examples/local/local.json index 71ff72b..0b7c4c2 100644 --- a/examples/local/local.json +++ b/examples/local/local.json @@ -5,9 +5,9 @@ "engine": { "initial_snapshot": null, "data_files": [ - "/workspaces/trading-framework/tests/data/parts/part-000.npz", - "/workspaces/trading-framework/tests/data/parts/part-001.npz", - "/workspaces/trading-framework/tests/data/parts/part-002.npz" + "/workspaces/core/tests/data/parts/part-000.npz", + "/workspaces/core/tests/data/parts/part-001.npz", + "/workspaces/core/tests/data/parts/part-002.npz" ], "instrument": "BTC_USDC-PERPETUAL", @@ -32,8 +32,8 @@ "roi_lb": 40000, "roi_ub": 80000, - "stats_npz_path": "/workspaces/trading-framework/tests/data/results/stats.npz", - "event_bus_path": "/workspaces/trading-framework/tests/data/results/events.json" + "stats_npz_path": "/workspaces/core/tests/data/results/stats.npz", + "event_bus_path": "/workspaces/core/tests/data/results/events.json" }, "risk": { From 29c87b529fbdd0e6d523446ef213c040e9952f35 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 14:47:23 +0200 Subject: [PATCH 02/61] m1-slice1-backtest-move --- examples/local/backtest.py | 8 +- trading_framework/__init__.py | 17 +- .../backtest/adapters/__init__.py | 0 .../backtest/adapters/execution.py | 164 ------ trading_framework/backtest/adapters/venue.py | 49 -- trading_framework/backtest/engine/__init__.py | 0 .../backtest/engine/engine_base.py | 41 -- .../backtest/engine/hft_engine.py | 182 ------- .../backtest/engine/strategy_runner.py | 331 ------------ trading_framework/backtest/io/__init__.py | 0 trading_framework/backtest/io/s3_adapter.py | 290 ---------- .../backtest/orchestrator/__init__.py | 0 .../backtest/orchestrator/manifest.py | 46 -- .../backtest/orchestrator/planner.py | 151 ------ .../backtest/orchestrator/planner_models.py | 38 -- .../backtest/orchestrator/s3_manifest.py | 128 ----- .../backtest/orchestrator/segmenter.py | 44 -- .../backtest/orchestrator/summary.py | 141 ----- .../backtest/orchestrator/sweeps.py | 83 --- .../backtest/runtime/__init__.py | 0 trading_framework/backtest/runtime/context.py | 105 ---- .../backtest/runtime/entrypoint.py | 268 ---------- .../runtime/experiment_finalize_entrypoint.py | 197 ------- .../backtest/runtime/mlflow_segment_logger.py | 64 --- .../backtest/runtime/prometheus_metrics.py | 95 ---- .../backtest/runtime/run_sweep.py | 496 ------------------ .../runtime/segment_finalize_entrypoint.py | 194 ------- .../core/events/sinks/file_recorder.py | 30 -- 28 files changed, 9 insertions(+), 3153 deletions(-) delete mode 100644 trading_framework/backtest/adapters/__init__.py delete mode 100644 trading_framework/backtest/adapters/execution.py delete mode 100644 trading_framework/backtest/adapters/venue.py delete mode 100644 trading_framework/backtest/engine/__init__.py delete mode 100644 trading_framework/backtest/engine/engine_base.py delete mode 100644 trading_framework/backtest/engine/hft_engine.py delete mode 100644 trading_framework/backtest/engine/strategy_runner.py delete mode 100644 trading_framework/backtest/io/__init__.py delete mode 100644 trading_framework/backtest/io/s3_adapter.py delete mode 100644 trading_framework/backtest/orchestrator/__init__.py delete mode 100644 trading_framework/backtest/orchestrator/manifest.py delete mode 100644 trading_framework/backtest/orchestrator/planner.py delete mode 100644 trading_framework/backtest/orchestrator/planner_models.py delete mode 100644 trading_framework/backtest/orchestrator/s3_manifest.py delete mode 100644 trading_framework/backtest/orchestrator/segmenter.py delete mode 100644 trading_framework/backtest/orchestrator/summary.py delete mode 100644 trading_framework/backtest/orchestrator/sweeps.py delete mode 100644 trading_framework/backtest/runtime/__init__.py delete mode 100644 trading_framework/backtest/runtime/context.py delete mode 100644 trading_framework/backtest/runtime/entrypoint.py delete mode 100644 trading_framework/backtest/runtime/experiment_finalize_entrypoint.py delete mode 100644 trading_framework/backtest/runtime/mlflow_segment_logger.py delete mode 100644 trading_framework/backtest/runtime/prometheus_metrics.py delete mode 100644 trading_framework/backtest/runtime/run_sweep.py delete mode 100644 trading_framework/backtest/runtime/segment_finalize_entrypoint.py delete mode 100644 trading_framework/core/events/sinks/file_recorder.py diff --git a/examples/local/backtest.py b/examples/local/backtest.py index 0f9bf26..cd723fe 100644 --- a/examples/local/backtest.py +++ b/examples/local/backtest.py @@ -14,14 +14,14 @@ sys.path.insert(0, str(PROJECT_ROOT)) if TYPE_CHECKING: - from trading_framework import BacktestResult + from trading_runtime.backtest.engine.engine_base import BacktestResult -from trading_framework import ( +from trading_framework.core.risk.risk_config import RiskConfig +from trading_framework.strategies.strategy_config import StrategyConfig +from trading_runtime.backtest.engine.hft_engine import ( HftBacktestConfig, HftBacktestEngine, HftEngineConfig, - RiskConfig, - StrategyConfig, ) diff --git a/trading_framework/__init__.py b/trading_framework/__init__.py index d3975c7..db1be39 100644 --- a/trading_framework/__init__.py +++ b/trading_framework/__init__.py @@ -11,12 +11,11 @@ # ---------------------------------------------------------------------- # Backtest Engine API # ---------------------------------------------------------------------- -from trading_framework.backtest.engine.engine_base import BacktestResult -from trading_framework.backtest.engine.hft_engine import ( - HftBacktestConfig, - HftBacktestEngine, - HftEngineConfig, -) +# +# Backtest engine/runtime code is runtime-owned and has moved to the +# `trading-runtime` repository (import from `trading_runtime.backtest.*`). +# +# This semantic-core package must remain importable without the runtime layer. from trading_framework.core.domain.slots import ( SlotKey, stable_slot_order_id, @@ -54,12 +53,6 @@ # ---------------------------------------------------------------------- __all__ = [ - # Engine - "HftBacktestEngine", - "HftBacktestConfig", - "HftEngineConfig", - "BacktestResult", - # Config "RiskConfig", "StrategyConfig", diff --git a/trading_framework/backtest/adapters/__init__.py b/trading_framework/backtest/adapters/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/adapters/execution.py b/trading_framework/backtest/adapters/execution.py deleted file mode 100644 index 206f5e7..0000000 --- a/trading_framework/backtest/adapters/execution.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Execution adapter for hftbacktest backtests.""" - -from __future__ import annotations - -import hashlib -from dataclasses import dataclass -from typing import TYPE_CHECKING, Protocol - -if TYPE_CHECKING: - from hftbacktest import ROIVectorMarketDepthBacktest - - from trading_framework.core.domain.types import OrderIntent - -from trading_framework.core.domain.reject_reasons import RejectReason - - -class ExecutionAdapter(Protocol): - """Venue-facing execution boundary. - - Strategy, state, and risk layers must not depend on venue-specific - APIs. Only this adapter is allowed to call into the venue engine. - """ - - def apply_intents( - self, intents: list[OrderIntent] - ) -> list[tuple[OrderIntent, str]]: - """Send a batch of intents to the venue. - - Returns: - List of (intent, reason) pairs for venue-side failures. - """ - - -def _to_i64_order_id(external_id: str) -> int: - """Convert an external string order ID into a signed 64-bit integer.""" - sanitized = external_id.strip() - if sanitized.isdigit(): - value = int(sanitized) - else: - digest = hashlib.blake2b( - sanitized.encode("utf-8"), digest_size=8 - ).digest() - value = int.from_bytes(digest, byteorder="big", signed=False) - return value & ((1 << 63) - 1) - - -@dataclass(frozen=True) -class HftBacktestExecutionAdapter(ExecutionAdapter): - """Execution adapter for hftbacktest.""" - - hbt: ROIVectorMarketDepthBacktest - asset_no: int - - def apply_intents( - self, intents: list[OrderIntent] - ) -> list[tuple[OrderIntent, str]]: - """Apply a batch of order intents to the backtest venue.""" - # pylint: disable=too-many-locals,too-many-branches - - # hftbacktest enums (kept local to the adapter) - gtc = 0 - gtx = 1 # post-only - fok = 2 - ioc = 3 - - limit = 0 - market = 1 - - tif_map = { - "GTC": gtc, - "IOC": ioc, - "FOK": fok, - "POST_ONLY": gtx, - } - order_type_map = {"limit": limit, "market": market} - - execution_errors: list[tuple[OrderIntent, str]] = [] - - for intent in intents: - if intent.intent_type == "new": - order_id = _to_i64_order_id(intent.client_order_id) - tif = tif_map[intent.time_in_force] - order_type = order_type_map[intent.order_type] - quantity = intent.intended_qty.value - price = ( - intent.intended_price.value - if intent.intended_price is not None - else 0.0 - ) - - try: - if intent.side == "buy": - result_code = self.hbt.submit_buy_order( - self.asset_no, - order_id, - price, - quantity, - tif, - order_type, - False, - ) - else: - result_code = self.hbt.submit_sell_order( - self.asset_no, - order_id, - price, - quantity, - tif, - order_type, - False, - ) - except Exception: # pylint: disable=broad-exception-caught - execution_errors.append( - (intent, RejectReason.EXCHANGE_ERROR) - ) - continue - - if result_code != 0: - execution_errors.append( - (intent, RejectReason.EXCHANGE_REJECT) - ) - - elif intent.intent_type == "cancel": - order_id = _to_i64_order_id(intent.client_order_id) - try: - result_code = self.hbt.cancel( - self.asset_no, order_id, False - ) - except Exception: # pylint: disable=broad-exception-caught - execution_errors.append( - (intent, RejectReason.EXCHANGE_ERROR) - ) - continue - - if result_code != 0: - execution_errors.append( - (intent, RejectReason.EXCHANGE_REJECT) - ) - - elif intent.intent_type == "replace": - order_id = _to_i64_order_id(intent.client_order_id) - new_price = intent.intended_price.value - new_quantity = intent.intended_qty.value - - try: - result_code = self.hbt.modify( - self.asset_no, - order_id, - new_price, - new_quantity, - False, - ) - except Exception: # pylint: disable=broad-exception-caught - execution_errors.append( - (intent, RejectReason.EXCHANGE_ERROR) - ) - continue - - if result_code != 0: - execution_errors.append( - (intent, RejectReason.EXCHANGE_REJECT) - ) - - return execution_errors diff --git a/trading_framework/backtest/adapters/venue.py b/trading_framework/backtest/adapters/venue.py deleted file mode 100644 index 09c524d..0000000 --- a/trading_framework/backtest/adapters/venue.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Venue adapter implementation for hftbacktest backtests.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from hftbacktest import ROIVectorMarketDepthBacktest - -from trading_framework.core.ports.venue_adapter import VenueAdapter - - -@dataclass(frozen=True) -class HftBacktestVenueAdapter(VenueAdapter): - """VenueAdapter implementation for hftbacktest. - - This adapter is the only place where the strategy loop is allowed to depend - on hftbacktest APIs. - """ - - hbt: ROIVectorMarketDepthBacktest - asset_no: int - - def wait_next(self, *, timeout_ns: int, include_order_resp: bool) -> int: - """Wait for the next venue event and return its type.""" - # hftbacktest backends are frequently Numba jitclass objects. - # Those methods often do not support keyword arguments. - return self.hbt.wait_next_feed(include_order_resp, timeout_ns) - - def current_timestamp_ns(self) -> int: - """Return the current venue timestamp in nanoseconds.""" - return self.hbt.current_timestamp - - def read_market_snapshot(self) -> Any: - """Return the current market depth snapshot.""" - return self.hbt.depth(self.asset_no) - - def read_orders_snapshot(self) -> tuple[Any, Any]: - """Return the current orders and state snapshot.""" - return ( - self.hbt.state_values(self.asset_no), - self.hbt.orders(self.asset_no), - ) - - def record(self, recorder: Any) -> None: - """Record the current backtest state using the given recorder.""" - # hftbacktest recorder is a thin wrapper exposing .recorder.record(hbt). - recorder.recorder.record(self.hbt) diff --git a/trading_framework/backtest/engine/__init__.py b/trading_framework/backtest/engine/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/engine/engine_base.py b/trading_framework/backtest/engine/engine_base.py deleted file mode 100644 index babed91..0000000 --- a/trading_framework/backtest/engine/engine_base.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - - -@dataclass -class BacktestConfig: - """Generic backtest configuration. - - Engine configs should subclass this - and add engine-specific fields. - """ - id: str - description: str - - -@dataclass -class BacktestResult: - """Lightweight container for backtest outputs. - - For now we only track the stats file path. - Can be extended with PnL curves, summary metrics, etc. - """ - id: str - stats_file: str | None = None - extra_metadata: dict[str, Any] | None = None - - -class BacktestEngine: - """Abstract base class for all backtest engines.""" - - def __init__(self, config: BacktestConfig) -> None: - self.config = config - - def run(self) -> BacktestResult: - """Run the backtest and return a result object. - - Subclass engines must implement this method. - """ - raise NotImplementedError("run() must be implemented by subclasses") diff --git a/trading_framework/backtest/engine/hft_engine.py b/trading_framework/backtest/engine/hft_engine.py deleted file mode 100644 index ca2f535..0000000 --- a/trading_framework/backtest/engine/hft_engine.py +++ /dev/null @@ -1,182 +0,0 @@ -"""HFT backtest engine implementation based on hftbacktest.""" - -from __future__ import annotations - -import importlib -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from hftbacktest import ( - BacktestAsset, - Recorder, - ROIVectorMarketDepthBacktest, -) - -if TYPE_CHECKING: - from trading_framework.core.risk.risk_config import RiskConfig - -from trading_framework.backtest.adapters.execution import HftBacktestExecutionAdapter -from trading_framework.backtest.adapters.venue import HftBacktestVenueAdapter -from trading_framework.backtest.engine.engine_base import ( - BacktestConfig, - BacktestEngine, - BacktestResult, -) -from trading_framework.backtest.engine.strategy_runner import HftStrategyRunner -from trading_framework.strategies.base import Strategy -from trading_framework.strategies.strategy_config import StrategyConfig - - -# pylint: disable=too-many-instance-attributes -@dataclass -class HftEngineConfig: - """Configuration for the HFT backtest engine.""" - - # Data wiring - initial_snapshot: str | None - data_files: list[str] - - # Contract / microstructure parameters - instrument: str - tick_size: float - lot_size: float - contract_size: float - - # Simple fee model: maker / taker in rate on trading value - maker_fee_rate: float - taker_fee_rate: float - - # Latency model (constant latency) - entry_latency_ns: int - response_latency_ns: int - - # Queue model / venue model toggles - use_risk_adverse_queue_model: bool - partial_fill_venue: bool - - # Strategy loop timing - max_steps: int - - last_trades_capacity: int - max_price_tick_levels: int - - roi_lb: int - roi_ub: int - - # Output - stats_npz_path: str - event_bus_path: str - - -@dataclass -class HftBacktestConfig(BacktestConfig): - """Backtest configuration for the HFT engine.""" - - engine_cfg: HftEngineConfig - strategy_cfg: StrategyConfig - risk_cfg: RiskConfig - - -def _build_backtester(engine_cfg: HftEngineConfig) -> ROIVectorMarketDepthBacktest: - """Create an ROIVectorMarketDepthBacktest from the engine configuration.""" - asset = BacktestAsset() - - # For now we assume file paths. Later this can be replaced with an S3 resolver. - asset = asset.data(engine_cfg.data_files) - - if engine_cfg.initial_snapshot is not None: - asset = asset.initial_snapshot(engine_cfg.initial_snapshot) - - asset = ( - asset - .linear_asset(engine_cfg.contract_size) - .constant_latency(engine_cfg.entry_latency_ns, engine_cfg.response_latency_ns) - .tick_size(engine_cfg.tick_size) - .lot_size(engine_cfg.lot_size) - .trading_value_fee_model(engine_cfg.maker_fee_rate, engine_cfg.taker_fee_rate) - .last_trades_capacity(engine_cfg.last_trades_capacity) - .roi_lb(engine_cfg.roi_lb) - .roi_ub(engine_cfg.roi_ub) - ) - - if engine_cfg.use_risk_adverse_queue_model: - asset = asset.risk_adverse_queue_model() - - if engine_cfg.partial_fill_venue: - asset = asset.partial_fill_exchange() - else: - asset = asset.no_partial_fill_exchange() - - return ROIVectorMarketDepthBacktest([asset]) - - -class HftBacktestEngine(BacktestEngine): - """Backtest engine that uses hftbacktest internally.""" - - def __init__(self, config: HftBacktestConfig) -> None: - # pylint: disable=useless-super-delegation - super().__init__(config) - - def _load_strategy_class(self, class_path: str) -> type[Strategy]: - """Dynamically load a Strategy class from a module path.""" - module_path, class_name = class_path.split(":") - module = importlib.import_module(module_path) - cls = getattr(module, class_name) - - if not issubclass(cls, Strategy): - raise TypeError( - f"Loaded class {class_name} is not a subclass of Strategy." - ) - - return cls - - def _build_strategy(self, strategy_cfg: StrategyConfig) -> Strategy: - """Instantiate the strategy specified in the configuration.""" - cls = self._load_strategy_class(strategy_cfg.class_path) - return cls(**strategy_cfg.to_engine_params()) - - def run(self) -> BacktestResult: - """Run the backtest and return the aggregated result.""" - cfg: HftBacktestConfig = self.config - engine_cfg: HftEngineConfig = cfg.engine_cfg - strategy_cfg: StrategyConfig = cfg.strategy_cfg - risk_cfg: RiskConfig = cfg.risk_cfg - - # 1) Build hftbacktest backtester from engine config - hbt = _build_backtester(engine_cfg) - - # 2) Prepare recorder (single asset, record every step) - recorder = Recorder(1, engine_cfg.max_steps) - - # 3) Build strategy and runner - strategy = self._build_strategy(strategy_cfg) - runner = HftStrategyRunner( - engine_cfg=engine_cfg, - strategy=strategy, - risk_cfg=risk_cfg, - ) - - # 4) Backtest-only venue and execution adapters - asset_no = 0 - venue = HftBacktestVenueAdapter(hbt=hbt, asset_no=asset_no) - execution = HftBacktestExecutionAdapter(hbt=hbt, asset_no=asset_no) - - # 5) Run strategy loop (venue-agnostic) - runner.run(venue, execution, recorder) - - # 6) Close backtester and persist statistics - _ = hbt.close() - recorder.to_npz(engine_cfg.stats_npz_path) - - return BacktestResult( - id=cfg.id, - stats_file=engine_cfg.stats_npz_path, - extra_metadata={ - "engine": "hftbacktest", - "instrument": engine_cfg.instrument, - "strategy_name": strategy_cfg.class_path, - "strategy_params": strategy_cfg.params, - "risk_scope": risk_cfg.scope, - "risk_params": risk_cfg.params, - }, - ) diff --git a/trading_framework/backtest/engine/strategy_runner.py b/trading_framework/backtest/engine/strategy_runner.py deleted file mode 100644 index 6f9ace1..0000000 --- a/trading_framework/backtest/engine/strategy_runner.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Strategy execution loop for HFT backtests.""" - -from __future__ import annotations - -import logging -from collections import deque -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( - BookLevel, - BookPayload, - MarketEvent, - OrderIntent, - Price, - Quantity, -) -from trading_framework.core.events.event_bus import EventBus -from trading_framework.core.events.sinks.file_recorder import FileRecorderSink -from trading_framework.core.events.sinks.sink_logging import LoggingEventSink -from trading_framework.core.ports.venue_adapter import VenueAdapter -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RejectedIntent, RiskEngine - -if TYPE_CHECKING: - from trading_framework.backtest.adapters.execution import HftBacktestExecutionAdapter - from trading_framework.backtest.engine.hft_engine import HftEngineConfig - from trading_framework.strategies.base import Strategy - - -MAX_TIMEOUT_NS = 1 << 62 # Effectively "wait forever" without a heartbeat - - -class HftStrategyRunner: - """Strategy runner for HFT backtests. - - Invariant: - - One wait_next() wakeup corresponds to one fully committed timestamp block. - - Strategy is evaluated at most once per wakeup on a stable state. - """ - # pylint: disable=too-many-instance-attributes - - def __init__( - self, - *, - engine_cfg: HftEngineConfig, - strategy: Strategy, - risk_cfg: RiskConfig, - ) -> None: - self.engine_cfg = engine_cfg - self.strategy = strategy - - event_bus = self._build_event_bus( - path=Path(engine_cfg.event_bus_path), - ) - - self.strategy_state = StrategyState( - event_bus=event_bus, - ) - - self.risk = RiskEngine( - risk_cfg=risk_cfg, - event_bus=event_bus, - ) - - self._next_send_ts_ns_local: int | None = None - - def _build_event_bus( - self, - *, - path: Path, - ) -> EventBus: - logger = logging.getLogger("bus") - - sinks = [ - LoggingEventSink(logger), - FileRecorderSink(path), - ] - - return EventBus(sinks=sinks) - - def _close_event_bus(self) -> None: - self.strategy_state._event_bus.close() - self.risk._event_bus.close() - - def _compute_timeout_ns(self, now_local_ns: int) -> int: - """Compute wait timeout in nanoseconds.""" - if self._next_send_ts_ns_local is None: - return MAX_TIMEOUT_NS - delta = self._next_send_ts_ns_local - now_local_ns - return 0 if delta <= 0 else delta - - def _sort_intents_for_gate(self, intents: list[OrderIntent]) -> list[OrderIntent]: - """Sort intents to ensure cancels are evaluated first.""" - - 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 - - return sorted(intents, key=lambda it: (intent_priority(it), it.ts_ns_local)) - - def run( - self, - venue: VenueAdapter, - execution: HftBacktestExecutionAdapter, - recorder: Any, - ) -> None: - """Run the backtest loop.""" - # pylint: disable=too-many-locals,too-many-branches,too-many-statements - - instrument = self.engine_cfg.instrument - contract_size = self.engine_cfg.contract_size - - # Initialize hftbacktest engine - # Fetch very first event block to set local timestamp - venue.wait_next(timeout_ns=MAX_TIMEOUT_NS, include_order_resp=False) - observed_local_ns = venue.current_timestamp_ns() - self.strategy_state.update_timestamp(observed_local_ns) - sim_now_ns = self.strategy_state.sim_ts_ns_local - - while True: - timeout_ns = self._compute_timeout_ns(self.strategy_state.sim_ts_ns_local) - rc = venue.wait_next(timeout_ns=timeout_ns, include_order_resp=True) - - if rc == 1: - self._close_event_bus() - break - - observed_local_ns = venue.current_timestamp_ns() - self.strategy_state.update_timestamp(observed_local_ns) - sim_now_ns = self.strategy_state.sim_ts_ns_local - - raw_intents: list[OrderIntent] = [] - - # ----------------------------------------------------------------- - # Market update - # ----------------------------------------------------------------- - if rc == 2: - depth = venue.read_market_snapshot() - - bids: list[BookLevel] = [] - asks: list[BookLevel] = [] - - max_levels = max(0, int(self.engine_cfg.max_price_tick_levels)) - if max_levels > 0: - roi_lb_tick = depth.roi_lb_tick - tick_size = depth.tick_size - - # ----------------------- - # ASK side (fixed ticks) - # ----------------------- - for offset in range(max_levels): - price_tick = depth.best_ask_tick + offset - i = price_tick - roi_lb_tick - - qty = 0.0 - if 0 <= i < len(depth.ask_depth): - qty = depth.ask_depth[i] - - asks.append( - BookLevel( - price=Price( - currency="UNKNOWN", - value=price_tick * tick_size, - ), - quantity=Quantity( - value=qty, - unit="contracts", - ), - ) - ) - - # ----------------------- - # BID side (fixed ticks) - # ----------------------- - for offset in range(max_levels): - price_tick = depth.best_bid_tick - offset - i = price_tick - roi_lb_tick - - qty = 0.0 - if 0 <= i < len(depth.bid_depth): - qty = depth.bid_depth[i] - - bids.append( - BookLevel( - price=Price( - currency="UNKNOWN", - value=price_tick * tick_size, - ), - quantity=Quantity( - value=qty, - unit="contracts", - ), - ) - ) - - market_event = MarketEvent( - ts_ns_exch=sim_now_ns, - ts_ns_local=sim_now_ns, - instrument=instrument, - event_type="book", - book=BookPayload( - book_type="snapshot", - bids=bids, - asks=asks, - depth=min(len(bids), len(asks)), - ), - ) - - self.strategy_state.update_market( - instrument=instrument, - best_bid=depth.best_bid, - best_ask=depth.best_ask, - best_bid_qty=depth.best_bid_qty, - best_ask_qty=depth.best_ask_qty, - tick_size=depth.tick_size, - lot_size=depth.lot_size, - contract_size=contract_size, - ts_ns_local=sim_now_ns, - ts_ns_exch=sim_now_ns, - ) - - constraints = self.risk.build_constraints(sim_now_ns) - raw_intents.extend( - self.strategy.on_feed( - self.strategy_state, - market_event, - self.engine_cfg, - constraints, - ) - ) - - # ----------------------------------------------------------------- - # Order / account update - # ----------------------------------------------------------------- - if rc == 3: - state_values, orders = venue.read_orders_snapshot() - - self.strategy_state.update_account( - instrument=instrument, - position=state_values.position, - balance=state_values.balance, - fee=state_values.fee, - trading_volume=state_values.trading_volume, - trading_value=state_values.trading_value, - num_trades=state_values.num_trades, - ) - self.strategy_state.ingest_order_snapshots( - instrument, - orders.values(), - ) - - constraints = self.risk.build_constraints(sim_now_ns) - raw_intents.extend( - self.strategy.on_order_update( - self.strategy_state, - self.engine_cfg, - constraints, - ) - ) - - # ----------------------------------------------------------------- - # Queue flush - # ----------------------------------------------------------------- - if ( - self._next_send_ts_ns_local is not None - and sim_now_ns >= self._next_send_ts_ns_local - ): - raw_intents.extend( - self.strategy_state.pop_queued_intents(instrument) - ) - - # ----------------------------------------------------------------- - # Gate + execution - # ----------------------------------------------------------------- - if raw_intents: - combined = self._sort_intents_for_gate(raw_intents) - - decision = self.risk.decide_intents( - raw_intents=combined, - state=self.strategy_state, - now_ts_ns_local=sim_now_ns, - ) - - execution_errors: list[tuple[OrderIntent, str]] = [] - if decision.accepted_now: - execution_errors = execution.apply_intents( - decision.accepted_now - ) - - failed_keys = { - (it.instrument, it.client_order_id) - for it, _ in execution_errors - } - - for it in decision.accepted_now: - if (it.instrument, it.client_order_id) in failed_keys: - continue - self.strategy_state.mark_intent_sent( - it.instrument, - it.client_order_id, - it.intent_type, - ) - - if execution_errors: - for it, reason in execution_errors: - decision.execution_rejected.append( - RejectedIntent(it, reason) - ) - - self.strategy.on_risk_decision(decision) - self._next_send_ts_ns_local = decision.next_send_ts_ns_local - - # If there are queued intents but the gate did not provide a next_send_ts_ns_local, - # wake up at the next second boundary to ensure progress. - if self._next_send_ts_ns_local is None: - queue = self.strategy_state.queued_intents.setdefault( - instrument, - deque(), - ) - if queue: - sec = sim_now_ns // 1_000_000_000 - self._next_send_ts_ns_local = (sec + 1) * 1_000_000_000 - - venue.record(recorder) diff --git a/trading_framework/backtest/io/__init__.py b/trading_framework/backtest/io/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/io/s3_adapter.py b/trading_framework/backtest/io/s3_adapter.py deleted file mode 100644 index 4177eaa..0000000 --- a/trading_framework/backtest/io/s3_adapter.py +++ /dev/null @@ -1,290 +0,0 @@ -from __future__ import annotations - -import io -from pathlib import Path - -from oci.auth.signers import InstancePrincipalsSecurityTokenSigner -from oci.config import from_file -from oci.object_storage import ObjectStorageClient -from oci.signer import Signer - - -class OCIObjectStorageS3Shim: - """ - Lightweight adapter that exposes a small, S3-like interface on top of - Oracle Cloud Infrastructure (OCI) Object Storage. - - The goal of this class is *API shape compatibility*, not feature parity: - it mimics a minimal subset of the boto3 S3 client that is sufficient for - simple readers/writers and data pipelines. - - Authentication modes: - - "instance_principal": - Uses the OCI Instance Principal of the current Compute instance. - Suitable only when running on OCI infrastructure. - - "api_key": - Uses a user-scoped OCI API key (private PEM key + config file). - Suitable for local development, CI, and non-OCI environments. - - Implemented operations: - - put_object: upload an object (write) - - list_objects: list objects under a bucket/prefix (read) - - get_object: download an object (read) - - Design notes: - - Method signatures and return shapes are intentionally boto3-like. - - This adapter talks directly to OCI Object Storage APIs, NOT to the - S3-compatibility HTTP endpoint. - - Authorization is fully governed by OCI IAM policies. - """ - def __init__( - self, - *, - region: str | None = None, - auth_mode: str = "instance_principal", - oci_config_file: str | None = None, - oci_profile: str = "DEFAULT", - ) -> None: - """ - Create a new Object Storage client wrapper. - - Parameters: - region: - OCI region identifier (e.g. "eu-frankfurt-1"). - If provided, it overrides the region in the OCI config file. - - auth_mode: - Authentication strategy to use: - - "instance_principal": use the instances identity (OCI-only) - - "api_key": use a user API key defined in an OCI config file - - oci_config_file: - Path to an OCI CLI-style config file (required for api_key auth). - Typically "~/.oci/config". - - oci_profile: - Profile name inside the OCI config file to load credentials from. - """ - if auth_mode == "instance_principal": - signer = InstancePrincipalsSecurityTokenSigner() - config = {} - - elif auth_mode == "api_key": - if oci_config_file is None: - raise ValueError("oci_config_file is required for api_key auth") - - config = from_file( - file_location=oci_config_file, - profile_name=oci_profile, - ) - signer = Signer( - tenancy=config["tenancy"], - user=config["user"], - fingerprint=config["fingerprint"], - private_key_file_location=config["key_file"], - pass_phrase=config.get("pass_phrase"), - ) - - else: - raise ValueError(f"Unknown auth_mode: {auth_mode}") - - client_kwargs = {} - if region: - client_kwargs["region"] = region - - self.client = ObjectStorageClient( - config=config, - signer=signer, - **client_kwargs, - ) - - self.namespace = self.client.get_namespace().data - - def put_object(self, bucket: str, key: str, body, content_type: str = "application/octet-stream"): - """ - Upload an object to an OCI Object Storage bucket. - - Parameters mirror boto3 semantics: - Bucket: bucket name - Key: object name (path-like) - Body: bytes or file-like object - ContentType: optional MIME type - - Returns a minimal boto3-like dict containing the object's ETag - (if provided by OCI). - """ - resp = self.client.put_object( - namespace_name=self.namespace, - bucket_name=bucket, - object_name=key, - put_object_body=body, - content_type=content_type, - ) - etag = None - try: - etag = resp.headers.get("etag") - except Exception: - pass - return {"ETag": etag} - - def list_objects( - self, - bucket: str, - prefix: str | None = None, - continuation_token: str | None = None, - max_keys: int = 1000, - ) -> dict[str, object]: - """ - List objects in a bucket, optionally filtered by prefix. - - This method approximates boto3's list_objects behavior: - - 'Prefix' filters object names - - pagination is exposed via ContinuationToken / NextContinuationToken - - Internally, this maps to OCI's 'list_objects' API, using: - - 'prefix' for filtering - - 'start' for pagination - - Returns: - A dict with keys: - - Contents: list of {"Key", "Size"} - - IsTruncated: whether more results are available - - NextContinuationToken: token for the next page (or None) - """ - kwargs = { - "namespace_name": self.namespace, - "bucket_name": bucket, - "limit": max_keys, - } - if prefix: - kwargs["prefix"] = prefix - if continuation_token: - kwargs["start"] = continuation_token - - resp = self.client.list_objects(**kwargs) - objects = [] - for o in resp.data.objects or []: - objects.append({"Key": o.name, "Size": getattr(o, "size", None)}) - - next_token = getattr(resp.data, "next_start_with", None) - return { - "Contents": objects, - "IsTruncated": bool(next_token), - "NextContinuationToken": next_token, - } - - def get_object(self, bucket: str, key: str) -> dict[str, object]: - """ - Download an object from OCI Object Storage. - - Returns a boto3-like response where: - - 'Body' is a file-like object (io.BytesIO) - - 'ContentLength' and 'ContentType' are best-effort metadata - - The OCI Python SDK exposes response bodies in different shapes - depending on transport and SDK version; this method normalizes - them into a single bytes buffer. - """ - resp = self.client.get_object( - namespace_name=self.namespace, - bucket_name=bucket, - object_name=key, - ) - - data_bytes = None - d = resp.data - - # Case 1: direct .read() - if hasattr(d, "read") and callable(getattr(d, "read")): - data_bytes = d.read() - - # Case 2: .content (bytes already) - elif hasattr(d, "content"): - data_bytes = d.content - - # Case 3: raw.read() - elif hasattr(d, "raw") and hasattr(d.raw, "read") and callable(getattr(d.raw, "read")): - data_bytes = d.raw.read() - - # Case 4: stream chunks (fallback) - elif hasattr(d, "raw") and hasattr(d.raw, "stream") and callable(getattr(d.raw, "stream")): - chunks = [] - for chunk in d.raw.stream(1024 * 1024, decode_content=False): - chunks.append(chunk) - data_bytes = b"".join(chunks) - - else: - raise TypeError("Unsupported OCI get_object response type; no readable data attribute found.") - - # Content-Length/Type (best effort) - content_length = None - try: - content_length = int(resp.headers.get("content-length", "0")) - except Exception: - pass - if not content_length and data_bytes is not None: - content_length = len(data_bytes) - - content_type = "" - try: - content_type = resp.headers.get("content-type", "") - except Exception: - pass - - return { - "Body": io.BytesIO(data_bytes if data_bytes is not None else b""), - "ContentLength": content_length or 0, - "ContentType": content_type, - } - - def download_to_file( - self, - bucket: str, - key: str, - destination: str | Path, - *, - chunk_size_bytes: int = 8 * 1024 * 1024, - ) -> None: - """ - Stream an object from OCI Object Storage directly to a local file. - - This method performs a chunked download over HTTPS and writes each - chunk incrementally to disk. The entire object is never loaded into - memory at once, ensuring constant and predictable RAM usage. - - Parameters: - bucket: - Name of the OCI Object Storage bucket. - key: - Object name (path-like key) within the bucket. - destination: - Local filesystem path where the object will be written. - Parent directories must already exist. - chunk_size_bytes: - Size of each streamed chunk in bytes. Defaults to 8 MiB. - - Raises: - RuntimeError: - If the OCI response does not expose a streamable body. - """ - destination_path = Path(destination) - - response = self.client.get_object( - namespace_name=self.namespace, - bucket_name=bucket, - object_name=key, - ) - - data = response.data - - if not hasattr(data, "raw") or not hasattr(data.raw, "stream"): - raise RuntimeError( - "OCI get_object response does not expose a streamable body." - ) - - with destination_path.open("wb") as file_handle: - for chunk in data.raw.stream( - chunk_size_bytes, - decode_content=False, - ): - file_handle.write(chunk) diff --git a/trading_framework/backtest/orchestrator/__init__.py b/trading_framework/backtest/orchestrator/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/orchestrator/manifest.py b/trading_framework/backtest/orchestrator/manifest.py deleted file mode 100644 index 9aa8a8a..0000000 --- a/trading_framework/backtest/orchestrator/manifest.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -Dataset manifest definitions. - -This module defines metadata structures and protocols used to describe -datasets and their underlying data files. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol - - -@dataclass(frozen=True, slots=True) -class DataFileMeta: - """ - Immutable metadata describing a single data file. - """ - - file_id: str - object_key: str - start_ts_ns: int - end_ts_ns: int - size_bytes: int - symbol: str - venue: str - datatype: str - - -class DatasetManifest(Protocol): - """ - Protocol describing a dataset manifest interface. - """ - - def iter_files( - self, - *, - start_ts_ns: int, - end_ts_ns: int, - symbol: str, - venue: str, - datatype: str, - ) -> list[DataFileMeta]: - """ - Iterate over data files matching the given constraints. - """ diff --git a/trading_framework/backtest/orchestrator/planner.py b/trading_framework/backtest/orchestrator/planner.py deleted file mode 100644 index 7fa8676..0000000 --- a/trading_framework/backtest/orchestrator/planner.py +++ /dev/null @@ -1,151 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from trading_framework.backtest.orchestrator.manifest import DataFileMeta, DatasetManifest - -from trading_framework.backtest.orchestrator.planner_models import ( - ExperimentPlan, - SegmentPlan, -) -from trading_framework.backtest.orchestrator.segmenter import segment_files -from trading_framework.backtest.orchestrator.sweeps import ( - expand_parameter_grid, - expand_ranges, -) - - -def plan_experiment( - *, - experiment_id: str, - start_ts_ns: int, - end_ts_ns: int, - symbol: str, - venue: str, - datatype: str, - sweep_spec: dict[str, Any], - manifest: DatasetManifest, - max_segment_bytes: int, -) -> ExperimentPlan: - """ - Build a deterministic execution plan for an experiment. - - This function performs *planning only*. - It does not access S3 directly, does not allocate scratch space, - and does not execute any backtests. - - Responsibilities: - - resolve relevant data files via the manifest - - segment data according to scratch size limits - - expand parameter sweeps - - produce a pure ExperimentPlan - - Parameters - ---------- - experiment_id: - Stable identifier for the experiment. - - start_ts_ns / end_ts_ns: - Experiment time range (unix timestamp, nanoseconds). - - symbol: - Instrument included in the experiment. - - sweep_spec: - User-facing sweep specification. May contain: - - explicit values - - iterables - - RangeSpec instances - - manifest: - Dataset manifest used to resolve physical data files. - - max_segment_bytes: - Maximum total size (bytes) allowed per segment. - - Returns - ------- - ExperimentPlan - Fully expanded execution plan. - """ - - if start_ts_ns >= end_ts_ns: - raise ValueError("start_ts_ns must be < end_ts_ns") - - if max_segment_bytes <= 0: - raise ValueError("max_segment_bytes must be > 0") - - # ------------------------------------------------------------------ - # 1. Resolve all relevant data files - # ------------------------------------------------------------------ - - files: list[DataFileMeta] = manifest.iter_files( - start_ts_ns=start_ts_ns, - end_ts_ns=end_ts_ns, - symbol=symbol, - venue=venue, - datatype=datatype, - ) - - if not files: - raise RuntimeError("No data files found for given experiment range") - - # ------------------------------------------------------------------ - # 2. Segment files according to scratch constraints - # ------------------------------------------------------------------ - - file_segments: list[list[DataFileMeta]] = segment_files( - files=files, - max_bytes=max_segment_bytes, - ) - - if not file_segments: - raise RuntimeError("Segmenter produced no segments") - - # ------------------------------------------------------------------ - # 3. Expand parameter sweeps - # ------------------------------------------------------------------ - - normalized_grid = expand_ranges(sweep_spec) - sweep_plans = expand_parameter_grid(normalized_grid) - - # ------------------------------------------------------------------ - # 4. Build SegmentPlans - # ------------------------------------------------------------------ - - segments: list[SegmentPlan] = [] - - for index, segment in enumerate(file_segments): - segment_id = f"segment_{index:04d}" - - segment_start = min(f.start_ts_ns for f in segment) - segment_end = max(f.end_ts_ns for f in segment) - - estimated_bytes = sum(f.size_bytes for f in segment) - - if estimated_bytes > max_segment_bytes: - raise RuntimeError( - f"Segment {segment_id} exceeds max_segment_bytes " - f"({estimated_bytes} > {max_segment_bytes})" - ) - - segments.append( - SegmentPlan( - segment_id=segment_id, - start_ts_ns=segment_start, - end_ts_ns=segment_end, - estimated_bytes=estimated_bytes, - files=[f.object_key for f in segment], - sweeps=sweep_plans, - ) - ) - - # ------------------------------------------------------------------ - # 5. Return final experiment plan - # ------------------------------------------------------------------ - - return ExperimentPlan( - experiment_id=experiment_id, - segments=segments, - ) diff --git a/trading_framework/backtest/orchestrator/planner_models.py b/trading_framework/backtest/orchestrator/planner_models.py deleted file mode 100644 index 4e7c7b8..0000000 --- a/trading_framework/backtest/orchestrator/planner_models.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Planning model definitions. - -This module contains immutable planning structures used to describe -experiments, segments, and sweeps. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from trading_framework.backtest.orchestrator.sweeps import SweepPlan - - -@dataclass(frozen=True, slots=True) -class SegmentPlan: - """ - Execution plan for a single segment of data. - """ - - segment_id: str - start_ts_ns: int - end_ts_ns: int - estimated_bytes: int - files: list[str] - sweeps: list[SweepPlan] - - -@dataclass(frozen=True, slots=True) -class ExperimentPlan: - """ - High-level execution plan for an experiment. - """ - - experiment_id: str - segments: list[SegmentPlan] diff --git a/trading_framework/backtest/orchestrator/s3_manifest.py b/trading_framework/backtest/orchestrator/s3_manifest.py deleted file mode 100644 index 86c49bd..0000000 --- a/trading_framework/backtest/orchestrator/s3_manifest.py +++ /dev/null @@ -1,128 +0,0 @@ -from __future__ import annotations - -import json - -from trading_framework.backtest.io.s3_adapter import OCIObjectStorageS3Shim -from trading_framework.backtest.orchestrator.manifest import DataFileMeta, DatasetManifest - - -class S3DatasetManifest(DatasetManifest): - """ - DatasetManifest implementation backed by S3. - - Semantics: - - Manifests live under a canonical prefix (e.g. s3://data/canonical/) - - All filtering is semantic (venue, datatype, symbol, time) - - Path layout is NOT part of the contract - """ - - def __init__( - self, - *, - bucket: str, - stage: str, - ) -> None: - self._s3 = OCIObjectStorageS3Shim(region="eu-frankfurt-1") - self._bucket = bucket - self._prefix = stage.rstrip("/") - - # ------------------------------------------------------------------ - - def iter_files( - self, - *, - start_ts_ns: int, - end_ts_ns: int, - symbol: str, - venue: str, - datatype: str, - ) -> list[DataFileMeta]: - files: list[DataFileMeta] = [] - - for key in self._list_manifest_keys(): - manifest = self._load_manifest(key) - - dataset = manifest["dataset"] - - if dataset["venue"] != venue: - continue - - if dataset["datatype"] != datatype: - continue - - time_range = manifest["time_range_ns"] - if not self._overlaps( - start_ts_ns, - end_ts_ns, - time_range["start"], - time_range["end"], - ): - continue - - for entry in manifest["files"]: - if not self._overlaps( - start_ts_ns, - end_ts_ns, - entry["start_ts_ns"], - entry["end_ts_ns"], - ): - continue - - manifest_key = key - manifest_dir = manifest_key.rsplit("/", 1)[0] - object_key = f"{manifest_dir}/{entry['file_id']}" - - files.append( - DataFileMeta( - file_id=entry["file_id"], - object_key=object_key, - start_ts_ns=entry["start_ts_ns"], - end_ts_ns=entry["end_ts_ns"], - size_bytes=entry["size_bytes"], - symbol=symbol, - venue=venue, - datatype=datatype, - ) - ) - - return files - - # ------------------------------------------------------------------ - - def _list_manifest_keys(self) -> list[str]: - resp = self._s3.list_objects( - bucket=self._bucket, - prefix=self._prefix, - ) - - contents = resp.get("Contents", []) - - return [ - obj["Key"] - for obj in contents - if obj["Key"].endswith("/manifest.json") - ] - - def _load_manifest(self, key: str) -> dict: - resp = self._s3.get_object( - bucket=self._bucket, - key=key, - ) - - body = resp["Body"] - - if hasattr(body, "read"): - raw_bytes = body.read() - else: - raw_bytes = body - - return json.loads(raw_bytes) - - @staticmethod - def _overlaps( - a_start: int, - a_end: int, - b_start: int, - b_end: int, - ) -> bool: - return a_start < b_end and b_start < a_end diff --git a/trading_framework/backtest/orchestrator/segmenter.py b/trading_framework/backtest/orchestrator/segmenter.py deleted file mode 100644 index b560b77..0000000 --- a/trading_framework/backtest/orchestrator/segmenter.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -File segmentation logic. - -This module contains utilities for splitting data files into -byte-size-constrained segments. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from trading_framework.backtest.orchestrator.manifest import DataFileMeta - - -def segment_files( - files: list[DataFileMeta], - max_bytes: int, -) -> list[list[DataFileMeta]]: - """ - Split files into ordered segments such that each segment does not - exceed the given maximum size in bytes. - """ - - segments: list[list[DataFileMeta]] = [] - current_segment: list[DataFileMeta] = [] - current_bytes = 0 - - # Sort files by start timestamp to ensure deterministic segmentation - for file_meta in sorted(files, key=lambda item: item.start_ts_ns): - exceeds_limit = current_bytes + file_meta.size_bytes > max_bytes - - if current_segment and exceeds_limit: - segments.append(current_segment) - current_segment = [] - current_bytes = 0 - - current_segment.append(file_meta) - current_bytes += file_meta.size_bytes - - if current_segment: - segments.append(current_segment) - - return segments diff --git a/trading_framework/backtest/orchestrator/summary.py b/trading_framework/backtest/orchestrator/summary.py deleted file mode 100644 index 6b1c01d..0000000 --- a/trading_framework/backtest/orchestrator/summary.py +++ /dev/null @@ -1,141 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING, List - -if TYPE_CHECKING: - from trading_framework.backtest.orchestrator.planner_models import ExperimentPlan - - -# --------------------------------------------------------------------------- -# Data models -# --------------------------------------------------------------------------- - -@dataclass(frozen=True, slots=True) -class SegmentSummary: - segment_id: str - start_ts_ns: int - end_ts_ns: int - estimated_bytes: int - file_count: int - sweep_count: int - scratch_utilization: float # 0.0 - 1.0 - - -@dataclass(frozen=True, slots=True) -class ExperimentSummary: - experiment_id: str - segment_count: int - sweeps_per_segment: int - total_backtests: int - max_segment_bytes: int - segments: List[SegmentSummary] - warnings: List[str] - - -# --------------------------------------------------------------------------- -# Summary builder -# --------------------------------------------------------------------------- - -def summarize_experiment( - *, - plan: ExperimentPlan, - max_segment_bytes: int, -) -> ExperimentSummary: - warnings: list[str] = [] - segments: list[SegmentSummary] = [] - - if not plan.segments: - warnings.append("Experiment contains no segments") - - sweeps_per_segment = ( - len(plan.segments[0].sweeps) if plan.segments else 0 - ) - - total_backtests = len(plan.segments) * sweeps_per_segment - - if sweeps_per_segment == 0: - warnings.append("No sweeps defined (0 backtests will run)") - - if total_backtests > 500: - warnings.append( - f"High number of backtests ({total_backtests}); runtime may be long" - ) - - if len(plan.segments) > 50: - warnings.append( - f"High number of segments ({len(plan.segments)})" - ) - - for segment in plan.segments: - utilization = segment.estimated_bytes / max_segment_bytes - - if utilization > 1.0: - warnings.append( - f"{segment.segment_id} exceeds scratch size " - f"({utilization:.0%})" - ) - elif utilization > 0.9: - warnings.append( - f"{segment.segment_id} uses {utilization:.0%} of scratch size" - ) - - if segment.estimated_bytes < max_segment_bytes * 0.1: - warnings.append( - f"{segment.segment_id} is very small " - f"({utilization:.0%} of scratch)" - ) - - segments.append( - SegmentSummary( - segment_id=segment.segment_id, - start_ts_ns=segment.start_ts_ns, - end_ts_ns=segment.end_ts_ns, - estimated_bytes=segment.estimated_bytes, - file_count=len(segment.files), - sweep_count=len(segment.sweeps), - scratch_utilization=utilization, - ) - ) - - return ExperimentSummary( - experiment_id=plan.experiment_id, - segment_count=len(plan.segments), - sweeps_per_segment=sweeps_per_segment, - total_backtests=total_backtests, - max_segment_bytes=max_segment_bytes, - segments=segments, - warnings=warnings, - ) - - -# --------------------------------------------------------------------------- -# Pretty printer -# --------------------------------------------------------------------------- - -def print_experiment_summary(summary: ExperimentSummary) -> None: - max_gb = summary.max_segment_bytes / 1024**3 - - print(f"Experiment: {summary.experiment_id}") - print(f"Segments: {summary.segment_count}") - print(f"Sweeps per segment: {summary.sweeps_per_segment}") - print(f"Total backtests: {summary.total_backtests}") - print(f"Max segment size: {max_gb:.2f} GB") - print() - - if summary.warnings: - print("Warnings:") - for w in summary.warnings: - print(f" - {w}") - print() - - print("Segments:") - for s in summary.segments: - used_gb = s.estimated_bytes / 1024**3 - print( - f" - {s.segment_id}: " - f"{s.file_count} files | " - f"{used_gb:.2f} / {max_gb:.2f} GB | " - f"{s.sweep_count} sweeps | " - f"{s.scratch_utilization:.0%} scratch" - ) diff --git a/trading_framework/backtest/orchestrator/sweeps.py b/trading_framework/backtest/orchestrator/sweeps.py deleted file mode 100644 index 7eb2960..0000000 --- a/trading_framework/backtest/orchestrator/sweeps.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Parameter sweep utilities. - -This module provides helpers to expand parameter specifications into -concrete sweep plans. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from itertools import product -from typing import Any, Iterable - - -@dataclass(frozen=True, slots=True) -class RangeSpec: - """ - Numeric range specification used for parameter sweeps. - """ - - start: float - stop: float - step: float - - -@dataclass(frozen=True, slots=True) -class SweepPlan: - """ - Concrete parameter sweep configuration. - """ - - sweep_id: str - parameters: dict[str, Any] - - -def expand_ranges(spec: dict[str, Any]) -> dict[str, list[Any]]: - """ - Expand range and iterable specifications into explicit value lists. - """ - - expanded: dict[str, list[Any]] = {} - - for key, value in spec.items(): - if isinstance(value, RangeSpec): - values: list[Any] = [] - current = value.start - - # Add small epsilon to avoid floating point termination issues - while current <= value.stop + 1e-12: - values.append(round(current, 10)) - current += value.step - - expanded[key] = values - continue - - if isinstance(value, Iterable) and not isinstance(value, (str, bytes)): - expanded[key] = list(value) - continue - - expanded[key] = [value] - - return expanded - - -def expand_parameter_grid(grid: dict[str, list[Any]]) -> list[SweepPlan]: - """ - Generate all parameter combinations from a parameter grid. - """ - - if not grid: - return [SweepPlan("sweep_0000", {})] - - keys = sorted(grid.keys()) - values = [grid[key] for key in keys] - - sweeps: list[SweepPlan] = [] - - for index, combination in enumerate(product(*values)): - parameters = dict(zip(keys, combination, strict=True)) - sweep_id = f"sweep_{index:04d}" - sweeps.append(SweepPlan(sweep_id, parameters)) - - return sweeps diff --git a/trading_framework/backtest/runtime/__init__.py b/trading_framework/backtest/runtime/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/runtime/context.py b/trading_framework/backtest/runtime/context.py deleted file mode 100644 index e9301bf..0000000 --- a/trading_framework/backtest/runtime/context.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import Mapping - - -@dataclass(frozen=True, slots=True) -class ExperimentContext: - experiment_id: str - - expected_segments: int - completed_segments: int - failed_segments: int - - experiment_started_at: datetime - - scratch_root: Path - - def __post_init__(self) -> None: - object.__setattr__(self, "scratch_root", Path(self.scratch_root)) - - @property - def scratch_experiment_dir(self) -> Path: - return self.scratch_root / self.experiment_id - - -@dataclass(frozen=True, slots=True) -class SegmentContext: - experiment_id: str - segment_id: str - - expected_sweeps: int - completed_sweeps: int - failed_sweeps: int - - segment_started_at: datetime - - scratch_root: Path - - def __post_init__(self) -> None: - object.__setattr__(self, "scratch_root", Path(self.scratch_root)) - - @property - def scratch_segment_dir(self) -> Path: - return ( - self.scratch_root - / self.experiment_id - / self.segment_id - ) - - -@dataclass(frozen=True, slots=True) -class SweepContext: - """ - Immutable runtime context for a single backtest sweep. - - One SweepContext == one Pod == one backtest execution. - """ - - # Identity - experiment_id: str - segment_id: str - sweep_id: str - - # Data - stage: str - venue: str - datatype: str - symbol: str - file_keys: tuple[str, ...] - - # Parameters - parameters: Mapping[str, object] - - # Runtime paths - scratch_root: Path - results_root: Path - - def __post_init__(self) -> None: - """ - Normalize runtime paths after JSON deserialization. - - JSON has no Path type, so scratch_root / results_root - may arrive as strings in worker pods. - """ - object.__setattr__(self, "scratch_root", Path(self.scratch_root)) - object.__setattr__(self, "results_root", Path(self.results_root)) - - @property - def scratch_segment_dir(self) -> Path: - return ( - self.scratch_root - / self.experiment_id - / self.segment_id - ) - - @property - def scratch_data_dir(self) -> Path: - return self.scratch_segment_dir / "data" - - @property - def scratch_results_dir(self) -> Path: - return self.scratch_segment_dir / "results" / self.sweep_id diff --git a/trading_framework/backtest/runtime/entrypoint.py b/trading_framework/backtest/runtime/entrypoint.py deleted file mode 100644 index 0348153..0000000 --- a/trading_framework/backtest/runtime/entrypoint.py +++ /dev/null @@ -1,268 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import sys -from dataclasses import asdict -from pathlib import Path -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from trading_framework.backtest.orchestrator.planner_models import ExperimentPlan - -from trading_framework.backtest.orchestrator.planner import plan_experiment -from trading_framework.backtest.orchestrator.s3_manifest import S3DatasetManifest -from trading_framework.backtest.orchestrator.summary import ( - print_experiment_summary, - summarize_experiment, -) -from trading_framework.backtest.orchestrator.sweeps import RangeSpec -from trading_framework.backtest.runtime.context import SweepContext - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _load_json(path: Path) -> dict[str, Any]: - if not path.exists(): - raise FileNotFoundError(path) - return json.loads(path.read_text(encoding="utf-8")) - - -def _parse_sweep_spec(raw: dict[str, Any]) -> dict[str, Any]: - """ - Same semantics as your planner CLI: - dict -> RangeSpec or explicit lists - """ - parsed: dict[str, Any] = {} - for key, value in raw.items(): - if isinstance(value, dict): - parsed[key] = RangeSpec( - start=value["start"], - stop=value["stop"], - step=value["step"], - ) - else: - parsed[key] = value - return parsed - - -def _emit_sweep_context( - *, - plan: ExperimentPlan, - base_cfg: dict[str, Any], - scratch_root: Path, - results_root: Path, - out_dir: Path, -) -> None: - """ - Emit one SweepContext JSON per sweep. - These JSON files are what Argo consumes. - """ - out_dir.mkdir(parents=True, exist_ok=True) - - experiment: dict = base_cfg["experiment"] - stage: str = experiment.get("stage", "derived") - venue: str = experiment["venue"] - datatype: str = experiment["datatype"] - symbol: str = experiment["symbol"] - - for segment in plan.segments: - for sweep in segment.sweeps: - ctx = SweepContext( - experiment_id=plan.experiment_id, - segment_id=segment.segment_id, - sweep_id=sweep.sweep_id, - stage=stage, - venue=venue, - datatype=datatype, - symbol=symbol, - file_keys=tuple(segment.files), - parameters={ - # pass through full engine/strategy/risk blocks - "engine": base_cfg["engine"], - "strategy": base_cfg["strategy"], - "risk": base_cfg["risk"], - # plus sweep-specific parameters - "sweep": sweep.parameters, - }, - scratch_root=scratch_root, - results_root=results_root, - ) - - out_path = out_dir / f"{segment.segment_id}__{sweep.sweep_id}.json" - out_path.write_text( - json.dumps(asdict(ctx), indent=2, default=str), - encoding="utf-8", - ) - - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- - -def main() -> None: - parser = argparse.ArgumentParser( - description="Backtest entrypoint (plan or run via sweep fan-out)" - ) - - parser.add_argument( - "--config", - type=Path, - required=True, - help="Path to experiment JSON config (inside image or mounted).", - ) - - parser.add_argument( - "--plan", - action="store_true", - help="Plan experiment and print summary (no execution).", - ) - - parser.add_argument( - "--run", - action="store_true", - help="Plan experiment and emit sweep contexts for execution.", - ) - - parser.add_argument( - "--scratch-root", - type=Path, - default=Path("/mnt/scratch"), - help="Root directory for scratch volume.", - ) - - parser.add_argument( - "--results-root", - type=Path, - default=Path("/results"), - help="Logical results root (used for context only).", - ) - - parser.add_argument( - "--emit-dir", - type=Path, - default=Path("/mnt/scratch/sweeps"), - help="Directory where SweepContext JSONs are emitted.", - ) - - args = parser.parse_args() - - if not args.plan and not args.run: - print("Error: one of --plan or --run must be specified.", file=sys.stderr) - sys.exit(2) - - # ------------------------------------------------------------------ - # Load config - # ------------------------------------------------------------------ - - cfg = _load_json(args.config) - - experiment_id: str = cfg["id"] - experiment_cfg = cfg["experiment"] - - start_ts_ns: int = experiment_cfg["start_ts_ns"] - end_ts_ns: int = experiment_cfg["end_ts_ns"] - symbol: str = experiment_cfg["symbol"] - venue: str = experiment_cfg["venue"] - datatype: str = experiment_cfg["datatype"] - - segmentation: dict = experiment_cfg.get("segmentation", {}) - max_segment_gb: float = segmentation.get("max_segment_gb", 100) - max_segment_bytes = max_segment_gb * 1024**3 - - sweep_spec = _parse_sweep_spec(experiment_cfg.get("sweeps", {})) - - manifest = S3DatasetManifest( - bucket="data", - stage=experiment_cfg.get("stage", "derived"), - ) - - # ------------------------------------------------------------------ - # Planning - # ------------------------------------------------------------------ - - plan = plan_experiment( - experiment_id=experiment_id, - start_ts_ns=start_ts_ns, - end_ts_ns=end_ts_ns, - symbol=symbol, - venue=venue, - datatype=datatype, - sweep_spec=sweep_spec, - manifest=manifest, - max_segment_bytes=max_segment_bytes, - ) - - summary = summarize_experiment( - plan=plan, - max_segment_bytes=max_segment_bytes, - ) - - # Always show the plan (this is what you want in Argo logs) - print_experiment_summary(summary) - - if args.plan and not args.run: - # Plan-only mode: exit after printing - return - - # ------------------------------------------------------------------ - # Run preparation (emit sweep contexts) - # ------------------------------------------------------------------ - - index: list[str] = [] - segments_index: list[dict[str, object]] = [] - - out_dir: Path = args.emit_dir - out_dir.mkdir(parents=True, exist_ok=True) - - (out_dir / "experiment_id.txt").write_text( - plan.experiment_id, - encoding="utf-8", - ) - - expected_segments = len(plan.segments) - - (out_dir / "expected_segments.txt").write_text( - str(expected_segments), - encoding="utf-8", - ) - - _emit_sweep_context( - plan=plan, - base_cfg=cfg, - scratch_root=args.scratch_root, - results_root=args.results_root, - out_dir=out_dir, - ) - - for segment in plan.segments: - segments_index.append( - { - "segment_id": segment.segment_id, - "expected_sweeps": len(segment.sweeps), - } - ) - - (out_dir / "segments.json").write_text( - json.dumps(segments_index, indent=2), - encoding="utf-8", - ) - - for segment in plan.segments: - for sweep in segment.sweeps: - out_path = out_dir / f"{segment.segment_id}__{sweep.sweep_id}.json" - index.append(str(out_path)) - - (out_dir / "index.json").write_text( - json.dumps(index, indent=2), - encoding="utf-8", - ) - - print() - print(f"Emitted sweep contexts to: {args.emit_dir}") - print("Each JSON represents exactly one sweep (one Pod).") - - -if __name__ == "__main__": - main() diff --git a/trading_framework/backtest/runtime/experiment_finalize_entrypoint.py b/trading_framework/backtest/runtime/experiment_finalize_entrypoint.py deleted file mode 100644 index bbf5e13..0000000 --- a/trading_framework/backtest/runtime/experiment_finalize_entrypoint.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import logging -import os -import shutil -from datetime import datetime, timezone -from pathlib import Path - -from trading_framework.backtest.io.s3_adapter import OCIObjectStorageS3Shim -from trading_framework.backtest.runtime.context import ExperimentContext -from trading_framework.backtest.runtime.prometheus_metrics import PrometheusMetricsClient - -LOGGER = logging.getLogger(__name__) - - -class ExperimentFinalizer: - """ - Finalizes an experiment after all segments have completed. - - Responsibilities: - - write experiment_metadata.json - - write _DONE marker - """ - - def finalize(self, *, ctx: ExperimentContext) -> None: - finished_at = datetime.now(timezone.utc) - - status = "success" - if ctx.failed_segments > 0: - status = "failed" - - metadata = { - "schema_version": "1.0", - "identity": { - "experiment_id": ctx.experiment_id, - }, - "lifecycle": { - "status": status, - "started_at": ctx.experiment_started_at.isoformat(), - "finished_at": finished_at.isoformat(), - "duration_seconds": ( - finished_at - ctx.experiment_started_at - ).total_seconds(), - }, - "segments": { - "expected": ctx.expected_segments, - "completed": ctx.completed_segments, - "failed": ctx.failed_segments, - }, - } - - experiment_dir = ctx.scratch_experiment_dir - experiment_dir.mkdir(parents=True, exist_ok=True) - - (experiment_dir / "experiment_metadata.json").write_text( - json.dumps(metadata, indent=2), - encoding="utf-8", - ) - - (experiment_dir / "_DONE").write_text( - finished_at.isoformat(), - encoding="utf-8", - ) - - # --- Prometheus metrics (side-effect only) --- - metrics = PrometheusMetricsClient() - - if metrics.is_enabled(): - try: - labels = { - "experiment_id": ctx.experiment_id, - "status": status, - } - - metrics.push_gauge( - name="backtest_experiment_duration_seconds", - value=metadata["lifecycle"]["duration_seconds"], - labels=labels, - ) - - metrics.push_gauge( - name="backtest_experiment_completed_segments", - value=float(ctx.completed_segments), - labels=labels, - ) - - metrics.push_gauge( - name="backtest_experiment_failed_segments", - value=float(ctx.failed_segments), - labels=labels, - ) - - metrics.push_all(job="backtest_experiment") - - except Exception: - LOGGER.exception("Prometheus push failed") - - -class ExperimentMetadataPersister: - def __init__( - self, - *, - bucket: str, - prefix: str = "backtests", - ) -> None: - self._s3 = OCIObjectStorageS3Shim(region="eu-frankfurt-1") - self._bucket = bucket - self._prefix = prefix - - def persist( - self, - *, - experiment_id: str, - experiment_dir: Path, - ) -> None: - prefix = f"{self._prefix}/{experiment_id}" - - for name in ("experiment_metadata.json", "_DONE"): - path = experiment_dir / name - if not path.exists(): - continue - - with path.open("rb") as fh: - self._s3.put_object( - bucket=self._bucket, - key=f"{prefix}/{name}", - body=fh, - ) - - -def _cleanup_scratch(*, experiment_id: str, scratch_root: Path) -> None: - """ - Remove all scratch data for this workflow + experiment. - This is safe to call ONLY after successful finalization. - """ - - workflow_uid = os.environ.get("ARGO_WORKFLOW_UID") - if not workflow_uid: - raise RuntimeError("ARGO_WORKFLOW_UID is not set") - - sweeps_dir = scratch_root / "sweeps" / workflow_uid - experiment_dir = scratch_root / experiment_id - - if sweeps_dir.exists(): - shutil.rmtree(sweeps_dir) - - if experiment_dir.exists(): - shutil.rmtree(experiment_dir) - - -def main() -> None: - parser = argparse.ArgumentParser("finalize experiment") - - parser.add_argument("--experiment-id", type=str, required=True) - - parser.add_argument("--expected-segments", type=int, required=True) - parser.add_argument("--completed-segments", type=int, required=True) - parser.add_argument("--failed-segments", type=int, required=True) - - parser.add_argument( - "--experiment-started-at", - type=str, - required=True, - help="ISO-8601 timestamp (UTC)", - ) - - parser.add_argument("--scratch-root", type=Path, required=True) - - args = parser.parse_args() - - ctx = ExperimentContext( - experiment_id=args.experiment_id, - expected_segments=args.expected_segments, - completed_segments=args.completed_segments, - failed_segments=args.failed_segments, - experiment_started_at=datetime.fromisoformat(args.experiment_started_at), - scratch_root=args.scratch_root, - ) - - ExperimentFinalizer().finalize(ctx=ctx) - - persister = ExperimentMetadataPersister(bucket="data") - persister.persist( - experiment_id=ctx.experiment_id, - experiment_dir=ctx.scratch_experiment_dir, - ) - - _cleanup_scratch( - experiment_id=ctx.experiment_id, - scratch_root=ctx.scratch_root, - ) - - -if __name__ == "__main__": - main() diff --git a/trading_framework/backtest/runtime/mlflow_segment_logger.py b/trading_framework/backtest/runtime/mlflow_segment_logger.py deleted file mode 100644 index c484d6d..0000000 --- a/trading_framework/backtest/runtime/mlflow_segment_logger.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import TYPE_CHECKING - -import mlflow - -if TYPE_CHECKING: - from trading_framework.backtest.runtime.context import SegmentContext - -LOGGER = logging.getLogger(__name__) - - -class MlflowSegmentLogger: - """Logs segment-level health & progress information to MLflow. - - Tracking is configured via environment variables (recommended for Kubernetes): - - MLFLOW_TRACKING_URI: HTTP(S) address of the MLflow tracking server. - Example: http://mlflow.ml.svc.cluster.local:5000 - - This logger is best-effort. Callers should catch exceptions and continue. - """ - - def __init__(self) -> None: - tracking_uri = os.environ.get("MLFLOW_TRACKING_URI") - if tracking_uri: - mlflow.set_tracking_uri(tracking_uri) - - def log( - self, - *, - ctx: SegmentContext, - duration_seconds: float, - status: str, - ) -> None: - """Log segment metadata as MLflow parameters/metrics/tags.""" - - # mlflow.set_experiment creates the experiment if it does not exist and - # avoids an explicit get/create race. - mlflow.set_experiment(ctx.experiment_id) - - with mlflow.start_run(run_name=ctx.segment_id): - # Parameters (stable, comparable) - mlflow.log_param("expected_sweeps", ctx.expected_sweeps) - mlflow.log_param("completed_sweeps", ctx.completed_sweeps) - mlflow.log_param("failed_sweeps", ctx.failed_sweeps) - - # Metrics - mlflow.log_metric("duration_seconds", duration_seconds) - - # Tags (UI / filtering) - mlflow.set_tag("status", status) - mlflow.set_tag("experiment_id", ctx.experiment_id) - mlflow.set_tag("segment_id", ctx.segment_id) - - LOGGER.info( - "MLflow segment log submitted", - extra={ - "experiment_id": ctx.experiment_id, - "segment_id": ctx.segment_id, - "status": status, - }, - ) \ No newline at end of file diff --git a/trading_framework/backtest/runtime/prometheus_metrics.py b/trading_framework/backtest/runtime/prometheus_metrics.py deleted file mode 100644 index 39ccda9..0000000 --- a/trading_framework/backtest/runtime/prometheus_metrics.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import annotations - -import json -import logging -import os - -from prometheus_client import CollectorRegistry, Gauge, push_to_gateway - -LOGGER = logging.getLogger(__name__) - - -class PrometheusMetricsClient: - """Minimal Prometheus Pushgateway client for batch-style jobs. - - Expected environment: - - PROMETHEUS_PUSHGATEWAY_URL: URL to the Pushgateway. - Example: http://pushgateway.monitoring.svc.cluster.local:9091 - - Optional: - - PROMETHEUS_PUSHGATEWAY_GROUPING_KEY_JSON: JSON object used as grouping key. - If not set, metrics are grouped only by the 'job' argument, which often - causes pushes from different pods to overwrite each other. - - Example: - {"workflow_uid": "ARGO_WORKFLOW_UID"} - - This client is intentionally best-effort: callers should treat it as a - side-effect and never fail the workflow because of metrics delivery. - """ - - def __init__(self) -> None: - self._pushgateway_url = os.environ.get("PROMETHEUS_PUSHGATEWAY_URL") - self._grouping_key = self._load_grouping_key() - self._registry = CollectorRegistry() - - def is_enabled(self) -> bool: - return self._pushgateway_url is not None - - @staticmethod - def _load_grouping_key() -> dict[str, str]: - raw = os.environ.get("PROMETHEUS_PUSHGATEWAY_GROUPING_KEY_JSON") - if not raw: - return {} - - try: - data = json.loads(raw) - except json.JSONDecodeError: - LOGGER.warning( - "Invalid PROMETHEUS_PUSHGATEWAY_GROUPING_KEY_JSON; ignoring" - ) - return {} - - if not isinstance(data, dict): - return {} - - grouping: dict[str, str] = {} - for key, value in data.items(): - if isinstance(key, str) and isinstance(value, str): - grouping[key] = value - return grouping - - def push_gauge( - self, - *, - name: str, - value: float, - labels: dict[str, str], - ) -> None: - if not self._pushgateway_url: - return - - gauge = Gauge( - name, - documentation=name, - labelnames=list(labels.keys()), - registry=self._registry, - ) - - gauge.labels(**labels).set(value) - - def push_all(self, *, job: str) -> None: - if not self._pushgateway_url: - return - - push_to_gateway( - gateway=self._pushgateway_url, - job=job, - registry=self._registry, - grouping_key=self._grouping_key, - ) - - LOGGER.info( - "Prometheus metrics pushed", - extra={"job": job, "grouping_key": self._grouping_key}, - ) diff --git a/trading_framework/backtest/runtime/run_sweep.py b/trading_framework/backtest/runtime/run_sweep.py deleted file mode 100644 index 9fc9065..0000000 --- a/trading_framework/backtest/runtime/run_sweep.py +++ /dev/null @@ -1,496 +0,0 @@ -from __future__ import annotations - -import argparse -import importlib.metadata -import json -import os -import platform -import shutil -import sys -import tomllib -from dataclasses import replace -from datetime import datetime, timezone -from pathlib import Path -from typing import Any - -from trading_framework.backtest.engine.hft_engine import ( - HftBacktestConfig, - HftBacktestEngine, - HftEngineConfig, -) -from trading_framework.backtest.io.s3_adapter import OCIObjectStorageS3Shim -from trading_framework.backtest.runtime.context import SweepContext -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.strategies.strategy_config import StrategyConfig - - -class SweepMaterializer: - """ - Materializes sweep input data from S3 into a local scratch directory. - """ - - def __init__( - self, - *, - bucket: str, - ) -> None: - self._s3 = OCIObjectStorageS3Shim(region="eu-frankfurt-1") - self._bucket = bucket - - def materialize(self, ctx: SweepContext) -> None: - """ - Ensure all input files for the sweep are present locally. - - This operation is idempotent. - """ - data_dir = ctx.scratch_data_dir - ready_marker = data_dir / "_READY" - - if ready_marker.exists(): - return - - data_dir.mkdir(parents=True, exist_ok=True) - - for key in ctx.file_keys: - filename = Path(key).name - target_path = data_dir / filename - - if target_path.exists(): - continue - - self._s3.download_to_file( - bucket=self._bucket, - key=key, - destination=target_path, - ) - - ready_marker.touch() - - -class SweepEngineRunner: - """ - Runs exactly one HFT backtest sweep. - - One runner instance == one sweep == one engine.run(). - """ - - def __init__( - self, - *, - engine_cfg: HftEngineConfig, - strategy_cfg: StrategyConfig, - risk_cfg: RiskConfig, - ) -> None: - self._engine_cfg = engine_cfg - self._strategy_cfg = strategy_cfg - self._risk_cfg = risk_cfg - - def run(self, ctx: SweepContext) -> dict[str, Any]: - """ - Execute the backtest for this sweep. - - Returns lightweight metadata about the run. - """ - results_dir = ctx.scratch_results_dir - results_dir.mkdir(parents=True, exist_ok=True) - - # IMPORTANT: - # Engine expects a FIXED list of local file paths. - data_files = [ - str(ctx.scratch_data_dir / Path(key).name) - for key in ctx.file_keys - ] - - engine_cfg = self._build_engine_cfg(data_files, results_dir) - - # Defensive: numpy will not create parent directories for output files. - Path(engine_cfg.stats_npz_path).parent.mkdir(parents=True, exist_ok=True) - - backtest_cfg = HftBacktestConfig( - # Keep IDs filesystem-safe. Some engines/libraries may use the ID - # as part of output paths. - id=f"{ctx.experiment_id}__{ctx.segment_id}__{ctx.sweep_id}", - description="sweep execution", - engine_cfg=engine_cfg, - strategy_cfg=self._strategy_cfg, - risk_cfg=self._risk_cfg, - ) - - engine = HftBacktestEngine(backtest_cfg) - - # Ensure any relative writes performed by the engine end up inside the - # scratch subtree of this sweep. - previous_cwd = Path.cwd() - try: - os.chdir(ctx.scratch_segment_dir) - result = engine.run() - finally: - os.chdir(previous_cwd) - - done_marker = ctx.scratch_results_dir / "_DONE" - done_marker.touch() - - return { - "experiment_id": ctx.experiment_id, - "segment_id": ctx.segment_id, - "sweep_id": ctx.sweep_id, - "stats_file": result.stats_file, - "extra_metadata": result.extra_metadata, - } - - def _build_engine_cfg( - self, - data_files: list[str], - results_dir: Path, - ) -> HftEngineConfig: - """ - Clone the base engine config and inject sweep-specific paths. - """ - cfg = replace(self._engine_cfg) - - # THIS is the critical binding to the engine semantics - cfg.data_files = data_files - cfg.stats_npz_path = str(results_dir / "stats.npz") - cfg.event_bus_path = str(results_dir / "events.jsonl") - - return cfg - - -class SweepMetadataWriter: - """Writes immutable metadata.json for a completed sweep.""" - - def __init__(self, *, runner: str) -> None: - self._runner = runner - - @staticmethod - def _read_pyproject_project_info(pyproject_path: Path) -> tuple[str | None, str | None]: - """Read [project] name/version from pyproject.toml. - - This is used as a fallback when the project is executed from source without - being installed as a distribution (importlib.metadata won't find it). - """ - - try: - raw = pyproject_path.read_bytes() - except OSError: - return (None, None) - - try: - data = tomllib.loads(raw.decode("utf-8")) - except (UnicodeDecodeError, tomllib.TOMLDecodeError): - return (None, None) - - if "project" not in data: - return (None, None) - - project = data["project"] - name = project["name"] if isinstance(project, dict) and "name" in project else None - version = project["version"] if isinstance(project, dict) and "version" in project else None - - if not isinstance(name, str): - name = None - if not isinstance(version, str): - version = None - - return (name, version) - - @staticmethod - def _guess_repo_root(start: Path) -> Path | None: - """Walk upwards until pyproject.toml is found.""" - - current = start - for _ in range(20): - candidate = current / "pyproject.toml" - if candidate.exists(): - return current - if current.parent == current: - return None - current = current.parent - return None - - @classmethod - def _resolve_project_metadata(cls) -> dict[str, str | None]: - """Resolve project name/version without failing the sweep.""" - - repo_root = cls._guess_repo_root(Path(__file__).resolve()) - pyproject_path = (repo_root / "pyproject.toml") if repo_root is not None else None - - name_from_pyproject: str | None = None - version_from_pyproject: str | None = None - - if pyproject_path is not None: - name_from_pyproject, version_from_pyproject = cls._read_pyproject_project_info( - pyproject_path - ) - - distribution_name = name_from_pyproject or "trading-framework" - - version: str | None - source: str - try: - version = importlib.metadata.version(distribution_name) - source = "importlib.metadata" - except importlib.metadata.PackageNotFoundError: - version = version_from_pyproject - source = "pyproject.toml" if version is not None else "unknown" - - return { - "name": distribution_name, - "version": version, - "source": source, - } - - def write( - self, - *, - ctx: SweepContext, - status: str, - started_at: datetime, - finished_at: datetime, - ) -> None: - duration_seconds = (finished_at - started_at).total_seconds() - - project_meta = self._resolve_project_metadata() - - metadata = { - "schema_version": "1.0", - "identity": { - "experiment_id": ctx.experiment_id, - "segment_id": ctx.segment_id, - "sweep_id": ctx.sweep_id, - }, - "lifecycle": { - "status": status, - "started_at": started_at.isoformat(), - "finished_at": finished_at.isoformat(), - "duration_seconds": duration_seconds, - "runner": self._runner, - }, - "parameters": ctx.parameters, - "code": { - "git": { - "commit": os.environ.get("GIT_COMMIT"), - "dirty": os.environ.get("GIT_DIRTY") == "1", - "branch": os.environ.get("GIT_BRANCH"), - }, - "project": { - "name": project_meta["name"], - "version": project_meta["version"], - "version_source": project_meta["source"], - }, - }, - "environment": { - "python": sys.version.split()[0], - "framework": platform.platform(), - "container_image": os.environ.get("IMAGE_TAG"), - }, - "artifacts": { - "stats": "stats.npz", - "events": "events.jsonl", - }, - "links": {}, - } - - target = ctx.scratch_results_dir / "sweep_metadata.json" - target.write_text(json.dumps(metadata, indent=2), encoding="utf-8") - - -class SweepResultPersister: - """ - Persists sweep results from scratch to S3. - - Upload is atomic at sweep level via a _DONE marker. - """ - - def __init__( - self, - *, - bucket: str, - prefix: str = "backtests", - ) -> None: - self._s3 = OCIObjectStorageS3Shim(region="eu-frankfurt-1") - self._bucket = bucket - self._prefix = prefix.rstrip("/") - - def persist(self, ctx: SweepContext) -> None: - results_dir = ctx.scratch_results_dir - done_marker = results_dir / "_DONE" - - if not results_dir.exists(): - raise RuntimeError(f"Results directory does not exist: {results_dir}") - - if not done_marker.exists(): - raise RuntimeError( - f"Sweep results not finalized (_DONE missing): {results_dir}" - ) - - s3_base = self._s3_base_scratch_prefix(ctx) - - for path in results_dir.iterdir(): - if path.is_dir(): - continue - - key = f"{s3_base}/{path.name}" - self._upload_file(path, key) - - def _upload_file(self, path: Path, key: str) -> None: - with path.open("rb") as fh: - self._s3.put_object( - bucket=self._bucket, - key=key, - body=fh, - ) - - def _s3_base_scratch_prefix(self, ctx: SweepContext) -> str: - return ( - f"{self._prefix}/" - f"{ctx.experiment_id}/" - f"{ctx.segment_id}/" - f"{ctx.sweep_id}" - ) - - -class SweepCleaner: - """ - Handles safe cleanup of sweep scratch directories. - - Invariant: - - Only sweep-private state may be removed during parallel execution. - - Segment-level directories are shared across sweeps and must not be - deleted by a single sweep. - - Cleanup is allowed ONLY after successful persistence. - """ - - def __init__(self, *, keep_scratch: bool) -> None: - self._keep_scratch = keep_scratch - - def cleanup(self, ctx: SweepContext) -> None: - """ - Remove the sweep's private scratch subtree. - - This deletes only: - - ///results// - - It intentionally does NOT delete the segment directory itself, since that - directory is shared by all sweeps in the segment (parallel execution). - """ - if self._keep_scratch: - return - - sweep_results_dir = ctx.scratch_results_dir - if not sweep_results_dir.exists(): - return - - self._validate_target(ctx, sweep_results_dir) - shutil.rmtree(sweep_results_dir) - - @staticmethod - def _validate_target(ctx: SweepContext, target_dir: Path) -> None: - """ - Guard rails against accidental deletion of shared directories. - - This method raises if the computed target does not match the expected - sweep results layout. - """ - if target_dir.name != ctx.sweep_id: - raise RuntimeError( - "Refusing to delete: target_dir does not match sweep_id " - f"({target_dir} vs {ctx.sweep_id})" - ) - - if target_dir.parent.name != "results": - raise RuntimeError( - "Refusing to delete: target_dir is not under a 'results' folder " - f"({target_dir})" - ) - - segment_dir = ctx.scratch_segment_dir - try: - resolved_target = target_dir.resolve() - resolved_segment = segment_dir.resolve() - except FileNotFoundError: - # If a parent directory was removed concurrently, treat as no-op. - return - - if not resolved_target.is_relative_to(resolved_segment): - raise RuntimeError( - "Refusing to delete: target_dir is outside scratch_segment_dir " - f"({resolved_target} not under {resolved_segment})" - ) - - -def main() -> None: - parser = argparse.ArgumentParser("run single backtest sweep") - parser.add_argument("--context", type=Path, required=True) - parser.add_argument("--scratch-root", type=Path, required=True) - args = parser.parse_args() - - # ------------------------------------------------------------------ - # Load sweep context - # ------------------------------------------------------------------ - - if not args.context.exists(): - raise FileNotFoundError( - f"SweepContext file does not exist: {args.context}. " - "Ensure it is mounted as an Argo artifact." - ) - - ctx = SweepContext(**json.loads(args.context.read_text(encoding="utf-8"))) - ctx = replace(ctx, scratch_root=args.scratch_root) - - # ------------------------------------------------------------------ - # Setup - # ------------------------------------------------------------------ - - materializer = SweepMaterializer(bucket="data") - materializer.materialize(ctx) - - engine_cfg = HftEngineConfig(**ctx.parameters["engine"]) - strategy_cfg = StrategyConfig(**ctx.parameters["strategy"]) - risk_cfg = RiskConfig(**ctx.parameters["risk"]) - - runner = SweepEngineRunner( - engine_cfg=engine_cfg, - strategy_cfg=strategy_cfg, - risk_cfg=risk_cfg, - ) - - persister = SweepResultPersister(bucket="data") - - metadata_writer = SweepMetadataWriter(runner="argo") - cleaner = SweepCleaner(keep_scratch=False) - - # ------------------------------------------------------------------ - # Execute sweep - # ------------------------------------------------------------------ - - started_at = datetime.now(timezone.utc) - status = "success" - - try: - print(runner.run(ctx)) - except Exception: - status = "failed" - raise - else: - finished_at = datetime.now(timezone.utc) - - # Metadata is ALWAYS written - metadata_writer.write( - ctx=ctx, - status=status, - started_at=started_at, - finished_at=finished_at, - ) - - # Persist results ONLY on success - persister.persist(ctx) - finally: - # Sweep-level cleanup is ALWAYS allowed - cleaner.cleanup(ctx) - - -if __name__ == "__main__": - main() diff --git a/trading_framework/backtest/runtime/segment_finalize_entrypoint.py b/trading_framework/backtest/runtime/segment_finalize_entrypoint.py deleted file mode 100644 index f8cd9be..0000000 --- a/trading_framework/backtest/runtime/segment_finalize_entrypoint.py +++ /dev/null @@ -1,194 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import logging -from datetime import datetime, timezone -from pathlib import Path - -from trading_framework.backtest.io.s3_adapter import OCIObjectStorageS3Shim -from trading_framework.backtest.runtime.context import SegmentContext -from trading_framework.backtest.runtime.mlflow_segment_logger import MlflowSegmentLogger -from trading_framework.backtest.runtime.prometheus_metrics import PrometheusMetricsClient - -LOGGER = logging.getLogger(__name__) - - -class SegmentFinalizer: - """ - Finalizes a segment after all sweeps have completed. - - Responsibilities: - - write segment_metadata.json - - write _DONE marker - """ - - def finalize( - self, - *, - ctx: SegmentContext, - ) -> None: - finished_at = datetime.now(timezone.utc) - - status = "success" - if ctx.failed_sweeps > 0: - status = "failed" - - metadata = { - "schema_version": "1.0", - "identity": { - "experiment_id": ctx.experiment_id, - "segment_id": ctx.segment_id, - }, - "lifecycle": { - "status": status, - "started_at": ctx.segment_started_at.isoformat(), - "finished_at": finished_at.isoformat(), - "duration_seconds": ( - finished_at - ctx.segment_started_at - ).total_seconds(), - }, - "sweeps": { - "expected": ctx.expected_sweeps, - "completed": ctx.completed_sweeps, - "failed": ctx.failed_sweeps, - }, - } - - segment_dir = ctx.scratch_segment_dir - segment_dir.mkdir(parents=True, exist_ok=True) - - (segment_dir / "segment_metadata.json").write_text( - json.dumps(metadata, indent=2), - encoding="utf-8", - ) - - (segment_dir / "_DONE").write_text( - finished_at.isoformat(), - encoding="utf-8", - ) - - # --- MLflow logging (side-effect only) --- - try: - MlflowSegmentLogger().log( - ctx=ctx, - duration_seconds=metadata["lifecycle"]["duration_seconds"], - status=status, - ) - except Exception: - LOGGER.exception("MLflow logging failed") - - # --- Prometheus metrics (side-effect only) --- - metrics = PrometheusMetricsClient() - - if metrics.is_enabled(): - try: - labels = { - "experiment_id": ctx.experiment_id, - "segment_id": ctx.segment_id, - "status": status, - } - - metrics.push_gauge( - name="backtest_segment_duration_seconds", - value=metadata["lifecycle"]["duration_seconds"], - labels=labels, - ) - - metrics.push_gauge( - name="backtest_segment_completed_sweeps", - value=float(ctx.completed_sweeps), - labels=labels, - ) - - metrics.push_gauge( - name="backtest_segment_failed_sweeps", - value=float(ctx.failed_sweeps), - labels=labels, - ) - - metrics.push_all(job="backtest_segment") - - except Exception: - LOGGER.exception("Prometheus push failed") - - -class SegmentMetadataPersister: - def __init__( - self, - *, - bucket: str, - prefix: str = "backtests", - ) -> None: - self._s3 = OCIObjectStorageS3Shim(region="eu-frankfurt-1") - self._bucket = bucket - self._prefix = prefix - - def persist( - self, - *, - experiment_id: str, - segment_id: str, - segment_dir: Path, - ) -> None: - prefix = f"{self._prefix}/{experiment_id}/{segment_id}" - - for name in ("segment_metadata.json", "_DONE"): - path = segment_dir / name - if not path.exists(): - continue - - with path.open("rb") as fh: - self._s3.put_object( - bucket=self._bucket, - key=f"{prefix}/{name}", - body=fh, - ) - - -def main() -> None: - parser = argparse.ArgumentParser("finalize segment") - - parser.add_argument("--experiment-id", type=str, required=True) - parser.add_argument("--segment-id", type=str, required=True) - - parser.add_argument("--expected-sweeps", type=int, required=True) - parser.add_argument("--completed-sweeps", type=int, required=True) - parser.add_argument("--failed-sweeps", type=int, required=True) - - parser.add_argument( - "--segment-started-at", - type=str, - required=True, - help="ISO-8601 timestamp (UTC)", - ) - - parser.add_argument("--scratch-root", type=Path, required=True) - - args = parser.parse_args() - - ctx = SegmentContext( - experiment_id=args.experiment_id, - segment_id=args.segment_id, - expected_sweeps=args.expected_sweeps, - completed_sweeps=args.completed_sweeps, - failed_sweeps=args.failed_sweeps, - segment_started_at=datetime.fromisoformat(args.segment_started_at), - scratch_root=args.scratch_root, - ) - - finalizer = SegmentFinalizer() - finalizer.finalize( - ctx=ctx, - ) - - persister = SegmentMetadataPersister(bucket="data") - persister.persist( - experiment_id=ctx.experiment_id, - segment_id=ctx.segment_id, - segment_dir=ctx.scratch_segment_dir, - ) - - -if __name__ == "__main__": - main() diff --git a/trading_framework/core/events/sinks/file_recorder.py b/trading_framework/core/events/sinks/file_recorder.py deleted file mode 100644 index bfe46bf..0000000 --- a/trading_framework/core/events/sinks/file_recorder.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Append-only file recorder sink. -""" -from __future__ import annotations - -import json -from pathlib import Path -from typing import Any - - -class FileRecorderSink: - """Writes each event as a JSON line to a file.""" - - def __init__(self, path: str | Path) -> None: - self._path = Path(path) - self._path.parent.mkdir(parents=True, exist_ok=True) - self._fh = self._path.open("a", encoding="utf-8") - self._closed = False - - def on_event(self, event: Any) -> None: - record = event.__dict__ if hasattr(event, "__dict__") else {"event": str(event)} - self._fh.write(json.dumps(record) + "\n") - self._fh.flush() - - def close(self) -> None: - if self._closed: - return - self._fh.flush() - self._fh.close() - self._closed = True From 74e61793eefd6d38ca5ca0593a2dff3f4c9c548a Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 15:05:57 +0200 Subject: [PATCH 03/61] m1-slice2a-clean-core --- pyproject.toml | 40 ++++++---------------------------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ae8a1c8..b281c99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,15 +20,6 @@ classifiers = [ dependencies = [ "pydantic>=2,<3", - "ujson>=5,<6", - "tqdm>=4,<5", - "pyarrow>=16.0,<17", - "oci>=2,<3", - "hftbacktest>=2,<3", - "numpy>=2.0,<2.3", - "jsonschema>=4,<5", - "mlflow>=3,<4", - "prometheus-client>=0.24,<1" ] [project.optional-dependencies] @@ -36,7 +27,12 @@ dev = [ "pytest>=9,<10", "import-linter>=1.11,<2", "ruff>=0.4,<1", - "mypy>=1.9,<2" + "mypy>=1.9,<2", + "hftbacktest>=2,<3", + "jsonschema>=4,<5", + "matplotlib>=3,<4", + "numpy>=2.0,<2.3", + "referencing>=0.37,<1", ] # -------------------------------------------------- @@ -88,30 +84,6 @@ name = "Core must be pure" type = "forbidden" source_modules = ["trading_framework.core"] forbidden_modules = [ - "trading_framework.live", - "trading_framework.backtest", "trading_framework.strategies" ] -# Strategies use core only -[[tool.importlinter.contracts]] -name = "Strategies may depend on core only" -type = "forbidden" -source_modules = ["trading_framework.strategies"] -forbidden_modules = [ - "trading_framework.live", - "trading_framework.backtest" -] - -# Environments must not mix -[[tool.importlinter.contracts]] -name = "Live and backtest must not mix" -type = "forbidden" -source_modules = ["trading_framework.live"] -forbidden_modules = ["trading_framework.backtest"] - -[[tool.importlinter.contracts]] -name = "Backtest must not depend on live" -type = "forbidden" -source_modules = ["trading_framework.backtest"] -forbidden_modules = ["trading_framework.live"] From b9b745a5eb05e1a37d4ce99c7d4d9a8c3d735e1b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 17:22:19 +0200 Subject: [PATCH 04/61] m1 slice3: move examples --- README.md | 70 +-- examples/__init__.py | 0 examples/argo/argo.json | 106 ----- examples/local/__init__.py | 0 examples/local/backtest.py | 84 ---- examples/local/local.json | 88 ---- examples/local/oci.config.example | 6 - examples/strategies/__init__.py | 0 examples/strategies/debug_strategy.py | 184 -------- tests/data/parts/part-000.npz | Bin 9516 -> 0 bytes tests/data/parts/part-001.npz | Bin 9516 -> 0 bytes tests/data/parts/part-002.npz | Bin 9516 -> 0 bytes tests/data/results/events.json | 412 ------------------ tests/data/results/stats.npz | Bin 2459 -> 0 bytes ...enerate_synthetic_hbt_data_concatenated.py | 230 ---------- .../generate_synthetic_hbt_data_seperated.py | 277 ------------ tests/data/scripts/peek_data.py | 280 ------------ 17 files changed, 16 insertions(+), 1721 deletions(-) delete mode 100644 examples/__init__.py delete mode 100644 examples/argo/argo.json delete mode 100644 examples/local/__init__.py delete mode 100644 examples/local/backtest.py delete mode 100644 examples/local/local.json delete mode 100644 examples/local/oci.config.example delete mode 100644 examples/strategies/__init__.py delete mode 100644 examples/strategies/debug_strategy.py delete mode 100644 tests/data/parts/part-000.npz delete mode 100644 tests/data/parts/part-001.npz delete mode 100644 tests/data/parts/part-002.npz delete mode 100644 tests/data/results/events.json delete mode 100644 tests/data/results/stats.npz delete mode 100644 tests/data/scripts/generate_synthetic_hbt_data_concatenated.py delete mode 100644 tests/data/scripts/generate_synthetic_hbt_data_seperated.py delete mode 100644 tests/data/scripts/peek_data.py diff --git a/README.md b/README.md index 2dc5bd5..cd4aee7 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,7 @@ Internally: Core modules: - `core/` -- domain models, state machine, risk engine, events -- `backtest/` -- engine adapters, orchestration, runtime entrypoints - `strategies/` -- base strategy interfaces -- `live/` -- live adapters (work in progress) -- `examples/` -- minimal runnable setups - `tests/` -- semantic invariant validation - `scripts/` -- development helper scripts @@ -87,9 +84,9 @@ Core modules: ## 🚀 Quickstart -Minimal local example: `examples/local/backtest.py`\ -Runs entirely locally using bundled or synthetic example data.\ -No S3, cloud storage or live connectivity required. +This repository (`core`) is the **library-only** semantic core. + +For runnable backtests and runtime entrypoints, use `core-runtime` (the runtime/backtesting repository). ### Option 1 – Recommended: Dev Container @@ -103,7 +100,8 @@ cd trading-framework Open in an IDE supporting Dev Containers, reopen in container, then: ```bash -python examples/local/backtest.py --config examples/local/local.json +cd ../core-runtime +python trading_runtime/local/backtest.py --config trading_runtime/local/local.json ``` No manual `pip install` required inside the container. @@ -114,7 +112,6 @@ Python 3.11.x is required. ```bash pip install -e . -python examples/local/backtest.py --config examples/local/local.json ``` --- @@ -123,46 +120,11 @@ python examples/local/backtest.py --config examples/local/local.json ### Local Mode -- Fully local execution -- Uses bundled or synthetic data -- No cloud dependencies -- Suitable for development and testing - -```bash -python examples/local/backtest.py --config examples/local/local.json -``` +Local execution is provided by `core-runtime`. ### Cloud / Entrypoint Mode -The backtest runtime exposes entrypoints designed for cloud-native -execution environments. - -These enable: - -- Remote segment execution -- Distributed parameter sweeps -- Object storage integration -- Experiment orchestration via external workflow engines - -Entrypoints are located in: - -``` -trading_framework/backtest/runtime/ -``` - -Infrastructure and orchestration configuration are intentionally kept separate from the core trading framework. - -Cloud execution currently relies on [Oracle Cloud Infrastructure](https://cloud.oracle.com) (OCI) Object Storage accessed via Instance Principals and OCI IAM configuration. -The storage integration is implemented through an S3-compatible adapter in the I/O layer located in: - -``` -trading_framework/backtest/io/ -``` - -The runtime entrypoints are designed primarily for [Kubernetes](https://kubernetes.io)-based workloads orchestrated via [Argo Workflows](https://argoproj.github.io/workflows). - -While the core architecture is cloud-agnostic, the current infrastructure bindings are OCI-specific. -Other cloud providers and execution environments are not yet implemented. +Runtime/backtesting entrypoints and orchestration live in `core-runtime`. --- @@ -181,15 +143,15 @@ Key assumptions: Example synthetic datasets are provided in: ``` -tests/data/parts/ +core-runtime/tests/data/parts/ ``` Example parts: ``` -tests/data/parts/part-000.npz -tests/data/parts/part-001.npz -tests/data/parts/part-002.npz +core-runtime/tests/data/parts/part-000.npz +core-runtime/tests/data/parts/part-001.npz +core-runtime/tests/data/parts/part-002.npz ``` ### Result Artifacts @@ -197,20 +159,20 @@ tests/data/parts/part-002.npz Backtest runs produce deterministic result artifacts stored in: ``` -tests/data/results/ +core-runtime/tests/data/results/ ``` Generated files may include: ``` -tests/data/results/stats.npz -tests/data/results/events.json +core-runtime/tests/data/results/stats.npz +core-runtime/tests/data/results/events.json ``` Helper scripts for generating and inspecting synthetic datasets are located in: ``` -tests/data/scripts/ +core-runtime/tests/data/scripts/ ``` --- @@ -218,7 +180,7 @@ tests/data/scripts/ ## ⚙️ Configuration Execution is driven by explicit configuration files -(e.g. `examples/local/local.json`). +(see `core-runtime/trading_runtime/local/local.json` for a runnable example). Configurations define: diff --git a/examples/__init__.py b/examples/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/argo/argo.json b/examples/argo/argo.json deleted file mode 100644 index 0e30d6e..0000000 --- a/examples/argo/argo.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "id": "debug_strategy_v0", - "description": "Debug Strategy V0", - - "engine": { - "initial_snapshot": null, - "data_files": null, - - "instrument": "BTC_USDC-PERPETUAL", - "tick_size": 0.1, - "lot_size": 0.01, - "contract_size": 1, - - "maker_fee_rate": 0.0, - "taker_fee_rate": 0.0, - - "entry_latency_ns": 10000000, - "response_latency_ns": 10000000, - - "use_risk_adverse_queue_model": true, - "partial_fill_venue": true, - - "max_steps": 5000000, - - "last_trades_capacity": 10, - "max_price_tick_levels": 20, - - "roi_lb": 40000, - "roi_ub": 80000, - - "stats_npz_path": null, - "event_bus_path": null - }, - - "risk": { - "scope": "debug_strategy_v0", - - "position_limits": { - "currency": "USDC", - "max_position": 10 - }, - - "notional_limits": { - "currency": "USDC", - "max_gross_notional": 200000.0, - "max_single_order_notional": 10000.0 - }, - - "quote_limits": { - "currency": "USDC", - "max_gross_quote_notional": 20000.0, - "max_net_quote_notional": 10000.0, - "max_active_quotes": 20000 - }, - - "order_rate_limits": { - "max_orders_per_second": 20, - "max_cancels_per_second": 20 - }, - - "max_loss": { - "currency": "USDC", - "max_drawdown": -2000.0, - "rolling_loss": -200.0, - "rolling_loss_window": 60 - }, - - "extra": { - "venue_policy": { - "min_order_notional": 5.0, - "post_only_mode": "reject" - } - } - }, - - "strategy": { - "class_path": "examples.strategies.debug_strategy:DebugStrategyV0", - "spread": 5.0, - "order_qty": 0.1, - "use_price_tick_levels": 3, - "post_only": true - }, - - "experiment": { - "start_ts_ns": 1636035200000000000, - "end_ts_ns": 1836121600000000000, - "symbol": "BTC_USDC-PERPETUAL", - - "venue": "deribit", - "datatype": "mixed", - - "segmentation": { - "max_segment_gb": 0.00001 - }, - - "sweeps": { - "strategy.spread": { - "start": 2.0, - "stop": 3.0, - "step": 1.0 - }, - "strategy.order_qty": [0.1, 0.2] - } - } - -} diff --git a/examples/local/__init__.py b/examples/local/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/local/backtest.py b/examples/local/backtest.py deleted file mode 100644 index cd723fe..0000000 --- a/examples/local/backtest.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Command-line interface for running backtests in devcontainer.""" - -from __future__ import annotations - -import argparse -import json -import sys -from pathlib import Path -from typing import TYPE_CHECKING - -# Enable importing plugin-style modules outside the core package (e.g. examples/) -if __name__ == "__main__" or True: - PROJECT_ROOT = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(PROJECT_ROOT)) - -if TYPE_CHECKING: - from trading_runtime.backtest.engine.engine_base import BacktestResult - -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.strategies.strategy_config import StrategyConfig -from trading_runtime.backtest.engine.hft_engine import ( - HftBacktestConfig, - HftBacktestEngine, - HftEngineConfig, -) - - -def load_config(path: str) -> HftBacktestConfig: - """Load a backtest configuration from a JSON file.""" - config_path = Path(path) - raw_json = json.loads(config_path.read_text(encoding="utf-8")) - - try: - engine_raw = raw_json["engine"] - strategy_raw = raw_json["strategy"] - risk_raw = raw_json["risk"] - except KeyError as exc: - raise ValueError( - f"Missing top-level section in {config_path}: {exc}" - ) from exc - - engine_cfg = HftEngineConfig(**engine_raw) - strategy_cfg = StrategyConfig(**strategy_raw) - risk_cfg = RiskConfig(**risk_raw) - - return HftBacktestConfig( - id=raw_json["id"], - description=raw_json.get("description", ""), - engine_cfg=engine_cfg, - strategy_cfg=strategy_cfg, - risk_cfg=risk_cfg, - ) - - -def main() -> None: - """Entry point for the backtest command-line interface.""" - parser = argparse.ArgumentParser( - description="Run a strategy-based hftbacktest backtest." - ) - parser.add_argument( - "--config", - type=str, - required=True, - help="Path to JSON config file (HftBacktestConfig).", - ) - args = parser.parse_args() - - cfg = load_config(args.config) - engine = HftBacktestEngine(cfg) - - print("Backtest started.") - result: BacktestResult = engine.run() - - print("Backtest finished.") - print(f" id: {result.id}") - print(f" stats_npz: {result.stats_file}") - if result.extra_metadata is not None: - print(" metadata:") - for key, value in result.extra_metadata.items(): - print(f" {key}: {value}") - - -if __name__ == "__main__": - main() diff --git a/examples/local/local.json b/examples/local/local.json deleted file mode 100644 index 0b7c4c2..0000000 --- a/examples/local/local.json +++ /dev/null @@ -1,88 +0,0 @@ -{ - "id": "debug_strategy_v0", - "description": "Debug Strategy V0", - - "engine": { - "initial_snapshot": null, - "data_files": [ - "/workspaces/core/tests/data/parts/part-000.npz", - "/workspaces/core/tests/data/parts/part-001.npz", - "/workspaces/core/tests/data/parts/part-002.npz" - ], - - "instrument": "BTC_USDC-PERPETUAL", - "tick_size": 0.1, - "lot_size": 0.01, - "contract_size": 1, - - "maker_fee_rate": 0.0, - "taker_fee_rate": 0.0, - - "entry_latency_ns": 10000000, - "response_latency_ns": 10000000, - - "use_risk_adverse_queue_model": true, - "partial_fill_venue": true, - - "max_steps": 5000000, - - "last_trades_capacity": 10, - "max_price_tick_levels": 20, - - "roi_lb": 40000, - "roi_ub": 80000, - - "stats_npz_path": "/workspaces/core/tests/data/results/stats.npz", - "event_bus_path": "/workspaces/core/tests/data/results/events.json" - }, - - "risk": { - "scope": "debug_strategy_v0", - - "position_limits": { - "currency": "USDC", - "max_position": 10 - }, - - "notional_limits": { - "currency": "USDC", - "max_gross_notional": 200000.0, - "max_single_order_notional": 10000.0 - }, - - "quote_limits": { - "currency": "USDC", - "max_gross_quote_notional": 20000.0, - "max_net_quote_notional": 10000.0, - "max_active_quotes": 20000 - }, - - "order_rate_limits": { - "max_orders_per_second": 20, - "max_cancels_per_second": 20 - }, - - "max_loss": { - "currency": "USDC", - "max_drawdown": -2000.0, - "rolling_loss": -200.0, - "rolling_loss_window": 60 - }, - - "extra": { - "venue_policy": { - "min_order_notional": 5.0, - "post_only_mode": "reject" - } - } - }, - - "strategy": { - "class_path": "examples.strategies.debug_strategy:DebugStrategyV0", - "spread": 5.0, - "order_qty": 0.1, - "use_price_tick_levels": 3, - "post_only": true - } - -} diff --git a/examples/local/oci.config.example b/examples/local/oci.config.example deleted file mode 100644 index 031317e..0000000 --- a/examples/local/oci.config.example +++ /dev/null @@ -1,6 +0,0 @@ -[DEFAULT] -user=ocid1.user.oc1..REPLACE_ME -tenancy=ocid1.tenancy.oc1..REPLACE_ME -region=eu-frankfurt-1 -fingerprint=aa:bb:cc:dd:REPLACE_ME -key_file=/absolute/path/to/.oci/oci_api_key.pem diff --git a/examples/strategies/__init__.py b/examples/strategies/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/strategies/debug_strategy.py b/examples/strategies/debug_strategy.py deleted file mode 100644 index 4789159..0000000 --- a/examples/strategies/debug_strategy.py +++ /dev/null @@ -1,184 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from trading_framework import ( - EngineContext, - GateDecision, - MarketEvent, - RiskConstraints, - StrategyState, - ) - -from trading_framework import ( - NewOrderIntent, - OrderIntent, - Price, - Quantity, - ReplaceOrderIntent, - SlotKey, - Strategy, - stable_slot_order_id, -) - -_SLOT_NAMESPACE = "debug_strategy_v0" - - -class DebugStrategyV0(Strategy): - """Very simple market making example strategy.""" - - def __init__( - self, - spread: float, - order_qty: float, - use_price_tick_levels: int, - post_only: bool, - ) -> None: - self.spread = spread - self.order_qty = order_qty - self.use_price_tick_levels = use_price_tick_levels - self.post_only = post_only - - self.intents_on_event: list[OrderIntent] = [] - self.intents_after_risk: list[OrderIntent] = [] - - def round_to_tick(self, price: float, tick: float) -> float: - if tick <= 0: - raise ValueError("tick must be positive") - return round(price / tick) * tick - - def on_feed( - self, - state: StrategyState, - event: MarketEvent, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Feed-triggered logic (rc=2). Inputs are read-only for Strategy, otherwise considered a bug.""" - - self.intents_on_event = [] - - # NOTE: keep existing logic as-is for now; we will align field names/types later. - # This block is only to satisfy the new interface. - if not constraints.trading_enabled: - return self.intents_on_event - - if not event.is_book() or event.book is None: - return self.intents_on_event - - if not event.book.bids or not event.book.asks: - return self.intents_on_event - - best_bid = float(event.book.bids[0].price.value) - best_ask = float(event.book.asks[0].price.value) - mid = 0.5 * (best_bid + best_ask) - - tick = float(engine_cfg.tick_size) - tif = "POST_ONLY" if self.post_only else "GTC" - - num_levels = int(self.use_price_tick_levels) - if num_levels <= 0: - num_levels = 1 - - instrument = str(event.instrument) - - def is_slot_busy(client_order_id: str) -> bool: - return state.is_order_id_busy(instrument, client_order_id) - - def bid_price_for_level(level_index: int) -> float: - if level_index < len(event.book.bids): - px = float(event.book.bids[level_index].price.value) - else: - px = mid - (self.spread * 0.5) - (float(level_index) * tick) - return self.round_to_tick(px, tick) - - def ask_price_for_level(level_index: int) -> float: - if level_index < len(event.book.asks): - px = float(event.book.asks[level_index].price.value) - else: - px = mid + (self.spread * 0.5) + (float(level_index) * tick) - return self.round_to_tick(px, tick) - - intents: list[OrderIntent] = [] - - for level in range(num_levels): - bid_slot = SlotKey(instrument=instrument, side="buy", level_index=level) - ask_slot = SlotKey(instrument=instrument, side="sell", level_index=level) - - bid_id = stable_slot_order_id(bid_slot, namespace=_SLOT_NAMESPACE) - ask_id = stable_slot_order_id(ask_slot, namespace=_SLOT_NAMESPACE) - - bid_px = bid_price_for_level(level) - ask_px = ask_price_for_level(level) - - if is_slot_busy(bid_id): - intents.append( - ReplaceOrderIntent( - ts_ns_local=event.ts_ns_local, - instrument=instrument, - client_order_id=bid_id, - intent_type="replace", - order_type="limit", - side="buy", - intended_price=Price(currency="UNKNOWN", value=bid_px), - intended_qty=Quantity(value=self.order_qty, unit="contracts"), - ) - ) - else: - intents.append( - NewOrderIntent( - ts_ns_local=event.ts_ns_local, - instrument=instrument, - client_order_id=bid_id, - intent_type="new", - order_type="limit", - side="buy", - intended_price=Price(currency="UNKNOWN", value=bid_px), - intended_qty=Quantity(value=self.order_qty, unit="contracts"), - time_in_force=tif, - ) - ) - - if is_slot_busy(ask_id): - intents.append( - ReplaceOrderIntent( - ts_ns_local=event.ts_ns_local, - instrument=instrument, - client_order_id=ask_id, - intent_type="replace", - order_type="limit", - side="sell", - intended_price=Price(currency="UNKNOWN", value=ask_px), - intended_qty=Quantity(value=self.order_qty, unit="contracts"), - ) - ) - else: - intents.append( - NewOrderIntent( - ts_ns_local=event.ts_ns_local, - instrument=instrument, - client_order_id=ask_id, - intent_type="new", - order_type="limit", - side="sell", - intended_price=Price(currency="UNKNOWN", value=ask_px), - intended_qty=Quantity(value=self.order_qty, unit="contracts"), - time_in_force=tif, - ) - ) - - self.intents_on_event.extend(intents) - return self.intents_on_event - - def on_order_update( - self, - state: StrategyState, - engine_cfg: EngineContext, - constraints: RiskConstraints, - ) -> list[OrderIntent]: - """Order-update-triggered logic (rc=3). Inputs are read-only for Strategy, otherwise considered a bug.""" - return [] - - def on_risk_decision(self, decision: GateDecision) -> None: - self.intents_after_risk = decision.accepted_now diff --git a/tests/data/parts/part-000.npz b/tests/data/parts/part-000.npz deleted file mode 100644 index 28506c2b4599f000deb50218d601be8836f450fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9516 zcmb_i4R{k(x-N3HV)cplLAMtwWLLJ-+SPDH?RA09MHabBt)W(o@{_TyD_~c*U2JbOoBZQ>nIeszD@+C|@$B`3eTT39$Zl8Z@#QNf zmsb2u>L{R<4l!~`RXxR2UhDt5{)JiH{Yw>_?aSrozFn&5i#KRj9-<}&w25bzt~zyi zu50_ED}RX0>G|94`pEnpI_kZ1ru8+7_`w|KLw-K5=J1xrLX?!-^4ah0&%2dH-cuhl zeV5O>CYkJ9F^e+1&PM%1-ZN8lZD}3J+~415E}!a?nz!JluCA_*8D7Ixso)ShIjWu( z{xfcJ!Vh1TXDCgFSaX%P11)h??wVJ_x4HiHxBl229;Z-yHjw_OZQS`o(v+B$Si_`k zaa-4|}!Bfa^X;Mc&?1BJ%Pbv=7C(PMG5r1}t|FSs)w1V3)%kS5XBYY^cTL2B1asy~Uf+!G z*=-2j>$5n!(HH0`tW0pz8?|%fa#64^z;8(~Y-I6TzoCN+75SO(*@^hMyL#yk?suYF zW^ntOXq>No^@1baag5z;SDrHlqD(zxFpcWeUDJzEV()*55vESvC0dN|rYO(iLbeGG z_3On5IYl`w6|x7(a2^EtBysJ+#Q92D+?FTBNK+5(IELVybBzHQXdComcpycYTRr1N zWwuca7YxcNsSv@*v~aR;IN5Amh~TnXxNNwbtjw7OlwVCtk=-N2+gHlItW%x+{9%+pdi#|nl zbQcVrFqC0PK~T8|hs)(#Iiu^bI5FN>z0Yc^O{3sk8>$AT^DShMYOd2?VEE~L+h%?| zU#CC8^5fwYtCd&^&Zvhoj)x1Z7Go*6w!SMLg4_Y2XT70Nx)fnhWgd*8us`MHV##YW zUUb|tsbmnEu}HE|;igV4EL1ow6q(Esb4#ZBAeriUB#1i}v+q%{Ci1{rypC^SYt!(< z7I}Tu01POYGR?pqX?;%SXHs4juj#c&d15TyiRvi$u^RZXc}gsP&0I&pv}pXhAjmH# z?V_iQWmv!57@ogqDUbWUX-*merXgez{G3(_+0C9!h3rQL<+NNBgjonn!_WetL;z5p zMZutf!KcnCQ|k-@zG6z7uQ0#Zw}QX9=0gAoE;HQoMsm+|B@ZD0mXd~h zj)Z$&1NSWML=ReMx78qc9CPf&(wE%QR(0~gYd1_jaloOQk+byUn;8&VxzI|`j>;*W zT1OH3GwKZHMkoP>*9_+Blmbhrg`j2u&oVDWnChaxmV`ExYh`_Z>@JaRfjhwB zO?+t}bMoX^{4m@g{v`k_5En$!2w)MMg&>%xJOV*xJiUv%>_OL+O247cm)Y@IQZ!|< zC8>rlL!Z}nD(4uo&s>#jMZ(qiC&ds4-XKI>2$Rnd@_N=NMgVyc>Vw4^W!Vt&?o@f>OfqOD2LfFM2@hI;{j-^l}AX>wZ3w{uY9ApgTd?BRm-fpzkANA!{Qv{ z8;nmz^X_u)QKS#{a5AF8KU-_W5Hw|h*urk~z6Z+8a+(77O zbrnAz=!X=9k@W#)BveVp1N}hIdrg*msA^ubc)mG53f66csb`tv7_-@Kh8hfm7=YZk zK#G|_&J2(nCm75CIR+pX1B5wFPB8=I7=T<1)XQ;lG6bDOX=h@N7fW+Wjz6tN0XEK} z1_l?(UR!C7&xLz6>2YE!b#lK-7@o|Q1p+Z1&d4LwI573rvI~ubw7P6H(2$E7(Lc$3M#sQ}qpCaJ>|Hp@nHQlX_CpW2etzN?<` z8ah40(&Hb>X17>zj@|rMaj%e`w75Fksuec1VHQ28JXgJa0-8eg(nOkBnE#Se*?tRL zGCx4~=o&o99lC4jnvQfwja)tu$=jZQf?6>V2tPYvjglWQ^yvE2zwC?jSA0DfA-A>; zMtHNo8jJ{(qyqC_N|V+|)lT17oa;PVfW!}1Bq?*@ayNAVA?;9+C#s+}4qsM=m>K4u zD1c!>5Tf**R2>PbR?c6I#^tW()1jDjHVob(1BosnMGbg$lnU@0m>3%>xN2Lb-=54< zzkU0;kD1L{JlGt{l2FQ!#5hQGlF>(yCUQ|QFGi#Z$(m3?fvzAyL|4c$bp|A}SP*n# zdX)(S8&OJM`A^8ow9%H-YDBMc>?`0_UfMSmKTMz9aH|G@lqC|EXyVb8|2T%q_Tcxw zw&U8bK*pi#i^Gc~nsx4k`^7I&)&HLc9`((ZQkog>zhI^N>z+{s0_85-Wc;XEd1k9c z@uS`dSFu#$Y?#EdS#plU*X^2Op((9N_KUUMcH2Dj%9pCmlbw5vlqQg3Ve)}NVbkRE zn@+%gnAxB|X`bvSM`bv=PT6a>&B5PyyHd?=pZ6dIVO{wU=4fVc{{^qJNc!Q3>nT;3 z?3Z+1)(DVc3fNf2Ms4NxA^4e4xF?IR!}$jQpCdQ;j~t6e z-vrJ-t%^3y#nr8IyXPwzhJ9(O^qE=aYkih0J|RzZiwIxyMWf5R)$55f&aEu~2(MLV zwd`6S=xC0f*ni|ow}NrwF2&Aj$+;jAvfc zLzsUTgo$;6O=xGGvS)l}Ijh)8(Htvs-gmo(*%%m|c_>aqnP%xu%Za@XF7Rc^wx6~R zEb?ewDQ`)ZRHk^nS$uN}!lhQfkR< zqyc!7qco)y)S8f4ByHbvsrjd!zEKOu8@|z2OBXjOyFG=sePYtzh9saUjM|o4D7%=M z3voh4s~n9xTw`exsWIzSw|8Q|D;d;GfR z0y*%p-2w(tK2@Q{m_4dZMRGD4AqQ>$e#qJWk2BrG)4(N_g zYaaSu9WP(Tx|bVbMFakNyE_4mty!XJ)bvHuJosN~;=2h09TrOtsUjqU@;AF6Aq>XRTBx8*G{^XJlxShg9DH580d&& z9L?S|kesr7%Dk*lN;GB>Pfmri+Nksr<#+*du5Um8)8gl6J?7jaykg|@XCjk2kKt_E zC^>2Zh+_F1t-L2Qt3&9NH)t9`zTm4UhfZt!URZ6xK-dBTUHOlkqt}1N zb7UKn|Bi7nzwT1y@!MEgowb9beuTb*HX8WvJd5nAVAUrJV31(Bauvl$gADslt`$4t zRO@Fgmri`@z&(jkw`r_K|4f$d;P99QiDT|~Z0xOXIFUD)$lFZhou}bHAB-Ve!|yvS UNWAkdG9CkepN8+t!FQhi7yd5v-~a#s diff --git a/tests/data/parts/part-001.npz b/tests/data/parts/part-001.npz deleted file mode 100644 index 28506c2b4599f000deb50218d601be8836f450fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9516 zcmb_i4R{k(x-N3HV)cplLAMtwWLLJ-+SPDH?RA09MHabBt)W(o@{_TyD_~c*U2JbOoBZQ>nIeszD@+C|@$B`3eTT39$Zl8Z@#QNf zmsb2u>L{R<4l!~`RXxR2UhDt5{)JiH{Yw>_?aSrozFn&5i#KRj9-<}&w25bzt~zyi zu50_ED}RX0>G|94`pEnpI_kZ1ru8+7_`w|KLw-K5=J1xrLX?!-^4ah0&%2dH-cuhl zeV5O>CYkJ9F^e+1&PM%1-ZN8lZD}3J+~415E}!a?nz!JluCA_*8D7Ixso)ShIjWu( z{xfcJ!Vh1TXDCgFSaX%P11)h??wVJ_x4HiHxBl229;Z-yHjw_OZQS`o(v+B$Si_`k zaa-4|}!Bfa^X;Mc&?1BJ%Pbv=7C(PMG5r1}t|FSs)w1V3)%kS5XBYY^cTL2B1asy~Uf+!G z*=-2j>$5n!(HH0`tW0pz8?|%fa#64^z;8(~Y-I6TzoCN+75SO(*@^hMyL#yk?suYF zW^ntOXq>No^@1baag5z;SDrHlqD(zxFpcWeUDJzEV()*55vESvC0dN|rYO(iLbeGG z_3On5IYl`w6|x7(a2^EtBysJ+#Q92D+?FTBNK+5(IELVybBzHQXdComcpycYTRr1N zWwuca7YxcNsSv@*v~aR;IN5Amh~TnXxNNwbtjw7OlwVCtk=-N2+gHlItW%x+{9%+pdi#|nl zbQcVrFqC0PK~T8|hs)(#Iiu^bI5FN>z0Yc^O{3sk8>$AT^DShMYOd2?VEE~L+h%?| zU#CC8^5fwYtCd&^&Zvhoj)x1Z7Go*6w!SMLg4_Y2XT70Nx)fnhWgd*8us`MHV##YW zUUb|tsbmnEu}HE|;igV4EL1ow6q(Esb4#ZBAeriUB#1i}v+q%{Ci1{rypC^SYt!(< z7I}Tu01POYGR?pqX?;%SXHs4juj#c&d15TyiRvi$u^RZXc}gsP&0I&pv}pXhAjmH# z?V_iQWmv!57@ogqDUbWUX-*merXgez{G3(_+0C9!h3rQL<+NNBgjonn!_WetL;z5p zMZutf!KcnCQ|k-@zG6z7uQ0#Zw}QX9=0gAoE;HQoMsm+|B@ZD0mXd~h zj)Z$&1NSWML=ReMx78qc9CPf&(wE%QR(0~gYd1_jaloOQk+byUn;8&VxzI|`j>;*W zT1OH3GwKZHMkoP>*9_+Blmbhrg`j2u&oVDWnChaxmV`ExYh`_Z>@JaRfjhwB zO?+t}bMoX^{4m@g{v`k_5En$!2w)MMg&>%xJOV*xJiUv%>_OL+O247cm)Y@IQZ!|< zC8>rlL!Z}nD(4uo&s>#jMZ(qiC&ds4-XKI>2$Rnd@_N=NMgVyc>Vw4^W!Vt&?o@f>OfqOD2LfFM2@hI;{j-^l}AX>wZ3w{uY9ApgTd?BRm-fpzkANA!{Qv{ z8;nmz^X_u)QKS#{a5AF8KU-_W5Hw|h*urk~z6Z+8a+(77O zbrnAz=!X=9k@W#)BveVp1N}hIdrg*msA^ubc)mG53f66csb`tv7_-@Kh8hfm7=YZk zK#G|_&J2(nCm75CIR+pX1B5wFPB8=I7=T<1)XQ;lG6bDOX=h@N7fW+Wjz6tN0XEK} z1_l?(UR!C7&xLz6>2YE!b#lK-7@o|Q1p+Z1&d4LwI573rvI~ubw7P6H(2$E7(Lc$3M#sQ}qpCaJ>|Hp@nHQlX_CpW2etzN?<` z8ah40(&Hb>X17>zj@|rMaj%e`w75Fksuec1VHQ28JXgJa0-8eg(nOkBnE#Se*?tRL zGCx4~=o&o99lC4jnvQfwja)tu$=jZQf?6>V2tPYvjglWQ^yvE2zwC?jSA0DfA-A>; zMtHNo8jJ{(qyqC_N|V+|)lT17oa;PVfW!}1Bq?*@ayNAVA?;9+C#s+}4qsM=m>K4u zD1c!>5Tf**R2>PbR?c6I#^tW()1jDjHVob(1BosnMGbg$lnU@0m>3%>xN2Lb-=54< zzkU0;kD1L{JlGt{l2FQ!#5hQGlF>(yCUQ|QFGi#Z$(m3?fvzAyL|4c$bp|A}SP*n# zdX)(S8&OJM`A^8ow9%H-YDBMc>?`0_UfMSmKTMz9aH|G@lqC|EXyVb8|2T%q_Tcxw zw&U8bK*pi#i^Gc~nsx4k`^7I&)&HLc9`((ZQkog>zhI^N>z+{s0_85-Wc;XEd1k9c z@uS`dSFu#$Y?#EdS#plU*X^2Op((9N_KUUMcH2Dj%9pCmlbw5vlqQg3Ve)}NVbkRE zn@+%gnAxB|X`bvSM`bv=PT6a>&B5PyyHd?=pZ6dIVO{wU=4fVc{{^qJNc!Q3>nT;3 z?3Z+1)(DVc3fNf2Ms4NxA^4e4xF?IR!}$jQpCdQ;j~t6e z-vrJ-t%^3y#nr8IyXPwzhJ9(O^qE=aYkih0J|RzZiwIxyMWf5R)$55f&aEu~2(MLV zwd`6S=xC0f*ni|ow}NrwF2&Aj$+;jAvfc zLzsUTgo$;6O=xGGvS)l}Ijh)8(Htvs-gmo(*%%m|c_>aqnP%xu%Za@XF7Rc^wx6~R zEb?ewDQ`)ZRHk^nS$uN}!lhQfkR< zqyc!7qco)y)S8f4ByHbvsrjd!zEKOu8@|z2OBXjOyFG=sePYtzh9saUjM|o4D7%=M z3voh4s~n9xTw`exsWIzSw|8Q|D;d;GfR z0y*%p-2w(tK2@Q{m_4dZMRGD4AqQ>$e#qJWk2BrG)4(N_g zYaaSu9WP(Tx|bVbMFakNyE_4mty!XJ)bvHuJosN~;=2h09TrOtsUjqU@;AF6Aq>XRTBx8*G{^XJlxShg9DH580d&& z9L?S|kesr7%Dk*lN;GB>Pfmri+Nksr<#+*du5Um8)8gl6J?7jaykg|@XCjk2kKt_E zC^>2Zh+_F1t-L2Qt3&9NH)t9`zTm4UhfZt!URZ6xK-dBTUHOlkqt}1N zb7UKn|Bi7nzwT1y@!MEgowb9beuTb*HX8WvJd5nAVAUrJV31(Bauvl$gADslt`$4t zRO@Fgmri`@z&(jkw`r_K|4f$d;P99QiDT|~Z0xOXIFUD)$lFZhou}bHAB-Ve!|yvS UNWAkdG9CkepN8+t!FQhi7yd5v-~a#s diff --git a/tests/data/parts/part-002.npz b/tests/data/parts/part-002.npz deleted file mode 100644 index 28506c2b4599f000deb50218d601be8836f450fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9516 zcmb_i4R{k(x-N3HV)cplLAMtwWLLJ-+SPDH?RA09MHabBt)W(o@{_TyD_~c*U2JbOoBZQ>nIeszD@+C|@$B`3eTT39$Zl8Z@#QNf zmsb2u>L{R<4l!~`RXxR2UhDt5{)JiH{Yw>_?aSrozFn&5i#KRj9-<}&w25bzt~zyi zu50_ED}RX0>G|94`pEnpI_kZ1ru8+7_`w|KLw-K5=J1xrLX?!-^4ah0&%2dH-cuhl zeV5O>CYkJ9F^e+1&PM%1-ZN8lZD}3J+~415E}!a?nz!JluCA_*8D7Ixso)ShIjWu( z{xfcJ!Vh1TXDCgFSaX%P11)h??wVJ_x4HiHxBl229;Z-yHjw_OZQS`o(v+B$Si_`k zaa-4|}!Bfa^X;Mc&?1BJ%Pbv=7C(PMG5r1}t|FSs)w1V3)%kS5XBYY^cTL2B1asy~Uf+!G z*=-2j>$5n!(HH0`tW0pz8?|%fa#64^z;8(~Y-I6TzoCN+75SO(*@^hMyL#yk?suYF zW^ntOXq>No^@1baag5z;SDrHlqD(zxFpcWeUDJzEV()*55vESvC0dN|rYO(iLbeGG z_3On5IYl`w6|x7(a2^EtBysJ+#Q92D+?FTBNK+5(IELVybBzHQXdComcpycYTRr1N zWwuca7YxcNsSv@*v~aR;IN5Amh~TnXxNNwbtjw7OlwVCtk=-N2+gHlItW%x+{9%+pdi#|nl zbQcVrFqC0PK~T8|hs)(#Iiu^bI5FN>z0Yc^O{3sk8>$AT^DShMYOd2?VEE~L+h%?| zU#CC8^5fwYtCd&^&Zvhoj)x1Z7Go*6w!SMLg4_Y2XT70Nx)fnhWgd*8us`MHV##YW zUUb|tsbmnEu}HE|;igV4EL1ow6q(Esb4#ZBAeriUB#1i}v+q%{Ci1{rypC^SYt!(< z7I}Tu01POYGR?pqX?;%SXHs4juj#c&d15TyiRvi$u^RZXc}gsP&0I&pv}pXhAjmH# z?V_iQWmv!57@ogqDUbWUX-*merXgez{G3(_+0C9!h3rQL<+NNBgjonn!_WetL;z5p zMZutf!KcnCQ|k-@zG6z7uQ0#Zw}QX9=0gAoE;HQoMsm+|B@ZD0mXd~h zj)Z$&1NSWML=ReMx78qc9CPf&(wE%QR(0~gYd1_jaloOQk+byUn;8&VxzI|`j>;*W zT1OH3GwKZHMkoP>*9_+Blmbhrg`j2u&oVDWnChaxmV`ExYh`_Z>@JaRfjhwB zO?+t}bMoX^{4m@g{v`k_5En$!2w)MMg&>%xJOV*xJiUv%>_OL+O247cm)Y@IQZ!|< zC8>rlL!Z}nD(4uo&s>#jMZ(qiC&ds4-XKI>2$Rnd@_N=NMgVyc>Vw4^W!Vt&?o@f>OfqOD2LfFM2@hI;{j-^l}AX>wZ3w{uY9ApgTd?BRm-fpzkANA!{Qv{ z8;nmz^X_u)QKS#{a5AF8KU-_W5Hw|h*urk~z6Z+8a+(77O zbrnAz=!X=9k@W#)BveVp1N}hIdrg*msA^ubc)mG53f66csb`tv7_-@Kh8hfm7=YZk zK#G|_&J2(nCm75CIR+pX1B5wFPB8=I7=T<1)XQ;lG6bDOX=h@N7fW+Wjz6tN0XEK} z1_l?(UR!C7&xLz6>2YE!b#lK-7@o|Q1p+Z1&d4LwI573rvI~ubw7P6H(2$E7(Lc$3M#sQ}qpCaJ>|Hp@nHQlX_CpW2etzN?<` z8ah40(&Hb>X17>zj@|rMaj%e`w75Fksuec1VHQ28JXgJa0-8eg(nOkBnE#Se*?tRL zGCx4~=o&o99lC4jnvQfwja)tu$=jZQf?6>V2tPYvjglWQ^yvE2zwC?jSA0DfA-A>; zMtHNo8jJ{(qyqC_N|V+|)lT17oa;PVfW!}1Bq?*@ayNAVA?;9+C#s+}4qsM=m>K4u zD1c!>5Tf**R2>PbR?c6I#^tW()1jDjHVob(1BosnMGbg$lnU@0m>3%>xN2Lb-=54< zzkU0;kD1L{JlGt{l2FQ!#5hQGlF>(yCUQ|QFGi#Z$(m3?fvzAyL|4c$bp|A}SP*n# zdX)(S8&OJM`A^8ow9%H-YDBMc>?`0_UfMSmKTMz9aH|G@lqC|EXyVb8|2T%q_Tcxw zw&U8bK*pi#i^Gc~nsx4k`^7I&)&HLc9`((ZQkog>zhI^N>z+{s0_85-Wc;XEd1k9c z@uS`dSFu#$Y?#EdS#plU*X^2Op((9N_KUUMcH2Dj%9pCmlbw5vlqQg3Ve)}NVbkRE zn@+%gnAxB|X`bvSM`bv=PT6a>&B5PyyHd?=pZ6dIVO{wU=4fVc{{^qJNc!Q3>nT;3 z?3Z+1)(DVc3fNf2Ms4NxA^4e4xF?IR!}$jQpCdQ;j~t6e z-vrJ-t%^3y#nr8IyXPwzhJ9(O^qE=aYkih0J|RzZiwIxyMWf5R)$55f&aEu~2(MLV zwd`6S=xC0f*ni|ow}NrwF2&Aj$+;jAvfc zLzsUTgo$;6O=xGGvS)l}Ijh)8(Htvs-gmo(*%%m|c_>aqnP%xu%Za@XF7Rc^wx6~R zEb?ewDQ`)ZRHk^nS$uN}!lhQfkR< zqyc!7qco)y)S8f4ByHbvsrjd!zEKOu8@|z2OBXjOyFG=sePYtzh9saUjM|o4D7%=M z3voh4s~n9xTw`exsWIzSw|8Q|D;d;GfR z0y*%p-2w(tK2@Q{m_4dZMRGD4AqQ>$e#qJWk2BrG)4(N_g zYaaSu9WP(Tx|bVbMFakNyE_4mty!XJ)bvHuJosN~;=2h09TrOtsUjqU@;AF6Aq>XRTBx8*G{^XJlxShg9DH580d&& z9L?S|kesr7%Dk*lN;GB>Pfmri+Nksr<#+*du5Um8)8gl6J?7jaykg|@XCjk2kKt_E zC^>2Zh+_F1t-L2Qt3&9NH)t9`zTm4UhfZt!URZ6xK-dBTUHOlkqt}1N zb7UKn|Bi7nzwT1y@!MEgowb9beuTb*HX8WvJd5nAVAUrJV31(Bauvl$gADslt`$4t zRO@Fgmri`@z&(jkw`r_K|4f$d;P99QiDT|~Z0xOXIFUD)$lFZhou}bHAB-Ve!|yvS UNWAkdG9CkepN8+t!FQhi7yd5v-~a#s diff --git a/tests/data/results/events.json b/tests/data/results/events.json deleted file mode 100644 index afb305a..0000000 --- a/tests/data/results/events.json +++ /dev/null @@ -1,412 +0,0 @@ -{"event": "RiskDecisionEvent(ts_ns_local=1723161256101000000, accepted=6, queued=0, rejected=0, handled=0, reject_reasons={})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='working')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='working')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='working')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1078790734324421344', prev_state=None, next_state='working')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='working')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='7581400325422276892', prev_state=None, next_state='working')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256201000000, accepted=0, queued=0, rejected=0, handled=6, reject_reasons={})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state='working', next_state='expired')"} -{"event": "DerivedFillEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', side='buy', delta_qty=0.08, cum_qty=0.08, price=59999.9)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state='working', next_state='expired')"} -{"event": "DerivedFillEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', side='sell', delta_qty=0.01, cum_qty=0.01, price=60000.100000000006)"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256301000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161256310000000, instrument='BTC_USDC-PERPETUAL', exposure=1200.0000000000002, delta_exposure=1200.0000000000002)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='expired')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256401000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256501000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256601000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256701000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='expired')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256801000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161256810000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=0.0009999999999763531, cum_realized_pnl=0.0009999999999763531)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161256810000000, instrument='BTC_USDC-PERPETUAL', exposure=599.9999999999997, delta_exposure=-600.0000000000006)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161256901000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257001000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257101000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257201000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257301000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257401000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257501000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257601000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257701000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257801000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161257901000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258001000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258101000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258201000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258301000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258401000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258501000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258601000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258701000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258801000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161258901000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259001000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259101000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259201000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259301000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259401000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161259410000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=-0.0004999999999881766, cum_realized_pnl=0.0004999999999881766)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161259410000000, instrument='BTC_USDC-PERPETUAL', exposure=599.9994999999997, delta_exposure=-0.0004999999999881766)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state='working', next_state='expired')"} -{"event": "DerivedFillEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', side='buy', delta_qty=0.02, cum_qty=0.02, price=59999.8)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259501000000, accepted=2, queued=0, rejected=3, handled=1, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 3})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='expired')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259601000000, accepted=0, queued=0, rejected=3, handled=3, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 3})"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161259610000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=0.012000000000284672, cum_realized_pnl=0.012500000000272848)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161259610000000, instrument='BTC_USDC-PERPETUAL', exposure=5399.9955, delta_exposure=4799.996)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259701000000, accepted=0, queued=0, rejected=3, handled=3, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 3})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259801000000, accepted=0, queued=0, rejected=3, handled=3, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 3})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state='working', next_state='expired')"} -{"event": "DerivedFillEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', side='sell', delta_qty=0.02, cum_qty=0.02, price=60000.100000000006)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161259901000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='expired')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260001000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260101000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260201000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161260210000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=0.007500000000163709, cum_realized_pnl=0.020000000000436557)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161260210000000, instrument='BTC_USDC-PERPETUAL', exposure=2399.9979999999996, delta_exposure=-2999.9975000000004)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260301000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260401000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260501000000, accepted=4, queued=0, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260601000000, accepted=6, queued=0, rejected=0, handled=0, reject_reasons={})"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161260621000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=0.00400000000036016, cum_realized_pnl=0.024000000000796717)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161260621000000, instrument='BTC_USDC-PERPETUAL', exposure=2400.002, delta_exposure=0.00400000000036016)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260701000000, accepted=2, queued=2, rejected=0, handled=2, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260751000000, accepted=1, queued=1, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260801000000, accepted=1, queued=3, rejected=1, handled=2, reject_reasons={'ORDER_NOT_FOUND': 1})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260901000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161260951000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261001000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261051000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261101000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261151000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261201000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261251000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261301000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261351000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261401000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261451000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261501000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261551000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261601000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261651000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261701000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261751000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261801000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261901000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161261951000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262001000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262051000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262101000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262151000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262201000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262251000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262301000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262351000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262401000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262451000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262501000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262551000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262601000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262651000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262701000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262751000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262801000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262901000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161262951000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263001000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263051000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263101000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263151000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263201000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263251000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263301000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263351000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263401000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263451000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263501000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263551000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263601000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263651000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263701000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263751000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263801000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263901000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161263951000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264001000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264051000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264101000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264151000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264201000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264251000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264301000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264351000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264401000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264451000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264501000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264551000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264601000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264651000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264701000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264751000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264801000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264901000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161264951000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265001000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265051000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265101000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265151000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265201000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265251000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265301000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265351000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265401000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265451000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265501000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265551000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265601000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265651000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265701000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265751000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265801000000, accepted=1, queued=3, rejected=2, handled=2, reject_reasons={'ORDER_NOT_FOUND': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265851000000, accepted=1, queued=2, rejected=0, handled=0, reject_reasons={})"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "DerivedPnLEvent(ts_ns_local=1723161265851000000, instrument='BTC_USDC-PERPETUAL', delta_pnl=0.020000000000436557, cum_realized_pnl=0.044000000001233275)"} -{"event": "ExposureDerivedEvent(ts_ns_local=1723161265851000000, instrument='BTC_USDC-PERPETUAL', exposure=7200.006, delta_exposure=4800.004000000001)"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161259501000000, instrument='BTC_USDC-PERPETUAL', client_order_id='4218267878951418420', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='1478678295735644481', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='9220587067321093634', prev_state=None, next_state='filled')"} -{"event": "OrderStateTransitionEvent(ts_ns_local=1723161256101000000, instrument='BTC_USDC-PERPETUAL', client_order_id='3670455356658261962', prev_state=None, next_state='filled')"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=2, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=2, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=2, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=4, handled=2, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 4})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=2, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} -{"event": "RiskDecisionEvent(ts_ns_local=1723161265860000000, accepted=0, queued=0, rejected=2, handled=4, reject_reasons={'MAX_GROSS_QUOTE_NOTIONAL': 2})"} diff --git a/tests/data/results/stats.npz b/tests/data/results/stats.npz deleted file mode 100644 index da56e6210e9e1cfefbedc6212ec6715ee079611c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2459 zcmb`}eOwIt9>DQUme{h99&l=RbHb5Y6sw|rI!@@N_Ig@{mO3b<4k31Hx93jeHYXij zDx0{%!J$T8{T% zFc?!5pux0$NqTW--tVv(j51rAn{5I#heGZlP+Lw)s znr`=a&7P3_%gZioE$ymn=jP0dJZigo^U`~<-1nZZZc6fZo-5s8IrG-$q6aL;h+~Uy zNGhx{EOJ(6&k==BfBm!Uxa};ys8wmI z>YuxROxZ3?oK&sbQ{pcOI5u=rccw+G#yGFJ(8IrxJ!(@+Z?rGcu+bhG+Cj1_C-$F7 z)aK~p8<{)SE625}+{#2_MS^;n(CHTIokzd2B&_CE6d&b-D=r1k(13wYhqI$i+duq0 zyZfYGPA)%}a37z2xvHsdw&x~sjqPDZ-&LqSYt%c=y)|HgXR7ikKS-hvkM(JNylbOq ztT<|;3|Y99HSC6jrH|&Q;-DPuSU zzb}hBEZ>zoB=)Aw4N{BY8iqO;(zz2illRcP{%L+vaXAJtb_ z_4TiHk;N&S>_&0EAgHgm?O2ceQR5|7@>6#I$M=u2)st0q-AA7GgzY5gY zr^|x*7mbIuWDlK85GE_{9_ii#w`r4=JK#l(zK##6C7=>D{<~4=?uFKkb@Y(rnhk5~ z0yN?)9;e@y-87PB^Ye??)sfn$^HpKClc5gl;WuYiU6dowB>Im>IyY--N=ify^Ge%- z1)?$H&G_@W_?mocylVJ60Fmo$u-b-GJZP9dC{p*l+Xg81?PsF}4ucuQo!KD(RQr;F z7VeMdDyoGXOlouxftpNvOz#oELz1>kMg0ob@D%1}t6`3^+!|9=_5#Q?A4|Mmw@IWv z_|^v&>TgWOSZz;5>MD&hS|}HYz)u2>7;G<_gryY-q~LXQCaUs`Ng;5Se_CR|14kgv z6yt{)$@dOuLVaI4Q8l9rKt@ie0L6FxDMWLCLaH`HPzuqNlmtM-cNu6uIO>Akzn#fL z$Y)llsS|iO718gV-^_~Ft<6frclw}gfj<-1SC(_3bB`urh3?uE;xxO6M;>?)4`|SI z7P_00kU<>i9|sFu)=AXp-IV~RsL~0O?^H@aLqjhxxaV_fV%-J>tj(*KhM8=}p$TwkSB z*25XdAmHu=F!Ga;f$I96q!2ruabQrXXJEylR{&ve&pCkX`ll;Ac#XeubgrQ-Tvd2? zlUwta0mZiQxS-HM>hA}so3$JvVlG_cb13NDx)n{q5rZ3h8g-X_C)D4_-Fli?$f|t& zOZU1>y-fVs8U?)DfRv6^EVjU2;MI}B*EJkeS3N-l_Q|*?-^Xb@e)P(5ps@j2X}Vdw z10eT?E<=&k*~!Go?`=?lQz@5>$(e!$mWK0$@~c@yPW>l9`%Rt|-oD)qjSDTDj7Jky zQn2mv0a$+Rxdk47XE7I1hs;6Q%J*r6OLYtky&H8w6{iYVxZ|5|Qi=6JSvRQTKsOB2a4ZfeQ|95UUZx5g1K?Ds=b$W$DF8H-5G*w(K8=ABJ{Gg^D*lr+0(6@U4~C8`N2@(Y+)%;%P9F3+ zgbnkPc1=PJJ`2)_E@6@dwzfsjQ|vamqFLta)37nfk&D!7mY}s{3mYpQoG1eC_}OAv zu}rag@c3~6>2?^0*SFo{LZkbap<2+%0?#-&6K(`;nfM#_K_G6q9SiSUuY}oZp$nE# z`aNu1;4L;2!V3~GqTvu+T_2Q1n4dTUNTru0*74*Nfc)b&8*@K#lLxhqxS)o*Hxl!} zTPXl7!4{(Z_gFk(Ov@tJh{&vQ|CG5vSmY5+xpQ;**tbn9I)u}@%Ew%(;LySQ-(b@_ z9BPN0`u~r3lMp@ALGp~9X7(+^XAQifnv@RQ8sqG5{9puBv6`&Tc#{uB8fjufk!Xr2 zXfl%`jWo%mh!;h)6j57KL_rZmLJ@n4lu?9Cr-&OxI5e@Mh>#{?ir7$OH%(G0Vo(2t zOOf#u;m|}x5hg|E(j=K8O!_Z0VcaqdkSA0FW`8OWI+jxb(cw#lmX2jqXy{lT^%wT_3wTV!kOf+d^RWEuOhu1P^a#cLIz4^4CtJ|VmkCQ2 S$echwSWu@IwXXXzW&H>GfiL|4 diff --git a/tests/data/scripts/generate_synthetic_hbt_data_concatenated.py b/tests/data/scripts/generate_synthetic_hbt_data_concatenated.py deleted file mode 100644 index f653e34..0000000 --- a/tests/data/scripts/generate_synthetic_hbt_data_concatenated.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -Synthetic hftbacktest market data generator. - -This script generates deterministic synthetic Level-2 depth snapshots, -incremental depth updates, and trades in a single unified event stream -compatible with hftbacktest v2.x. -""" - -from __future__ import annotations - -import os - -import numpy as np -from hftbacktest import ( - BUY_EVENT, - DEPTH_EVENT, - DEPTH_SNAPSHOT_EVENT, - EXCH_EVENT, - LOCAL_EVENT, - SELL_EVENT, - TRADE_EVENT, -) - -# --------------------------------------------------------------------------- -# dtype used by hftbacktest v2.x -# px / qty MUST be integer tick / lot units -# --------------------------------------------------------------------------- - -DTYPE = np.dtype( - [ - ("ev", "u8"), - ("exch_ts", "i8"), - ("local_ts", "i8"), - ("px", "f8"), - ("qty", "f8"), - ("order_id", "u8"), - ("ival", "i8"), - ("fval", "f8"), - ], - align=True, -) - - -def make_synthetic_snapshot( - exch_ts: int, - local_ts: int, - mid_price: float = 60000.0, - tick_size: float = 0.1, - lot_size: float = 0.001, - n_levels: int = 5, -) -> np.ndarray: - """ - Build a simple Level-2 snapshot: n bid levels and n ask levels around a mid price. - Timestamps = exch_ts / local_ts. - """ - n_rows = 2 * n_levels - data = np.zeros(n_rows, dtype=DTYPE) - - bid_flag = EXCH_EVENT | DEPTH_SNAPSHOT_EVENT | BUY_EVENT - ask_flag = EXCH_EVENT | DEPTH_SNAPSHOT_EVENT | SELL_EVENT - - # Bid levels - for i in range(n_levels): - idx = i - price = mid_price - (i + 1) * tick_size - qty = (i + 1) * lot_size - - data["ev"][idx] = bid_flag - data["exch_ts"][idx] = exch_ts - data["local_ts"][idx] = local_ts - data["px"][idx] = price - data["qty"][idx] = qty - data["order_id"][idx] = 0 - data["ival"][idx] = 0 - data["fval"][idx] = 0.0 - - # Ask levels - for i in range(n_levels): - idx = n_levels + i - price = mid_price + (i + 1) * tick_size - qty = (i + 1) * lot_size - - data["ev"][idx] = ask_flag - data["exch_ts"][idx] = exch_ts - data["local_ts"][idx] = local_ts - data["px"][idx] = price - data["qty"][idx] = qty - data["order_id"][idx] = 0 - data["ival"][idx] = 0 - data["fval"][idx] = 0.0 - - return data - - -def _depth_profile_qty( - level: int, - base_qty: float, - profile: str = "linear", -) -> float: - """ - Compute quantity for a given depth level. - """ - if profile == "flat": - return base_qty - if profile == "linear": - return base_qty * (level + 1) - if profile == "exp": - return base_qty * np.exp(-level / 4.0) - return base_qty - - -def make_synthetic_day( - n_steps: int = 1_000, - interval_ns: int = 100_000_000, - tick_size: float = 0.1, - lot_size: float = 0.001, - base_price: float = 60_000.0, - *, - n_levels: int = 10, - base_exch_ts_ns: int = 1_723_161_256_000_000_000, - feed_latency_ns: int = 1_000_000, - trade_prob: float = 0.7, - max_trades_per_step: int = 2, - depth_profile: str = "linear", -) -> np.ndarray: - """ - Synthetic trading session producing depth updates and trades. - """ - rng = np.random.default_rng(42) - - max_events_per_step = 2 * n_levels + max_trades_per_step - buffer = np.zeros(n_steps * max_events_per_step, dtype=DTYPE) - ptr = 0 - - mid_price = base_price - - for step in range(n_steps): - exch_ts = base_exch_ts_ns + step * interval_ns - local_ts = exch_ts + feed_latency_ns - - mid_price += rng.normal(scale=0.2) * tick_size - mid_price = max(tick_size, mid_price) - - mid_rounded = round(mid_price / tick_size) * tick_size - best_bid = mid_rounded - tick_size - best_ask = mid_rounded + tick_size - - for level in range(n_levels): - bid_px = best_bid - level * tick_size - ask_px = best_ask + level * tick_size - qty = _depth_profile_qty(level, lot_size, depth_profile) - - buffer["ev"][ptr] = EXCH_EVENT | LOCAL_EVENT | DEPTH_EVENT | BUY_EVENT - buffer["exch_ts"][ptr] = exch_ts - buffer["local_ts"][ptr] = local_ts - buffer["px"][ptr] = bid_px - buffer["qty"][ptr] = qty - ptr += 1 - - buffer["ev"][ptr] = EXCH_EVENT | LOCAL_EVENT | DEPTH_EVENT | SELL_EVENT - buffer["exch_ts"][ptr] = exch_ts - buffer["local_ts"][ptr] = local_ts - buffer["px"][ptr] = ask_px - buffer["qty"][ptr] = qty - ptr += 1 - - trades = 0 - while trades < max_trades_per_step and rng.random() < trade_prob: - is_buy = rng.random() < 0.5 - side_flag = BUY_EVENT if is_buy else SELL_EVENT - - buffer["ev"][ptr] = EXCH_EVENT | LOCAL_EVENT | TRADE_EVENT | side_flag - buffer["exch_ts"][ptr] = exch_ts - buffer["local_ts"][ptr] = local_ts - buffer["px"][ptr] = best_ask if is_buy else best_bid - buffer["qty"][ptr] = rng.integers(1, 10) * lot_size - - ptr += 1 - trades += 1 - - data = buffer[:ptr] - order = np.lexsort((data["local_ts"], data["exch_ts"])) - return data[order] - - -def save_npz(filename: str, data: np.ndarray) -> None: - """Save event stream in hftbacktest-compatible NPZ format.""" - os.makedirs(os.path.dirname(filename), exist_ok=True) - np.savez_compressed(filename, data=data) - print(f"Saved {filename} with shape {data.shape}") - - -if __name__ == "__main__": - # ----------------------------------------------------------------- - # Example usages - # ----------------------------------------------------------------- - - output_dir = "tests/data/parts" - os.makedirs(output_dir, exist_ok=True) - - tick_size = 0.1 - lot_size = 0.01 - - snapshot_data = make_synthetic_snapshot( - exch_ts=1_723_161_256_000_000_000, - local_ts=1_723_161_256_001_000_000, - mid_price=60_000.0, - tick_size=tick_size, - lot_size=lot_size, - n_levels=10, - ) - - day_data = make_synthetic_day( - n_steps=100_000, - interval_ns=100_000_000, - tick_size=tick_size, - lot_size=lot_size, - base_price=60_000.0, - base_exch_ts_ns=1_723_161_256_000_000_000, - ) - - unified_data = np.concatenate((snapshot_data, day_data)) - unified_data = unified_data[ - np.lexsort((unified_data["local_ts"], unified_data["exch_ts"])) - ] - - save_npz( - os.path.join(output_dir, "part-000.npz"), - unified_data, - ) diff --git a/tests/data/scripts/generate_synthetic_hbt_data_seperated.py b/tests/data/scripts/generate_synthetic_hbt_data_seperated.py deleted file mode 100644 index b196d1f..0000000 --- a/tests/data/scripts/generate_synthetic_hbt_data_seperated.py +++ /dev/null @@ -1,277 +0,0 @@ -""" -Synthetic hftbacktest market data generator. - -This script generates deterministic synthetic Level-2 depth and trade data -compatible with hftbacktest v2.x. It is intended for testing and benchmarking -purposes and therefore prioritizes explicitness and configurability over -minimal function signatures. -""" - -# pylint: disable=line-too-long -# pylint: disable=too-many-arguments,too-many-positional-arguments -# pylint: disable=too-many-locals,too-many-statements -# pylint: disable=redefined-outer-name,no-else-return -# pylint: disable=invalid-name -from __future__ import annotations - -import os - -import numpy as np -from hftbacktest import ( - BUY_EVENT, - DEPTH_EVENT, - DEPTH_SNAPSHOT_EVENT, - EXCH_EVENT, - LOCAL_EVENT, - SELL_EVENT, - TRADE_EVENT, -) - -# --------------------------------------------------------------------------- -# dtype used by hftbacktest v2.x -# px / qty MUST be integer tick / lot units -# --------------------------------------------------------------------------- - -DTYPE = np.dtype( - [ - ("ev", "u8"), # uint64 event flags - ("exch_ts", "i8"), # venue timestamp (ns) - ("local_ts", "i8"), # local receive timestamp (ns) - ("px", "f8"), # price in tick units - ("qty", "f8"), # quantity in lot units - ("order_id", "u8"), # uint64 (used only for L3 feeds) - ("ival", "i8"), # auxiliary integer - ("fval", "f8"), # auxiliary float - ], - align=True, -) - - -def make_synthetic_snapshot( - exch_ts: int, - local_ts: int, - mid_price: float = 60000.0, - tick_size: float = 0.1, - lot_size: float = 0.001, - n_levels: int = 5, -) -> np.ndarray: - """ - Build a simple Level-2 snapshot: n bid levels and n ask levels around a mid price. - Timestamps = exch_ts / local_ts. - """ - n_rows = 2 * n_levels - data = np.zeros(n_rows, dtype=DTYPE) - - bid_flag = EXCH_EVENT | DEPTH_SNAPSHOT_EVENT | BUY_EVENT - ask_flag = EXCH_EVENT | DEPTH_SNAPSHOT_EVENT | SELL_EVENT - - # Bid levels - for i in range(n_levels): - idx = i - price = mid_price - (i + 1) * tick_size - qty = (i + 1) * lot_size - - data["ev"][idx] = bid_flag - data["exch_ts"][idx] = exch_ts - data["local_ts"][idx] = local_ts - data["px"][idx] = price - data["qty"][idx] = qty - data["order_id"][idx] = 0 - data["ival"][idx] = 0 - data["fval"][idx] = 0.0 - - # Ask levels - for i in range(n_levels): - idx = n_levels + i - price = mid_price + (i + 1) * tick_size - qty = (i + 1) * lot_size - - data["ev"][idx] = ask_flag - data["exch_ts"][idx] = exch_ts - data["local_ts"][idx] = local_ts - data["px"][idx] = price - data["qty"][idx] = qty - data["order_id"][idx] = 0 - data["ival"][idx] = 0 - data["fval"][idx] = 0.0 - - return data - - -def _depth_profile_qty( - level: int, - base_qty: float, - profile: str = "linear", -) -> float: - """ - Compute quantity for a given depth level. - - profile: - - "flat": same qty on all levels - - "linear": grows linearly with depth - - "exp": decays exponentially with depth - """ - if profile == "flat": - return base_qty - if profile == "linear": - return base_qty * (level + 1) - if profile == "exp": - return base_qty * np.exp(-level / 4.0) - return base_qty - - -def make_synthetic_day( - n_steps: int = 1000, - interval_ns: int = 100_000_000, - tick_size: float = 0.1, - lot_size: float = 0.001, - base_price: float = 60_000.0, - *, - n_levels: int = 10, - base_exch_ts_ns: int = 1_723_161_256_000_000_000, - feed_latency_ns: int = 1_000_000, - regime_switch_prob: float = 0.01, - trend_strength_ticks: float = 0.1, - mean_reversion_strength: float = 0.02, - sigma_low_ticks: float = 0.2, - sigma_high_ticks: float = 0.7, - volatility_switch_prob: float = 0.03, - depth_profile: str = "linear", - trade_prob: float = 0.7, - max_trades_per_step: int = 2, -) -> np.ndarray: - """ - Synthetic trading day generator producing L2 depth and trades. - All prices and quantities are stored in integer tick / lot units. - """ - rng = np.random.default_rng(42) - - max_events_per_step = 2 * n_levels + max_trades_per_step - data = np.zeros(n_steps * max_events_per_step, dtype=DTYPE) - ptr = 0 - - mid = base_price - regimes = np.array(["mean_revert", "trend_up", "trend_down"]) - regime = "mean_revert" - vol_state = "low" - - for step in range(n_steps): - if rng.random() < regime_switch_prob: - regime = rng.choice(regimes) - - if rng.random() < volatility_switch_prob: - vol_state = "high" if vol_state == "low" else "low" - - sigma_ticks = sigma_low_ticks if vol_state == "low" else sigma_high_ticks - - if regime == "mean_revert": - diff_ticks = (mid - base_price) / tick_size - drift_ticks = -mean_reversion_strength * diff_ticks - elif regime == "trend_up": - drift_ticks = trend_strength_ticks - else: - drift_ticks = -trend_strength_ticks - - mid += (drift_ticks + rng.normal(scale=sigma_ticks)) * tick_size - mid = max(tick_size, mid) - - exch_ts = base_exch_ts_ns + step * interval_ns - local_ts = exch_ts + feed_latency_ns - - mid_rounded = np.round(mid / tick_size) * tick_size - best_bid = mid_rounded - tick_size - best_ask = best_bid + tick_size - - for level in range(n_levels): - bid_px = best_bid - level * tick_size - bid_qty = _depth_profile_qty(level, lot_size, depth_profile) - - data["ev"][ptr] = EXCH_EVENT | LOCAL_EVENT | DEPTH_EVENT | BUY_EVENT - data["exch_ts"][ptr] = exch_ts - data["local_ts"][ptr] = local_ts - data["px"][ptr] = bid_px - data["qty"][ptr] = bid_qty - data["order_id"][ptr] = 0 - data["ival"][ptr] = 0 - data["fval"][ptr] = 0.0 - ptr += 1 - - ask_px = best_ask + level * tick_size - ask_qty = _depth_profile_qty(level, lot_size, depth_profile) - - data["ev"][ptr] = EXCH_EVENT | LOCAL_EVENT | DEPTH_EVENT | SELL_EVENT - data["exch_ts"][ptr] = exch_ts - data["local_ts"][ptr] = local_ts - data["px"][ptr] = ask_px - data["qty"][ptr] = ask_qty - data["order_id"][ptr] = 0 - data["ival"][ptr] = 0 - data["fval"][ptr] = 0.0 - ptr += 1 - - n_trades = 0 - while n_trades < max_trades_per_step and rng.random() < trade_prob: - side_is_buy = rng.random() < 0.5 - flag_side = BUY_EVENT if side_is_buy else SELL_EVENT - ev_flag = EXCH_EVENT | LOCAL_EVENT | TRADE_EVENT | flag_side - - trade_px = best_ask if side_is_buy else best_bid - trade_qty = rng.integers(1, 11) * lot_size - - data["ev"][ptr] = ev_flag - data["exch_ts"][ptr] = exch_ts - data["local_ts"][ptr] = local_ts - data["px"][ptr] = trade_px - data["qty"][ptr] = trade_qty - data["order_id"][ptr] = 0 - data["ival"][ptr] = 0 - data["fval"][ptr] = 0.0 - - ptr += 1 - n_trades += 1 - - data = data[:ptr] - sort_idx = np.lexsort((data["local_ts"], data["exch_ts"])) - return data[sort_idx] - - -def save_npz(filename: str, data: np.ndarray, compress: bool = True) -> None: - """Save data in hftbacktest-compatible NPZ format.""" - os.makedirs(os.path.dirname(filename), exist_ok=True) - if compress: - np.savez_compressed(filename, data=data) - else: - np.savez(filename, data=data) - print(f"Saved {filename} with shape {data.shape}") - - -if __name__ == "__main__": - # ----------------------------------------------------------------- - # Example usages - # ----------------------------------------------------------------- - - out_dir = "tests/data/parts" - os.makedirs(out_dir, exist_ok=True) - - tick_size = 0.1 - lot_size = 0.01 - - snapshot = make_synthetic_snapshot( - exch_ts=1_723_161_256_000_000_000, - local_ts=1_723_161_256_001_000_000, - mid_price=60000.0, - tick_size=tick_size, - lot_size=lot_size, - n_levels=10, - ) - save_npz(os.path.join(out_dir, "part-000-eod.npz"), snapshot) - - day_data = make_synthetic_day( - n_steps=1000, - interval_ns=100_000_000, - tick_size=tick_size, - lot_size=lot_size, - base_price=60000.0, - base_exch_ts_ns=1_723_161_256_000_000_000, - ) - save_npz(os.path.join(out_dir, "part-000.npz"), day_data) diff --git a/tests/data/scripts/peek_data.py b/tests/data/scripts/peek_data.py deleted file mode 100644 index a389755..0000000 --- a/tests/data/scripts/peek_data.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Utilities for inspecting and visualizing hftbacktest NPZ data. - -This module provides small helper functions to peek into raw market-data -and statistics NPZ files and to plot simple price series for exploratory -analysis and debugging. -""" - -# pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals -from __future__ import annotations - -import os -from typing import Mapping - -import matplotlib.pyplot as plt -import numpy as np -from hftbacktest import ( - BUY_EVENT, - DEPTH_EVENT, - DEPTH_SNAPSHOT_EVENT, - SELL_EVENT, - TRADE_EVENT, -) - -# --------------------------------------------------------------------- -# Event filter configuration -# --------------------------------------------------------------------- -# Mapping from human-friendly string names to bit masks that can be used -# to filter rows by their event flags (column "ev"). -# -# Convention: -# - "trade" : any trade event (buy or sell aggressor) -# - "trade_buy" : trade with BUY side flag set (buy aggressor) -# - "trade_sell" : trade with SELL side flag set (sell aggressor) -# - "depth" : any depth (order book) event -# - "depth_bid" : depth event on bid side -# - "depth_ask" : depth event on ask side -# - "snapshot" : any snapshot event -# - "snapshot_bid" : snapshot on bid side -# - "snapshot_ask" : snapshot on ask side -EVENT_FILTERS: Mapping[str, int] = { - "trade": TRADE_EVENT, - "trade_buy": TRADE_EVENT | BUY_EVENT, - "trade_sell": TRADE_EVENT | SELL_EVENT, - "depth": DEPTH_EVENT, - "depth_bid": DEPTH_EVENT | BUY_EVENT, - "depth_ask": DEPTH_EVENT | SELL_EVENT, - "snapshot": DEPTH_SNAPSHOT_EVENT, - "snapshot_bid": DEPTH_SNAPSHOT_EVENT | BUY_EVENT, - "snapshot_ask": DEPTH_SNAPSHOT_EVENT | SELL_EVENT, -} - - -def _filter_by_event(data, filter_name): - """ - Accepts either a single filter name (string) or multiple filter names (list/tuple of strings). - Returns a boolean mask selecting all rows that match ANY of the filters. - """ - if isinstance(filter_name, str): - filter_names = [filter_name] - else: - filter_names = list(filter_name) - - masks = [] - for name in filter_names: - if name not in EVENT_FILTERS: - available = ", ".join(sorted(EVENT_FILTERS.keys())) - raise ValueError(f"Unknown filter '{name}'. Available: {available}") - bits = EVENT_FILTERS[name] - masks.append((data["ev"] & bits) == bits) - - # OR-combine all masks → row is included if it matches ANY filter - return np.logical_or.reduce(masks) - - -def peek_data( - path: str = "data/btcusdt_20240809.npz", - key: str = "data", - n: int = 15, - ) -> None: - """ - Print basic information and the first `n` rows of an NPZ dataset. - - Parameters - ---------- - path : str, optional - Path to the NPZ file, by default "data/btcusdt_20240809.npz". - key : str, optional - Array key inside the NPZ file, by default "data". - n : int, optional - Number of rows to print from the start of the array, by default 15. - """ - npz = np.load(path) - if key not in npz.files: - raise KeyError( - f"Key '{key}' not found in NPZ file. " - f"Available keys: {list(npz.files)}" - ) - - data = npz[key] - print("file:", path) - print("dtype:", data.dtype) - print("shape:", data.shape) - print(f"first {n} rows:") - print(data[:n]) - - # --- Time range inspection (timestamps) --- - if "exch_ts" in data.dtype.names: - ts = data["exch_ts"] - - start_ns = ts[0] - end_ns = ts[-1] - duration_sec = (end_ns - start_ns) / 1e9 - - print("\nTime range (exch_ts):") - print(f" start ns : {start_ns}") - print(f" end ns : {end_ns}") - - if duration_sec < 120: - print(f" duration : {duration_sec:.2f} seconds") - elif duration_sec < 7200: - print(f" duration : {duration_sec/60:.2f} minutes") - else: - print(f" duration : {duration_sec/3600:.2f} hours") - -def inspect_stats(path: str = "stats_example.npz", n: int = 10) -> None: - """ - Inspect a stats NPZ file produced by `Recorder.to_npz()`. - - This is intended for quick manual inspection of an example stats file. - - Parameters - ---------- - path : str, optional - Path to the stats NPZ file, by default "stats_example.npz". - """ - npz = np.load(path) - print("Keys in stats NPZ:", npz.files) - - # By convention, backtest stats are stored under key "0" - if "0" not in npz.files: - raise KeyError( - f"Key '0' not found in stats NPZ. " - f"Available keys: {list(npz.files)}" - ) - - stats = npz["0"] - print("Stats dtype:", stats.dtype) - print("Stats shape:", stats.shape) - if stats.shape[0] > 0: - print("First rows:") - print(stats[:n]) - - -def plot_price_series( - path: str = "data/btcusdt_20240809.npz", - key: str = "data", - ts_field: str = "exch_ts", - px_field: str = "px", - event_filter: str | None = None, - output_path: str | None = None, - ) -> None: - """ - Plot a price series from a raw market-data NPZ file. - - The function optionally filters rows by logical event type (e.g. only trades), - converts timestamps from nanoseconds to seconds (relative to the first event), - and saves the resulting plot as a PNG. - - Expected dtype of the `key` array (typical hftbacktest format): - [ - ('ev', 'u8'), # event flags - ('exch_ts', 'i8'), # venue timestamp (ns) - ('local_ts', 'i8'), # local receive timestamp (ns) - ('px', 'f8'), # price - ('qty', 'f8'), # quantity - ('order_id', 'u8'), # order ID (for L3 feeds) - ('ival', 'i8'), # auxiliary integer - ('fval', 'f8'), # auxiliary float - ] - - Parameters - ---------- - path : str, optional - Path to the NPZ file, by default "data/btcusdt_20240809.npz". - key : str, optional - Array key inside the NPZ file, by default "data". - ts_field : str, optional - Name of the timestamp field, by default "exch_ts". - px_field : str, optional - Name of the price field, by default "px". - event_filter : str or None, optional - Logical event filter name (see EVENT_FILTERS). If None, all rows are used. - output_path : str or None, optional - Output PNG filename. If None, a name is derived from `path` and `event_filter`. - """ - npz = np.load(path) - if key not in npz.files: - raise KeyError( - f"Key '{key}' not found in NPZ file. " - f"Available keys: {list(npz.files)}" - ) - - data = npz[key] - - if ts_field not in data.dtype.names: - raise KeyError( - f"Timestamp field '{ts_field}' not found. " - f"Available fields: {data.dtype.names}" - ) - if px_field not in data.dtype.names: - raise KeyError( - f"Price field '{px_field}' not found. " - f"Available fields: {data.dtype.names}" - ) - - # Optionally filter by event type using human-friendly name - if event_filter is not None: - mask = _filter_by_event(data, event_filter) - data = data[mask] - if data.size == 0: - raise ValueError( - f"No rows match event filter '{event_filter}'. " - "Try a different filter or inspect EVENT_FILTERS." - ) - - if data.size == 0: - raise ValueError("No rows in data array, nothing to plot.") - - ts = data[ts_field] - px = data[px_field] - - # Convert nanoseconds to seconds and make it relative to the first timestamp - t_sec = (ts - ts[0]) / 1e9 - - # Auto-generate output filename if none is provided - if output_path is None: - base = os.path.splitext(os.path.basename(path))[0] - suffix = f"_{event_filter}" if event_filter else "" - output_path = f"{base}_price{suffix}.png" - - # Build a human-readable title - filter_label = event_filter if event_filter is not None else "all events" - title = f"Price series ({px_field}) from {os.path.basename(path)}\nfilter: {filter_label}" - - # Create and save the plot - plt.figure(figsize=(14, 6)) - plt.plot(t_sec, px) - plt.title(title) - plt.xlabel("Time (s, relative to first event)") - plt.ylabel("Price") - plt.grid(True) - plt.tight_layout() - plt.savefig(output_path) - plt.close() - print(f"Saved price plot to: {output_path}") - - -if __name__ == "__main__": - # ----------------------------------------------------------------- - # Example usages - # ----------------------------------------------------------------- - - out_dir_parts = "tests/data/parts" - out_dir_res = "tests/data/results" - - parts_file_0 = f"{out_dir_parts}/part-000.npz" - parts_file_1 = f"{out_dir_parts}/part-001.npz" - stats_file = f"{out_dir_res}/stats.npz" - - # Quick peek at the raw market data file - peek_data(parts_file_0, n=30) - - # Plot full price series (all events) - plot_price_series(parts_file_0, event_filter=["trade"], output_path=f"{out_dir_res}/part-000.png") - - # Plot only buy-side trade prices (aggressive buys) - plot_price_series(parts_file_1, event_filter="trade_buy", output_path=f"{out_dir_res}/part-001.png") - - # Inspect a stats file produced by Recorder.to_npz() - inspect_stats(stats_file, n=20) From 716013e2dbcabbc4d3461ac2be8b7ed114216012 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 18:03:07 +0000 Subject: [PATCH 05/61] m1 slice3: done --- scripts/check.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check.sh b/scripts/check.sh index 8065fcb..afb9120 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,10 +5,10 @@ echo "🔍 Running import-linter..." lint-imports --verbose echo "⚡ Running ruff (check only)..." -ruff check trading_framework examples tests +ruff check trading_framework tests echo "🧠 Running mypy..." -mypy trading_framework examples tests +mypy trading_framework tests echo "🧪 Running pytest..." pytest From 3e97f7c0c13f6baa7b409e04a78a2b537f34ed0b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 18:03:43 +0000 Subject: [PATCH 06/61] m1 slice3: done --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 0e313f1..b8833f9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,3 @@ FROM mcr.microsoft.com/devcontainers/python:3.11 -RUN pip install --upgrade pip setuptools wheel \ No newline at end of file +RUN pip install --upgrade pip setuptools wheel From 802a2f7c144ff3e8797eff037d554a32455b3d32 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 18:56:46 +0000 Subject: [PATCH 07/61] m1: final touch --- trading_framework/backtest/__init__.py | 0 trading_framework/backtest/sinks/__init__.py | 0 trading_framework/backtest/sinks/logging.py | 0 trading_framework/backtest/sinks/research_recorder.py | 0 trading_framework/live/__init__.py | 0 trading_framework/live/adapters/__init__.py | 0 trading_framework/live/adapters/deribit/__init__.py | 0 trading_framework/live/runtime/__init__.py | 0 trading_framework/live/sinks/__init__.py | 0 9 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 trading_framework/backtest/__init__.py delete mode 100644 trading_framework/backtest/sinks/__init__.py delete mode 100644 trading_framework/backtest/sinks/logging.py delete mode 100644 trading_framework/backtest/sinks/research_recorder.py delete mode 100644 trading_framework/live/__init__.py delete mode 100644 trading_framework/live/adapters/__init__.py delete mode 100644 trading_framework/live/adapters/deribit/__init__.py delete mode 100644 trading_framework/live/runtime/__init__.py delete mode 100644 trading_framework/live/sinks/__init__.py diff --git a/trading_framework/backtest/__init__.py b/trading_framework/backtest/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/sinks/__init__.py b/trading_framework/backtest/sinks/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/sinks/logging.py b/trading_framework/backtest/sinks/logging.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/backtest/sinks/research_recorder.py b/trading_framework/backtest/sinks/research_recorder.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/live/__init__.py b/trading_framework/live/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/live/adapters/__init__.py b/trading_framework/live/adapters/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/live/adapters/deribit/__init__.py b/trading_framework/live/adapters/deribit/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/live/runtime/__init__.py b/trading_framework/live/runtime/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/trading_framework/live/sinks/__init__.py b/trading_framework/live/sinks/__init__.py deleted file mode 100644 index e69de29..0000000 From 8ae8ce5fb9114444cc487c3d01df083f93cb95c3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 20:56:46 +0000 Subject: [PATCH 08/61] m2 slice2 step1: first semantic structuring refactor --- ...nts_do_not_enter_queue_characterization.py | 63 ++++++ ...nt_dominance_sequences_characterization.py | 103 ++++++++++ .../test_new_queued_on_rate_limit.py | 66 +++++++ ...ate_pop_queued_intents_characterization.py | 158 +++++++++++++++ .../core/execution_control/__init__.py | 11 ++ .../execution_control/execution_control.py | 185 ++++++++++++++++++ trading_framework/core/risk/risk_engine.py | 146 +++++--------- 7 files changed, 635 insertions(+), 97 deletions(-) create mode 100644 tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py create mode 100644 tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py create mode 100644 tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py create mode 100644 trading_framework/core/execution_control/__init__.py create mode 100644 trading_framework/core/execution_control/execution_control.py 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 new file mode 100644 index 0000000..36628fe --- /dev/null +++ b/tests/semantics/gate_risk_invariants/test_rejected_intents_do_not_enter_queue_characterization.py @@ -0,0 +1,63 @@ +""" +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 trading_framework.core.domain.reject_reasons import RejectReason +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + NewOrderIntent, + NotionalLimits, + Price, + Quantity, +) +from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from trading_framework.core.risk.risk_config import RiskConfig +from trading_framework.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/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py b/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py new file mode 100644 index 0000000..9c9c2b6 --- /dev/null +++ b/tests/semantics/queue_semantics/test_mixed_queued_intent_dominance_sequences_characterization.py @@ -0,0 +1,103 @@ +""" +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 trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + CancelOrderIntent, + NewOrderIntent, + NotionalLimits, + OrderRateLimits, + Price, + Quantity, + ReplaceOrderIntent, +) +from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from trading_framework.core.risk.risk_config import RiskConfig +from trading_framework.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 index c3c8a55..25f4037 100644 --- a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py +++ b/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py @@ -13,8 +13,10 @@ NewOrderIntent, NotionalLimits, OrderRateLimits, + OrderStateEvent, Price, Quantity, + CancelOrderIntent, ) from trading_framework.core.events.sinks.null_event_bus import NullEventBus from trading_framework.core.risk.risk_config import RiskConfig @@ -66,3 +68,67 @@ def test_new_is_queued_when_rate_limit_blocks() -> None: 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_strategy_state_pop_queued_intents_characterization.py b/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py new file mode 100644 index 0000000..04b9109 --- /dev/null +++ b/tests/semantics/queue_semantics/test_strategy_state_pop_queued_intents_characterization.py @@ -0,0 +1,158 @@ +""" +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 trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + CancelOrderIntent, + NewOrderIntent, + Price, + Quantity, + ReplaceOrderIntent, +) +from trading_framework.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/trading_framework/core/execution_control/__init__.py b/trading_framework/core/execution_control/__init__.py new file mode 100644 index 0000000..c802eac --- /dev/null +++ b/trading_framework/core/execution_control/__init__.py @@ -0,0 +1,11 @@ +"""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. +""" + +from trading_framework.core.execution_control.execution_control import ExecutionControl + +__all__ = ["ExecutionControl"] + diff --git a/trading_framework/core/execution_control/execution_control.py b/trading_framework/core/execution_control/execution_control.py new file mode 100644 index 0000000..0576ac9 --- /dev/null +++ b/trading_framework/core/execution_control/execution_control.py @@ -0,0 +1,185 @@ +"""Execution control (internal extraction from RiskEngine). + +Owns: +- token bucket rate limiting state & math +- inflight gating that routes NEW/REPLACE to queue +- queue admission via StrategyState.merge_intents_into_queue(...) +- queue-only local handling for certain CANCEL/REPLACE cases +""" + +from __future__ import annotations + +import math +from collections import defaultdict +from typing import TYPE_CHECKING, Callable + +from trading_framework.core.domain.types import NewOrderIntent, OrderIntent + +if TYPE_CHECKING: + from trading_framework.core.domain.state import StrategyState + + +class ExecutionControl: + """Internal execution control component (stateful).""" + + def __init__(self) -> None: + # Persistent token buckets keyed by kind. + self._rate_state: dict[str, dict[str, float]] = { + "order": {"tokens": 0.0, "last_ts": 0.0}, + "cancel": {"tokens": 0.0, "last_ts": 0.0}, + } + + @staticmethod + def _sec(ts_ns: int) -> int: + return ts_ns // 1_000_000_000 + + def consume_rate(self, kind: str, ts_ns_local: int, limit_per_sec: float) -> tuple[bool, int]: + """Token bucket rate limiting. + + Returns: + (allowed_now, wake_ts_ns_local) + + If not allowed, wake_ts is the earliest local timestamp when one token becomes available. + """ + if limit_per_sec <= 0: + sec = self._sec(ts_ns_local) + return False, (sec + 1) * 1_000_000_000 + + state = self._rate_state.setdefault(kind, {"tokens": 0.0, "last_ts": float(ts_ns_local)}) + now_ts = ts_ns_local + last_ts = state["last_ts"] + + dt_sec = max(0.0, (now_ts - last_ts) / 1_000_000_000) + + # Capacity allows bursts up to ~1 second worth of requests. + capacity = limit_per_sec + + tokens = state["tokens"] + tokens = min(capacity, tokens + dt_sec * limit_per_sec) + + if tokens >= 1.0: + tokens -= 1.0 + state["tokens"] = tokens + state["last_ts"] = now_ts + return True, ts_ns_local + + deficit = 1.0 - tokens + wait_sec = deficit / limit_per_sec + wait_ns = int(math.ceil(wait_sec * 1_000_000_000)) + wake_ts = ts_ns_local + max(1, wait_ns) + + state["tokens"] = tokens + state["last_ts"] = now_ts + return False, wake_ts + + def maybe_route_new_replace_to_queue_on_inflight( + self, + it: OrderIntent, + state: StrategyState, + to_queue_by_instr: defaultdict[str, list[OrderIntent]], + ) -> bool: + """Inflight gating: if an update is already in flight, enqueue and skip sending now.""" + if it.intent_type in ("new", "replace"): + if state.has_inflight(it.instrument, it.client_order_id): + to_queue_by_instr[it.instrument].append(it) + return True + return False + + def handle_cancel_against_queued_only_state( + self, + it: OrderIntent, + *, + state: StrategyState, + replaced_in_queue: list[tuple[OrderIntent, OrderIntent]], + handled_in_queue: list[OrderIntent], + ) -> bool: + """CANCEL against queued-only state: remove queued intents, do not send cancel.""" + if it.intent_type != "cancel": + return False + + removed = state.pop_queued_intents_for_order(it.instrument, it.client_order_id) + for qi in removed: + replaced_in_queue.append((qi.intent, it)) + handled_in_queue.append(it) + return True + + def handle_replace_against_queued_new( + self, + it: OrderIntent, + *, + state: StrategyState, + queued_new: NewOrderIntent, + replaced_in_queue: list[tuple[OrderIntent, OrderIntent]], + dropped_in_queue: list[OrderIntent], + queued: list[OrderIntent], + handled_in_queue: list[OrderIntent], + ) -> None: + """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)) + + updated_new = NewOrderIntent( + ts_ns_local=it.ts_ns_local, + instrument=it.instrument, + client_order_id=it.client_order_id, + intents_correlation_id=it.intents_correlation_id, + side=it.side, + order_type=it.order_type, + intended_qty=it.intended_qty, + intended_price=it.intended_price, + time_in_force=queued_new.time_in_force, + ) + + q_items, replaced, dropped = state.merge_intents_into_queue( + instrument=it.instrument, + intents=[updated_new], + ) + + handled_in_queue.append(it) + replaced_in_queue.extend(replaced) + dropped_in_queue.extend(dropped) + queued.extend(q_items) + + @staticmethod + def is_replace_noop_against_working( + *, + replace_intent: OrderIntent, + working_intended_price: float, + working_intended_qty: float, + float_equal: Callable[[float, float], bool], + ) -> bool: + replace_px = replace_intent.intended_price.value + replace_qty = replace_intent.intended_qty.value + return float_equal(working_intended_price, replace_px) and float_equal(working_intended_qty, replace_qty) + + @staticmethod + def is_replace_noop_against_queued_new( + *, + replace_intent: OrderIntent, + queued_new: NewOrderIntent, + float_equal: Callable[[float, float], bool], + ) -> bool: + replace_px = replace_intent.intended_price.value + replace_qty = replace_intent.intended_qty.value + q_px = queued_new.intended_price.value + q_qty = queued_new.intended_qty.value + return float_equal(q_px, replace_px) and float_equal(q_qty, replace_qty) + + def merge_to_queue_per_instrument( + self, + *, + state: StrategyState, + to_queue_by_instr: defaultdict[str, list[OrderIntent]], + queued: list[OrderIntent], + replaced_in_queue: list[tuple[OrderIntent, OrderIntent]], + dropped_in_queue: list[OrderIntent], + ) -> None: + for instr, intents in to_queue_by_instr.items(): + if not intents: + continue + q, replaced, dropped = state.merge_intents_into_queue(instrument=instr, intents=intents) + queued.extend(q) + replaced_in_queue.extend(replaced) + dropped_in_queue.extend(dropped) + diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index e869e7a..9d3cce9 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -2,13 +2,13 @@ from __future__ import annotations -import math from collections import defaultdict from dataclasses import dataclass from typing import TYPE_CHECKING from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.types import NewOrderIntent, OrderIntent, RiskConstraints +from trading_framework.core.domain.types import OrderIntent, RiskConstraints +from trading_framework.core.execution_control import ExecutionControl from trading_framework.core.events.events import RiskDecisionEvent from trading_framework.core.ports.venue_policy import VenuePolicy @@ -82,12 +82,9 @@ def __init__(self, risk_cfg: RiskConfig, event_bus: EventBus) -> None: post_only_mode=venue_policy_cfg["post_only_mode"], ) - # Persistent per-second rate buckets keyed by local timestamp second. - # Example: {sec: {"order": 3, "cancel": 10}} - self._rate_state: dict[str, dict[str, float]] = { - "order": {"tokens": 0.0, "last_ts": 0.0}, - "cancel": {"tokens": 0.0, "last_ts": 0.0}, - } + # 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]: @@ -394,8 +391,12 @@ def _count_reject(reason: str) -> None: working = state.get_working_order_snapshot(it.instrument, it.client_order_id) if working is not None: if ( - self._float_equal(working.intended_price, replace_px) - and self._float_equal(working.intended_qty, replace_qty) + self._execution_control.is_replace_noop_against_working( + replace_intent=it, + working_intended_price=working.intended_price, + working_intended_qty=working.intended_qty, + float_equal=self._float_equal, + ) ): handled_in_queue.append(it) continue @@ -403,9 +404,11 @@ def _count_reject(reason: str) -> None: if not has_working and has_queued: queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) if queued_new is not None: - q_px = queued_new.intended_price.value - q_qty = queued_new.intended_qty.value - if self._float_equal(q_px, replace_px) and self._float_equal(q_qty, replace_qty): + if self._execution_control.is_replace_noop_against_queued_new( + replace_intent=it, + queued_new=queued_new, + float_equal=self._float_equal, + ): handled_in_queue.append(it) continue @@ -419,10 +422,12 @@ def _count_reject(reason: str) -> None: if not has_working: if has_queued: # Cancel only queued state: remove queued intents and do not send a cancel. - removed = state.pop_queued_intents_for_order(it.instrument, it.client_order_id) - for qi in removed: - replaced_in_queue.append((qi.intent, it)) - handled_in_queue.append(it) + self._execution_control.handle_cancel_against_queued_only_state( + it, + state=state, + replaced_in_queue=replaced_in_queue, + handled_in_queue=handled_in_queue, + ) continue rejected.append(RejectedIntent(it, RejectReason.ORDER_NOT_FOUND)) @@ -438,31 +443,15 @@ def _count_reject(reason: str) -> None: _count_reject(RejectReason.ORDER_NOT_FOUND) continue - removed = state.pop_queued_intents_for_order(it.instrument, it.client_order_id) - for qi in removed: - replaced_in_queue.append((qi.intent, it)) - - updated_new = NewOrderIntent( - ts_ns_local=it.ts_ns_local, - instrument=it.instrument, - client_order_id=it.client_order_id, - intents_correlation_id=it.intents_correlation_id, - side=it.side, - order_type=it.order_type, - intended_qty=it.intended_qty, - intended_price=it.intended_price, - time_in_force=queued_new.time_in_force, - ) - - q_items, replaced, dropped = state.merge_intents_into_queue( - instrument=it.instrument, - intents=[updated_new], + self._execution_control.handle_replace_against_queued_new( + it, + state=state, + queued_new=queued_new, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + queued=queued, + handled_in_queue=handled_in_queue, ) - - handled_in_queue.append(it) - replaced_in_queue.extend(replaced) - dropped_in_queue.extend(dropped) - queued.extend(q_items) continue # 0.6) Inflight gating: if an update is already in flight for this @@ -473,10 +462,12 @@ def _count_reject(reason: str) -> None: # This enforces *eventual consistency*, not ACK-synchronous behavior. # An intent may be queued even though the previous request has already # reached the venue but is not yet observable via snapshots. - if it.intent_type in ("new", "replace"): - if state.has_inflight(it.instrument, it.client_order_id): - to_queue_by_instr[it.instrument].append(it) - continue + if self._execution_control.maybe_route_new_replace_to_queue_on_inflight( + it, + state, + to_queue_by_instr, + ): + continue # 1) Outbound hygiene validation (hard reject) ok, reason = self._validate_intent(it, state) @@ -504,7 +495,9 @@ def _count_reject(reason: str) -> None: # 3) Rate limiting -> queue (soft, not reject) if it.intent_type == "cancel": if max_cancels_per_sec is not None: - allowed, wake_ts = self._consume_rate("cancel", now_ts_ns_local, max_cancels_per_sec) + allowed, wake_ts = self._execution_control.consume_rate( + "cancel", now_ts_ns_local, max_cancels_per_sec + ) if not allowed: to_queue_by_instr[it.instrument].append(it) next_send_ts = wake_ts if next_send_ts is None else min(next_send_ts, wake_ts) @@ -514,7 +507,9 @@ def _count_reject(reason: str) -> None: # new / replace if max_orders_per_sec is not None: - allowed, wake_ts = self._consume_rate("order", now_ts_ns_local, max_orders_per_sec) + allowed, wake_ts = self._execution_control.consume_rate( + "order", now_ts_ns_local, max_orders_per_sec + ) if not allowed: to_queue_by_instr[it.instrument].append(it) next_send_ts = wake_ts if next_send_ts is None else min(next_send_ts, wake_ts) @@ -525,13 +520,13 @@ def _count_reject(reason: str) -> None: # ----------------------------------------------------------------- # Queue merge per instrument (replacement rules live in StrategyState) # ----------------------------------------------------------------- - for instr, intents in to_queue_by_instr.items(): - if not intents: - continue - q, replaced, dropped = state.merge_intents_into_queue(instrument=instr, intents=intents) - queued.extend(q) - replaced_in_queue.extend(replaced) - dropped_in_queue.extend(dropped) + 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, @@ -562,49 +557,6 @@ def _count_reject(reason: str) -> None: # Internals # --------------------------------------------------------------------- - @staticmethod - def _sec(ts_ns: int) -> int: - return ts_ns // 1_000_000_000 - - def _consume_rate(self, kind: str, ts_ns_local: int, limit_per_sec: float) -> tuple[bool, int]: - """Token bucket rate limiting. - - Returns: - (allowed_now, wake_ts_ns_local) - - If not allowed, wake_ts is the earliest local timestamp when one token becomes available. - """ - if limit_per_sec <= 0: - sec = self._sec(ts_ns_local) - return False, (sec + 1) * 1_000_000_000 - - state = self._rate_state.setdefault(kind, {"tokens": 0.0, "last_ts": float(ts_ns_local)}) - now_ts = ts_ns_local - last_ts = state["last_ts"] - - dt_sec = max(0.0, (now_ts - last_ts) / 1_000_000_000) - - # Capacity allows bursts up to ~1 second worth of requests. - capacity = limit_per_sec - - tokens = state["tokens"] - tokens = min(capacity, tokens + dt_sec * limit_per_sec) - - if tokens >= 1.0: - tokens -= 1.0 - state["tokens"] = tokens - state["last_ts"] = now_ts - return True, ts_ns_local - - deficit = 1.0 - tokens - wait_sec = deficit / limit_per_sec - wait_ns = int(math.ceil(wait_sec * 1_000_000_000)) - wake_ts = ts_ns_local + max(1, wait_ns) - - state["tokens"] = tokens - state["last_ts"] = now_ts - return False, wake_ts - def _validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: """Outbound intent sanity. From 8f2f50647783a37f3cc602b4d557617bbf9fe6b1 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Wed, 29 Apr 2026 21:18:58 +0000 Subject: [PATCH 09/61] m2 slice2 step2.1: extract RiskPolicy from RiskEngine without behavior changes --- trading_framework/core/risk/risk_engine.py | 306 ++++----------------- trading_framework/core/risk/risk_policy.py | 274 ++++++++++++++++++ 2 files changed, 323 insertions(+), 257 deletions(-) create mode 100644 trading_framework/core/risk/risk_policy.py diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index 9d3cce9..2c23a84 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -11,6 +11,7 @@ from trading_framework.core.execution_control import ExecutionControl from trading_framework.core.events.events import RiskDecisionEvent from trading_framework.core.ports.venue_policy import VenuePolicy +from trading_framework.core.risk.risk_policy import RiskPolicy if TYPE_CHECKING: from risk.risk_config import RiskConfig @@ -82,6 +83,8 @@ def __init__(self, risk_cfg: RiskConfig, event_bus: EventBus) -> None: 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() @@ -225,14 +228,15 @@ def _count_reject(reason: str) -> None: reject_counts[reason] = reject_counts.get(reason, 0) + 1 # --- Trading enabled gate --- - if not self.risk_cfg.trading_enabled: - for it in raw_intents: - if it.intent_type == "cancel": - # Cancels are risk-reducing: allow them through even when disabled - accepted_now.append(it) - else: - rejected.append(RejectedIntent(it, RejectReason.TRADING_DISABLED)) - _count_reject(RejectReason.TRADING_DISABLED) + 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, @@ -261,81 +265,42 @@ def _count_reject(reason: str) -> None: return decision # --- Max loss (portfolio drawdown kill-switch) --- - max_loss_cfg = self.risk_cfg.max_loss - if max_loss_cfg is not None: - pnl = state.get_total_pnl() - if pnl <= max_loss_cfg.max_drawdown: - for it in raw_intents: - if it.intent_type == "cancel": - accepted_now.append(it) - else: - rejected.append(RejectedIntent(it, RejectReason.MAX_LOSS_DRAWDOWN)) - _count_reject(RejectReason.MAX_LOSS_DRAWDOWN) - - 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, - ) - - 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 + 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) - # 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: - window_ns = int(max_loss_cfg.rolling_loss_window * 1_000_000_000) - rolling = state.get_rolling_loss( - now_ts_ns_local=now_ts_ns_local, - window_ns=window_ns, + 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, ) - if rolling is not None and rolling <= max_loss_cfg.rolling_loss: - for it in raw_intents: - if it.intent_type == "cancel": - accepted_now.append(it) - else: - rejected.append(RejectedIntent(it, RejectReason.MAX_LOSS_ROLLING)) - _count_reject(RejectReason.MAX_LOSS_ROLLING) - - 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, - ) - 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, - ) - ) + 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 + return decision # --- Rate limits (per second, local time) --- rate_cfg = self.risk_cfg.order_rate_limits @@ -354,16 +319,16 @@ def _count_reject(reason: str) -> None: quote_book = None if quote_cfg is not None: - quote_book = self._quote_book_global(state) + quote_book = self._risk_policy.quote_book_global(state) # Base portfolio gross notional (best-effort) - base_gross_notional = self._portfolio_gross_notional(state) + base_gross_notional = self._risk_policy.portfolio_gross_notional(state) # ----------------------------------------------------------------- # Per-intent decision # ----------------------------------------------------------------- for it in raw_intents: - norm = self._venue_policy.normalize_intent(it, state) + 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) @@ -470,14 +435,14 @@ def _count_reject(reason: str) -> None: continue # 1) Outbound hygiene validation (hard reject) - ok, reason = self._validate_intent(it, state) + 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._hard_checks( + ok, reason = self._risk_policy.hard_checks( it, state, max_pos=max_pos, @@ -552,176 +517,3 @@ def _count_reject(reason: str) -> None: ) return decision - - # --------------------------------------------------------------------- - # Internals - # --------------------------------------------------------------------- - - def _validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: - """Outbound intent sanity. - - Even if your schemas allow 0 placeholders, outbound intents should still be sensible. - """ - if it.ts_ns_local <= 0: - return False, RejectReason.INVALID_TS - if not it.instrument: - return False, RejectReason.INVALID_INSTRUMENT - - if it.intent_type == "cancel": - return True, "OK" - - # new / replace - if it.intended_qty is None or it.intended_qty.value <= 0: - return False, RejectReason.INVALID_QTY - - if it.order_type == "limit": - if it.intended_price is None or it.intended_price.value <= 0: - return False, RejectReason.INVALID_LIMIT_PRICE - - if it.order_type == "market": - # if notional checks need a price proxy, require a mid - if state.get_mid(it.instrument) <= 0: - return False, RejectReason.NO_MID_FOR_MARKET - - return True, "OK" - - # pylint: disable=too-many-locals,too-many-branches,too-many-return-statements - def _hard_checks( - self, - it: OrderIntent, - state: StrategyState, - *, - max_pos: float | None, - max_single_order_notional: float | None, - max_gross_notional: float | None, - base_gross_notional: float | None, - quote_cfg: QuoteLimits | None, - quote_book: dict[tuple[str, str | None, tuple[float, float]]], - ) -> tuple[bool, str]: - """Apply hard risk checks. Returns (ok, reason).""" - - # Cancels are always allowed (risk reducing). - if it.intent_type == "cancel": - return True, "OK" - - qty = it.intended_qty.value - px = self._intent_price(it, state) or 0.0 - contract_size = state.get_contract_size(it.instrument) - notional = abs(px * qty * contract_size) - - # Position limit (symmetric absolute), based on account position - if max_pos is not None: - cur_pos = state.account[it.instrument].position if it.instrument in state.account else 0.0 - delta = qty if it.side == "buy" else -qty - if cur_pos + delta > max_pos or cur_pos + delta < -max_pos: - return False, RejectReason.MAX_POSITION - - # Single-order notional - if max_single_order_notional is not None and notional > max_single_order_notional: - return False, RejectReason.MAX_SINGLE_ORDER_NOTIONAL - - # Portfolio gross notional - if max_gross_notional is not None and base_gross_notional is not None: - if base_gross_notional + notional > max_gross_notional: - return False, RejectReason.MAX_GROSS_NOTIONAL - - # Quote limits (global, queued included) - if quote_cfg is not None: - book = self._quote_book_global(state) if quote_book is None else quote_book - key = (it.instrument, it.client_order_id) - - existing = book.get(key) - existing_abs = 0.0 if existing is None else existing[0] - existing_signed = 0.0 if existing is None else existing[1] - - active = len(book) - gross_q = sum(v[0] for v in book.values()) - net_q = sum(v[1] for v in book.values()) - - # Apply delta for this intent (new or replace). - new_abs = notional - new_signed = notional if it.side == "buy" else -notional - - active_after = active if existing is not None else active + 1 - gross_after = gross_q - existing_abs + new_abs - net_after = net_q - existing_signed + new_signed - - if quote_cfg.max_active_quotes is not None: - if active_after > quote_cfg.max_active_quotes: - return False, RejectReason.MAX_ACTIVE_QUOTES - - if quote_cfg.max_gross_quote_notional is not None: - if gross_after > quote_cfg.max_gross_quote_notional: - return False, RejectReason.MAX_GROSS_QUOTE_NOTIONAL - - if quote_cfg.max_net_quote_notional is not None: - if abs(net_after) > quote_cfg.max_net_quote_notional: - return False, RejectReason.MAX_NET_QUOTE_NOTIONAL - - return True, "OK" - - def _intent_price(self, it: OrderIntent, state: StrategyState) -> float | 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) - return None if mid <= 0 else mid - - def _portfolio_gross_notional(self, state: StrategyState) -> float | None: - total = 0.0 - for instr, acct in state.account.items(): - mid = state.get_mid(instr) - if mid <= 0: - return None - total += abs(acct.position * mid * state.get_contract_size(instr)) - return total - - def _quote_book_global(self, state: StrategyState) -> dict[tuple[str, str], tuple[float, float]]: - """Build a best-effort global quote book including queued intents. - - Returns: - Mapping (instrument, client_order_id) -> (abs_notional, signed_notional) - - Notes: - - Working orders are sourced from StrategyState.orders. - - Queued intents in StrategyState.queued_intents are applied on top. - - This is used only for quote-limits enforcement. - """ - - book: dict[tuple[str, str], tuple[float, float]] = {} - - # Working orders - for instr, bucket in state.orders.items(): - contract_size = state.get_contract_size(instr) - for oid, o in bucket.items(): - qty = o.remaining_qty if o.remaining_qty > 0 else o.intended_qty - if qty <= 0: - continue - px = o.intended_price - notional = abs(px * qty * contract_size) - signed = notional if o.side == "buy" else -notional - book[(instr, oid)] = (notional, signed) - - # Queued intents (apply on top of working) - for instr, q in state.queued_intents.items(): - contract_size = state.get_contract_size(instr) - for qi in q: - it = qi.intent - key = (instr, it.client_order_id) - - if it.intent_type == "cancel": - if key in book: - book.pop(key) - continue - - if it.intent_type not in ("new", "replace"): - continue - - qty_val = it.intended_qty.value - px_val = self._intent_price(it, state) - if px_val is None: - continue - notional = abs(px_val * qty_val * contract_size) - signed = notional if it.side == "buy" else -notional - book[key] = (notional, signed) - - return book diff --git a/trading_framework/core/risk/risk_policy.py b/trading_framework/core/risk/risk_policy.py new file mode 100644 index 0000000..5135c4e --- /dev/null +++ b/trading_framework/core/risk/risk_policy.py @@ -0,0 +1,274 @@ +"""Pure risk policy logic extracted from RiskEngine. + +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. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from trading_framework.core.domain.reject_reasons import RejectReason +from trading_framework.core.domain.types import OrderIntent +from trading_framework.core.ports.venue_policy import NormalizationOutcome, VenuePolicy + +if TYPE_CHECKING: + from trading_framework.core.domain.state import StrategyState + from trading_framework.core.domain.types import MaxLoss, QuoteLimits + + +class RiskPolicy: + """Pure policy layer used by RiskEngine.""" + + def __init__(self, *, venue_policy: VenuePolicy) -> None: + self._venue_policy = venue_policy + + def trading_enabled_gate( + self, + *, + trading_enabled: bool, + raw_intents: list[OrderIntent], + ) -> tuple[bool, list[OrderIntent], list[tuple[OrderIntent, str]]]: + """Trading enabled gate. + + Returns: + (triggered, accepted_now, rejected_pairs) + """ + if trading_enabled: + return False, [], [] + + accepted_now: list[OrderIntent] = [] + rejected: list[tuple[OrderIntent, str]] = [] + for it in raw_intents: + if it.intent_type == "cancel": + # Cancels are risk-reducing: allow them through even when disabled + accepted_now.append(it) + else: + rejected.append((it, RejectReason.TRADING_DISABLED)) + return True, accepted_now, rejected + + def max_loss_gate( + self, + *, + max_loss_cfg: MaxLoss | None, + raw_intents: list[OrderIntent], + state: StrategyState, + now_ts_ns_local: int, + ) -> tuple[bool, list[OrderIntent], list[tuple[OrderIntent, str]]]: + """Max-loss / kill-switch gate. + + Returns: + (triggered, accepted_now, rejected_pairs) + """ + if max_loss_cfg is None: + return False, [], [] + + pnl = state.get_total_pnl() + if pnl <= max_loss_cfg.max_drawdown: + return True, self._accept_cancels_reject_others( + raw_intents, + RejectReason.MAX_LOSS_DRAWDOWN, + ) + + # 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: + window_ns = int(max_loss_cfg.rolling_loss_window * 1_000_000_000) + rolling = state.get_rolling_loss( + now_ts_ns_local=now_ts_ns_local, + window_ns=window_ns, + ) + if rolling is not None and rolling <= max_loss_cfg.rolling_loss: + return True, self._accept_cancels_reject_others( + raw_intents, + RejectReason.MAX_LOSS_ROLLING, + ) + + return False, [], [] + + @staticmethod + def _accept_cancels_reject_others( + raw_intents: list[OrderIntent], + reason: str, + ) -> tuple[list[OrderIntent], list[tuple[OrderIntent, str]]]: + accepted_now: list[OrderIntent] = [] + rejected: list[tuple[OrderIntent, str]] = [] + for it in raw_intents: + if it.intent_type == "cancel": + accepted_now.append(it) + else: + rejected.append((it, reason)) + return accepted_now, rejected + + def normalize_intent(self, it: OrderIntent, state: StrategyState) -> NormalizationOutcome: + return self._venue_policy.normalize_intent(it, state) + + def validate_intent(self, it: OrderIntent, state: StrategyState) -> tuple[bool, str]: + """Outbound intent sanity. + + Even if your schemas allow 0 placeholders, outbound intents should still be sensible. + """ + if it.ts_ns_local <= 0: + return False, RejectReason.INVALID_TS + if not it.instrument: + return False, RejectReason.INVALID_INSTRUMENT + + if it.intent_type == "cancel": + return True, "OK" + + # new / replace + if it.intended_qty is None or it.intended_qty.value <= 0: + return False, RejectReason.INVALID_QTY + + if it.order_type == "limit": + if it.intended_price is None or it.intended_price.value <= 0: + return False, RejectReason.INVALID_LIMIT_PRICE + + if it.order_type == "market": + # if notional checks need a price proxy, require a mid + if state.get_mid(it.instrument) <= 0: + return False, RejectReason.NO_MID_FOR_MARKET + + return True, "OK" + + # pylint: disable=too-many-locals,too-many-branches,too-many-return-statements + def hard_checks( + self, + it: OrderIntent, + state: StrategyState, + *, + max_pos: float | None, + max_single_order_notional: float | None, + max_gross_notional: float | None, + base_gross_notional: float | None, + quote_cfg: QuoteLimits | None, + quote_book: dict[tuple[str, str | None, tuple[float, float]]], + ) -> tuple[bool, str]: + """Apply hard risk checks. Returns (ok, reason).""" + + # Cancels are always allowed (risk reducing). + if it.intent_type == "cancel": + return True, "OK" + + qty = it.intended_qty.value + px = self.intent_price(it, state) or 0.0 + contract_size = state.get_contract_size(it.instrument) + notional = abs(px * qty * contract_size) + + # Position limit (symmetric absolute), based on account position + if max_pos is not None: + cur_pos = state.account[it.instrument].position if it.instrument in state.account else 0.0 + delta = qty if it.side == "buy" else -qty + if cur_pos + delta > max_pos or cur_pos + delta < -max_pos: + return False, RejectReason.MAX_POSITION + + # Single-order notional + if max_single_order_notional is not None and notional > max_single_order_notional: + return False, RejectReason.MAX_SINGLE_ORDER_NOTIONAL + + # Portfolio gross notional + if max_gross_notional is not None and base_gross_notional is not None: + if base_gross_notional + notional > max_gross_notional: + return False, RejectReason.MAX_GROSS_NOTIONAL + + # Quote limits (global, queued included) + if quote_cfg is not None: + book = self.quote_book_global(state) if quote_book is None else quote_book + key = (it.instrument, it.client_order_id) + + existing = book.get(key) + existing_abs = 0.0 if existing is None else existing[0] + existing_signed = 0.0 if existing is None else existing[1] + + active = len(book) + gross_q = sum(v[0] for v in book.values()) + net_q = sum(v[1] for v in book.values()) + + # Apply delta for this intent (new or replace). + new_abs = notional + new_signed = notional if it.side == "buy" else -notional + + active_after = active if existing is not None else active + 1 + gross_after = gross_q - existing_abs + new_abs + net_after = net_q - existing_signed + new_signed + + if quote_cfg.max_active_quotes is not None: + if active_after > quote_cfg.max_active_quotes: + return False, RejectReason.MAX_ACTIVE_QUOTES + + if quote_cfg.max_gross_quote_notional is not None: + if gross_after > quote_cfg.max_gross_quote_notional: + return False, RejectReason.MAX_GROSS_QUOTE_NOTIONAL + + if quote_cfg.max_net_quote_notional is not None: + if abs(net_after) > quote_cfg.max_net_quote_notional: + return False, RejectReason.MAX_NET_QUOTE_NOTIONAL + + return True, "OK" + + def intent_price(self, it: OrderIntent, state: StrategyState) -> float | 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) + return None if mid <= 0 else mid + + def portfolio_gross_notional(self, state: StrategyState) -> float | None: + total = 0.0 + for instr, acct in state.account.items(): + mid = state.get_mid(instr) + if mid <= 0: + return None + total += abs(acct.position * mid * state.get_contract_size(instr)) + return total + + def quote_book_global(self, state: StrategyState) -> dict[tuple[str, str], tuple[float, float]]: + """Build a best-effort global quote book including queued intents. + + Returns: + Mapping (instrument, client_order_id) -> (abs_notional, signed_notional) + + Notes: + - Working orders are sourced from StrategyState.orders. + - Queued intents in StrategyState.queued_intents are applied on top. + - This is used only for quote-limits enforcement. + """ + + book: dict[tuple[str, str], tuple[float, float]] = {} + + # Working orders + for instr, bucket in state.orders.items(): + contract_size = state.get_contract_size(instr) + for oid, o in bucket.items(): + qty = o.remaining_qty if o.remaining_qty > 0 else o.intended_qty + if qty <= 0: + continue + px = o.intended_price + notional = abs(px * qty * contract_size) + signed = notional if o.side == "buy" else -notional + book[(instr, oid)] = (notional, signed) + + # Queued intents (apply on top of working) + for instr, q in state.queued_intents.items(): + contract_size = state.get_contract_size(instr) + for qi in q: + it = qi.intent + key = (instr, it.client_order_id) + + if it.intent_type == "cancel": + if key in book: + book.pop(key) + continue + + if it.intent_type not in ("new", "replace"): + continue + + qty_val = it.intended_qty.value + px_val = self.intent_price(it, state) + if px_val is None: + continue + notional = abs(px_val * qty_val * contract_size) + signed = notional if it.side == "buy" else -notional + book[key] = (notional, signed) + + return book + From dfe34c948b9d218acb5dee505a0fe7c9e6f607ec Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 30 Apr 2026 21:12:45 +0000 Subject: [PATCH 10/61] m2 slice2 step2.1: ruff fix --- .../queue_semantics/test_new_queued_on_rate_limit.py | 2 +- trading_framework/core/risk/risk_engine.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 index 25f4037..8e08846 100644 --- a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py +++ b/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py @@ -10,13 +10,13 @@ from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( + CancelOrderIntent, NewOrderIntent, NotionalLimits, OrderRateLimits, OrderStateEvent, Price, Quantity, - CancelOrderIntent, ) from trading_framework.core.events.sinks.null_event_bus import NullEventBus from trading_framework.core.risk.risk_config import RiskConfig diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index 2c23a84..d17ffde 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -8,8 +8,8 @@ from trading_framework.core.domain.reject_reasons import RejectReason from trading_framework.core.domain.types import OrderIntent, RiskConstraints -from trading_framework.core.execution_control import ExecutionControl from trading_framework.core.events.events import RiskDecisionEvent +from trading_framework.core.execution_control import ExecutionControl from trading_framework.core.ports.venue_policy import VenuePolicy from trading_framework.core.risk.risk_policy import RiskPolicy @@ -17,7 +17,6 @@ from risk.risk_config import RiskConfig from trading_framework.core.domain.state import StrategyState - from trading_framework.core.domain.types import QuoteLimits from trading_framework.core.events.event_bus import EventBus @@ -349,8 +348,8 @@ def _count_reject(reason: str) -> None: # 0.5) Replace delta gating (after venue normalization) # Drop replace intents that do not materially change price or quantity. if it.intent_type == "replace": - replace_px = it.intended_price.value - replace_qty = it.intended_qty.value + # replace_px = it.intended_price.value + # replace_qty = it.intended_qty.value if has_working: working = state.get_working_order_snapshot(it.instrument, it.client_order_id) From 1b99ad43d38cda0e3a275bb436dc2f1c81c722c3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 30 Apr 2026 21:58:10 +0000 Subject: [PATCH 11/61] m2 slice2 step3: Core Semantic Refactor --- .../execution_control/execution_control.py | 88 ++++++++++++++ trading_framework/core/risk/risk_engine.py | 107 +++--------------- 2 files changed, 105 insertions(+), 90 deletions(-) diff --git a/trading_framework/core/execution_control/execution_control.py b/trading_framework/core/execution_control/execution_control.py index 0576ac9..ebe55fc 100644 --- a/trading_framework/core/execution_control/execution_control.py +++ b/trading_framework/core/execution_control/execution_control.py @@ -13,6 +13,7 @@ from collections import defaultdict from typing import TYPE_CHECKING, Callable +from trading_framework.core.domain.reject_reasons import RejectReason from trading_framework.core.domain.types import NewOrderIntent, OrderIntent if TYPE_CHECKING: @@ -85,6 +86,93 @@ def maybe_route_new_replace_to_queue_on_inflight( return True return False + def route_pre_submission_lifecycle_and_inflight( + self, + it: OrderIntent, + *, + state: StrategyState, + to_queue_by_instr: defaultdict[str, list[OrderIntent]], + replaced_in_queue: list[tuple[OrderIntent, OrderIntent]], + dropped_in_queue: list[OrderIntent], + queued: list[OrderIntent], + handled_in_queue: list[OrderIntent], + float_equal: Callable[[float, float], bool], + ) -> tuple[bool, str | None]: + """Apply pre-submission lifecycle/identity/noop/inflight handling. + + Returns: + (continue_to_policy_checks, reject_reason) + """ + has_working = state.has_working_order(it.instrument, it.client_order_id) + has_queued = state.has_queued_intent(it.instrument, it.client_order_id) + + if it.intent_type == "replace": + if has_working: + working = state.get_working_order_snapshot(it.instrument, it.client_order_id) + if working is not None: + if self.is_replace_noop_against_working( + replace_intent=it, + working_intended_price=working.intended_price, + working_intended_qty=working.intended_qty, + float_equal=float_equal, + ): + handled_in_queue.append(it) + return False, None + + if not has_working and has_queued: + queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) + if queued_new is not None: + if self.is_replace_noop_against_queued_new( + replace_intent=it, + queued_new=queued_new, + float_equal=float_equal, + ): + handled_in_queue.append(it) + return False, None + + if it.intent_type == "new": + if has_working or has_queued: + return False, RejectReason.DUPLICATE_ID + + if it.intent_type == "cancel": + if not has_working: + if has_queued: + self.handle_cancel_against_queued_only_state( + it, + state=state, + replaced_in_queue=replaced_in_queue, + handled_in_queue=handled_in_queue, + ) + return False, None + + return False, RejectReason.ORDER_NOT_FOUND + + if it.intent_type == "replace": + if not has_working: + queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) + if queued_new is None: + return False, RejectReason.ORDER_NOT_FOUND + + self.handle_replace_against_queued_new( + it, + state=state, + queued_new=queued_new, + replaced_in_queue=replaced_in_queue, + dropped_in_queue=dropped_in_queue, + queued=queued, + handled_in_queue=handled_in_queue, + ) + return False, None + + if self.maybe_route_new_replace_to_queue_on_inflight( + it, + state, + to_queue_by_instr, + ): + return False, None + + return True, None + def handle_cancel_against_queued_only_state( self, it: OrderIntent, diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index d17ffde..db3112d 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -341,96 +341,23 @@ def _count_reject(reason: str) -> None: continue it = norm.normalized - # 0) Existence / uniqueness guards (C1) - has_working = state.has_working_order(it.instrument, it.client_order_id) - has_queued = state.has_queued_intent(it.instrument, it.client_order_id) - - # 0.5) Replace delta gating (after venue normalization) - # Drop replace intents that do not materially change price or quantity. - if it.intent_type == "replace": - # replace_px = it.intended_price.value - # replace_qty = it.intended_qty.value - - if has_working: - working = state.get_working_order_snapshot(it.instrument, it.client_order_id) - if working is not None: - if ( - self._execution_control.is_replace_noop_against_working( - replace_intent=it, - working_intended_price=working.intended_price, - working_intended_qty=working.intended_qty, - float_equal=self._float_equal, - ) - ): - handled_in_queue.append(it) - continue - - if not has_working and has_queued: - queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) - if queued_new is not None: - if self._execution_control.is_replace_noop_against_queued_new( - replace_intent=it, - queued_new=queued_new, - float_equal=self._float_equal, - ): - handled_in_queue.append(it) - continue - - if it.intent_type == "new": - if has_working or has_queued: - rejected.append(RejectedIntent(it, RejectReason.DUPLICATE_ID)) - _count_reject(RejectReason.DUPLICATE_ID) - continue - - if it.intent_type == "cancel": - if not has_working: - if has_queued: - # Cancel only queued state: remove queued intents and do not send a cancel. - self._execution_control.handle_cancel_against_queued_only_state( - it, - state=state, - replaced_in_queue=replaced_in_queue, - handled_in_queue=handled_in_queue, - ) - continue - - rejected.append(RejectedIntent(it, RejectReason.ORDER_NOT_FOUND)) - _count_reject(RejectReason.ORDER_NOT_FOUND) - continue - - if it.intent_type == "replace": - if not has_working: - # Replace acting on queued NEW: transform to NEW (update planned order). - queued_new = state.find_queued_new_intent(it.instrument, it.client_order_id) - if queued_new is None: - rejected.append(RejectedIntent(it, RejectReason.ORDER_NOT_FOUND)) - _count_reject(RejectReason.ORDER_NOT_FOUND) - continue - - self._execution_control.handle_replace_against_queued_new( - it, - state=state, - queued_new=queued_new, - replaced_in_queue=replaced_in_queue, - dropped_in_queue=dropped_in_queue, - queued=queued, - handled_in_queue=handled_in_queue, - ) - continue - - # 0.6) Inflight gating: if an update is already in flight for this - # order id, do not send another new/replace immediately. Instead, - # enqueue the latest desired intent to be flushed once inflight clears. - # NOTE: - # Inflight gating is best-effort and snapshot-driven. - # This enforces *eventual consistency*, not ACK-synchronous behavior. - # An intent may be queued even though the previous request has already - # reached the venue but is not yet observable via snapshots. - if self._execution_control.maybe_route_new_replace_to_queue_on_inflight( - it, - state, - to_queue_by_instr, - ): + # 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) From 6fab227286f0ce60736bd17766d26a7b23088733 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Thu, 30 Apr 2026 22:21:46 +0000 Subject: [PATCH 12/61] m2 slice2 step4: move post-policy rate-limit routing and wake-timestamp decision logic --- .../execution_control/execution_control.py | 51 +++++++++++++++++++ trading_framework/core/risk/risk_engine.py | 31 ++++------- 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/trading_framework/core/execution_control/execution_control.py b/trading_framework/core/execution_control/execution_control.py index ebe55fc..0d4b0b8 100644 --- a/trading_framework/core/execution_control/execution_control.py +++ b/trading_framework/core/execution_control/execution_control.py @@ -11,6 +11,7 @@ import math from collections import defaultdict +from dataclasses import dataclass from typing import TYPE_CHECKING, Callable from trading_framework.core.domain.reject_reasons import RejectReason @@ -20,6 +21,13 @@ from trading_framework.core.domain.state import StrategyState +@dataclass(slots=True) +class _RateRoutingResult: + accept_now: bool + stage_to_queue: bool + wake_ts_ns_local: int | None + + class ExecutionControl: """Internal execution control component (stateful).""" @@ -73,6 +81,49 @@ def consume_rate(self, kind: str, ts_ns_local: int, limit_per_sec: float) -> tup state["last_ts"] = now_ts return False, wake_ts + def route_after_policy_rate_limit( + self, + it: OrderIntent, + *, + now_ts_ns_local: int, + max_orders_per_sec: float | None, + max_cancels_per_sec: float | None, + ) -> _RateRoutingResult: + """Route policy-allowed intent by rate-limits (accept now vs stage).""" + if it.intent_type == "cancel": + if max_cancels_per_sec is not None: + allowed, wake_ts = self.consume_rate( + "cancel", now_ts_ns_local, max_cancels_per_sec + ) + if not allowed: + return _RateRoutingResult( + accept_now=False, + stage_to_queue=True, + wake_ts_ns_local=wake_ts, + ) + return _RateRoutingResult( + accept_now=True, + stage_to_queue=False, + wake_ts_ns_local=None, + ) + + if max_orders_per_sec is not None: + allowed, wake_ts = self.consume_rate( + "order", now_ts_ns_local, max_orders_per_sec + ) + if not allowed: + return _RateRoutingResult( + accept_now=False, + stage_to_queue=True, + wake_ts_ns_local=wake_ts, + ) + + return _RateRoutingResult( + accept_now=True, + stage_to_queue=False, + wake_ts_ns_local=None, + ) + def maybe_route_new_replace_to_queue_on_inflight( self, it: OrderIntent, diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index db3112d..620e40d 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -384,27 +384,18 @@ def _count_reject(reason: str) -> None: continue # 3) Rate limiting -> queue (soft, not reject) - if it.intent_type == "cancel": - if max_cancels_per_sec is not None: - allowed, wake_ts = self._execution_control.consume_rate( - "cancel", now_ts_ns_local, max_cancels_per_sec - ) - if not allowed: - to_queue_by_instr[it.instrument].append(it) - next_send_ts = wake_ts if next_send_ts is None else min(next_send_ts, wake_ts) - continue - accepted_now.append(it) - continue - - # new / replace - if max_orders_per_sec is not None: - allowed, wake_ts = self._execution_control.consume_rate( - "order", now_ts_ns_local, max_orders_per_sec - ) - if not allowed: - to_queue_by_instr[it.instrument].append(it) + 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) + wake_ts = rate_result.wake_ts_ns_local + if wake_ts is not None: next_send_ts = wake_ts if next_send_ts is None else min(next_send_ts, wake_ts) - continue + continue accepted_now.append(it) From 831c362c57b9e6a7a470cbf1573f8a8b325ef310 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 13:48:24 +0000 Subject: [PATCH 13/61] m2 p1: Introduce an explicit `ControlSchedulingObligation` value object --- .../core/execution_control/execution_control.py | 17 ++++++++++++----- .../core/execution_control/types.py | 17 +++++++++++++++++ trading_framework/core/risk/risk_engine.py | 10 +++++++--- 3 files changed, 36 insertions(+), 8 deletions(-) create mode 100644 trading_framework/core/execution_control/types.py diff --git a/trading_framework/core/execution_control/execution_control.py b/trading_framework/core/execution_control/execution_control.py index 0d4b0b8..643acc7 100644 --- a/trading_framework/core/execution_control/execution_control.py +++ b/trading_framework/core/execution_control/execution_control.py @@ -16,6 +16,7 @@ from trading_framework.core.domain.reject_reasons import RejectReason from trading_framework.core.domain.types import NewOrderIntent, OrderIntent +from trading_framework.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: from trading_framework.core.domain.state import StrategyState @@ -25,7 +26,7 @@ class _RateRoutingResult: accept_now: bool stage_to_queue: bool - wake_ts_ns_local: int | None + scheduling_obligation: ControlSchedulingObligation | None class ExecutionControl: @@ -99,12 +100,15 @@ def route_after_policy_rate_limit( return _RateRoutingResult( accept_now=False, stage_to_queue=True, - wake_ts_ns_local=wake_ts, + scheduling_obligation=ControlSchedulingObligation( + ts_ns_local=wake_ts, + reason="rate_limit", + ), ) return _RateRoutingResult( accept_now=True, stage_to_queue=False, - wake_ts_ns_local=None, + scheduling_obligation=None, ) if max_orders_per_sec is not None: @@ -115,13 +119,16 @@ def route_after_policy_rate_limit( return _RateRoutingResult( accept_now=False, stage_to_queue=True, - wake_ts_ns_local=wake_ts, + scheduling_obligation=ControlSchedulingObligation( + ts_ns_local=wake_ts, + reason="rate_limit", + ), ) return _RateRoutingResult( accept_now=True, stage_to_queue=False, - wake_ts_ns_local=None, + scheduling_obligation=None, ) def maybe_route_new_replace_to_queue_on_inflight( diff --git a/trading_framework/core/execution_control/types.py b/trading_framework/core/execution_control/types.py new file mode 100644 index 0000000..35dc21c --- /dev/null +++ b/trading_framework/core/execution_control/types.py @@ -0,0 +1,17 @@ +"""Execution control internal semantic types.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ControlSchedulingObligation: + """Internal runtime-facing scheduling obligation. + + This is a derived control signal (not an Event) and does not mutate State. + """ + + ts_ns_local: int + reason: str + diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index 620e40d..d8fd550 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -392,9 +392,13 @@ def _count_reject(reason: str) -> None: ) if rate_result.stage_to_queue: to_queue_by_instr[it.instrument].append(it) - wake_ts = rate_result.wake_ts_ns_local - if wake_ts is not None: - next_send_ts = wake_ts if next_send_ts is None else min(next_send_ts, wake_ts) + obligation = rate_result.scheduling_obligation + if obligation is not None: + next_send_ts = ( + obligation.ts_ns_local + if next_send_ts is None + else min(next_send_ts, obligation.ts_ns_local) + ) continue accepted_now.append(it) From ca6357486d29881a184a807221ab5da894cea128 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 13:57:56 +0000 Subject: [PATCH 14/61] m2 p1: Test Hardening for explicit `ControlSchedulingObligation` value object --- ..._scheduling_obligation_characterization.py | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py diff --git a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py new file mode 100644 index 0000000..d3a8e66 --- /dev/null +++ b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py @@ -0,0 +1,123 @@ +"""Characterization tests for internal scheduling obligation mapping.""" + +from __future__ import annotations + +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + CancelOrderIntent, + NewOrderIntent, + NotionalLimits, + OrderRateLimits, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from trading_framework.core.execution_control import ExecutionControl +from trading_framework.core.risk.risk_config import RiskConfig +from trading_framework.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 + + +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 + assert result.scheduling_obligation.ts_ns_local == 1_000_000_000 + assert result.scheduling_obligation.reason == "rate_limit" + From 4f43273a9b1741ac269daab5fe8e10f2875cb7d4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 14:27:04 +0000 Subject: [PATCH 15/61] m2 p1: slice for Order Lifecycle / Submitted Boundary --- ...est_submitted_boundary_characterization.py | 215 ++++++++++++++++++ trading_framework/core/domain/state.py | 47 ++++ 2 files changed, 262 insertions(+) create mode 100644 tests/semantics/state_transitions/test_submitted_boundary_characterization.py diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py new file mode 100644 index 0000000..6b8a27a --- /dev/null +++ b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py @@ -0,0 +1,215 @@ +""" +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 trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import NewOrderIntent, OrderStateEvent, Price, Quantity +from trading_framework.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 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_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 == "working" + + +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 == "working" + assert projection.updated_ts_ns_local == 550 diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index 32b5123..b64d5d9 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -89,6 +89,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.""" @@ -145,6 +156,9 @@ def __init__(self, event_bus: EventBus) -> None: 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) @@ -195,6 +209,21 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s inflight_bucket[client_order_id] = InflightInfo(action=intent_type, ts_sent_ns_local=ts_now) + if intent_type != "new": + return + + 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, + state="submitted", + submitted_ts_ns_local=ts_now, + updated_ts_ns_local=ts_now, + ) + def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket = self.inflight.get(instrument) if inflight_bucket is None: @@ -492,6 +521,8 @@ def _should_drop_transition_update(cur: OrderSnapshot, event: OrderStateEvent) - return False def apply_order_state_event(self, event: OrderStateEvent) -> None: + 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) @@ -635,6 +666,22 @@ def apply_order_state_event(self, event: OrderStateEvent) -> None: 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 + + if event.state_type == "pending_new": + return + if event.state_type == "replaced": + return + if event.ts_ns_local < projection.updated_ts_ns_local: + return + + projection.state = event.state_type + projection.updated_ts_ns_local = event.ts_ns_local + # ---- Fills ---- # NOTE: From c699f8c834665c70ae60980889625a88bb7930be Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 15:26:07 +0000 Subject: [PATCH 16/61] m2 p1: Canonical Order Lifecycle Transition Policy --- ...est_submitted_boundary_characterization.py | 270 +++++++++++++++++- .../core/domain/order_lifecycle.py | 63 ++++ trading_framework/core/domain/state.py | 13 +- 3 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 trading_framework/core/domain/order_lifecycle.py diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py index 6b8a27a..6e6542b 100644 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py @@ -188,7 +188,7 @@ def test_mark_intent_sent_replace_and_cancel_do_not_create_canonical_submitted_o 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 == "working" + assert state.canonical_orders[(instrument, "existing-1")].state == "accepted" def test_post_dispatch_feedback_advances_existing_canonical_projection() -> None: @@ -211,5 +211,271 @@ def test_post_dispatch_feedback_advances_existing_canonical_projection() -> None ) projection = state.canonical_orders[(instrument, client_order_id)] - assert projection.state == "working" + 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, {}) diff --git a/trading_framework/core/domain/order_lifecycle.py b/trading_framework/core/domain/order_lifecycle.py new file mode 100644 index 0000000..9a2eb1b --- /dev/null +++ b/trading_framework/core/domain/order_lifecycle.py @@ -0,0 +1,63 @@ +""" +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/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index b64d5d9..e2219dd 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -15,6 +15,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, Iterable +from trading_framework.core.domain.order_lifecycle import ( + is_valid_canonical_order_transition, + normalize_compatibility_state_to_canonical, +) from trading_framework.core.domain.order_state_machine import is_valid_transition from trading_framework.core.domain.slots import SlotKey, stable_slot_order_id from trading_framework.core.domain.types import OrderStateEvent @@ -672,14 +676,15 @@ def _advance_canonical_order_projection(self, event: OrderStateEvent) -> None: if projection is None: return - if event.state_type == "pending_new": - return - if event.state_type == "replaced": + 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 = event.state_type + projection.state = next_canonical_state projection.updated_ts_ns_local = event.ts_ns_local # ---- Fills ---- From 6118de80fece80438749add05df235e6971b674c Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 16:57:34 +0000 Subject: [PATCH 17/61] m2 p1: Introduce a lightweight docs-aligned event taxonomy boundary --- .../models/test_event_taxonomy_boundary.py | 85 +++++++++++++++++++ trading_framework/core/domain/event_model.py | 85 +++++++++++++++++++ trading_framework/core/domain/state.py | 7 +- trading_framework/core/domain/types.py | 13 ++- trading_framework/core/events/event_bus.py | 6 +- trading_framework/core/events/events.py | 13 ++- .../core/execution_control/types.py | 6 +- 7 files changed, 203 insertions(+), 12 deletions(-) create mode 100644 tests/semantics/models/test_event_taxonomy_boundary.py create mode 100644 trading_framework/core/domain/event_model.py diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py new file mode 100644 index 0000000..7be701c --- /dev/null +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -0,0 +1,85 @@ +"""Semantics tests for the lightweight core event taxonomy boundary.""" + +from __future__ import annotations + +from trading_framework.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 trading_framework.core.domain.types import FillEvent, MarketEvent, OrderStateEvent +from trading_framework.core.events.event_bus import EventBus +from trading_framework.core.events.events import ( + DerivedFillEvent, + DerivedPnLEvent, + ExposureDerivedEvent, + OrderStateTransitionEvent, + RiskDecisionEvent, +) +from trading_framework.core.execution_control.types import ControlSchedulingObligation + + +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 + + # 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_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 + diff --git a/trading_framework/core/domain/event_model.py b/trading_framework/core/domain/event_model.py new file mode 100644 index 0000000..cc713dc --- /dev/null +++ b/trading_framework/core/domain/event_model.py @@ -0,0 +1,85 @@ +"""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. +""" + +from __future__ import annotations + +from enum import Enum + +from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderStateEvent +from trading_framework.core.events.events import ( + DerivedFillEvent, + DerivedPnLEvent, + ExposureDerivedEvent, + OrderStateTransitionEvent, + RiskDecisionEvent, +) +from trading_framework.core.execution_control.types import ControlSchedulingObligation + + +class CanonicalEventCategory(str, Enum): + """Canonical Event Stream categories from docs.""" + + MARKET = "market" + INTENT_RELATED = "intent_related" + EXECUTION = "execution" + CONTROL = "control" + + +CANONICAL_EVENT_CATEGORY_NAMES: tuple[str, ...] = tuple( + 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, + FillEvent: CanonicalEventCategory.EXECUTION, +} + + +# Non-canonical telemetry / observability records. +TELEMETRY_EVENT_TYPES: frozenset[type[object]] = frozenset( + { + RiskDecisionEvent, + DerivedPnLEvent, + ExposureDerivedEvent, + OrderStateTransitionEvent, + } +) + + +# 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} +) + + +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/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index e2219dd..d192550 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -1,8 +1,11 @@ """Runtime strategy state management. This module maintains best-effort market, account, order, and queue state -derived from venue snapshots and events. It is intentionally stateful and -optimized for correctness and determinism rather than minimal complexity. +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. """ # pylint: disable=line-too-long,too-many-instance-attributes,too-many-public-methods diff --git a/trading_framework/core/domain/types.py b/trading_framework/core/domain/types.py index 4eecee7..9940ab3 100644 --- a/trading_framework/core/domain/types.py +++ b/trading_framework/core/domain/types.py @@ -1,8 +1,15 @@ """Core shared data models and schemas. -This module defines the canonical Pydantic models used across the system for -market data, order intents, risk constraints, and execution events. These -types are treated as schema definitions and intentionally prioritize +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. +- ``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. """ diff --git a/trading_framework/core/events/event_bus.py b/trading_framework/core/events/event_bus.py index 7a5fc34..a14db7a 100644 --- a/trading_framework/core/events/event_bus.py +++ b/trading_framework/core/events/event_bus.py @@ -1,5 +1,7 @@ -""" -Simple synchronous event bus. +"""Simple synchronous sink fanout transport. + +This bus is an in-process transport for sinks (logging/recording/monitoring). +It is not the canonical Event Stream abstraction. """ from __future__ import annotations diff --git a/trading_framework/core/events/events.py b/trading_framework/core/events/events.py index c6336fc..a1ef060 100644 --- a/trading_framework/core/events/events.py +++ b/trading_framework/core/events/events.py @@ -1,8 +1,13 @@ -""" -Domain event models. +"""Non-canonical telemetry and compatibility event 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 events represent immutable facts observed during execution. -They are consumed by loggers, recorders, and monitoring pipelines. +These records are transport payloads for local sinks and must not be interpreted +as canonical Event Stream semantics by default. """ from __future__ import annotations diff --git a/trading_framework/core/execution_control/types.py b/trading_framework/core/execution_control/types.py index 35dc21c..7d15a32 100644 --- a/trading_framework/core/execution_control/types.py +++ b/trading_framework/core/execution_control/types.py @@ -1,4 +1,8 @@ -"""Execution control internal semantic types.""" +"""Execution-control internal semantic types. + +The types in this module are non-canonical runtime helpers. They are not +canonical Events and are not part of the Event Stream taxonomy. +""" from __future__ import annotations From d89f50cded0902e21ce0ee5c9eb49b5513849c32 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 17:21:40 +0000 Subject: [PATCH 18/61] m2 p1: Introduce a minimal canonical event processing boundary --- .../test_canonical_processing_boundary.py | 149 ++++++++++++++++++ trading_framework/core/domain/processing.py | 76 +++++++++ 2 files changed, 225 insertions(+) create mode 100644 tests/semantics/models/test_canonical_processing_boundary.py create mode 100644 trading_framework/core/domain/processing.py diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py new file mode 100644 index 0000000..652d748 --- /dev/null +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -0,0 +1,149 @@ +"""Semantics tests for the minimal canonical processing boundary.""" + +from __future__ import annotations + +import pytest + +from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type +from trading_framework.core.domain.processing import process_canonical_event +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.core.events.event_bus import EventBus +from trading_framework.core.events.events import RiskDecisionEvent +from trading_framework.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) -> 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=0.25), + remaining_qty=Quantity(unit="contracts", value=0.75), + 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) -> 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="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_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_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_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_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 + diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py new file mode 100644 index 0000000..9f317ff --- /dev/null +++ b/trading_framework/core/domain/processing.py @@ -0,0 +1,76 @@ +"""Minimal canonical event processing boundary for core. + +This module introduces a narrow, docs-aligned processing boundary for canonical +event candidates only. It is intentionally small: + +- it is not a full Event Stream implementation; +- it does not define or enforce Processing Order; +- it does not implement replay semantics; +- compatibility ingestion paths remain separate. +""" + +from __future__ import annotations + +from trading_framework.core.domain.event_model import ( + CanonicalEventCategory, + canonical_category_for_type, + is_canonical_stream_candidate_type, +) +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import FillEvent, MarketEvent + + +def process_canonical_event(state: StrategyState, event: object) -> None: + """Process a canonical event candidate via existing state reducers. + + Accepted canonical candidates in the current slice: + - ``MarketEvent`` (category: ``market``) + - ``FillEvent`` (category: ``execution``) + + Non-canonical records (compatibility projections, telemetry payloads, bus + transports, and helper artifacts) are rejected at this boundary. + """ + + record_type = type(event) + if not is_canonical_stream_candidate_type(record_type): + raise TypeError(f"Unsupported non-canonical event type: {record_type.__name__}") + + category = canonical_category_for_type(record_type) + + if category == CanonicalEventCategory.MARKET and isinstance(event, MarketEvent): + if not event.is_book() or event.book is None: + raise ValueError( + "Unsupported MarketEvent payload for canonical processing: " + "book snapshot/delta with top-of-book levels is required." + ) + if not event.book.bids or not event.book.asks: + raise ValueError( + "Unsupported MarketEvent payload for canonical processing: " + "book payload must include at least one bid and one ask level." + ) + + 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, + ) + return + + if category == CanonicalEventCategory.EXECUTION and isinstance(event, FillEvent): + state.apply_fill_event(event) + return + + raise TypeError( + "Unsupported canonical event candidate for this processing boundary: " + f"{record_type.__name__}" + ) From d042fefd15e7519636f0fb6c5747e48d981f4665 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 17:42:11 +0000 Subject: [PATCH 19/61] m2 p1: Introduce a minimal `ProcessingPosition` value object --- .../test_canonical_processing_boundary.py | 72 +++++++++++++++++++ trading_framework/core/domain/processing.py | 13 +++- .../core/domain/processing_order.py | 20 ++++++ 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 trading_framework/core/domain/processing_order.py diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index 652d748..a4f7bd6 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -6,6 +6,7 @@ from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type from trading_framework.core.domain.processing import process_canonical_event +from trading_framework.core.domain.processing_order import ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( FillEvent, @@ -99,6 +100,23 @@ def test_process_canonical_event_accepts_market_event() -> None: 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) + + 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_fill_event() -> None: state = StrategyState(event_bus=NullEventBus()) event = _fill_event( @@ -116,6 +134,36 @@ def test_process_canonical_event_accepts_fill_event() -> None: 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 + + +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) + + 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_process_canonical_event_rejects_order_state_event() -> None: state = StrategyState(event_bus=NullEventBus()) event = _order_state_event( @@ -129,6 +177,20 @@ def test_process_canonical_event_rejects_order_state_event() -> None: 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_canonical_event_rejects_telemetry_record() -> None: state = StrategyState(event_bus=NullEventBus()) telemetry = RiskDecisionEvent( @@ -147,3 +209,13 @@ def test_process_canonical_event_rejects_telemetry_record() -> None: 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/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 9f317ff..0c5bf8e 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -16,20 +16,31 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from trading_framework.core.domain.processing_order import ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import FillEvent, MarketEvent -def process_canonical_event(state: StrategyState, event: object) -> None: +def process_canonical_event( + state: StrategyState, + event: object, + *, + position: ProcessingPosition | None = None, +) -> None: """Process a canonical event candidate via existing state reducers. Accepted canonical candidates in the current slice: - ``MarketEvent`` (category: ``market``) - ``FillEvent`` (category: ``execution``) + ``ProcessingPosition`` is accepted as Processing Order metadata at this + boundary. This function does not yet implement full Event Stream ordering + or replay, and reducers preserve existing timestamp-based behavior. + Non-canonical records (compatibility projections, telemetry payloads, bus transports, and helper artifacts) are rejected at this boundary. """ + _ = position record_type = type(event) if not is_canonical_stream_candidate_type(record_type): diff --git a/trading_framework/core/domain/processing_order.py b/trading_framework/core/domain/processing_order.py new file mode 100644 index 0000000..f9c6a4c --- /dev/null +++ b/trading_framework/core/domain/processing_order.py @@ -0,0 +1,20 @@ +"""Processing-order semantic value objects. + +This module introduces minimal Processing Order metadata for the canonical +processing boundary without implementing Event Stream or replay mechanics. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class ProcessingPosition: + """Boundary metadata representing a position in Processing Order.""" + + index: int + + def __post_init__(self) -> None: + if self.index < 0: + raise ValueError("ProcessingPosition.index must be non-negative") From b611574cc17d27ece0a017324b0cbed39e085b30 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 18:53:07 +0000 Subject: [PATCH 20/61] m2 p1 slice1b: add tests proving that the canonical boundary path is behavior-equivalent --- ...nonical_processing_differential_harness.py | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 tests/semantics/models/test_canonical_processing_differential_harness.py diff --git a/tests/semantics/models/test_canonical_processing_differential_harness.py b/tests/semantics/models/test_canonical_processing_differential_harness.py new file mode 100644 index 0000000..d20b4cc --- /dev/null +++ b/tests/semantics/models/test_canonical_processing_differential_harness.py @@ -0,0 +1,362 @@ +"""Differential characterization tests for canonical reducer boundary parity.""" + +from __future__ import annotations + +import copy + +import pytest + +from trading_framework.core.domain.processing import process_canonical_event +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.core.events.events import RiskDecisionEvent +from trading_framework.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 From 11aeebba4dc2b2a8a562257004f4c12f22aa6192 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 19:16:41 +0000 Subject: [PATCH 21/61] m2 p1 slice1c: make `process_canonical_event(...)` clearly documented and guarded --- .../test_canonical_reducer_authority_guard.py | 53 +++++++++++++++++++ trading_framework/core/domain/processing.py | 17 ++++-- trading_framework/core/domain/state.py | 15 ++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 tests/semantics/models/test_canonical_reducer_authority_guard.py diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py new file mode 100644 index 0000000..81ff2bc --- /dev/null +++ b/tests/semantics/models/test_canonical_reducer_authority_guard.py @@ -0,0 +1,53 @@ +"""Architectural guard for canonical reducer authority hardening.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_ALLOWED_CALLER = Path("trading_framework/core/domain/processing.py") +_TARGET_METHODS = frozenset({"update_market", "apply_fill_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 / "trading_framework" + + 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/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 0c5bf8e..55c91b9 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -1,7 +1,10 @@ """Minimal canonical event processing boundary for core. -This module introduces a narrow, docs-aligned processing boundary for canonical -event candidates only. It is intentionally small: +This module introduces a narrow, docs-aligned processing boundary for current +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: - it is not a full Event Stream implementation; - it does not define or enforce Processing Order; @@ -29,13 +32,19 @@ def process_canonical_event( ) -> None: """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 + currently supported canonical candidates. + - keep low-level reducer methods as compatibility primitives. + Accepted canonical candidates in the current slice: - ``MarketEvent`` (category: ``market``) - ``FillEvent`` (category: ``execution``) ``ProcessingPosition`` is accepted as Processing Order metadata at this - boundary. This function does not yet implement full Event Stream ordering - or replay, and reducers preserve existing timestamp-based behavior. + boundary. This function is not a full Event Stream, replay, or Processing + Order enforcement layer, and reducers preserve existing timestamp-based + behavior. Non-canonical records (compatibility projections, telemetry payloads, bus transports, and helper artifacts) are rejected at this boundary. diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index d192550..65f939f 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -313,6 +313,13 @@ 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() @@ -699,6 +706,14 @@ def _advance_canonical_order_projection(self, event: OrderStateEvent) -> None: # 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. + """ instrument = event.instrument client_order_id = event.client_order_id From 064ee56937110a40e21fe6ad8dfa39ba14ab227c Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 19:30:51 +0000 Subject: [PATCH 22/61] m2 p1 slice1d: harden compatibility boundaries --- .../models/test_event_taxonomy_boundary.py | 41 +++++++++++++++++++ trading_framework/core/domain/state.py | 22 +++++++++- trading_framework/core/domain/types.py | 6 +++ trading_framework/core/events/event_bus.py | 5 ++- trading_framework/core/events/events.py | 21 ++++++++++ trading_framework/core/risk/risk_engine.py | 3 ++ 6 files changed, 96 insertions(+), 2 deletions(-) diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index 7be701c..d486405 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -11,6 +11,8 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) +from trading_framework.core.domain.processing import process_canonical_event +from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderStateEvent from trading_framework.core.events.event_bus import EventBus from trading_framework.core.events.events import ( @@ -20,7 +22,9 @@ OrderStateTransitionEvent, RiskDecisionEvent, ) +from trading_framework.core.events.sinks.null_event_bus import NullEventBus from trading_framework.core.execution_control.types import ControlSchedulingObligation +from trading_framework.core.risk.risk_engine import GateDecision def test_canonical_event_category_names_are_stable() -> None: @@ -55,6 +59,13 @@ def test_event_bus_is_not_canonical_stream_record() -> None: 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.""" @@ -83,3 +94,33 @@ def test_telemetry_records_are_not_canonical_stream_candidates() -> None: 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") + diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index 65f939f..ffa0c95 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -52,7 +52,11 @@ @dataclass(slots=True) class OrderSnapshot: - """Best-effort internal order snapshot.""" + """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 @@ -199,6 +203,12 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s This is used for best-effort inflight handling. hftbacktest provides 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. """ bucket = self.last_sent_intents.get(instrument) @@ -535,6 +545,13 @@ def _should_drop_transition_update(cur: OrderSnapshot, event: OrderStateEvent) - 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()) @@ -749,6 +766,9 @@ def ingest_order_snapshots(self, instrument: str, orders_snapshot_iter: Iterable hftbacktest provides *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: diff --git a/trading_framework/core/domain/types.py b/trading_framework/core/domain/types.py index 9940ab3..93dec79 100644 --- a/trading_framework/core/domain/types.py +++ b/trading_framework/core/domain/types.py @@ -342,6 +342,12 @@ class FillEvent(BaseModel): 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) diff --git a/trading_framework/core/events/event_bus.py b/trading_framework/core/events/event_bus.py index a14db7a..0f950ba 100644 --- a/trading_framework/core/events/event_bus.py +++ b/trading_framework/core/events/event_bus.py @@ -11,7 +11,10 @@ class EventBus: - """Dispatches events to registered sinks.""" + """Dispatches records to registered sinks via synchronous fanout. + + This is a sink/telemetry transport helper and not a canonical Event Stream. + """ def __init__(self, sinks: Iterable[EventSink] | None = None) -> None: self._sinks: list[EventSink] = list(sinks) if sinks is not None else [] diff --git a/trading_framework/core/events/events.py b/trading_framework/core/events/events.py index a1ef060..4255fa5 100644 --- a/trading_framework/core/events/events.py +++ b/trading_framework/core/events/events.py @@ -16,6 +16,10 @@ @dataclass(slots=True) class OrderStateTransitionEvent: + """Observability payload for invalid/edge order-state transitions. + + Telemetry only; not a canonical Event Stream record. + """ ts_ns_local: int instrument: str client_order_id: str @@ -25,6 +29,11 @@ class OrderStateTransitionEvent: @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 @@ -39,6 +48,10 @@ class DerivedFillEvent: @dataclass(slots=True) class DerivedPnLEvent: + """Observability payload for derived realized-PnL changes. + + Telemetry only; not a canonical Event Stream record. + """ ts_ns_local: int instrument: str @@ -48,6 +61,10 @@ class DerivedPnLEvent: @dataclass(slots=True) class ExposureDerivedEvent: + """Observability payload for derived exposure changes. + + Telemetry only; not a canonical Event Stream record. + """ ts_ns_local: int instrument: str @@ -57,6 +74,10 @@ class ExposureDerivedEvent: @dataclass(slots=True) class RiskDecisionEvent: + """Observability payload summarizing risk/gate outcomes. + + Telemetry only; not a canonical Event Stream record. + """ ts_ns_local: int accepted: int diff --git a/trading_framework/core/risk/risk_engine.py b/trading_framework/core/risk/risk_engine.py index d8fd550..2fe3167 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/trading_framework/core/risk/risk_engine.py @@ -35,6 +35,9 @@ class RejectedIntent: 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 From ae36bdf4e77c2ad04a337cf2f9b33c054189865d Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 21:29:04 +0000 Subject: [PATCH 23/61] m2 p2 slice2a.1: introduce minimal ProcessingPosition monotonicity enforcement --- .../test_canonical_processing_boundary.py | 133 ++++++++++++++++++ ...cessing_position_cursor_ownership_guard.py | 86 +++++++++++ trading_framework/core/domain/processing.py | 15 +- trading_framework/core/domain/state.py | 18 +++ 4 files changed, 246 insertions(+), 6 deletions(-) create mode 100644 tests/semantics/models/test_processing_position_cursor_ownership_guard.py diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index a4f7bd6..83bcc20 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -2,6 +2,8 @@ from __future__ import annotations +import copy + import pytest from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type @@ -20,6 +22,14 @@ from trading_framework.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) -> MarketEvent: return MarketEvent( ts_ns_local=ts_ns_local, @@ -115,6 +125,7 @@ def test_process_canonical_event_accepts_market_event_with_processing_position() 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: @@ -150,6 +161,92 @@ def test_process_canonical_event_accepts_fill_event_with_processing_position() - 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_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)) + + 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)) + 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)) + 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)) + 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: @@ -164,6 +261,42 @@ def test_processing_position_is_not_derived_from_event_time() -> None: 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) + + process_canonical_event(state, first, position=ProcessingPosition(index=1)) + process_canonical_event(state, second, position=ProcessingPosition(index=2)) + + assert state._last_processing_position_index == 2 + # Existing reducer behavior remains timestamp-driven in this slice. + market = state.market["BTC-USDC-PERP"] + assert market.last_ts_ns_local == 200 + assert market.last_ts_ns_exch == 190 + + +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)) + 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 + + def test_process_canonical_event_rejects_order_state_event() -> None: state = StrategyState(event_bus=NullEventBus()) event = _order_state_event( diff --git a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py new file mode 100644 index 0000000..1aa505c --- /dev/null +++ b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py @@ -0,0 +1,86 @@ +"""Architectural guard for ProcessingPosition cursor ownership.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +_ALLOWED_CALLER = Path("trading_framework/core/domain/processing.py") +_ALLOWED_MUTATION_FILE = Path("trading_framework/core/domain/state.py") +_TARGET_METHOD = "_advance_processing_position" +_TARGET_ATTR = "_last_processing_position_index" + + +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_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 / "trading_framework" + + 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) + ) diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 55c91b9..b82360f 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -7,7 +7,7 @@ This module is intentionally small: - it is not a full Event Stream implementation; -- it does not define or enforce Processing Order; +- it enforces only minimal positioned monotonicity at the boundary; - it does not implement replay semantics; - compatibility ingestion paths remain separate. """ @@ -42,15 +42,13 @@ def process_canonical_event( - ``FillEvent`` (category: ``execution``) ``ProcessingPosition`` is accepted as Processing Order metadata at this - boundary. This function is not a full Event Stream, replay, or Processing - Order enforcement layer, and reducers preserve existing timestamp-based - behavior. + boundary. When provided, positions must be strictly increasing. This + function is not a full Event Stream or replay layer, and reducers preserve + existing timestamp-based behavior. Non-canonical records (compatibility projections, telemetry payloads, bus transports, and helper artifacts) are rejected at this boundary. """ - _ = position - record_type = type(event) if not is_canonical_stream_candidate_type(record_type): raise TypeError(f"Unsupported non-canonical event type: {record_type.__name__}") @@ -72,6 +70,9 @@ def process_canonical_event( best_bid_level = event.book.bids[0] best_ask_level = event.book.asks[0] + if position is not None: + state._advance_processing_position(position) + state.update_market( instrument=event.instrument, best_bid=best_bid_level.price.value, @@ -87,6 +88,8 @@ def process_canonical_event( return if category == CanonicalEventCategory.EXECUTION and isinstance(event, FillEvent): + if position is not None: + state._advance_processing_position(position) state.apply_fill_event(event) return diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index ffa0c95..bb7d643 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -23,6 +23,7 @@ normalize_compatibility_state_to_canonical, ) from trading_framework.core.domain.order_state_machine import is_valid_transition +from trading_framework.core.domain.processing_order import ProcessingPosition from trading_framework.core.domain.slots import SlotKey, stable_slot_order_id from trading_framework.core.domain.types import OrderStateEvent from trading_framework.core.events.events import ( @@ -186,6 +187,10 @@ def __init__(self, event_bus: EventBus) -> None: # 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. @@ -197,6 +202,19 @@ def sim_ts_ns_local(self) -> int: """Canonical monotone simulation time (ns, local axis).""" 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. From a306becb9b469fb45077b7bd074544f44da0244d Mon Sep 17 00:00:00 2001 From: bxvtr Date: Fri, 1 May 2026 21:58:23 +0000 Subject: [PATCH 24/61] m2 p2 slice2a.2: add characterization tests for the transitional split --- .../test_canonical_processing_boundary.py | 182 +++++++++++++++++- 1 file changed, 174 insertions(+), 8 deletions(-) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index 83bcc20..0a17875 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -30,7 +30,16 @@ def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: } -def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> MarketEvent: +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, @@ -40,14 +49,14 @@ def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> "book_type": "snapshot", "bids": [ { - "price": {"currency": "USDC", "value": 100.0}, - "quantity": {"unit": "contracts", "value": 2.0}, + "price": {"currency": "USDC", "value": best_bid}, + "quantity": {"unit": "contracts", "value": best_bid_qty}, } ], "asks": [ { - "price": {"currency": "USDC", "value": 101.0}, - "quantity": {"unit": "contracts", "value": 3.0}, + "price": {"currency": "USDC", "value": best_ask}, + "quantity": {"unit": "contracts", "value": best_ask_qty}, } ], "depth": 1, @@ -56,7 +65,14 @@ def _book_market_event(*, instrument: str, ts_ns_local: int, ts_ns_exch: int) -> ) -def _fill_event(*, instrument: str, client_order_id: str, ts_ns_local: int, ts_ns_exch: int) -> FillEvent: +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, @@ -66,8 +82,8 @@ def _fill_event(*, instrument: str, client_order_id: str, ts_ns_local: int, ts_n 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.25), - remaining_qty=Quantity(unit="contracts", value=0.75), + 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, @@ -297,6 +313,156 @@ def test_position_out_of_order_but_event_time_increasing_is_rejected_at_boundary 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, + ) + + process_canonical_event(state, positioned_10, position=ProcessingPosition(index=10)) + 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)) + 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_remains_exchange_timestamp_compatibility_behavior() -> 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, + ) + + process_canonical_event(state, base, position=ProcessingPosition(index=30)) + process_canonical_event(state, lower_exch, position=ProcessingPosition(index=31)) + + 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 == 200 + assert market.best_bid == 100.0 + assert market.best_ask == 101.0 + + process_canonical_event(state, higher_exch, position=ProcessingPosition(index=32)) + + 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_process_canonical_event_rejects_order_state_event() -> None: state = StrategyState(event_bus=NullEventBus()) event = _order_state_event( From 94aab316464e28951b7e00f039239b2928b3680f Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 05:22:07 +0000 Subject: [PATCH 25/61] m2 p2 slice2a.3a: For positioned canonical MarketEvents only, make ProcessingPosition the causal ordering authority --- .../test_canonical_processing_boundary.py | 14 +- .../test_market_reducer_positioned_target.py | 285 ++++++++++++++++++ trading_framework/core/domain/processing.py | 41 ++- trading_framework/core/domain/state.py | 41 +++ 4 files changed, 359 insertions(+), 22 deletions(-) create mode 100644 tests/semantics/models/test_market_reducer_positioned_target.py diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index 0a17875..7612e9c 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -286,10 +286,10 @@ def test_event_time_out_of_order_but_position_increasing_is_accepted_at_boundary process_canonical_event(state, second, position=ProcessingPosition(index=2)) assert state._last_processing_position_index == 2 - # Existing reducer behavior remains timestamp-driven in this slice. + # Positioned canonical market events are now ProcessingPosition-driven. market = state.market["BTC-USDC-PERP"] - assert market.last_ts_ns_local == 200 - assert market.last_ts_ns_exch == 190 + 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: @@ -390,7 +390,7 @@ def test_interleaved_positioned_and_unpositioned_processing_preserves_cursor_mon assert state._last_processing_position_index == 11 -def test_positioned_market_tiebreak_remains_exchange_timestamp_compatibility_behavior() -> None: +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", @@ -420,9 +420,9 @@ def test_positioned_market_tiebreak_remains_exchange_timestamp_compatibility_beh 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 == 200 - assert market.best_bid == 100.0 - assert market.best_ask == 101.0 + 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)) diff --git a/tests/semantics/models/test_market_reducer_positioned_target.py b/tests/semantics/models/test_market_reducer_positioned_target.py new file mode 100644 index 0000000..fce2cdd --- /dev/null +++ b/tests/semantics/models/test_market_reducer_positioned_target.py @@ -0,0 +1,285 @@ +"""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 trading_framework.core.domain.processing import process_canonical_event +from trading_framework.core.domain.processing_order import ProcessingPosition +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.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 _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, + ) + + process_canonical_event(state, first, position=ProcessingPosition(index=1)) + process_canonical_event(state, older_local_second, position=ProcessingPosition(index=2)) + + 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, + ) + + process_canonical_event(state, base, position=ProcessingPosition(index=10)) + process_canonical_event(state, lower_exchange_second, position=ProcessingPosition(index=11)) + + 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/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index b82360f..dca910e 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -43,8 +43,7 @@ def process_canonical_event( ``ProcessingPosition`` is accepted as Processing Order metadata at this boundary. When provided, positions must be strictly increasing. This - function is not a full Event Stream or replay layer, and reducers preserve - existing timestamp-based behavior. + function is not a full Event Stream or replay layer. Non-canonical records (compatibility projections, telemetry payloads, bus transports, and helper artifacts) are rejected at this boundary. @@ -72,19 +71,31 @@ def process_canonical_event( if position is not None: state._advance_processing_position(position) - - 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, - ) + state._update_market_from_positioned_canonical_event( + 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, + ) + else: + 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, + ) return if category == CanonicalEventCategory.EXECUTION and isinstance(event, FillEvent): diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index bb7d643..33c53b3 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -377,6 +377,47 @@ def update_market( else: m.mid = 0.0 + def _update_market_from_positioned_canonical_event( + self, + instrument: str, + best_bid: float, + best_ask: float, + best_bid_qty: float, + best_ask_qty: float, + tick_size: float, + lot_size: float, + contract_size: float, + *, + 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 + m.best_ask_qty = best_ask_qty + 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 + def get_mid(self, instrument: str) -> float: m = self.market.get(instrument) return 0.0 if m is None else m.mid From 6d5b3e6a1bc821a0cebab8e01c9634163af590b3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 05:28:36 +0000 Subject: [PATCH 26/61] m2 p2 slice2a final: private helper ownership guard --- ...cessing_position_cursor_ownership_guard.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py index 1aa505c..d2113d6 100644 --- a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py +++ b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py @@ -9,6 +9,7 @@ _ALLOWED_MUTATION_FILE = Path("trading_framework/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]: @@ -31,6 +32,22 @@ def _find_target_method_calls(path: Path) -> list[tuple[int, int]]: 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]] = [] @@ -84,3 +101,25 @@ def test_processing_position_cursor_is_mutated_only_via_canonical_boundary() -> "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 / "trading_framework" + + 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) + ) From e8bd734a208caf8d75809d21f514099fa4ea618e Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 05:49:49 +0000 Subject: [PATCH 27/61] m2 p2 slice2b.1: introduce a minimal EventStreamEntry value object and a process_event_entry(...) wrapper that delegates to the existing canonical boundary --- .../test_event_stream_entry_contract.py | 221 ++++++++++++++++++ trading_framework/core/domain/processing.py | 24 +- .../core/domain/processing_order.py | 19 ++ 3 files changed, 263 insertions(+), 1 deletion(-) create mode 100644 tests/semantics/models/test_event_stream_entry_contract.py diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py new file mode 100644 index 0000000..83f2b61 --- /dev/null +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -0,0 +1,221 @@ +"""Semantics tests for minimal EventStreamEntry contract (Phase 2B.1).""" + +from __future__ import annotations + +import copy + +import pytest + +from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type +from trading_framework.core.domain.processing import process_event_entry +from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.core.events.event_bus import EventBus +from trading_framework.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 _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 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_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) + + 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_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) + process_event_entry(state, second) + 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_configuration_parameter_is_explicit_but_not_consumed_yet() -> 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_without_config = StrategyState(event_bus=NullEventBus()) + state_with_config = StrategyState(event_bus=NullEventBus()) + + process_event_entry(state_without_config, entry) + process_event_entry(state_with_config, entry, configuration={"version": "v1"}) + + assert _state_subset_snapshot(state_with_config) == _state_subset_snapshot( + state_without_config + ) + + +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/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index dca910e..b60655e 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -19,7 +19,7 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) -from trading_framework.core.domain.processing_order import ProcessingPosition +from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import FillEvent, MarketEvent @@ -108,3 +108,25 @@ def process_canonical_event( "Unsupported canonical event candidate for this processing boundary: " f"{record_type.__name__}" ) + + +def process_event_entry( + state: StrategyState, + entry: EventStreamEntry, + *, + configuration: object | None = None, +) -> None: + """Process one minimal EventStreamEntry via the canonical boundary. + + This wrapper is intentionally minimal: + - it is not full Event Stream storage; + - it is not replay orchestration; + - it is not runtime integration. + + Configuration is accepted as explicit processing input to reflect the + docs contract, but current minimal reducers do not consume it yet. + Ordering is enforced through ``entry.position`` using existing + ``ProcessingPosition`` cursor monotonicity logic in canonical processing. + """ + _ = configuration + process_canonical_event(state, entry.event, position=entry.position) diff --git a/trading_framework/core/domain/processing_order.py b/trading_framework/core/domain/processing_order.py index f9c6a4c..dad7cc1 100644 --- a/trading_framework/core/domain/processing_order.py +++ b/trading_framework/core/domain/processing_order.py @@ -18,3 +18,22 @@ class ProcessingPosition: def __post_init__(self) -> None: if self.index < 0: raise ValueError("ProcessingPosition.index must be non-negative") + + +@dataclass(frozen=True, slots=True) +class EventStreamEntry: + """Minimal envelope for canonical event processing-order input. + + This value object intentionally carries only: + - the causal processing-order position; and + - the event payload consumed by canonical processing boundaries. + """ + + position: ProcessingPosition + event: object + + def __post_init__(self) -> None: + if not isinstance(self.position, ProcessingPosition): + raise TypeError("EventStreamEntry.position must be a ProcessingPosition") + if self.event is None: + raise ValueError("EventStreamEntry.event must be provided") From 19b281587704c4a95c24f1d588f1579b4091805d Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 05:57:48 +0000 Subject: [PATCH 28/61] m2 p2 slice2b.2: add the smallest deterministic fold utility that expresses the core contract: same ordered EventStreamEntry sequence + same explicit configuration => same derived StrategyState --- ...test_fold_event_stream_entries_contract.py | 357 ++++++++++++++++++ trading_framework/core/domain/processing.py | 21 ++ 2 files changed, 378 insertions(+) create mode 100644 tests/semantics/models/test_fold_event_stream_entries_contract.py diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py new file mode 100644 index 0000000..b90e665 --- /dev/null +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -0,0 +1,357 @@ +"""Semantics tests for minimal deterministic fold/replay contract (Phase 2B.2).""" + +from __future__ import annotations + +import copy + +import pytest + +from trading_framework.core.domain.processing import fold_event_stream_entries +from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.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_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 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 = {"version": "v1"} + + 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_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 = {"version": "v1"} + + 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) + + 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) + + +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) + + 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_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, + ), + ) + ] + + returned = fold_event_stream_entries(state, entries, configuration={"version": "v1"}) + + assert returned is state diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index b60655e..71c16ad 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -14,6 +14,8 @@ from __future__ import annotations +from collections.abc import Iterable + from trading_framework.core.domain.event_model import ( CanonicalEventCategory, canonical_category_for_type, @@ -130,3 +132,22 @@ def process_event_entry( """ _ = configuration process_canonical_event(state, entry.event, position=entry.position) + + +def fold_event_stream_entries( + state: StrategyState, + entries: Iterable[EventStreamEntry], + *, + configuration: object | None = None, +) -> StrategyState: + """Fold ordered EventStreamEntry values into the provided state. + + This utility is intentionally minimal and deterministic: + - entries are applied in caller-provided order; + - each entry is processed through ``process_event_entry``; + - errors from canonical/ordering validation are propagated unchanged; + - the same state instance is returned for ergonomic chaining. + """ + for entry in entries: + process_event_entry(state, entry, configuration=configuration) + return state From 07430d858b39fba8f2dccd0af2a04a84322fe1b9 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 06:13:31 +0000 Subject: [PATCH 29/61] m2 p2 slice2b.3: introduce a minimal CoreConfiguration value object that gives configuration a stable semantic identity/fingerprint for deterministic fold/replay contracts, without changing reducers --- .../test_core_configuration_contract.py | 70 ++++++++++++++ .../test_event_stream_entry_contract.py | 13 ++- ...test_fold_event_stream_entries_contract.py | 64 ++++++++++++- .../core/domain/configuration.py | 95 +++++++++++++++++++ trading_framework/core/domain/processing.py | 8 +- 5 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 tests/semantics/models/test_core_configuration_contract.py create mode 100644 trading_framework/core/domain/configuration.py diff --git a/tests/semantics/models/test_core_configuration_contract.py b/tests/semantics/models/test_core_configuration_contract.py new file mode 100644 index 0000000..794eebe --- /dev/null +++ b/tests/semantics/models/test_core_configuration_contract.py @@ -0,0 +1,70 @@ +"""Semantics tests for CoreConfiguration identity and stability contract.""" + +from __future__ import annotations + +import pytest + +from trading_framework.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_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 83f2b61..1bfa0d8 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -6,6 +6,7 @@ import pytest +from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type from trading_framework.core.domain.processing import process_event_entry from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition @@ -200,18 +201,28 @@ def test_process_event_entry_enforces_processing_position_monotonicity() -> None def test_configuration_parameter_is_explicit_but_not_consumed_yet() -> 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) + configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) state_without_config = StrategyState(event_bus=NullEventBus()) state_with_config = StrategyState(event_bus=NullEventBus()) process_event_entry(state_without_config, entry) - process_event_entry(state_with_config, entry, configuration={"version": "v1"}) + process_event_entry(state_with_config, entry, configuration=configuration) assert _state_subset_snapshot(state_with_config) == _state_subset_snapshot( state_without_config ) +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 diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py index b90e665..4411c57 100644 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -6,6 +6,7 @@ import pytest +from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.processing import fold_event_stream_entries from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState @@ -133,7 +134,7 @@ def test_fold_same_entries_same_configuration_produces_equivalent_final_state() ), ), ] - configuration = {"version": "v1"} + configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) left = StrategyState(event_bus=NullEventBus()) right = StrategyState(event_bus=NullEventBus()) @@ -177,7 +178,7 @@ def test_fold_same_prefix_produces_equivalent_prefix_state() -> None: ), ), ] - configuration = {"version": "v1"} + configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) left = StrategyState(event_bus=NullEventBus()) right = StrategyState(event_bus=NullEventBus()) @@ -352,6 +353,63 @@ def test_fold_returns_same_state_object_for_ergonomics() -> None: ) ] - returned = fold_event_stream_entries(state, entries, configuration={"version": "v1"}) + configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + 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_configuration_values_currently_produce_same_state_transitionally() -> 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 = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + configuration_b = CoreConfiguration(version="v2", payload={"risk_mode": "lenient"}) + + 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) + + # Transitional contract: configuration is explicit and validated at boundary, + # but current reducers do not consume it yet. + assert _state_subset_snapshot(left) == _state_subset_snapshot(right) diff --git a/trading_framework/core/domain/configuration.py b/trading_framework/core/domain/configuration.py new file mode 100644 index 0000000..24954b0 --- /dev/null +++ b/trading_framework/core/domain/configuration.py @@ -0,0 +1,95 @@ +"""Core configuration value object for deterministic processing contracts.""" + +from __future__ import annotations + +import hashlib +import json +import math +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import Mapping + +JSONPrimitive = None | bool | int | float | str +RawJSONValue = JSONPrimitive | list["RawJSONValue"] | tuple["RawJSONValue", ...] | dict[str, "RawJSONValue"] +CanonicalJSONValue = JSONPrimitive | tuple["CanonicalJSONValue", ...] | Mapping[str, "CanonicalJSONValue"] + + +def _normalize_value(value: object) -> CanonicalJSONValue: + if value is None or isinstance(value, (bool, int, str)): + return value + + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("Configuration payload contains non-finite float") + return value + + if isinstance(value, (list, tuple)): + return tuple(_normalize_value(item) for item in value) + + if isinstance(value, Mapping): + normalized: dict[str, CanonicalJSONValue] = {} + for key, nested_value in sorted(value.items(), key=lambda item: item[0]): + if not isinstance(key, str): + raise TypeError("Configuration payload mapping keys must be strings") + normalized[key] = _normalize_value(nested_value) + return MappingProxyType(normalized) + + raise TypeError(f"Unsupported configuration payload value type: {type(value).__name__}") + + +def _to_json_compatible(value: CanonicalJSONValue) -> RawJSONValue: + if value is None or isinstance(value, (bool, int, float, str)): + return value + + if isinstance(value, tuple): + return [_to_json_compatible(item) for item in value] + + if isinstance(value, Mapping): + as_dict: dict[str, RawJSONValue] = {} + for key, nested_value in value.items(): + as_dict[key] = _to_json_compatible(nested_value) + return as_dict + + raise TypeError(f"Unsupported canonical payload value type: {type(value).__name__}") + + +def _canonical_payload(payload: Mapping[str, object]) -> Mapping[str, CanonicalJSONValue]: + normalized: dict[str, CanonicalJSONValue] = {} + for key, value in sorted(payload.items(), key=lambda item: item[0]): + if not isinstance(key, str): + raise TypeError("Configuration payload mapping keys must be strings") + normalized[key] = _normalize_value(value) + return MappingProxyType(normalized) + + +@dataclass(frozen=True, slots=True) +class CoreConfiguration: + """Explicit, versioned core configuration with stable semantic identity.""" + + version: str + payload: Mapping[str, object] + fingerprint: str = field(init=False) + + def __post_init__(self) -> None: + if not isinstance(self.version, str) or not self.version: + raise ValueError("CoreConfiguration.version must be a non-empty string") + + if not isinstance(self.payload, Mapping): + raise TypeError("CoreConfiguration.payload must be a mapping") + + normalized_payload = _canonical_payload(self.payload) + object.__setattr__(self, "payload", normalized_payload) + + canonical = { + "version": self.version, + "payload": _to_json_compatible(normalized_payload), + } + canonical_json = json.dumps( + canonical, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + fingerprint = hashlib.sha256(canonical_json.encode("utf-8")).hexdigest() + object.__setattr__(self, "fingerprint", fingerprint) diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 71c16ad..ffa015c 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -16,6 +16,7 @@ from collections.abc import Iterable +from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import ( CanonicalEventCategory, canonical_category_for_type, @@ -116,7 +117,7 @@ def process_event_entry( state: StrategyState, entry: EventStreamEntry, *, - configuration: object | None = None, + configuration: CoreConfiguration | None = None, ) -> None: """Process one minimal EventStreamEntry via the canonical boundary. @@ -130,7 +131,8 @@ def process_event_entry( Ordering is enforced through ``entry.position`` using existing ``ProcessingPosition`` cursor monotonicity logic in canonical processing. """ - _ = configuration + if configuration is not None and not isinstance(configuration, CoreConfiguration): + raise TypeError("configuration must be CoreConfiguration or None") process_canonical_event(state, entry.event, position=entry.position) @@ -138,7 +140,7 @@ def fold_event_stream_entries( state: StrategyState, entries: Iterable[EventStreamEntry], *, - configuration: object | None = None, + configuration: CoreConfiguration | None = None, ) -> StrategyState: """Fold ordered EventStreamEntry values into the provided state. From 5a268f4565385f0a1834d5ff84fce07d1c2583cc Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 06:29:17 +0000 Subject: [PATCH 30/61] m2 p2 slice2b.4: add guardrail tests that make the current Phase 2B configuration contract explicit --- .../test_event_stream_entry_contract.py | 19 +++- ...test_fold_event_stream_entries_contract.py | 94 +++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 1bfa0d8..0b3cac3 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -3,12 +3,14 @@ from __future__ import annotations import copy +import dataclasses +import inspect import pytest from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type -from trading_framework.core.domain.processing import process_event_entry +from trading_framework.core.domain.processing import fold_event_stream_entries, process_event_entry from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( @@ -106,6 +108,21 @@ def test_event_stream_entry_requires_processing_position() -> None: 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) diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py index 4411c57..bc87974 100644 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -145,6 +145,49 @@ def test_fold_same_entries_same_configuration_produces_equivalent_final_state() 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 = CoreConfiguration( + version="v1", + payload={"risk_mode": "strict", "limits": {"max_open_orders": 4}}, + ) + cfg_v1_right = CoreConfiguration( + version="v1", + payload={"limits": {"max_open_orders": 4}, "risk_mode": "strict"}, + ) + + 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( @@ -412,4 +455,55 @@ def test_fold_different_configuration_values_currently_produce_same_state_transi # Transitional contract: configuration is explicit and validated at boundary, # but current reducers do not consume it yet. + assert configuration_a.fingerprint != configuration_b.fingerprint + assert _state_subset_snapshot(left) == _state_subset_snapshot(right) + + +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 = { + "risk_mode": "strict", + "limits": {"max_open_orders": 4}, + "symbols": ["BTC-USDC-PERP"], + } + configuration = CoreConfiguration(version="v1", payload=source_payload) + fingerprint_before = configuration.fingerprint + payload_before = configuration.payload + + source_payload["risk_mode"] = "lenient" + source_payload["limits"]["max_open_orders"] = 1 + source_payload["symbols"].append("ETH-USDC-PERP") + + 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["limits"]["max_open_orders"] = 99 + + assert configuration.fingerprint == fingerprint_before + assert configuration.payload == payload_before assert _state_subset_snapshot(left) == _state_subset_snapshot(right) From e2548d2167e0acc0bc6ba95b527890f8a3fa3dfb Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 12:18:55 +0000 Subject: [PATCH 31/61] m2 p2 slice3a: Strict Market Instrument Metadata from CoreConfiguration --- .../test_canonical_processing_boundary.py | 100 ++++- .../test_event_stream_entry_contract.py | 58 ++- ...test_fold_event_stream_entries_contract.py | 98 +++-- ...arket_configuration_positioned_contract.py | 362 ++++++++++++++++++ .../test_market_reducer_positioned_target.py | 54 ++- trading_framework/core/domain/processing.py | 83 +++- 6 files changed, 693 insertions(+), 62 deletions(-) create mode 100644 tests/semantics/models/test_market_configuration_positioned_contract.py diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index 7612e9c..1aa372d 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -6,6 +6,7 @@ import pytest +from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type from trading_framework.core.domain.processing import process_canonical_event from trading_framework.core.domain.processing_order import ProcessingPosition @@ -110,6 +111,29 @@ def _order_state_event(*, instrument: str, client_order_id: str, ts_ns_local: in ) +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_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) @@ -131,7 +155,7 @@ def test_process_canonical_event_accepts_market_event_with_processing_position() 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) + process_canonical_event(state, event, position=position, configuration=_market_configuration()) market = state.market["BTC-USDC-PERP"] assert market.last_ts_ns_local == 100 @@ -184,7 +208,12 @@ 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)) + process_canonical_event( + state, + event, + position=ProcessingPosition(index=0), + configuration=_market_configuration(), + ) assert state._last_processing_position_index == 0 @@ -199,7 +228,12 @@ def test_increasing_positions_are_accepted() -> None: ts_ns_exch=91, ) - process_canonical_event(state, first, position=ProcessingPosition(index=10)) + 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 @@ -215,7 +249,12 @@ def test_repeated_position_is_rejected_without_state_mutation() -> None: ts_ns_exch=91, ) - process_canonical_event(state, accepted, position=ProcessingPosition(index=3)) + 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"): @@ -236,7 +275,12 @@ def test_regressing_position_is_rejected_without_state_mutation() -> None: ts_ns_exch=92, ) - process_canonical_event(state, accepted, position=ProcessingPosition(index=8)) + 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"): @@ -270,7 +314,7 @@ def test_processing_position_is_not_derived_from_event_time() -> None: 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) + 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 @@ -282,8 +326,9 @@ def test_event_time_out_of_order_but_position_increasing_is_accepted_at_boundary 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) - process_canonical_event(state, first, position=ProcessingPosition(index=1)) - process_canonical_event(state, second, position=ProcessingPosition(index=2)) + 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. @@ -302,7 +347,12 @@ def test_position_out_of_order_but_event_time_increasing_is_rejected_at_boundary ts_ns_exch=180, ) - process_canonical_event(state, first, position=ProcessingPosition(index=5)) + 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"): @@ -373,13 +423,24 @@ def test_interleaved_positioned_and_unpositioned_processing_preserves_cursor_mon cum_filled_qty=0.50, ) - process_canonical_event(state, positioned_10, position=ProcessingPosition(index=10)) + 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)) + 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"): @@ -414,8 +475,14 @@ def test_positioned_market_tiebreak_no_longer_gates_positioned_market_updates() best_ask=121.0, ) - process_canonical_event(state, base, position=ProcessingPosition(index=30)) - process_canonical_event(state, lower_exch, position=ProcessingPosition(index=31)) + 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 @@ -424,7 +491,12 @@ def test_positioned_market_tiebreak_no_longer_gates_positioned_market_updates() assert market.best_bid == 80.0 assert market.best_ask == 81.0 - process_canonical_event(state, higher_exch, position=ProcessingPosition(index=32)) + 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 diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 0b3cac3..1866093 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -103,6 +103,29 @@ def _state_subset_snapshot(state: StrategyState) -> dict[str, object]: } +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}) @@ -128,7 +151,7 @@ def test_process_event_entry_processes_market_and_advances_state() -> 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) - process_event_entry(state, entry) + process_event_entry(state, entry, configuration=_market_configuration()) market = state.market["BTC-USDC-PERP"] assert state._last_processing_position_index == 0 @@ -201,8 +224,8 @@ def test_process_event_entry_enforces_processing_position_monotonicity() -> None ), ) - process_event_entry(state, first) - process_event_entry(state, second) + 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) @@ -215,20 +238,33 @@ def test_process_event_entry_enforces_processing_position_monotonicity() -> None assert state._last_processing_position_index == 11 -def test_configuration_parameter_is_explicit_but_not_consumed_yet() -> None: +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) - configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + state = StrategyState(event_bus=NullEventBus()) - state_without_config = StrategyState(event_bus=NullEventBus()) - state_with_config = StrategyState(event_bus=NullEventBus()) + with pytest.raises( + ValueError, + match="CoreConfiguration is required for positioned canonical MarketEvent processing", + ): + process_event_entry(state, entry, configuration=None) - process_event_entry(state_without_config, entry) - process_event_entry(state_with_config, entry, configuration=configuration) - assert _state_subset_snapshot(state_with_config) == _state_subset_snapshot( - state_without_config +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: diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py index bc87974..5cf22a3 100644 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -111,6 +111,30 @@ 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( @@ -134,7 +158,7 @@ def test_fold_same_entries_same_configuration_produces_equivalent_final_state() ), ), ] - configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + configuration = _market_configuration() left = StrategyState(event_bus=NullEventBus()) right = StrategyState(event_bus=NullEventBus()) @@ -169,14 +193,8 @@ def test_fold_uses_single_explicit_configuration_input_with_stable_identity() -> ), ), ] - cfg_v1_left = CoreConfiguration( - version="v1", - payload={"risk_mode": "strict", "limits": {"max_open_orders": 4}}, - ) - cfg_v1_right = CoreConfiguration( - version="v1", - payload={"limits": {"max_open_orders": 4}, "risk_mode": "strict"}, - ) + cfg_v1_left = _market_configuration(version="v1") + cfg_v1_right = _market_configuration(version="v1") left = StrategyState(event_bus=NullEventBus()) right = StrategyState(event_bus=NullEventBus()) @@ -221,7 +239,7 @@ def test_fold_same_prefix_produces_equivalent_prefix_state() -> None: ), ), ] - configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + configuration = _market_configuration() left = StrategyState(event_bus=NullEventBus()) right = StrategyState(event_bus=NullEventBus()) @@ -258,7 +276,7 @@ def test_fold_repeated_or_regressing_processing_position_raises_deterministicall ] with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(repeated_state, repeated_entries) + fold_event_stream_entries(repeated_state, repeated_entries, configuration=_market_configuration()) regressing_state = StrategyState(event_bus=NullEventBus()) regressing_entries = [ @@ -285,7 +303,7 @@ def test_fold_repeated_or_regressing_processing_position_raises_deterministicall ] with pytest.raises(ValueError, match="Non-monotonic ProcessingPosition index"): - fold_event_stream_entries(regressing_state, regressing_entries) + fold_event_stream_entries(regressing_state, regressing_entries, configuration=_market_configuration()) def test_fold_positioned_market_ordering_follows_processing_position_not_event_time() -> None: @@ -313,7 +331,7 @@ def test_fold_positioned_market_ordering_follows_processing_position_not_event_t ), ] - fold_event_stream_entries(state, entries) + fold_event_stream_entries(state, entries, configuration=_market_configuration()) market = state.market["BTC-USDC-PERP"] assert market.best_bid == 120.0 @@ -396,7 +414,7 @@ def test_fold_returns_same_state_object_for_ergonomics() -> None: ) ] - configuration = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) + configuration = _market_configuration() returned = fold_event_stream_entries(state, entries, configuration=configuration) assert returned is state @@ -421,7 +439,7 @@ def test_fold_rejects_non_core_configuration() -> None: fold_event_stream_entries(state, entries, configuration={"version": "v1"}) -def test_fold_different_configuration_values_currently_produce_same_state_transitionally() -> None: +def test_fold_different_market_configuration_values_produce_different_market_metadata() -> None: entries = [ _entry( 0, @@ -444,8 +462,18 @@ def test_fold_different_configuration_values_currently_produce_same_state_transi ), ), ] - configuration_a = CoreConfiguration(version="v1", payload={"risk_mode": "strict"}) - configuration_b = CoreConfiguration(version="v2", payload={"risk_mode": "lenient"}) + 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()) @@ -453,10 +481,20 @@ def test_fold_different_configuration_values_currently_produce_same_state_transi fold_event_stream_entries(left, entries, configuration=configuration_a) fold_event_stream_entries(right, entries, configuration=configuration_b) - # Transitional contract: configuration is explicit and validated at boundary, - # but current reducers do not consume it yet. assert configuration_a.fingerprint != configuration_b.fingerprint - assert _state_subset_snapshot(left) == _state_subset_snapshot(right) + 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: @@ -484,17 +522,23 @@ def test_fold_configuration_identity_stays_stable_after_source_payload_mutation( ), ] source_payload = { - "risk_mode": "strict", - "limits": {"max_open_orders": 4}, - "symbols": ["BTC-USDC-PERP"], + "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["risk_mode"] = "lenient" - source_payload["limits"]["max_open_orders"] = 1 - source_payload["symbols"].append("ETH-USDC-PERP") + 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()) @@ -502,7 +546,7 @@ def test_fold_configuration_identity_stays_stable_after_source_payload_mutation( fold_event_stream_entries(left, entries, configuration=configuration) fold_event_stream_entries(right, entries, configuration=configuration) - source_payload["limits"]["max_open_orders"] = 99 + source_payload["market"]["instruments"]["BTC-USDC-PERP"]["tick_size"] = 99.0 assert configuration.fingerprint == fingerprint_before assert configuration.payload == payload_before diff --git a/tests/semantics/models/test_market_configuration_positioned_contract.py b/tests/semantics/models/test_market_configuration_positioned_contract.py new file mode 100644 index 0000000..9c2cf7e --- /dev/null +++ b/tests/semantics/models/test_market_configuration_positioned_contract.py @@ -0,0 +1,362 @@ +"""Semantics tests for strict positioned MarketEvent configuration consumption.""" + +from __future__ import annotations + +import copy + +import pytest + +from trading_framework.core.domain.configuration import CoreConfiguration +from trading_framework.core.domain.processing import ( + fold_event_stream_entries, + process_canonical_event, + process_event_entry, +) +from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition +from trading_framework.core.domain.state import StrategyState +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + Price, + Quantity, +) +from trading_framework.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()) diff --git a/tests/semantics/models/test_market_reducer_positioned_target.py b/tests/semantics/models/test_market_reducer_positioned_target.py index fce2cdd..6509d12 100644 --- a/tests/semantics/models/test_market_reducer_positioned_target.py +++ b/tests/semantics/models/test_market_reducer_positioned_target.py @@ -12,6 +12,7 @@ import pytest +from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.processing import process_canonical_event from trading_framework.core.domain.processing_order import ProcessingPosition from trading_framework.core.domain.state import StrategyState @@ -25,6 +26,29 @@ from trading_framework.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, @@ -125,8 +149,19 @@ def test_target_positioned_market_lower_local_timestamp_still_advances_state() - best_ask=121.0, ) - process_canonical_event(state, first, position=ProcessingPosition(index=1)) - process_canonical_event(state, older_local_second, position=ProcessingPosition(index=2)) + 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 @@ -157,8 +192,19 @@ def test_target_positioned_market_lower_exchange_timestamp_still_advances_state( best_ask=81.0, ) - process_canonical_event(state, base, position=ProcessingPosition(index=10)) - process_canonical_event(state, lower_exchange_second, position=ProcessingPosition(index=11)) + 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 diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index ffa015c..20cdda3 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -14,7 +14,8 @@ from __future__ import annotations -from collections.abc import Iterable +import math +from collections.abc import Iterable, Mapping from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import ( @@ -27,11 +28,71 @@ from trading_framework.core.domain.types import FillEvent, MarketEvent +def _extract_required_positive_number(value: object, *, field_path: str) -> float: + if value is None: + raise ValueError(f"Missing required market configuration field: {field_path}") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"Market configuration field must be numeric: {field_path}") + + numeric = float(value) + if not math.isfinite(numeric): + raise ValueError(f"Market configuration field must be finite: {field_path}") + if numeric <= 0.0: + raise ValueError(f"Market configuration field must be > 0: {field_path}") + return numeric + + +def _extract_market_instrument_metadata( + configuration: CoreConfiguration | None, + *, + instrument: str, +) -> tuple[float, float, float]: + if configuration is None: + raise ValueError( + "CoreConfiguration is required for positioned canonical MarketEvent processing." + ) + + payload = configuration.payload + + market = payload.get("market") + if not isinstance(market, Mapping): + raise ValueError("Missing required market configuration object: payload.market") + + instruments = market.get("instruments") + if not isinstance(instruments, Mapping): + raise ValueError( + "Missing required market configuration object: payload.market.instruments" + ) + + instrument_cfg = instruments.get(instrument) + if not isinstance(instrument_cfg, Mapping): + raise ValueError( + "Missing required market instrument configuration: " + f"payload.market.instruments.{instrument}" + ) + + tick_size = _extract_required_positive_number( + instrument_cfg.get("tick_size"), + field_path=f"payload.market.instruments.{instrument}.tick_size", + ) + lot_size = _extract_required_positive_number( + instrument_cfg.get("lot_size"), + field_path=f"payload.market.instruments.{instrument}.lot_size", + ) + contract_size = _extract_required_positive_number( + instrument_cfg.get("contract_size"), + field_path=f"payload.market.instruments.{instrument}.contract_size", + ) + + return tick_size, lot_size, contract_size + + def process_canonical_event( state: StrategyState, event: object, *, position: ProcessingPosition | None = None, + configuration: CoreConfiguration | None = None, ) -> None: """Process a canonical event candidate via existing state reducers. @@ -73,6 +134,10 @@ def process_canonical_event( best_ask_level = event.book.asks[0] if position is not None: + tick_size, lot_size, contract_size = _extract_market_instrument_metadata( + configuration, + instrument=event.instrument, + ) state._advance_processing_position(position) state._update_market_from_positioned_canonical_event( instrument=event.instrument, @@ -80,9 +145,9 @@ def process_canonical_event( 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, + tick_size=tick_size, + lot_size=lot_size, + contract_size=contract_size, ts_ns_local=event.ts_ns_local, ts_ns_exch=event.ts_ns_exch, ) @@ -127,13 +192,19 @@ def process_event_entry( - it is not runtime integration. Configuration is accepted as explicit processing input to reflect the - docs contract, but current minimal reducers do not consume it yet. + docs contract. In this slice, positioned canonical MarketEvent reduction + consumes explicit instrument metadata from configuration. Ordering is enforced through ``entry.position`` using existing ``ProcessingPosition`` cursor monotonicity logic in canonical processing. """ if configuration is not None and not isinstance(configuration, CoreConfiguration): raise TypeError("configuration must be CoreConfiguration or None") - process_canonical_event(state, entry.event, position=entry.position) + process_canonical_event( + state, + entry.event, + position=entry.position, + configuration=configuration, + ) def fold_event_stream_entries( From 4b7f3a4c2fd66e3240e0236eb57225f9cecd9a10 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 12:46:18 +0000 Subject: [PATCH 32/61] m2 p3 sliceA3: CoreConfiguration-to-positioned-market contract guard/docs slice --- ...arket_configuration_positioned_contract.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/semantics/models/test_market_configuration_positioned_contract.py b/tests/semantics/models/test_market_configuration_positioned_contract.py index 9c2cf7e..a7de5e8 100644 --- a/tests/semantics/models/test_market_configuration_positioned_contract.py +++ b/tests/semantics/models/test_market_configuration_positioned_contract.py @@ -1,8 +1,14 @@ -"""Semantics tests for strict positioned MarketEvent configuration consumption.""" +"""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 @@ -360,3 +366,33 @@ def test_order_state_event_remains_compatibility_only() -> None: 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 / "trading_framework/core/domain/processing.py" + tree = ast.parse(processing_path.read_text(encoding="utf-8"), filename=str(processing_path)) + + forbidden_modules = ( + "core_runtime", + "trading_runtime", + "hft_engine_config", + "live_engine_config", + ) + forbidden_symbols = { + "HftEngineConfig", + "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 From f84f212161a39df45b32001cf83e1c87c73fb746 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 14:57:35 +0000 Subject: [PATCH 33/61] m2 p3 sliceb1: Create a stable Core Contract v1 document that freezes the currently implemented and tested core semantic kernel --- docs/core-stable-contract-v1.md | 191 ++++++++++++++++++ ...onfiguration-positioned-market-contract.md | 51 +++++ 2 files changed, 242 insertions(+) create mode 100644 docs/core-stable-contract-v1.md create mode 100644 docs/coreconfiguration-positioned-market-contract.md diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md new file mode 100644 index 0000000..102c295 --- /dev/null +++ b/docs/core-stable-contract-v1.md @@ -0,0 +1,191 @@ +# 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)**. + +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 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) +- `FillEvent` (execution category candidate) + +`CSC-07` — No additional canonical event categories or candidate types are guaranteed by `core` v1 in this contract snapshot. + +--- + +## 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` — Processing position cursor advancement is boundary-owned behavior and remains guarded against out-of-boundary mutation patterns. + +`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` — Control-Time Event injection mechanism and runtime realization behavior. + +`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` / `EventStreamCursor` extraction or introduction. + +--- + +## 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 + FillEvent only in v1 | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.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/coreconfiguration-positioned-market-contract.md b/docs/coreconfiguration-positioned-market-contract.md new file mode 100644 index 0000000..58dca5b --- /dev/null +++ b/docs/coreconfiguration-positioned-market-contract.md @@ -0,0 +1,51 @@ +# 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. From 32bb2e6c07c78ce3bb56789cc112da7842471609 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 15:01:02 +0000 Subject: [PATCH 34/61] m2 p3 sliceB1: Create a stable Core Contract v1 document that freezes the currently implemented and tested core semantic kernel --- docs/core-stable-contract-v1.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md index 102c295..6fff096 100644 --- a/docs/core-stable-contract-v1.md +++ b/docs/core-stable-contract-v1.md @@ -6,6 +6,11 @@ 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; @@ -20,7 +25,7 @@ Historical provenance for the positioned market configuration closure is recorde ## Normative sources and precedence -`CSC-01` — Terminology and architecture concepts remain the semantic source of truth. +`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. From 3b2fd25afaf014716939960970e576576afe712b Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 15:30:13 +0000 Subject: [PATCH 35/61] m2 p3 sliceB2: Runtime-to-CoreConfiguration Contract Boundary --- ...untime-to-coreconfiguration-contract-v1.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/runtime-to-coreconfiguration-contract-v1.md diff --git a/docs/runtime-to-coreconfiguration-contract-v1.md b/docs/runtime-to-coreconfiguration-contract-v1.md new file mode 100644 index 0000000..6e9970b --- /dev/null +++ b/docs/runtime-to-coreconfiguration-contract-v1.md @@ -0,0 +1,205 @@ +# 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 planning/docs-only contract slice: + +- it defines ownership boundaries and validation expectations; +- it documents the minimum mapping target required by current core behavior; +- it does not implement runtime mapping; +- 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 +`HftEngineConfig`, 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. From 2a73a08cf352bdb7eb357eaca25af0d9858883de Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 15:34:35 +0000 Subject: [PATCH 36/61] m2 p3 sliceB3: add a small index page under core/docs that helps readers navigate current core implementation contracts --- docs/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0ee5b26 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Core Docs Contract Index v1 + +This directory contains implementation-facing contracts and snapshots for `core`. + +The main `docs` repository remains the semantic source of truth for architecture +and terminology. Documents in `core/docs` must not contradict main docs +semantics. + +## Current documents + +- **[stable]** [Core Stable Contract v1](core-stable-contract-v1.md) + Stable snapshot of currently implemented and tested `core` v1 semantic + guarantees and boundaries. + +- **[boundary]** [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) + Boundary contract draft for runtime-owned mapping into `CoreConfiguration` + before calling `core` canonical processing APIs. + +- **[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. +- Introduction of new canonical event types. +- Control-Time Event injection mechanism/realization behavior. +- `OrderStateEvent` canonicalization. +- Full replay/storage/runtime integration. From b5ff5846c2bc6e72b2e808bb0e35f5f8ab545212 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 17:11:32 +0000 Subject: [PATCH 37/61] m2 p3 sliceC2: Add core implementation contract document that freezes the execution-feedback boundary for future FillEvent ingress --- docs/README.md | 5 + .../runtime-execution-feedback-contract-v1.md | 166 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/runtime-execution-feedback-contract-v1.md diff --git a/docs/README.md b/docs/README.md index 0ee5b26..41eb4bf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,11 @@ semantics. Boundary contract draft for runtime-owned mapping into `CoreConfiguration` before calling `core` canonical processing APIs. +- **[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. + - **[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`. diff --git a/docs/runtime-execution-feedback-contract-v1.md b/docs/runtime-execution-feedback-contract-v1.md new file mode 100644 index 0000000..395c4c0 --- /dev/null +++ b/docs/runtime-execution-feedback-contract-v1.md @@ -0,0 +1,166 @@ +# 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. + From b1795edf849fc072d8c29891953922aa70c0616d Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 17:29:28 +0000 Subject: [PATCH 38/61] m2 p3 sliceD2: plan for smallest safe Core implementation path for OrderSubmittedEvent --- docs/README.md | 4 + docs/order-submitted-event-contract-v1.md | 217 ++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 docs/order-submitted-event-contract-v1.md diff --git a/docs/README.md b/docs/README.md index 41eb4bf..6022d8e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,10 @@ semantics. runtime execution feedback emission (including `FillEvent`), while preserving current compatibility projection behavior. +- **[boundary/planned]** [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) + Planned boundary contract freezing dispatch-time canonical order-entry + semantics for `Submitted` lifecycle authority prior to implementation wiring. + - **[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`. diff --git a/docs/order-submitted-event-contract-v1.md b/docs/order-submitted-event-contract-v1.md new file mode 100644 index 0000000..fb442e5 --- /dev/null +++ b/docs/order-submitted-event-contract-v1.md @@ -0,0 +1,217 @@ +# OrderSubmittedEvent / Dispatch Boundary Contract v1 + +--- + +## Purpose and scope + +This document defines an implementation-facing boundary contract for a future +dispatch-time canonical order-entry record named `OrderSubmittedEvent`. + +This is a docs-contract slice only: + +- it does not implement `OrderSubmittedEvent`; +- 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 is additive +for planned boundary behavior and does not claim implementation in this slice. + +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 additive beyond current `core` stable +contract v1 canonical candidate set and does not modify current candidate +implementation behavior in this docs slice. + +--- + +## 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) + +`OSEC-12` - Required fields: + +- `position` (canonical ProcessingPosition / stream position authority) +- `ts_ns_local_dispatch` +- `instrument` +- `client_order_id` +- `side` +- `order_type` +- `intended_price` +- `intended_qty` +- `time_in_force` + +`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` - Once implemented, `OrderSubmittedEvent` is the canonical authority +for entering `Submitted`. + +`OSEC-20` - `CanonicalOrderProjection` should be created/advanced to +`submitted` from the `OrderSubmittedEvent` path when implementation begins. + +`OSEC-21` - `mark_intent_sent` remains compatibility/execution-control +bookkeeping during transition. + +`OSEC-22` - Transitional coexistence requirement: `mark_intent_sent`-based +submitted sidecar seeding must be treated as idempotent/mirrored behavior under +future coexistence with `OrderSubmittedEvent`. + +`OSEC-23` - This contract introduces no post-submission transition authority. +Post-submission canonical authority remains deferred pending explicit canonical +execution-feedback source. + +--- + +## ProcessingPosition policy + +`OSEC-24` - Canonical acceptance order uses one global canonical position +counter across canonical event categories. + +`OSEC-25` - Category-local canonical counters are not allowed. + +`OSEC-26` - Position must not be derived from timestamps. + +`OSEC-27` - Ordering semantics must be coherent relative to canonical +`MarketEvent` and future canonical execution-feedback records. + +--- + +## Compatibility boundaries preserved + +`OSEC-28` - `OrderStateEvent` remains non-canonical. + +`OSEC-29` - `ingest_order_snapshots` behavior remains unchanged. + +`OSEC-30` - `DerivedFillEvent` remains compatibility projection behavior. + +`OSEC-31` - `FillEvent` ingress remains deferred. + +`OSEC-32` - Snapshot reducer behavior remains unchanged; no rewrite is introduced +by this contract. + +`OSEC-33` - This docs slice introduces no runtime behavior change. + +--- + +## No-double-authority rules + +`OSEC-34` - Submitted entry authority belongs to `OrderSubmittedEvent` once +implemented. + +`OSEC-35` - Compatibility snapshots may mirror/advance sidecar projections only +under transitional compatibility rules; they are not canonical Submitted +authority. + +`OSEC-36` - Post-submission transitions remain deferred until explicit canonical +execution-feedback sources are defined and contracted. + +`OSEC-37` - Snapshot materialization must not become canonical Submitted +authority in this phase. + +--- + +## Explicitly out of scope + +`OSEC-38` - Implementing the `OrderSubmittedEvent` class. + +`OSEC-39` - Event taxonomy code changes. + +`OSEC-40` - Runtime dispatch wiring changes. + +`OSEC-41` - `FillEvent` ingress implementation. + +`OSEC-42` - `OrderStateEvent` canonicalization. + +`OSEC-43` - Replay/storage/`ProcessingContext`/`EventStreamCursor` +implementation. + +`OSEC-44` - 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) + From 71dc8effdb6f9ffb9ae289fe58dcbbaf87793e68 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 18:08:56 +0000 Subject: [PATCH 39/61] m2 p3 sliceD4: add core-side support for positioned canonical OrderSubmittedEvent ingestion --- .../test_canonical_processing_boundary.py | 146 +++++++++++++++++- .../test_canonical_reducer_authority_guard.py | 8 +- .../test_event_stream_entry_contract.py | 40 +++++ .../models/test_event_taxonomy_boundary.py | 13 +- ...est_submitted_boundary_characterization.py | 75 ++++++++- trading_framework/core/domain/event_model.py | 8 +- trading_framework/core/domain/processing.py | 12 +- trading_framework/core/domain/state.py | 31 +++- trading_framework/core/domain/types.py | 25 +++ 9 files changed, 350 insertions(+), 8 deletions(-) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index 1aa372d..edded5e 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -15,6 +15,7 @@ FillEvent, MarketEvent, OrderStateEvent, + OrderSubmittedEvent, Price, Quantity, ) @@ -91,14 +92,21 @@ def _fill_event( ) -def _order_state_event(*, instrument: str, client_order_id: str, ts_ns_local: int, ts_ns_exch: int) -> OrderStateEvent: +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="accepted", + state_type=state_type, side="buy", intended_price=Price(currency="USDC", value=100.0), filled_price=None, @@ -111,6 +119,27 @@ def _order_state_event(*, instrument: str, client_order_id: str, ts_ns_local: in ) +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 _market_configuration( *, instrument: str = "BTC-USDC-PERP", @@ -204,6 +233,39 @@ 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_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_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) @@ -535,6 +597,86 @@ def test_valid_processing_position_can_authorize_boundary_order_while_reducer_no 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( diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py index 81ff2bc..cdd3118 100644 --- a/tests/semantics/models/test_canonical_reducer_authority_guard.py +++ b/tests/semantics/models/test_canonical_reducer_authority_guard.py @@ -6,7 +6,13 @@ from pathlib import Path _ALLOWED_CALLER = Path("trading_framework/core/domain/processing.py") -_TARGET_METHODS = frozenset({"update_market", "apply_fill_event"}) +_TARGET_METHODS = frozenset( + { + "update_market", + "apply_fill_event", + "apply_order_submitted_event", + } +) def _iter_python_files(root: Path) -> list[Path]: diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 1866093..25f0a7b 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -17,6 +17,7 @@ FillEvent, MarketEvent, OrderStateEvent, + OrderSubmittedEvent, Price, Quantity, ) @@ -95,6 +96,27 @@ def _order_state_event(*, instrument: str, client_order_id: str) -> OrderStateEv ) +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 _state_subset_snapshot(state: StrategyState) -> dict[str, object]: return { "market": copy.deepcopy(state.market), @@ -178,6 +200,24 @@ def test_process_event_entry_processes_fill_and_updates_fill_state() -> None: 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_rejects_non_canonical_payload() -> None: state = StrategyState(event_bus=NullEventBus()) compat_event = _order_state_event( diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index d486405..5fb7c16 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -13,7 +13,12 @@ ) from trading_framework.core.domain.processing import process_canonical_event from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderStateEvent +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + OrderSubmittedEvent, +) from trading_framework.core.events.event_bus import EventBus from trading_framework.core.events.events import ( DerivedFillEvent, @@ -47,6 +52,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(OrderSubmittedEvent) is True + assert ( + canonical_category_for_type(OrderSubmittedEvent) + == CanonicalEventCategory.INTENT_RELATED + ) + # Compatibility execution feedback remains non-canonical in this slice. assert is_canonical_stream_candidate_type(OrderStateEvent) is False assert OrderStateEvent in COMPATIBILITY_PROJECTION_TYPES diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py index 6e6542b..e74a45e 100644 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py @@ -8,7 +8,13 @@ from __future__ import annotations from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import NewOrderIntent, OrderStateEvent, Price, Quantity +from trading_framework.core.domain.types import ( + NewOrderIntent, + OrderStateEvent, + OrderSubmittedEvent, + Price, + Quantity, +) from trading_framework.core.events.sinks.null_event_bus import NullEventBus @@ -54,6 +60,27 @@ def _order_state_event( ) +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" @@ -152,6 +179,52 @@ def test_mark_intent_sent_new_creates_canonical_submitted_projection() -> None: assert projection.updated_ts_ns_local == 300 +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" diff --git a/trading_framework/core/domain/event_model.py b/trading_framework/core/domain/event_model.py index cc713dc..f345ef7 100644 --- a/trading_framework/core/domain/event_model.py +++ b/trading_framework/core/domain/event_model.py @@ -12,7 +12,12 @@ from enum import Enum -from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderStateEvent +from trading_framework.core.domain.types import ( + FillEvent, + MarketEvent, + OrderStateEvent, + OrderSubmittedEvent, +) from trading_framework.core.events.events import ( DerivedFillEvent, DerivedPnLEvent, @@ -42,6 +47,7 @@ class CanonicalEventCategory(str, Enum): # 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, FillEvent: CanonicalEventCategory.EXECUTION, } diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 20cdda3..5954818 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -25,7 +25,7 @@ ) from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import FillEvent, MarketEvent +from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderSubmittedEvent def _extract_required_positive_number(value: object, *, field_path: str) -> float: @@ -103,6 +103,7 @@ def process_canonical_event( Accepted canonical candidates in the current slice: - ``MarketEvent`` (category: ``market``) + - ``OrderSubmittedEvent`` (category: ``intent_related``) - ``FillEvent`` (category: ``execution``) ``ProcessingPosition`` is accepted as Processing Order metadata at this @@ -172,6 +173,15 @@ def process_canonical_event( state.apply_fill_event(event) return + if ( + category == CanonicalEventCategory.INTENT_RELATED + and isinstance(event, OrderSubmittedEvent) + ): + if position is not None: + state._advance_processing_position(position) + state.apply_order_submitted_event(event) + return + raise TypeError( "Unsupported canonical event candidate for this processing boundary: " f"{record_type.__name__}" diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index 33c53b3..6f37316 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -34,7 +34,12 @@ ) if TYPE_CHECKING: - from trading_framework.core.domain.types import FillEvent, NewOrderIntent, OrderIntent + from trading_framework.core.domain.types import ( + FillEvent, + NewOrderIntent, + OrderIntent, + OrderSubmittedEvent, + ) from trading_framework.core.events.event_bus import EventBus @@ -259,6 +264,30 @@ def mark_intent_sent(self, instrument: str, client_order_id: str, intent_type: s updated_ts_ns_local=ts_now, ) + 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. + """ + key = (event.instrument, event.client_order_id) + projection = self.canonical_orders.get(key) + if projection is None: + self.canonical_orders[key] = 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 + + # Idempotent submitted-entry behavior: + # - If already submitted, keep existing projection unchanged. + # - If already beyond submitted, do not regress lifecycle state. + return + def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket = self.inflight.get(instrument) if inflight_bucket is None: diff --git a/trading_framework/core/domain/types.py b/trading_framework/core/domain/types.py index 93dec79..4b778d9 100644 --- a/trading_framework/core/domain/types.py +++ b/trading_framework/core/domain/types.py @@ -336,6 +336,31 @@ class FillEvent(BaseModel): 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") + + # --------------------------------------------------------------------------- # OrderStateEvent model (snapshot event) # --------------------------------------------------------------------------- From ee70b30be9b49ff4175ee994b2cdd7621a1db699 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 18:52:52 +0000 Subject: [PATCH 40/61] m2 p3 sliceD7: align implementation-facing core/docs contract snapshots --- docs/README.md | 6 +- docs/core-stable-contract-v1.md | 5 +- docs/order-submitted-event-contract-v1.md | 91 +++++++++++++---------- 3 files changed, 57 insertions(+), 45 deletions(-) diff --git a/docs/README.md b/docs/README.md index 6022d8e..756b0b9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,9 +21,9 @@ semantics. runtime execution feedback emission (including `FillEvent`), while preserving current compatibility projection behavior. -- **[boundary/planned]** [OrderSubmittedEvent / Dispatch Boundary Contract v1](order-submitted-event-contract-v1.md) - Planned boundary contract freezing dispatch-time canonical order-entry - semantics for `Submitted` lifecycle authority prior to implementation wiring. +- **[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. - **[historical/dev-log]** [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) Historical closure contract for positioned canonical `MarketEvent` diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md index 6fff096..19fd986 100644 --- a/docs/core-stable-contract-v1.md +++ b/docs/core-stable-contract-v1.md @@ -50,9 +50,10 @@ Historical provenance for the positioned market configuration closure is recorde `CSC-06` — `core` v1 currently guarantees the canonical event candidate set: - `MarketEvent` (market category candidate) +- `OrderSubmittedEvent` (intent-related category candidate) - `FillEvent` (execution category candidate) -`CSC-07` — No additional canonical event categories or candidate types are guaranteed by `core` v1 in this contract snapshot. +`CSC-07` — Current canonical runtime wiring in this snapshot processes positioned canonical `MarketEvent` and `OrderSubmittedEvent` through the canonical boundary. `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). --- @@ -181,7 +182,7 @@ Historical provenance for the positioned market configuration closure is recorde | 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 + FillEvent only in v1 | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.py` | +| `CSC-06`, `CSC-07` | Canonical candidate set is MarketEvent + OrderSubmittedEvent + FillEvent; current runtime wiring path is MarketEvent + OrderSubmittedEvent while FillEvent ingress remains deferred | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.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` | diff --git a/docs/order-submitted-event-contract-v1.md b/docs/order-submitted-event-contract-v1.md index fb442e5..c136f8c 100644 --- a/docs/order-submitted-event-contract-v1.md +++ b/docs/order-submitted-event-contract-v1.md @@ -4,12 +4,12 @@ ## Purpose and scope -This document defines an implementation-facing boundary contract for a future -dispatch-time canonical order-entry record named `OrderSubmittedEvent`. +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 slice only: +This is a docs-contract reconciliation slice only: -- it does not implement `OrderSubmittedEvent`; - it does not change runtime behavior; - it does not change snapshot compatibility reducers; - it does not canonicalize `OrderStateEvent`; @@ -29,8 +29,9 @@ 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 is additive -for planned boundary behavior and does not claim implementation in this slice. +[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: @@ -57,9 +58,8 @@ Normative semantic sources: from infrastructure processing. - Therefore the semantic class is Intent-related Event, not Execution Event. -`OSEC-07` - This classification is additive beyond current `core` stable -contract v1 canonical candidate set and does not modify current candidate -implementation behavior in this docs slice. +`OSEC-07` - This classification is implemented in current `core` v1 candidate +taxonomy and canonical processing boundary behavior. --- @@ -81,11 +81,10 @@ transmission/dispatch of a `new` intent. --- -## Required field contract (v1) +## Required field contract (v1, implemented boundary shape) -`OSEC-12` - Required fields: +`OSEC-12` - Required canonical boundary fields in this implemented slice: -- `position` (canonical ProcessingPosition / stream position authority) - `ts_ns_local_dispatch` - `instrument` - `client_order_id` @@ -95,6 +94,10 @@ transmission/dispatch of a `new` intent. - `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` @@ -123,20 +126,25 @@ restart lifecycle from `Submitted`. ## Projection and coexistence behavior (transitional) -`OSEC-19` - Once implemented, `OrderSubmittedEvent` is the canonical authority -for entering `Submitted`. +`OSEC-19` - `OrderSubmittedEvent` is the canonical authority for entering +`Submitted` in the current implemented boundary slice. -`OSEC-20` - `CanonicalOrderProjection` should be created/advanced to -`submitted` from the `OrderSubmittedEvent` path when implementation begins. +`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` - Transitional coexistence requirement: `mark_intent_sent`-based +`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-23` - This contract introduces no post-submission transition authority. +`OSEC-24` - This contract introduces no post-submission transition authority. Post-submission canonical authority remains deferred pending explicit canonical execution-feedback source. @@ -144,68 +152,71 @@ execution-feedback source. ## ProcessingPosition policy -`OSEC-24` - Canonical acceptance order uses one global canonical position +`OSEC-25` - Canonical acceptance order uses one global canonical position counter across canonical event categories. -`OSEC-25` - Category-local canonical counters are not allowed. +`OSEC-26` - Category-local canonical counters are not allowed. -`OSEC-26` - Position must not be derived from timestamps. +`OSEC-27` - Position must not be derived from timestamps. -`OSEC-27` - Ordering semantics must be coherent relative to canonical +`OSEC-28` - Ordering semantics must be coherent relative to canonical `MarketEvent` and future canonical execution-feedback records. --- ## Compatibility boundaries preserved -`OSEC-28` - `OrderStateEvent` remains non-canonical. +`OSEC-29` - `OrderStateEvent` remains non-canonical. -`OSEC-29` - `ingest_order_snapshots` behavior remains unchanged. +`OSEC-30` - `ingest_order_snapshots` behavior remains unchanged. -`OSEC-30` - `DerivedFillEvent` remains compatibility projection behavior. +`OSEC-31` - `DerivedFillEvent` remains compatibility projection behavior. -`OSEC-31` - `FillEvent` ingress remains deferred. +`OSEC-32` - `FillEvent` ingress remains deferred. -`OSEC-32` - Snapshot reducer behavior remains unchanged; no rewrite is introduced +`OSEC-33` - Snapshot reducer behavior remains unchanged; no rewrite is introduced by this contract. -`OSEC-33` - This docs slice introduces no runtime behavior change. +`OSEC-34` - This docs slice introduces no runtime behavior change. --- ## No-double-authority rules -`OSEC-34` - Submitted entry authority belongs to `OrderSubmittedEvent` once -implemented. +`OSEC-35` - Submitted entry authority belongs to `OrderSubmittedEvent` in this +implemented slice. -`OSEC-35` - Compatibility snapshots may mirror/advance sidecar projections only +`OSEC-36` - Compatibility snapshots may mirror/advance sidecar projections only under transitional compatibility rules; they are not canonical Submitted authority. -`OSEC-36` - Post-submission transitions remain deferred until explicit canonical +`OSEC-37` - Post-submission transitions remain deferred until explicit canonical execution-feedback sources are defined and contracted. -`OSEC-37` - Snapshot materialization must not become canonical Submitted +`OSEC-38` - Snapshot materialization must not become canonical Submitted authority in this phase. --- ## Explicitly out of scope -`OSEC-38` - Implementing the `OrderSubmittedEvent` class. +`OSEC-39` - Changing `OrderSubmittedEvent` model shape beyond current implemented +contract fields. -`OSEC-39` - Event taxonomy code changes. +`OSEC-40` - Event taxonomy semantic reclassification beyond current implemented +`intent_related` status. -`OSEC-40` - Runtime dispatch wiring changes. +`OSEC-41` - Runtime dispatch behavior expansion beyond current successful `new` +dispatch emission semantics. -`OSEC-41` - `FillEvent` ingress implementation. +`OSEC-42` - `FillEvent` ingress implementation. -`OSEC-42` - `OrderStateEvent` canonicalization. +`OSEC-43` - `OrderStateEvent` canonicalization. -`OSEC-43` - Replay/storage/`ProcessingContext`/`EventStreamCursor` +`OSEC-44` - Replay/storage/`ProcessingContext`/`EventStreamCursor` implementation. -`OSEC-44` - Broad order lifecycle migration or snapshot reducer migration. +`OSEC-45` - Broad order lifecycle migration or snapshot reducer migration. --- From 1c1ba3c0232edacda789a3cd13124a08ceb1baa4 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 19:08:58 +0000 Subject: [PATCH 41/61] m2 p3 sliceE1: add a core implementation contract document that freezes the Control-Time Event boundary before implementation --- docs/README.md | 4 + docs/control-time-event-contract-v1.md | 195 +++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 docs/control-time-event-contract-v1.md diff --git a/docs/README.md b/docs/README.md index 756b0b9..f46d482 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,10 @@ semantics. Implemented-transition boundary contract for dispatch-time canonical order-entry semantics and coexistence constraints around `Submitted` authority. +- **[boundary/planned]** [Control-Time Event Contract v1](control-time-event-contract-v1.md) + Planned boundary contract freezing canonical Control-Time Event realization + semantics before model/taxonomy/runtime injection implementation. + - **[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`. diff --git a/docs/control-time-event-contract-v1.md b/docs/control-time-event-contract-v1.md new file mode 100644 index 0000000..a0d530b --- /dev/null +++ b/docs/control-time-event-contract-v1.md @@ -0,0 +1,195 @@ +# Control-Time Event Contract v1 + +--- + +## Purpose and scope + +This document defines an implementation-facing boundary contract for a future +canonical Control-Time Event boundary across `core` and runtime. + +This is a docs-contract slice only: + +- it does not implement a `ControlTimeEvent` class; +- 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 is +planning-oriented and introduces no runtime or reducer behavior changes. + +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-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. + +--- + +## 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` - This contract does not implement reducer semantics for Control-Time +Event behavior. + +`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. + +--- + +## 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` - `ControlTimeEvent` class implementation. + +`CTEC-37` - Event taxonomy code changes. + +`CTEC-38` - Runtime injection wiring implementation. + +`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) + From d36347965f33e2735d233daa59fd8e5840604523 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 19:26:37 +0000 Subject: [PATCH 42/61] m2 p3 sliceE2: add core-side support for positioned canonical ControlTimeEvent ingestion --- .../test_canonical_processing_boundary.py | 94 +++++++++++++++++++ .../test_canonical_reducer_authority_guard.py | 1 + .../test_event_stream_entry_contract.py | 50 ++++++++++ .../models/test_event_taxonomy_boundary.py | 4 + ...test_fold_event_stream_entries_contract.py | 77 +++++++++++++++ trading_framework/core/domain/event_model.py | 2 + trading_framework/core/domain/processing.py | 14 ++- trading_framework/core/domain/state.py | 10 ++ trading_framework/core/domain/types.py | 27 ++++++ 9 files changed, 278 insertions(+), 1 deletion(-) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index edded5e..e8edab7 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -12,6 +12,7 @@ from trading_framework.core.domain.processing_order import ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, @@ -140,6 +141,24 @@ def _order_submitted_event( ) +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", @@ -163,6 +182,18 @@ def _market_configuration( ) +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) @@ -266,6 +297,69 @@ def test_process_canonical_event_accepts_order_submitted_event_with_processing_p 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) diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py index cdd3118..15ac40a 100644 --- a/tests/semantics/models/test_canonical_reducer_authority_guard.py +++ b/tests/semantics/models/test_canonical_reducer_authority_guard.py @@ -11,6 +11,7 @@ "update_market", "apply_fill_event", "apply_order_submitted_event", + "apply_control_time_event", } ) diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 25f0a7b..6a876f5 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -14,6 +14,7 @@ from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, @@ -117,6 +118,23 @@ def _order_submitted_event( ) +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), @@ -218,6 +236,38 @@ def test_process_event_entry_processes_order_submitted_and_updates_projection() 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( diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index 5fb7c16..d386a10 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -14,6 +14,7 @@ from trading_framework.core.domain.processing import process_canonical_event from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, @@ -58,6 +59,9 @@ def test_canonical_stream_candidate_classification_current_slice() -> None: == 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 diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py index 5cf22a3..d297093 100644 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -11,9 +11,11 @@ from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, + OrderSubmittedEvent, Price, Quantity, ) @@ -78,6 +80,44 @@ def _fill_event( ) +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, @@ -341,6 +381,43 @@ def test_fold_positioned_market_ordering_follows_processing_position_not_event_t 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 = [ diff --git a/trading_framework/core/domain/event_model.py b/trading_framework/core/domain/event_model.py index f345ef7..4896a7a 100644 --- a/trading_framework/core/domain/event_model.py +++ b/trading_framework/core/domain/event_model.py @@ -13,6 +13,7 @@ from enum import Enum from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, @@ -49,6 +50,7 @@ class CanonicalEventCategory(str, Enum): MarketEvent: CanonicalEventCategory.MARKET, OrderSubmittedEvent: CanonicalEventCategory.INTENT_RELATED, FillEvent: CanonicalEventCategory.EXECUTION, + ControlTimeEvent: CanonicalEventCategory.CONTROL, } diff --git a/trading_framework/core/domain/processing.py b/trading_framework/core/domain/processing.py index 5954818..7824515 100644 --- a/trading_framework/core/domain/processing.py +++ b/trading_framework/core/domain/processing.py @@ -25,7 +25,12 @@ ) from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import FillEvent, MarketEvent, OrderSubmittedEvent +from trading_framework.core.domain.types import ( + ControlTimeEvent, + FillEvent, + MarketEvent, + OrderSubmittedEvent, +) def _extract_required_positive_number(value: object, *, field_path: str) -> float: @@ -105,6 +110,7 @@ def process_canonical_event( - ``MarketEvent`` (category: ``market``) - ``OrderSubmittedEvent`` (category: ``intent_related``) - ``FillEvent`` (category: ``execution``) + - ``ControlTimeEvent`` (category: ``control``) ``ProcessingPosition`` is accepted as Processing Order metadata at this boundary. When provided, positions must be strictly increasing. This @@ -182,6 +188,12 @@ def process_canonical_event( state.apply_order_submitted_event(event) return + if category == CanonicalEventCategory.CONTROL and isinstance(event, ControlTimeEvent): + if position is not None: + state._advance_processing_position(position) + state.apply_control_time_event(event) + return + raise TypeError( "Unsupported canonical event candidate for this processing boundary: " f"{record_type.__name__}" diff --git a/trading_framework/core/domain/state.py b/trading_framework/core/domain/state.py index 6f37316..768ee6d 100644 --- a/trading_framework/core/domain/state.py +++ b/trading_framework/core/domain/state.py @@ -35,6 +35,7 @@ if TYPE_CHECKING: from trading_framework.core.domain.types import ( + ControlTimeEvent, FillEvent, NewOrderIntent, OrderIntent, @@ -288,6 +289,15 @@ def apply_order_submitted_event(self, event: OrderSubmittedEvent) -> None: # - If already beyond submitted, do not regress lifecycle state. return + def apply_control_time_event(self, event: ControlTimeEvent) -> None: + """Reduce canonical control-time event at boundary without state mutation. + + This first core-only slice intentionally keeps ControlTimeEvent reduction + as a no-op for queue/rate/inflight and compatibility projections. + """ + _ = event + return + def _clear_inflight(self, instrument: str, client_order_id: str) -> None: inflight_bucket = self.inflight.get(instrument) if inflight_bucket is None: diff --git a/trading_framework/core/domain/types.py b/trading_framework/core/domain/types.py index 4b778d9..a368d3e 100644 --- a/trading_framework/core/domain/types.py +++ b/trading_framework/core/domain/types.py @@ -361,6 +361,33 @@ class OrderSubmittedEvent(BaseModel): 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") + def validate_due_or_realized_present(self) -> ControlTimeEvent: + if self.due_ts_ns_local is None and self.realized_ts_ns_local is None: + raise ValueError( + "at least one of due_ts_ns_local or realized_ts_ns_local is required" + ) + return self + + # --------------------------------------------------------------------------- # OrderStateEvent model (snapshot event) # --------------------------------------------------------------------------- From 58e13fc8a1148f0a06ccd7b2eb603d3af361b776 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sat, 2 May 2026 20:05:01 +0000 Subject: [PATCH 43/61] m2 p3 sliceE4: align implementation-facing core/docs contract snapshots --- docs/README.md | 13 +++++---- docs/control-time-event-contract-v1.md | 40 +++++++++++++++++++------- docs/core-stable-contract-v1.md | 7 +++-- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/docs/README.md b/docs/README.md index f46d482..495f58f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,9 +25,9 @@ semantics. Implemented-transition boundary contract for dispatch-time canonical order-entry semantics and coexistence constraints around `Submitted` authority. -- **[boundary/planned]** [Control-Time Event Contract v1](control-time-event-contract-v1.md) - Planned boundary contract freezing canonical Control-Time Event realization - semantics before model/taxonomy/runtime injection implementation. +- **[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. - **[historical/dev-log]** [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) Historical closure contract for positioned canonical `MarketEvent` @@ -36,7 +36,8 @@ semantics. ## Deferred / not implemented here - Runtime mapping implementation details. -- Introduction of new canonical event types. -- Control-Time Event injection mechanism/realization behavior. +- Queue/rate reducer migration and full control-time authority migration. +- FillEvent runtime ingress and source authority rollout. +- Post-submission execution feedback canonicalization. - `OrderStateEvent` canonicalization. -- Full replay/storage/runtime integration. +- Replay/storage/`ProcessingContext`/`EventStreamCursor` and full runtime stream integration. diff --git a/docs/control-time-event-contract-v1.md b/docs/control-time-event-contract-v1.md index a0d530b..b3924ab 100644 --- a/docs/control-time-event-contract-v1.md +++ b/docs/control-time-event-contract-v1.md @@ -4,12 +4,12 @@ ## Purpose and scope -This document defines an implementation-facing boundary contract for a future -canonical Control-Time Event boundary across `core` and runtime. +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 slice only: +This is a docs-contract reconciliation slice only: -- it does not implement a `ControlTimeEvent` class; - it does not change runtime wakeup behavior; - it does not modify `ExecutionControl` behavior; - it does not modify queue/rate/inflight behavior; @@ -28,8 +28,8 @@ 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 is -planning-oriented and introduces no runtime or reducer behavior changes. +[Core Stable Contract v1](core-stable-contract-v1.md). This contract records +the current implemented transition slice and what remains deferred. Normative semantic sources: @@ -53,6 +53,13 @@ scheduling surface in this contract snapshot. 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. @@ -75,6 +82,11 @@ 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 @@ -130,8 +142,8 @@ ProcessingPosition sequence shared with other canonical candidates, including `CTEC-24` - Future Control-Time Event processing should allow deterministic queue/rate/inflight derived processing to run at the canonical event boundary. -`CTEC-25` - This contract does not implement reducer semantics for Control-Time -Event behavior. +`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. @@ -151,6 +163,9 @@ 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 @@ -170,11 +185,14 @@ future strict-mode canonical behavior. ## Explicitly out of scope -`CTEC-36` - `ControlTimeEvent` class implementation. +`CTEC-36` - Additional ControlTimeEvent model shape expansion beyond the current +implemented contract fields. -`CTEC-37` - Event taxonomy code changes. +`CTEC-37` - Further event taxonomy semantic changes beyond current +`ControlTimeEvent` canonical `CONTROL` mapping. -`CTEC-38` - Runtime injection wiring implementation. +`CTEC-38` - Runtime injection generalization beyond current scheduled-deadline +realization transition behavior. `CTEC-39` - Queue/rate reducer migration. diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md index 19fd986..5264470 100644 --- a/docs/core-stable-contract-v1.md +++ b/docs/core-stable-contract-v1.md @@ -52,8 +52,9 @@ Historical provenance for the positioned market configuration closure is recorde - `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` and `OrderSubmittedEvent` through the canonical boundary. `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). +`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). --- @@ -155,7 +156,7 @@ Historical provenance for the positioned market configuration closure is recorde `CSC-33` — Runtime/backtest-to-`CoreConfiguration` mapping implementation. -`CSC-34` — Control-Time Event injection mechanism and runtime realization behavior. +`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. @@ -182,7 +183,7 @@ Historical provenance for the positioned market configuration closure is recorde | 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; current runtime wiring path is MarketEvent + OrderSubmittedEvent while FillEvent ingress remains deferred | `core/tests/semantics/models/test_event_taxonomy_boundary.py`, `core/tests/semantics/models/test_canonical_processing_boundary.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` | From bfff4e377708034f0a65bae07137bd6928144c3f Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 09:35:01 +0000 Subject: [PATCH 44/61] m2 p4 s1: add source-authority contract document that defines what a future runtime/adapter execution-feedback source must guarantee before canonical FillEvent ingress can be implemented --- docs/README.md | 4 + ...r-execution-feedback-source-contract-v1.md | 259 ++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 docs/runtime-adapter-execution-feedback-source-contract-v1.md diff --git a/docs/README.md b/docs/README.md index 495f58f..447e7c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,10 @@ semantics. runtime execution feedback emission (including `FillEvent`), while preserving current compatibility projection behavior. +- **[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. + - **[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. diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md new file mode 100644 index 0000000..678e51c --- /dev/null +++ b/docs/runtime-adapter-execution-feedback-source-contract-v1.md @@ -0,0 +1,259 @@ +# 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. + +--- From 683761fbbac2f3d300999b494f30f8282cfec8d5 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 09:47:03 +0000 Subject: [PATCH 45/61] m2 p4 s2: add docs-only adapter-facing appendix that defines the conceptual ExecutionFeedbackRecord source shape and guarantees required before future FillEvent ingress work --- ...r-execution-feedback-source-contract-v1.md | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md index 678e51c..799e3f8 100644 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ b/docs/runtime-adapter-execution-feedback-source-contract-v1.md @@ -257,3 +257,167 @@ ordering determinism before ingress rollout. 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. + +--- From 50c99f990c6fe780ffd9de6d2e2667ab4dff27e3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 10:02:14 +0000 Subject: [PATCH 46/61] m2 p4 s3: document adapter API contract for future ExecutionFeedbackRecord sources --- ...r-execution-feedback-source-contract-v1.md | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md index 799e3f8..8cf6c15 100644 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ b/docs/runtime-adapter-execution-feedback-source-contract-v1.md @@ -421,3 +421,201 @@ fill progression for the same canonical order progression. 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` - hftbacktest 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. + +--- From d37794bc47a32c13fe0f4c11e9a440fb56fb5d72 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 10:11:32 +0000 Subject: [PATCH 47/61] m2 p4 s4: document hftbacktest-specific feasibility decision and exact adapter/source gap --- ...r-execution-feedback-source-contract-v1.md | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/docs/runtime-adapter-execution-feedback-source-contract-v1.md b/docs/runtime-adapter-execution-feedback-source-contract-v1.md index 8cf6c15..9e022fd 100644 --- a/docs/runtime-adapter-execution-feedback-source-contract-v1.md +++ b/docs/runtime-adapter-execution-feedback-source-contract-v1.md @@ -619,3 +619,193 @@ decision and test-backed reconciliation rules. implementation. --- + +## Appendix C: hftbacktest source feasibility and gap decision (Phase 4H) + +This appendix records the hftbacktest-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 hftbacktest-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 +decision **C** for `ExecutionFeedbackRecordSource` eligibility. + +`RAEFSC-136` - No currently exposed hftbacktest/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. + +--- + +### 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 hftbacktest 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 hftbacktest/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 hftbacktest 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 hftbacktest scope + +`RAEFSC-165` - Canonical `FillEvent` implementation planning for hftbacktest +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`. + +--- From 14b99d38b49ac4101548e67e94373aa6a0507e3d Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 11:12:33 +0000 Subject: [PATCH 48/61] m2 p4 s7: add docs-only lifecycle compatibility map that freezes the current post-submission lifecycle authority split --- docs/README.md | 5 + ...bmission-lifecycle-compatibility-map-v1.md | 144 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 docs/post-submission-lifecycle-compatibility-map-v1.md diff --git a/docs/README.md b/docs/README.md index 447e7c7..579b827 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,11 @@ semantics. Implemented-transition boundary contract for canonical Control-Time Event realization semantics and coexistence constraints with compatibility wakeups. +- **[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. + - **[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`. diff --git a/docs/post-submission-lifecycle-compatibility-map-v1.md b/docs/post-submission-lifecycle-compatibility-map-v1.md new file mode 100644 index 0000000..4f1b097 --- /dev/null +++ b/docs/post-submission-lifecycle-compatibility-map-v1.md @@ -0,0 +1,144 @@ +# 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. + +--- From 0ae1aa73d38bd039d4b5e9b3bfaa795775d25d1c Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 14:15:25 +0000 Subject: [PATCH 49/61] m2 p5 s1: add smallest core semantics tests that directly protect the post-submission lifecycle authority split --- .../test_canonical_processing_boundary.py | 26 +++++++- .../models/test_event_taxonomy_boundary.py | 22 +++++++ ...est_submitted_boundary_characterization.py | 65 +++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index e8edab7..b49c52d 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -8,8 +8,8 @@ from trading_framework.core.domain.configuration import CoreConfiguration from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type -from trading_framework.core.domain.processing import process_canonical_event -from trading_framework.core.domain.processing_order import ProcessingPosition +from trading_framework.core.domain.processing import process_canonical_event, process_event_entry +from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition from trading_framework.core.domain.state import StrategyState from trading_framework.core.domain.types import ( ControlTimeEvent, @@ -21,7 +21,7 @@ Quantity, ) from trading_framework.core.events.event_bus import EventBus -from trading_framework.core.events.events import RiskDecisionEvent +from trading_framework.core.events.events import DerivedFillEvent, RiskDecisionEvent from trading_framework.core.events.sinks.null_event_bus import NullEventBus @@ -798,6 +798,26 @@ def test_process_canonical_event_rejects_order_state_event_with_processing_posit 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( diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index d386a10..3fca204 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -139,3 +139,25 @@ def test_process_canonical_event_rejects_order_state_event_guard() -> None: 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") + diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py index e74a45e..183da56 100644 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py @@ -179,6 +179,22 @@ def test_mark_intent_sent_new_creates_canonical_submitted_projection() -> None: 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" @@ -552,3 +568,52 @@ def test_expired_does_not_introduce_canonical_expired_state() -> None: 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 == {} From 5520aa7c00fa98645289b7e9942547f07bed9712 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 14:46:48 +0000 Subject: [PATCH 50/61] m2 p6 s1: add docs-only venue adapter capability model that defines generic adapter source capabilities and their semantic authority classifications without adding adapter APIs or hftbacktest-specific Core semantics --- docs/README.md | 5 + docs/venue-adapter-capability-model-v1.md | 325 ++++++++++++++++++++++ 2 files changed, 330 insertions(+) create mode 100644 docs/venue-adapter-capability-model-v1.md diff --git a/docs/README.md b/docs/README.md index 579b827..82012db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -38,6 +38,11 @@ semantics. `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. + - **[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`. diff --git a/docs/venue-adapter-capability-model-v1.md b/docs/venue-adapter-capability-model-v1.md new file mode 100644 index 0000000..ffabd5e --- /dev/null +++ b/docs/venue-adapter-capability-model-v1.md @@ -0,0 +1,325 @@ +# 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 hftbacktest capability map (Phase 6C snapshot) + +`VACM-37` - This table records current capability support classification for the +hftbacktest adapter/runtime integration without changing behavior. + +| capability | current hftbacktest 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 hftbacktest 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 hftbacktest-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 ffb6e5b312e9ab4a635902fc576d5c4f1669260f Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 15:17:20 +0000 Subject: [PATCH 51/61] m2 p6 s2: add docs-only boundary contract that defines conceptual responsibilities for ProcessingContext and EventStreamCursor --- docs/README.md | 5 + ...context-event-stream-cursor-contract-v1.md | 198 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 docs/processing-context-event-stream-cursor-contract-v1.md diff --git a/docs/README.md b/docs/README.md index 82012db..a6d2bb7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,6 +43,11 @@ semantics. 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 conceptual ownership and responsibility + split for future `ProcessingContext` and `EventStreamCursor` abstractions + without implementation or runtime behavior changes. + - **[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`. diff --git a/docs/processing-context-event-stream-cursor-contract-v1.md b/docs/processing-context-event-stream-cursor-contract-v1.md new file mode 100644 index 0000000..83c881d --- /dev/null +++ b/docs/processing-context-event-stream-cursor-contract-v1.md @@ -0,0 +1,198 @@ +# ProcessingContext / EventStreamCursor Contract v1 + +--- + +## Purpose and scope + +This document defines docs-only ownership and boundary semantics for future +`ProcessingContext` and `EventStreamCursor` abstractions. + +This is a planning/contract slice only: + +- it does not implement `ProcessingContext` or `EventStreamCursor`; +- 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 ownership uses an internal +`_next_canonical_processing_position_index` counter 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` - If implemented in a future slice, `EventStreamCursor` would replace +ad hoc runtime canonical counters 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. + +--- From 968885ddd334ab479ae26e7d4f9219558ff5eab6 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 16:28:35 +0000 Subject: [PATCH 52/61] m2 p6 s3: clean up wording drift in core/docs without changing contract semantics --- docs/README.md | 5 +++-- docs/core-stable-contract-v1.md | 2 +- docs/runtime-to-coreconfiguration-contract-v1.md | 6 ++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index a6d2bb7..c68696a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,8 +13,9 @@ semantics. guarantees and boundaries. - **[boundary]** [Runtime-to-CoreConfiguration Contract Boundary v1](runtime-to-coreconfiguration-contract-v1.md) - Boundary contract draft for runtime-owned mapping into `CoreConfiguration` - before calling `core` canonical processing APIs. + 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. - **[boundary/deferred]** [Runtime Execution Feedback Contract v1](runtime-execution-feedback-contract-v1.md) Boundary contract freezing eligibility requirements for future canonical diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md index 5264470..4149362 100644 --- a/docs/core-stable-contract-v1.md +++ b/docs/core-stable-contract-v1.md @@ -85,7 +85,7 @@ Historical provenance for the positioned market configuration closure is recorde `CSC-15` — For positioned canonical processing, position indexes are strictly monotonic; repeated or regressing indexes fail. -`CSC-16` — Processing position cursor advancement is boundary-owned behavior and remains guarded against out-of-boundary mutation patterns. +`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. diff --git a/docs/runtime-to-coreconfiguration-contract-v1.md b/docs/runtime-to-coreconfiguration-contract-v1.md index 6e9970b..6a5dc22 100644 --- a/docs/runtime-to-coreconfiguration-contract-v1.md +++ b/docs/runtime-to-coreconfiguration-contract-v1.md @@ -8,11 +8,13 @@ This document defines a **boundary contract draft (v1)** for how runtime-owned r configuration is mapped into `CoreConfiguration` before calling core canonical processing APIs. -This is a planning/docs-only contract slice: +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; -- it does not implement runtime mapping; +- 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. --- From 6417be5bb5e3cf8249fa842fd71d82d0b7d4bfdc Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 16:33:45 +0000 Subject: [PATCH 53/61] m2 p6 s4: create read-only characterization note for the current runtime cursor behavior and future EventStreamCursor extraction invariants --- docs/README.md | 5 + ...event-stream-cursor-characterization-v1.md | 165 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 docs/event-stream-cursor-characterization-v1.md diff --git a/docs/README.md b/docs/README.md index c68696a..ed07df6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,6 +49,11 @@ semantics. split for future `ProcessingContext` and `EventStreamCursor` abstractions without implementation or runtime behavior changes. +- **[boundary/characterization]** [EventStreamCursor Characterization Note v1](event-stream-cursor-characterization-v1.md) + Read-only characterization of current runtime canonical position counter + behavior and extraction invariants for future `EventStreamCursor` work, + without implementation or behavior change. + - **[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`. diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md new file mode 100644 index 0000000..8671994 --- /dev/null +++ b/docs/event-stream-cursor-characterization-v1.md @@ -0,0 +1,165 @@ +# EventStreamCursor Characterization Note v1 + +--- + +## Purpose and scope + +This note characterizes the **current runtime cursor behavior** used for canonical +`ProcessingPosition` assignment and records invariants that any future +`EventStreamCursor` extraction must preserve. + +This is read-only characterization/planning documentation: + +- it does not implement `EventStreamCursor`; +- 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/trading_runtime/backtest/engine/strategy_runner.py`. + +`ESCC-04` - Runtime runner owns `_next_canonical_processing_position_index`. + +`ESCC-05` - Initial counter value is `0`. + +`ESCC-06` - `_process_canonical_event(...)` constructs `EventStreamEntry` using +the current counter value as `ProcessingPosition(index=...)`. + +`ESCC-07` - Runner calls `process_event_entry(state, entry, configuration=core_cfg)` +for canonical boundary processing. + +`ESCC-08` - Counter increments by exactly `+1` only after successful +`process_event_entry(...)` return. + +`ESCC-09` - If canonical boundary processing raises, counter does not advance +(increment line is not reached). + +`ESCC-10` - One global counter 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` - Counter 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` 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/tests/runtime/test_hftbacktest_execution_feedback_probe.py::test_runner_contains_rc3_snapshot_branch` +- 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` - `EventStreamCursor` implementation. + +`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. + +--- From 686dd949e74f6cd482e3e96236669058d9484899 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 17:38:02 +0000 Subject: [PATCH 54/61] m2 p6 completion: create a concise milestone closure document that records the current semantic implementation status --- docs/README.md | 18 +-- docs/core-stable-contract-v1.md | 5 +- ...event-stream-cursor-characterization-v1.md | 35 +++--- ...context-event-stream-cursor-contract-v1.md | 18 +-- ...antic-core-upgrade-milestone-closure-v1.md | 109 ++++++++++++++++++ 5 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 docs/semantic-core-upgrade-milestone-closure-v1.md diff --git a/docs/README.md b/docs/README.md index ed07df6..9a31599 100644 --- a/docs/README.md +++ b/docs/README.md @@ -45,14 +45,18 @@ semantics. 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 conceptual ownership and responsibility - split for future `ProcessingContext` and `EventStreamCursor` abstractions - without implementation or runtime behavior changes. + 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 canonical position counter - behavior and extraction invariants for future `EventStreamCursor` work, - without implementation or behavior change. + 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`. - **[historical/dev-log]** [CoreConfiguration to Positioned Market Contract](coreconfiguration-positioned-market-contract.md) Historical closure contract for positioned canonical `MarketEvent` @@ -65,4 +69,4 @@ semantics. - FillEvent runtime ingress and source authority rollout. - Post-submission execution feedback canonicalization. - `OrderStateEvent` canonicalization. -- Replay/storage/`ProcessingContext`/`EventStreamCursor` and full runtime stream integration. +- Replay/storage/`ProcessingContext` and full runtime stream integration. diff --git a/docs/core-stable-contract-v1.md b/docs/core-stable-contract-v1.md index 4149362..78e4270 100644 --- a/docs/core-stable-contract-v1.md +++ b/docs/core-stable-contract-v1.md @@ -164,7 +164,10 @@ Historical provenance for the positioned market configuration closure is recorde `CSC-37` — Full replay engine/runtime integration. -`CSC-38` — `ProcessingContext` / `EventStreamCursor` extraction or introduction. +`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.) --- diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md index 8671994..e35ad05 100644 --- a/docs/event-stream-cursor-characterization-v1.md +++ b/docs/event-stream-cursor-characterization-v1.md @@ -4,13 +4,13 @@ ## Purpose and scope -This note characterizes the **current runtime cursor behavior** used for canonical -`ProcessingPosition` assignment and records invariants that any future -`EventStreamCursor` extraction must preserve. +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 implement `EventStreamCursor`; +- 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; @@ -40,23 +40,24 @@ redefine existing contracts. Current behavior is implemented in `core-runtime/trading_runtime/backtest/engine/strategy_runner.py`. -`ESCC-04` - Runtime runner owns `_next_canonical_processing_position_index`. +`ESCC-04` - Runtime runner owns an `EventStreamCursor` instance. -`ESCC-05` - Initial counter value is `0`. +`ESCC-05` - Cursor starts at index `0`. -`ESCC-06` - `_process_canonical_event(...)` constructs `EventStreamEntry` using -the current counter value as `ProcessingPosition(index=...)`. +`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` - Counter increments by exactly `+1` only after successful -`process_event_entry(...)` return. +`ESCC-08` - Cursor advances by exactly `+1` only after successful +`process_event_entry(...)` return via `commit_success(...)`. -`ESCC-09` - If canonical boundary processing raises, counter does not advance -(increment line is not reached). +`ESCC-09` - If canonical boundary processing raises, cursor does not advance. -`ESCC-10` - One global counter is shared by currently wired canonical categories: +`ESCC-10` - One global cursor sequence is shared by currently wired canonical +categories: - `MarketEvent` - `OrderSubmittedEvent` @@ -83,7 +84,7 @@ each successful canonical boundary processing call. `ESCC-16` - Failed canonical processing must not consume/advance position. -`ESCC-17` - Counter scope remains global across canonical categories; no +`ESCC-17` - Cursor scope remains global across canonical categories; no category-local counters. `ESCC-18` - Compatibility snapshot path (`rc == 3`) remains non-canonical and @@ -101,7 +102,8 @@ position allocator. ## Future EventStreamCursor extraction semantics (non-implemented) -`ESCC-22` - Any future `EventStreamCursor` remains runtime-owned and ordering-only. +`ESCC-22` - Any future `EventStreamCursor` work remains runtime-owned and +ordering-only. `ESCC-23` - Recommended extraction model is reservation/commit semantics: @@ -150,7 +152,8 @@ dedicated assertion currently checks that runner cursor remains unchanged during ## Out of scope -`ESCC-31` - `EventStreamCursor` implementation. +`ESCC-31` - Additional `EventStreamCursor` feature expansion beyond the current +runtime ordering helper behavior characterized here. `ESCC-32` - `ProcessingContext` implementation. diff --git a/docs/processing-context-event-stream-cursor-contract-v1.md b/docs/processing-context-event-stream-cursor-contract-v1.md index 83c881d..487559c 100644 --- a/docs/processing-context-event-stream-cursor-contract-v1.md +++ b/docs/processing-context-event-stream-cursor-contract-v1.md @@ -4,12 +4,14 @@ ## Purpose and scope -This document defines docs-only ownership and boundary semantics for future -`ProcessingContext` and `EventStreamCursor` abstractions. +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` or `EventStreamCursor`; +- 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; @@ -102,9 +104,8 @@ move into `EventStreamEntry` payload shape. ## Current state snapshot (frozen for this phase) -`PCESC-23` - Current runtime runner ownership uses an internal -`_next_canonical_processing_position_index` counter for canonical positioned -entry formation. +`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(...)`. @@ -120,9 +121,8 @@ compatibility behavior in this phase. ## Conceptual future relation (non-implemented) -`PCESC-27` - If implemented in a future slice, `EventStreamCursor` would replace -ad hoc runtime canonical counters while preserving current global ordering -semantics. +`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. diff --git a/docs/semantic-core-upgrade-milestone-closure-v1.md b/docs/semantic-core-upgrade-milestone-closure-v1.md new file mode 100644 index 0000000..7ba3c1c --- /dev/null +++ b/docs/semantic-core-upgrade-milestone-closure-v1.md @@ -0,0 +1,109 @@ +# 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). +- hftbacktest 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 hftbacktest backtests: **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. + +--- From 2881069234435264ace2e95f9d4e2b5afde82885 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 21:03:22 +0000 Subject: [PATCH 55/61] m2 p9 s1: create a Stage 0 naming decision record that chooses exact target names and package structure before any mechanical rename --- docs/README.md | 4 + docs/package-rename-stage-0-decision-v1.md | 182 +++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 docs/package-rename-stage-0-decision-v1.md diff --git a/docs/README.md b/docs/README.md index 9a31599..c55ee60 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,10 @@ semantics. 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`. diff --git a/docs/package-rename-stage-0-decision-v1.md b/docs/package-rename-stage-0-decision-v1.md new file mode 100644 index 0000000..94b5c8c --- /dev/null +++ b/docs/package-rename-stage-0-decision-v1.md @@ -0,0 +1,182 @@ +# 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. + +--- From 632067b470e18b2c636008d08281551412a8ac3a Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 21:21:00 +0000 Subject: [PATCH 56/61] m2 p9 s2: rename the core import root from trading_framework to tradingchassis_core while preserving old nested imports through a compatibility shim --- pyproject.toml | 8 +- scripts/check.sh | 6 +- .../test_cancel_non_existing_rejected.py | 12 +- .../test_duplicate_new_rejected.py | 12 +- .../test_inflight_blocks_replace.py | 10 +- ...nts_do_not_enter_queue_characterization.py | 12 +- .../test_replace_noop_handled.py | 10 +- .../test_canonical_processing_boundary.py | 18 +- ...nonical_processing_differential_harness.py | 10 +- .../test_canonical_reducer_authority_guard.py | 4 +- .../test_core_configuration_contract.py | 2 +- .../test_event_stream_entry_contract.py | 19 +- .../models/test_event_taxonomy_boundary.py | 18 +- ...test_fold_event_stream_entries_contract.py | 12 +- .../models/test_import_compatibility_shim.py | 51 ++++++ ...arket_configuration_positioned_contract.py | 14 +- .../test_market_reducer_positioned_target.py | 12 +- .../models/test_models_against_schemas.py | 4 +- ...cessing_position_cursor_ownership_guard.py | 8 +- ..._scheduling_obligation_characterization.py | 12 +- ...nt_dominance_sequences_characterization.py | 10 +- .../test_new_queued_on_rate_limit.py | 10 +- .../test_queue_cancel_dominates_new.py | 10 +- ...ate_pop_queued_intents_characterization.py | 6 +- .../state_transitions/test_new_to_working.py | 4 +- .../test_replace_to_replaced.py | 4 +- ...est_submitted_boundary_characterization.py | 6 +- .../test_terminal_clears_inflight.py | 4 +- .../test_working_to_filled.py | 4 +- trading_framework/__init__.py | 163 +++++++++--------- tradingchassis_core/__init__.py | 88 ++++++++++ .../core/__init__.py | 0 .../core/domain/__init__.py | 0 .../core/domain/configuration.py | 0 .../core/domain/event_model.py | 6 +- .../core/domain/order_lifecycle.py | 0 .../core/domain/order_state_machine.py | 0 .../core/domain/processing.py | 10 +- .../core/domain/processing_order.py | 0 .../core/domain/reject_reasons.py | 0 .../core/domain/slots.py | 0 .../core/domain/state.py | 16 +- .../core/domain/types.py | 0 .../core/events/__init__.py | 0 .../core/events/event_bus.py | 2 +- .../core/events/event_sink.py | 0 .../core/events/events.py | 0 .../core/events/sinks/null_event_bus.py | 2 +- .../core/events/sinks/sink_logging.py | 0 .../core/execution_control/__init__.py | 2 +- .../execution_control/execution_control.py | 8 +- .../core/execution_control/types.py | 0 .../core/ports/__init__.py | 0 .../core/ports/engine_context.py | 0 .../core/ports/venue_adapter.py | 0 .../core/ports/venue_policy.py | 6 +- .../core/risk/__init__.py | 0 .../core/risk/risk_config.py | 2 +- .../core/risk/risk_engine.py | 16 +- .../core/risk/risk_policy.py | 10 +- .../core/schemas/common.schema.json | 0 .../core/schemas/fill_event.schema.json | 0 .../core/schemas/market_event.schema.json | 0 .../core/schemas/order_intent.schema.json | 0 .../schemas/order_state_event.schema.json | 0 .../core/schemas/risk_constraints.schema.json | 0 .../strategies/__init__.py | 0 .../strategies/base.py | 8 +- .../strategies/strategy_config.py | 0 69 files changed, 397 insertions(+), 254 deletions(-) create mode 100644 tests/semantics/models/test_import_compatibility_shim.py create mode 100644 tradingchassis_core/__init__.py rename {trading_framework => tradingchassis_core}/core/__init__.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/__init__.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/configuration.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/event_model.py (93%) rename {trading_framework => tradingchassis_core}/core/domain/order_lifecycle.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/order_state_machine.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/processing.py (96%) rename {trading_framework => tradingchassis_core}/core/domain/processing_order.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/reject_reasons.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/slots.py (100%) rename {trading_framework => tradingchassis_core}/core/domain/state.py (98%) rename {trading_framework => tradingchassis_core}/core/domain/types.py (100%) rename {trading_framework => tradingchassis_core}/core/events/__init__.py (100%) rename {trading_framework => tradingchassis_core}/core/events/event_bus.py (94%) rename {trading_framework => tradingchassis_core}/core/events/event_sink.py (100%) rename {trading_framework => tradingchassis_core}/core/events/events.py (100%) rename {trading_framework => tradingchassis_core}/core/events/sinks/null_event_bus.py (85%) rename {trading_framework => tradingchassis_core}/core/events/sinks/sink_logging.py (100%) rename {trading_framework => tradingchassis_core}/core/execution_control/__init__.py (73%) rename {trading_framework => tradingchassis_core}/core/execution_control/execution_control.py (97%) rename {trading_framework => tradingchassis_core}/core/execution_control/types.py (100%) rename {trading_framework => tradingchassis_core}/core/ports/__init__.py (100%) rename {trading_framework => tradingchassis_core}/core/ports/engine_context.py (100%) rename {trading_framework => tradingchassis_core}/core/ports/venue_adapter.py (100%) rename {trading_framework => tradingchassis_core}/core/ports/venue_policy.py (97%) rename {trading_framework => tradingchassis_core}/core/risk/__init__.py (100%) rename {trading_framework => tradingchassis_core}/core/risk/risk_config.py (97%) rename {trading_framework => tradingchassis_core}/core/risk/risk_engine.py (96%) rename {trading_framework => tradingchassis_core}/core/risk/risk_policy.py (96%) rename {trading_framework => tradingchassis_core}/core/schemas/common.schema.json (100%) rename {trading_framework => tradingchassis_core}/core/schemas/fill_event.schema.json (100%) rename {trading_framework => tradingchassis_core}/core/schemas/market_event.schema.json (100%) rename {trading_framework => tradingchassis_core}/core/schemas/order_intent.schema.json (100%) rename {trading_framework => tradingchassis_core}/core/schemas/order_state_event.schema.json (100%) rename {trading_framework => tradingchassis_core}/core/schemas/risk_constraints.schema.json (100%) rename {trading_framework => tradingchassis_core}/strategies/__init__.py (100%) rename {trading_framework => tradingchassis_core}/strategies/base.py (84%) rename {trading_framework => tradingchassis_core}/strategies/strategy_config.py (100%) diff --git a/pyproject.toml b/pyproject.toml index b281c99..a077f1d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dev = [ # Explicit package discovery # -------------------------------------------------- [tool.setuptools.packages.find] -include = ["trading_framework*"] +include = ["tradingchassis_core*", "trading_framework*"] # -------------------------------------------------- # Pytest @@ -75,15 +75,15 @@ ignore_errors = true # Import Linter # -------------------------------------------------- [tool.importlinter] -root_package = "trading_framework" +root_package = "tradingchassis_core" include_external_packages = true # Core stays pure [[tool.importlinter.contracts]] name = "Core must be pure" type = "forbidden" -source_modules = ["trading_framework.core"] +source_modules = ["tradingchassis_core.core"] forbidden_modules = [ - "trading_framework.strategies" + "tradingchassis_core.strategies" ] diff --git a/scripts/check.sh b/scripts/check.sh index afb9120..1d47d65 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,12 +5,12 @@ echo "🔍 Running import-linter..." lint-imports --verbose echo "⚡ Running ruff (check only)..." -ruff check trading_framework tests +ruff check tradingchassis_core trading_framework tests echo "🧠 Running mypy..." -mypy trading_framework tests +mypy tradingchassis_core trading_framework tests echo "🧪 Running pytest..." -pytest +python -m pytest echo "✅ All checks passed!" 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 index a1a7a23..b346d93 100644 --- a/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py +++ b/tests/semantics/gate_risk_invariants/test_cancel_non_existing_rejected.py @@ -8,15 +8,15 @@ from __future__ import annotations -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: diff --git a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py b/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py index 0435d79..2c531fe 100644 --- a/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py +++ b/tests/semantics/gate_risk_invariants/test_duplicate_new_rejected.py @@ -8,18 +8,18 @@ from __future__ import annotations -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: diff --git a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py b/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py index e898a64..2d5c62c 100644 --- a/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py +++ b/tests/semantics/gate_risk_invariants/test_inflight_blocks_replace.py @@ -11,17 +11,17 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( NotionalLimits, OrderStateEvent, Price, Quantity, ReplaceOrderIntent, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: 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 index 36628fe..8460b73 100644 --- 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 @@ -6,17 +6,17 @@ from __future__ import annotations -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: diff --git a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py b/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py index bb21f9b..fdd95c7 100644 --- a/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py +++ b/tests/semantics/gate_risk_invariants/test_replace_noop_handled.py @@ -8,17 +8,17 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( NotionalLimits, OrderStateEvent, Price, Quantity, ReplaceOrderIntent, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: diff --git a/tests/semantics/models/test_canonical_processing_boundary.py b/tests/semantics/models/test_canonical_processing_boundary.py index b49c52d..b7bda90 100644 --- a/tests/semantics/models/test_canonical_processing_boundary.py +++ b/tests/semantics/models/test_canonical_processing_boundary.py @@ -6,12 +6,12 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type -from trading_framework.core.domain.processing import process_canonical_event, process_event_entry -from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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, @@ -20,9 +20,9 @@ Price, Quantity, ) -from trading_framework.core.events.event_bus import EventBus -from trading_framework.core.events.events import DerivedFillEvent, RiskDecisionEvent -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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]: diff --git a/tests/semantics/models/test_canonical_processing_differential_harness.py b/tests/semantics/models/test_canonical_processing_differential_harness.py index d20b4cc..85b3102 100644 --- a/tests/semantics/models/test_canonical_processing_differential_harness.py +++ b/tests/semantics/models/test_canonical_processing_differential_harness.py @@ -6,17 +6,17 @@ import pytest -from trading_framework.core.domain.processing import process_canonical_event -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.events import RiskDecisionEvent -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.events.events import RiskDecisionEvent +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus def _book_market_event( diff --git a/tests/semantics/models/test_canonical_reducer_authority_guard.py b/tests/semantics/models/test_canonical_reducer_authority_guard.py index 15ac40a..480154c 100644 --- a/tests/semantics/models/test_canonical_reducer_authority_guard.py +++ b/tests/semantics/models/test_canonical_reducer_authority_guard.py @@ -5,7 +5,7 @@ import ast from pathlib import Path -_ALLOWED_CALLER = Path("trading_framework/core/domain/processing.py") +_ALLOWED_CALLER = Path("tradingchassis_core/core/domain/processing.py") _TARGET_METHODS = frozenset( { "update_market", @@ -39,7 +39,7 @@ def _find_target_calls(path: Path) -> list[tuple[int, int, str]]: def test_direct_reducer_calls_are_limited_to_canonical_processing_boundary() -> None: repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "trading_framework" + production_root = repo_root / "tradingchassis_core" violations: list[str] = [] diff --git a/tests/semantics/models/test_core_configuration_contract.py b/tests/semantics/models/test_core_configuration_contract.py index 794eebe..251d294 100644 --- a/tests/semantics/models/test_core_configuration_contract.py +++ b/tests/semantics/models/test_core_configuration_contract.py @@ -4,7 +4,7 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.configuration import CoreConfiguration def test_same_version_and_semantic_payload_produce_same_fingerprint() -> None: diff --git a/tests/semantics/models/test_event_stream_entry_contract.py b/tests/semantics/models/test_event_stream_entry_contract.py index 6a876f5..e9c0923 100644 --- a/tests/semantics/models/test_event_stream_entry_contract.py +++ b/tests/semantics/models/test_event_stream_entry_contract.py @@ -8,12 +8,15 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.event_model import is_canonical_stream_candidate_type -from trading_framework.core.domain.processing import fold_event_stream_entries, process_event_entry -from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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, @@ -22,8 +25,8 @@ Price, Quantity, ) -from trading_framework.core.events.event_bus import EventBus -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/tests/semantics/models/test_event_taxonomy_boundary.py b/tests/semantics/models/test_event_taxonomy_boundary.py index 3fca204..ac622f5 100644 --- a/tests/semantics/models/test_event_taxonomy_boundary.py +++ b/tests/semantics/models/test_event_taxonomy_boundary.py @@ -2,7 +2,7 @@ from __future__ import annotations -from trading_framework.core.domain.event_model import ( +from tradingchassis_core.core.domain.event_model import ( CANONICAL_EVENT_CATEGORY_NAMES, COMPATIBILITY_PROJECTION_TYPES, NON_CANONICAL_CONTROL_HELPER_TYPES, @@ -11,26 +11,26 @@ canonical_category_for_type, is_canonical_stream_candidate_type, ) -from trading_framework.core.domain.processing import process_canonical_event -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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, OrderStateEvent, OrderSubmittedEvent, ) -from trading_framework.core.events.event_bus import EventBus -from trading_framework.core.events.events import ( +from tradingchassis_core.core.events.event_bus import EventBus +from tradingchassis_core.core.events.events import ( DerivedFillEvent, DerivedPnLEvent, ExposureDerivedEvent, OrderStateTransitionEvent, RiskDecisionEvent, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.execution_control.types import ControlSchedulingObligation -from trading_framework.core.risk.risk_engine import GateDecision +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: diff --git a/tests/semantics/models/test_fold_event_stream_entries_contract.py b/tests/semantics/models/test_fold_event_stream_entries_contract.py index d297093..2b3d212 100644 --- a/tests/semantics/models/test_fold_event_stream_entries_contract.py +++ b/tests/semantics/models/test_fold_event_stream_entries_contract.py @@ -6,11 +6,11 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.processing import fold_event_stream_entries -from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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, @@ -19,7 +19,7 @@ Price, Quantity, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus def _book_market_event( diff --git a/tests/semantics/models/test_import_compatibility_shim.py b/tests/semantics/models/test_import_compatibility_shim.py new file mode 100644 index 0000000..fc9781f --- /dev/null +++ b/tests/semantics/models/test_import_compatibility_shim.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import warnings + + +def test_legacy_and_new_nested_modules_share_identity() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import trading_framework.core.domain.processing as old_processing + import trading_framework.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_legacy_and_new_symbols_share_identity() -> None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from trading_framework.core.domain.configuration import ( + CoreConfiguration as OldCoreConfiguration, + ) + from trading_framework.core.domain.types import ( + ControlTimeEvent as OldControlTimeEvent, + ) + from trading_framework.core.domain.types import ( + MarketEvent as OldMarketEvent, + ) + from trading_framework.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 index a7de5e8..8e1b6ac 100644 --- a/tests/semantics/models/test_market_configuration_positioned_contract.py +++ b/tests/semantics/models/test_market_configuration_positioned_contract.py @@ -12,22 +12,22 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.processing import ( +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 trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus def _book_market_event( @@ -371,7 +371,7 @@ def test_order_state_event_remains_compatibility_only() -> None: 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 / "trading_framework/core/domain/processing.py" + 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 = ( diff --git a/tests/semantics/models/test_market_reducer_positioned_target.py b/tests/semantics/models/test_market_reducer_positioned_target.py index 6509d12..94d2758 100644 --- a/tests/semantics/models/test_market_reducer_positioned_target.py +++ b/tests/semantics/models/test_market_reducer_positioned_target.py @@ -12,18 +12,18 @@ import pytest -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.processing import process_canonical_event -from trading_framework.core.domain.processing_order import ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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 trading_framework.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus def _market_configuration( diff --git a/tests/semantics/models/test_models_against_schemas.py b/tests/semantics/models/test_models_against_schemas.py index afc8f03..4b1328e 100644 --- a/tests/semantics/models/test_models_against_schemas.py +++ b/tests/semantics/models/test_models_against_schemas.py @@ -22,7 +22,7 @@ from referencing import Registry, Resource from referencing.jsonschema import DRAFT202012 -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.types import ( FillEvent, MarketEvent, OrderIntent, @@ -44,7 +44,7 @@ def load_schema(name: str) -> dict: global SCHEMA_REGISTRY root = Path(__file__).parent.parent.parent.parent # /workspaces/trading-framework - name = "trading_framework/core/schemas/" + name + name = "tradingchassis_core/core/schemas/" + name schema_path = root / name with schema_path.open("r", encoding="utf-8") as f: diff --git a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py index d2113d6..429d3ae 100644 --- a/tests/semantics/models/test_processing_position_cursor_ownership_guard.py +++ b/tests/semantics/models/test_processing_position_cursor_ownership_guard.py @@ -5,8 +5,8 @@ import ast from pathlib import Path -_ALLOWED_CALLER = Path("trading_framework/core/domain/processing.py") -_ALLOWED_MUTATION_FILE = Path("trading_framework/core/domain/state.py") +_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" @@ -71,7 +71,7 @@ def _find_target_attr_mutations(path: Path) -> list[tuple[int, int]]: def test_processing_position_cursor_is_mutated_only_via_canonical_boundary() -> None: repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "trading_framework" + production_root = repo_root / "tradingchassis_core" call_violations: list[str] = [] mutation_violations: list[str] = [] @@ -105,7 +105,7 @@ def test_processing_position_cursor_is_mutated_only_via_canonical_boundary() -> def test_positioned_market_helper_is_called_only_via_canonical_boundary() -> None: repo_root = Path(__file__).resolve().parents[3] - production_root = repo_root / "trading_framework" + production_root = repo_root / "tradingchassis_core" call_violations: list[str] = [] 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 d3a8e66..e886c86 100644 --- a/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py +++ b/tests/semantics/queue_semantics/test_control_scheduling_obligation_characterization.py @@ -2,8 +2,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, NotionalLimits, @@ -12,10 +12,10 @@ Price, Quantity, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.execution_control import ExecutionControl -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: 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 index 9c9c2b6..04c091b 100644 --- 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 @@ -9,8 +9,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, NotionalLimits, @@ -19,9 +19,9 @@ Quantity, ReplaceOrderIntent, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: 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 index 8e08846..6d4ecd8 100644 --- a/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py +++ b/tests/semantics/queue_semantics/test_new_queued_on_rate_limit.py @@ -8,8 +8,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, NotionalLimits, @@ -18,9 +18,9 @@ Price, Quantity, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: diff --git a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py b/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py index 5a92fab..428b4d9 100644 --- a/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py +++ b/tests/semantics/queue_semantics/test_queue_cancel_dominates_new.py @@ -9,8 +9,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, NotionalLimits, @@ -18,9 +18,9 @@ Price, Quantity, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import RiskEngine +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: 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 index 04b9109..5bf4505 100644 --- 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 @@ -10,15 +10,15 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( CancelOrderIntent, NewOrderIntent, Price, Quantity, ReplaceOrderIntent, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +from tradingchassis_core.core.events.sinks.null_event_bus import NullEventBus def test_pop_queued_intents_orders_by_priority_then_fifo_characterization() -> None: diff --git a/tests/semantics/state_transitions/test_new_to_working.py b/tests/semantics/state_transitions/test_new_to_working.py index 7d2a756..7adb2cd 100644 --- a/tests/semantics/state_transitions/test_new_to_working.py +++ b/tests/semantics/state_transitions/test_new_to_working.py @@ -8,8 +8,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/tests/semantics/state_transitions/test_replace_to_replaced.py b/tests/semantics/state_transitions/test_replace_to_replaced.py index 5d09ba3..d00004d 100644 --- a/tests/semantics/state_transitions/test_replace_to_replaced.py +++ b/tests/semantics/state_transitions/test_replace_to_replaced.py @@ -8,8 +8,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py index 183da56..6179981 100644 --- a/tests/semantics/state_transitions/test_submitted_boundary_characterization.py +++ b/tests/semantics/state_transitions/test_submitted_boundary_characterization.py @@ -7,15 +7,15 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.state import StrategyState +from tradingchassis_core.core.domain.types import ( NewOrderIntent, OrderStateEvent, OrderSubmittedEvent, Price, Quantity, ) -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/tests/semantics/state_transitions/test_terminal_clears_inflight.py b/tests/semantics/state_transitions/test_terminal_clears_inflight.py index c577f86..6bada2c 100644 --- a/tests/semantics/state_transitions/test_terminal_clears_inflight.py +++ b/tests/semantics/state_transitions/test_terminal_clears_inflight.py @@ -7,8 +7,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/tests/semantics/state_transitions/test_working_to_filled.py b/tests/semantics/state_transitions/test_working_to_filled.py index 8c54f93..de5a89f 100644 --- a/tests/semantics/state_transitions/test_working_to_filled.py +++ b/tests/semantics/state_transitions/test_working_to_filled.py @@ -7,8 +7,8 @@ from __future__ import annotations -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.events.sinks.null_event_bus import NullEventBus +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: diff --git a/trading_framework/__init__.py b/trading_framework/__init__.py index db1be39..b2d6614 100644 --- a/trading_framework/__init__.py +++ b/trading_framework/__init__.py @@ -1,88 +1,89 @@ -"""Public API for the trading_framework package. +"""Compatibility import shim for the legacy trading_framework package. -Only symbols imported here are considered part of the stable, -supported external interface. +This package is kept for one transition window and redirects imports to +tradingchassis_core. """ from __future__ import annotations -from importlib.metadata import PackageNotFoundError, version - -# ---------------------------------------------------------------------- -# Backtest Engine API -# ---------------------------------------------------------------------- -# -# Backtest engine/runtime code is runtime-owned and has moved to the -# `trading-runtime` repository (import from `trading_runtime.backtest.*`). -# -# This semantic-core package must remain importable without the runtime layer. -from trading_framework.core.domain.slots import ( - SlotKey, - stable_slot_order_id, -) +import importlib +import importlib.abc +import importlib.machinery +import importlib.util +import sys +import warnings + +_OLD_ROOT = "trading_framework" +_NEW_ROOT = "tradingchassis_core" + + +def _to_new_name(fullname: str) -> str | None: + if fullname == _OLD_ROOT: + return _NEW_ROOT + if fullname.startswith(f"{_OLD_ROOT}."): + return f"{_NEW_ROOT}{fullname[len(_OLD_ROOT):]}" + return None + + +class _LegacyAliasLoader(importlib.abc.Loader): + def __init__(self, old_name: str, new_name: str) -> None: + self._old_name = old_name + self._new_name = new_name + + def create_module(self, spec: importlib.machinery.ModuleSpec) -> object: + module = importlib.import_module(self._new_name) + sys.modules[self._old_name] = module + return module + + def exec_module(self, module: object) -> None: + return None + -# ---------------------------------------------------------------------- -# Domain Types (used by strategies) -# ---------------------------------------------------------------------- -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( - MarketEvent, - NewOrderIntent, - OrderIntent, - Price, - Quantity, - ReplaceOrderIntent, - RiskConstraints, +class _LegacyAliasFinder(importlib.abc.MetaPathFinder): + def find_spec( + self, + fullname: str, + path: object | None, + target: object | None = None, + ) -> importlib.machinery.ModuleSpec | None: + if fullname == _OLD_ROOT: + return None + new_name = _to_new_name(fullname) + if new_name is None: + return None + + new_spec = importlib.util.find_spec(new_name) + if new_spec is None: + return None + + is_package = new_spec.submodule_search_locations is not None + spec = importlib.machinery.ModuleSpec( + name=fullname, + loader=_LegacyAliasLoader(old_name=fullname, new_name=new_name), + is_package=is_package, + ) + if is_package: + spec.submodule_search_locations = list(new_spec.submodule_search_locations or []) + spec.origin = f"alias:{new_name}" + return spec + + +def _install_alias_finder() -> None: + for finder in sys.meta_path: + if isinstance(finder, _LegacyAliasFinder): + return + sys.meta_path.insert(0, _LegacyAliasFinder()) + + +_install_alias_finder() +warnings.warn( + "trading_framework is deprecated; import from tradingchassis_core instead.", + DeprecationWarning, + stacklevel=2, ) -from trading_framework.core.ports.engine_context import EngineContext - -# ---------------------------------------------------------------------- -# Config API (used by consumers) -# ---------------------------------------------------------------------- -from trading_framework.core.risk.risk_config import RiskConfig -from trading_framework.core.risk.risk_engine import GateDecision - -# ---------------------------------------------------------------------- -# Strategy Interface -# ---------------------------------------------------------------------- -from trading_framework.strategies.base import Strategy -from trading_framework.strategies.strategy_config import StrategyConfig - -# ---------------------------------------------------------------------- -# Public API definition -# ---------------------------------------------------------------------- - -__all__ = [ - # Config - "RiskConfig", - "StrategyConfig", - - # Strategy interface - "Strategy", - - # Strategy-facing domain API - "StrategyState", - "MarketEvent", - "RiskConstraints", - "OrderIntent", - "NewOrderIntent", - "ReplaceOrderIntent", - "Price", - "Quantity", - "SlotKey", - "stable_slot_order_id", - "EngineContext", - "GateDecision", - - # Version - "__version__", -] - -# ---------------------------------------------------------------------- -# Package version -# ---------------------------------------------------------------------- - -try: - __version__ = version("trading-framework") -except PackageNotFoundError: - __version__ = "0.0.0" + +_new_pkg = importlib.import_module(_NEW_ROOT) +__all__ = list(getattr(_new_pkg, "__all__", [])) + +for _name in __all__: + globals()[_name] = getattr(_new_pkg, _name) diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py new file mode 100644 index 0000000..15462d4 --- /dev/null +++ b/tradingchassis_core/__init__.py @@ -0,0 +1,88 @@ +"""Public API for the tradingchassis_core package. + +Only symbols imported here are considered part of the stable, +supported external interface. +""" + +from __future__ import annotations + +from importlib.metadata import PackageNotFoundError, version + +# ---------------------------------------------------------------------- +# Backtest Engine API +# ---------------------------------------------------------------------- +# +# Backtest engine/runtime code is runtime-owned and has moved to the +# `trading-runtime` repository (import from `trading_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.types import ( + MarketEvent, + NewOrderIntent, + OrderIntent, + Price, + Quantity, + ReplaceOrderIntent, + RiskConstraints, +) +from tradingchassis_core.core.ports.engine_context import EngineContext + +# ---------------------------------------------------------------------- +# Config API (used by consumers) +# ---------------------------------------------------------------------- +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 +# ---------------------------------------------------------------------- + +__all__ = [ + # Config + "RiskConfig", + "StrategyConfig", + + # Strategy interface + "Strategy", + + # Strategy-facing domain API + "StrategyState", + "MarketEvent", + "RiskConstraints", + "OrderIntent", + "NewOrderIntent", + "ReplaceOrderIntent", + "Price", + "Quantity", + "SlotKey", + "stable_slot_order_id", + "EngineContext", + "GateDecision", + + # Version + "__version__", +] + +# ---------------------------------------------------------------------- +# Package version +# ---------------------------------------------------------------------- + +try: + __version__ = version("trading-framework") +except PackageNotFoundError: + __version__ = "0.0.0" diff --git a/trading_framework/core/__init__.py b/tradingchassis_core/core/__init__.py similarity index 100% rename from trading_framework/core/__init__.py rename to tradingchassis_core/core/__init__.py diff --git a/trading_framework/core/domain/__init__.py b/tradingchassis_core/core/domain/__init__.py similarity index 100% rename from trading_framework/core/domain/__init__.py rename to tradingchassis_core/core/domain/__init__.py diff --git a/trading_framework/core/domain/configuration.py b/tradingchassis_core/core/domain/configuration.py similarity index 100% rename from trading_framework/core/domain/configuration.py rename to tradingchassis_core/core/domain/configuration.py diff --git a/trading_framework/core/domain/event_model.py b/tradingchassis_core/core/domain/event_model.py similarity index 93% rename from trading_framework/core/domain/event_model.py rename to tradingchassis_core/core/domain/event_model.py index 4896a7a..126233c 100644 --- a/trading_framework/core/domain/event_model.py +++ b/tradingchassis_core/core/domain/event_model.py @@ -12,21 +12,21 @@ from enum import Enum -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.types import ( ControlTimeEvent, FillEvent, MarketEvent, OrderStateEvent, OrderSubmittedEvent, ) -from trading_framework.core.events.events import ( +from tradingchassis_core.core.events.events import ( DerivedFillEvent, DerivedPnLEvent, ExposureDerivedEvent, OrderStateTransitionEvent, RiskDecisionEvent, ) -from trading_framework.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation class CanonicalEventCategory(str, Enum): diff --git a/trading_framework/core/domain/order_lifecycle.py b/tradingchassis_core/core/domain/order_lifecycle.py similarity index 100% rename from trading_framework/core/domain/order_lifecycle.py rename to tradingchassis_core/core/domain/order_lifecycle.py diff --git a/trading_framework/core/domain/order_state_machine.py b/tradingchassis_core/core/domain/order_state_machine.py similarity index 100% rename from trading_framework/core/domain/order_state_machine.py rename to tradingchassis_core/core/domain/order_state_machine.py diff --git a/trading_framework/core/domain/processing.py b/tradingchassis_core/core/domain/processing.py similarity index 96% rename from trading_framework/core/domain/processing.py rename to tradingchassis_core/core/domain/processing.py index 7824515..85ac603 100644 --- a/trading_framework/core/domain/processing.py +++ b/tradingchassis_core/core/domain/processing.py @@ -17,15 +17,15 @@ import math from collections.abc import Iterable, Mapping -from trading_framework.core.domain.configuration import CoreConfiguration -from trading_framework.core.domain.event_model import ( +from tradingchassis_core.core.domain.configuration import CoreConfiguration +from tradingchassis_core.core.domain.event_model import ( CanonicalEventCategory, canonical_category_for_type, is_canonical_stream_candidate_type, ) -from trading_framework.core.domain.processing_order import EventStreamEntry, ProcessingPosition -from trading_framework.core.domain.state import StrategyState -from trading_framework.core.domain.types import ( +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, diff --git a/trading_framework/core/domain/processing_order.py b/tradingchassis_core/core/domain/processing_order.py similarity index 100% rename from trading_framework/core/domain/processing_order.py rename to tradingchassis_core/core/domain/processing_order.py diff --git a/trading_framework/core/domain/reject_reasons.py b/tradingchassis_core/core/domain/reject_reasons.py similarity index 100% rename from trading_framework/core/domain/reject_reasons.py rename to tradingchassis_core/core/domain/reject_reasons.py diff --git a/trading_framework/core/domain/slots.py b/tradingchassis_core/core/domain/slots.py similarity index 100% rename from trading_framework/core/domain/slots.py rename to tradingchassis_core/core/domain/slots.py diff --git a/trading_framework/core/domain/state.py b/tradingchassis_core/core/domain/state.py similarity index 98% rename from trading_framework/core/domain/state.py rename to tradingchassis_core/core/domain/state.py index 768ee6d..40de89d 100644 --- a/trading_framework/core/domain/state.py +++ b/tradingchassis_core/core/domain/state.py @@ -18,15 +18,15 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Callable, Iterable -from trading_framework.core.domain.order_lifecycle import ( +from tradingchassis_core.core.domain.order_lifecycle import ( is_valid_canonical_order_transition, normalize_compatibility_state_to_canonical, ) -from trading_framework.core.domain.order_state_machine import is_valid_transition -from trading_framework.core.domain.processing_order import ProcessingPosition -from trading_framework.core.domain.slots import SlotKey, stable_slot_order_id -from trading_framework.core.domain.types import OrderStateEvent -from trading_framework.core.events.events import ( +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, @@ -34,14 +34,14 @@ ) if TYPE_CHECKING: - from trading_framework.core.domain.types import ( + from tradingchassis_core.core.domain.types import ( ControlTimeEvent, FillEvent, NewOrderIntent, OrderIntent, OrderSubmittedEvent, ) - from trading_framework.core.events.event_bus import EventBus + from tradingchassis_core.core.events.event_bus import EventBus # --------------------------------------------------------------------------- diff --git a/trading_framework/core/domain/types.py b/tradingchassis_core/core/domain/types.py similarity index 100% rename from trading_framework/core/domain/types.py rename to tradingchassis_core/core/domain/types.py diff --git a/trading_framework/core/events/__init__.py b/tradingchassis_core/core/events/__init__.py similarity index 100% rename from trading_framework/core/events/__init__.py rename to tradingchassis_core/core/events/__init__.py diff --git a/trading_framework/core/events/event_bus.py b/tradingchassis_core/core/events/event_bus.py similarity index 94% rename from trading_framework/core/events/event_bus.py rename to tradingchassis_core/core/events/event_bus.py index 0f950ba..5cf493d 100644 --- a/trading_framework/core/events/event_bus.py +++ b/tradingchassis_core/core/events/event_bus.py @@ -7,7 +7,7 @@ from typing import Any, Iterable -from trading_framework.core.events.event_sink import EventSink +from tradingchassis_core.core.events.event_sink import EventSink class EventBus: diff --git a/trading_framework/core/events/event_sink.py b/tradingchassis_core/core/events/event_sink.py similarity index 100% rename from trading_framework/core/events/event_sink.py rename to tradingchassis_core/core/events/event_sink.py diff --git a/trading_framework/core/events/events.py b/tradingchassis_core/core/events/events.py similarity index 100% rename from trading_framework/core/events/events.py rename to tradingchassis_core/core/events/events.py diff --git a/trading_framework/core/events/sinks/null_event_bus.py b/tradingchassis_core/core/events/sinks/null_event_bus.py similarity index 85% rename from trading_framework/core/events/sinks/null_event_bus.py rename to tradingchassis_core/core/events/sinks/null_event_bus.py index 1816fe9..4193d58 100644 --- a/trading_framework/core/events/sinks/null_event_bus.py +++ b/tradingchassis_core/core/events/sinks/null_event_bus.py @@ -2,7 +2,7 @@ from typing import Any -from trading_framework.core.events.event_bus import EventBus +from tradingchassis_core.core.events.event_bus import EventBus class _NullSink: diff --git a/trading_framework/core/events/sinks/sink_logging.py b/tradingchassis_core/core/events/sinks/sink_logging.py similarity index 100% rename from trading_framework/core/events/sinks/sink_logging.py rename to tradingchassis_core/core/events/sinks/sink_logging.py diff --git a/trading_framework/core/execution_control/__init__.py b/tradingchassis_core/core/execution_control/__init__.py similarity index 73% rename from trading_framework/core/execution_control/__init__.py rename to tradingchassis_core/core/execution_control/__init__.py index c802eac..1a3be4a 100644 --- a/trading_framework/core/execution_control/__init__.py +++ b/tradingchassis_core/core/execution_control/__init__.py @@ -5,7 +5,7 @@ policy decisions. """ -from trading_framework.core.execution_control.execution_control import ExecutionControl +from tradingchassis_core.core.execution_control.execution_control import ExecutionControl __all__ = ["ExecutionControl"] diff --git a/trading_framework/core/execution_control/execution_control.py b/tradingchassis_core/core/execution_control/execution_control.py similarity index 97% rename from trading_framework/core/execution_control/execution_control.py rename to tradingchassis_core/core/execution_control/execution_control.py index 643acc7..66f47e4 100644 --- a/trading_framework/core/execution_control/execution_control.py +++ b/tradingchassis_core/core/execution_control/execution_control.py @@ -14,12 +14,12 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Callable -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.types import NewOrderIntent, OrderIntent -from trading_framework.core.execution_control.types import ControlSchedulingObligation +from tradingchassis_core.core.domain.reject_reasons import RejectReason +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.execution_control.types import ControlSchedulingObligation if TYPE_CHECKING: - from trading_framework.core.domain.state import StrategyState + from tradingchassis_core.core.domain.state import StrategyState @dataclass(slots=True) diff --git a/trading_framework/core/execution_control/types.py b/tradingchassis_core/core/execution_control/types.py similarity index 100% rename from trading_framework/core/execution_control/types.py rename to tradingchassis_core/core/execution_control/types.py diff --git a/trading_framework/core/ports/__init__.py b/tradingchassis_core/core/ports/__init__.py similarity index 100% rename from trading_framework/core/ports/__init__.py rename to tradingchassis_core/core/ports/__init__.py diff --git a/trading_framework/core/ports/engine_context.py b/tradingchassis_core/core/ports/engine_context.py similarity index 100% rename from trading_framework/core/ports/engine_context.py rename to tradingchassis_core/core/ports/engine_context.py diff --git a/trading_framework/core/ports/venue_adapter.py b/tradingchassis_core/core/ports/venue_adapter.py similarity index 100% rename from trading_framework/core/ports/venue_adapter.py rename to tradingchassis_core/core/ports/venue_adapter.py diff --git a/trading_framework/core/ports/venue_policy.py b/tradingchassis_core/core/ports/venue_policy.py similarity index 97% rename from trading_framework/core/ports/venue_policy.py rename to tradingchassis_core/core/ports/venue_policy.py index f2717b2..1640b98 100644 --- a/trading_framework/core/ports/venue_policy.py +++ b/tradingchassis_core/core/ports/venue_policy.py @@ -12,11 +12,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.types import NewOrderIntent, OrderIntent +from tradingchassis_core.core.domain.reject_reasons import RejectReason +from tradingchassis_core.core.domain.types import NewOrderIntent, OrderIntent if TYPE_CHECKING: - from trading_framework.core.domain.state import StrategyState + from tradingchassis_core.core.domain.state import StrategyState @dataclass(slots=True) diff --git a/trading_framework/core/risk/__init__.py b/tradingchassis_core/core/risk/__init__.py similarity index 100% rename from trading_framework/core/risk/__init__.py rename to tradingchassis_core/core/risk/__init__.py diff --git a/trading_framework/core/risk/risk_config.py b/tradingchassis_core/core/risk/risk_config.py similarity index 97% rename from trading_framework/core/risk/risk_config.py rename to tradingchassis_core/core/risk/risk_config.py index 20e4629..7d8ac54 100644 --- a/trading_framework/core/risk/risk_config.py +++ b/tradingchassis_core/core/risk/risk_config.py @@ -6,7 +6,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator -from trading_framework.core.domain.types import ( +from tradingchassis_core.core.domain.types import ( MaxLoss, NotionalLimits, OrderRateLimits, diff --git a/trading_framework/core/risk/risk_engine.py b/tradingchassis_core/core/risk/risk_engine.py similarity index 96% rename from trading_framework/core/risk/risk_engine.py rename to tradingchassis_core/core/risk/risk_engine.py index 2fe3167..20d0d17 100644 --- a/trading_framework/core/risk/risk_engine.py +++ b/tradingchassis_core/core/risk/risk_engine.py @@ -6,18 +6,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.types import OrderIntent, RiskConstraints -from trading_framework.core.events.events import RiskDecisionEvent -from trading_framework.core.execution_control import ExecutionControl -from trading_framework.core.ports.venue_policy import VenuePolicy -from trading_framework.core.risk.risk_policy import RiskPolicy +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.ports.venue_policy import VenuePolicy +from tradingchassis_core.core.risk.risk_policy import RiskPolicy if TYPE_CHECKING: from risk.risk_config import RiskConfig - from trading_framework.core.domain.state import StrategyState - from trading_framework.core.events.event_bus import EventBus + from tradingchassis_core.core.domain.state import StrategyState + from tradingchassis_core.core.events.event_bus import EventBus # --------------------------------------------------------------------------- diff --git a/trading_framework/core/risk/risk_policy.py b/tradingchassis_core/core/risk/risk_policy.py similarity index 96% rename from trading_framework/core/risk/risk_policy.py rename to tradingchassis_core/core/risk/risk_policy.py index 5135c4e..4e17f31 100644 --- a/trading_framework/core/risk/risk_policy.py +++ b/tradingchassis_core/core/risk/risk_policy.py @@ -9,13 +9,13 @@ from typing import TYPE_CHECKING -from trading_framework.core.domain.reject_reasons import RejectReason -from trading_framework.core.domain.types import OrderIntent -from trading_framework.core.ports.venue_policy import NormalizationOutcome, VenuePolicy +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 if TYPE_CHECKING: - from trading_framework.core.domain.state import StrategyState - from trading_framework.core.domain.types import MaxLoss, QuoteLimits + from tradingchassis_core.core.domain.state import StrategyState + from tradingchassis_core.core.domain.types import MaxLoss, QuoteLimits class RiskPolicy: diff --git a/trading_framework/core/schemas/common.schema.json b/tradingchassis_core/core/schemas/common.schema.json similarity index 100% rename from trading_framework/core/schemas/common.schema.json rename to tradingchassis_core/core/schemas/common.schema.json diff --git a/trading_framework/core/schemas/fill_event.schema.json b/tradingchassis_core/core/schemas/fill_event.schema.json similarity index 100% rename from trading_framework/core/schemas/fill_event.schema.json rename to tradingchassis_core/core/schemas/fill_event.schema.json diff --git a/trading_framework/core/schemas/market_event.schema.json b/tradingchassis_core/core/schemas/market_event.schema.json similarity index 100% rename from trading_framework/core/schemas/market_event.schema.json rename to tradingchassis_core/core/schemas/market_event.schema.json diff --git a/trading_framework/core/schemas/order_intent.schema.json b/tradingchassis_core/core/schemas/order_intent.schema.json similarity index 100% rename from trading_framework/core/schemas/order_intent.schema.json rename to tradingchassis_core/core/schemas/order_intent.schema.json diff --git a/trading_framework/core/schemas/order_state_event.schema.json b/tradingchassis_core/core/schemas/order_state_event.schema.json similarity index 100% rename from trading_framework/core/schemas/order_state_event.schema.json rename to tradingchassis_core/core/schemas/order_state_event.schema.json diff --git a/trading_framework/core/schemas/risk_constraints.schema.json b/tradingchassis_core/core/schemas/risk_constraints.schema.json similarity index 100% rename from trading_framework/core/schemas/risk_constraints.schema.json rename to tradingchassis_core/core/schemas/risk_constraints.schema.json diff --git a/trading_framework/strategies/__init__.py b/tradingchassis_core/strategies/__init__.py similarity index 100% rename from trading_framework/strategies/__init__.py rename to tradingchassis_core/strategies/__init__.py diff --git a/trading_framework/strategies/base.py b/tradingchassis_core/strategies/base.py similarity index 84% rename from trading_framework/strategies/base.py rename to tradingchassis_core/strategies/base.py index e72693f..e6389e8 100644 --- a/trading_framework/strategies/base.py +++ b/tradingchassis_core/strategies/base.py @@ -11,10 +11,10 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from trading_framework.core.domain.state import StrategyState - from trading_framework.core.domain.types import MarketEvent, OrderIntent, RiskConstraints - from trading_framework.core.ports.engine_context import EngineContext - from trading_framework.core.risk.risk_engine import GateDecision + 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): diff --git a/trading_framework/strategies/strategy_config.py b/tradingchassis_core/strategies/strategy_config.py similarity index 100% rename from trading_framework/strategies/strategy_config.py rename to tradingchassis_core/strategies/strategy_config.py From 94fd9fe9dc8a3e4793254a2cd51b0a010962ae76 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 21:59:23 +0000 Subject: [PATCH 57/61] m2 p9 s2.1: cleanup --- CHANGELOG.md | 4 +- README.md | 4 +- pyproject.toml | 6 +- scripts/check.sh | 4 +- .../models/test_import_compatibility_shim.py | 12 +-- .../models/test_models_against_schemas.py | 2 +- trading_framework/__init__.py | 89 ------------------- 7 files changed, 16 insertions(+), 105 deletions(-) delete mode 100644 trading_framework/__init__.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f62df6..39c7683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to Semantic Versioning. ## [0.1.0] - 2026-02-17 -Initial public release of the trading framework backtest core. +Initial public release of the core. ### Added @@ -43,7 +43,7 @@ Initial public release of the trading framework backtest core. - Cloud-native runtime entrypoints - S3-compatible storage adapter -#### Strategy Framework +#### Strategy - Base strategy interface - Structured strategy configuration diff --git a/README.md b/README.md index cd4aee7..589b01a 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # TradingChassis – Core -![CI](https://github.com/trading-engineering/trading-framework/actions/workflows/tests.yaml/badge.svg) +![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, event-driven core framework for trading engineering, +Deterministic, event-driven core for TradingChassis infrastructure project, built on top of [hftbacktest](https://github.com/nkaz001/hftbacktest), and extended with explicit risk management, order state machines, queue semantics, and research orchestration. diff --git a/pyproject.toml b/pyproject.toml index a077f1d..7c257cc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,9 +3,9 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "trading-framework" +name = "core" version = "0.1.0" -description = "Deterministic, event-driven core framework, with explicit risk management, order state machines, queue semantics, and research orchestration." +description = "Deterministic, event-driven core, with explicit risk management, order state machines, queue semantics, and research orchestration." readme = "README.md" requires-python = ">=3.11" authors = [{ name = "tradingeng@protonmail.com" }] @@ -39,7 +39,7 @@ dev = [ # Explicit package discovery # -------------------------------------------------- [tool.setuptools.packages.find] -include = ["tradingchassis_core*", "trading_framework*"] +include = ["tradingchassis_core*"] # -------------------------------------------------- # Pytest diff --git a/scripts/check.sh b/scripts/check.sh index 1d47d65..f10493e 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -5,10 +5,10 @@ echo "🔍 Running import-linter..." lint-imports --verbose echo "⚡ Running ruff (check only)..." -ruff check tradingchassis_core trading_framework tests +ruff check tradingchassis_core tests echo "🧠 Running mypy..." -mypy tradingchassis_core trading_framework tests +mypy tradingchassis_core tests echo "🧪 Running pytest..." python -m pytest diff --git a/tests/semantics/models/test_import_compatibility_shim.py b/tests/semantics/models/test_import_compatibility_shim.py index fc9781f..8c4f7b8 100644 --- a/tests/semantics/models/test_import_compatibility_shim.py +++ b/tests/semantics/models/test_import_compatibility_shim.py @@ -6,8 +6,8 @@ def test_legacy_and_new_nested_modules_share_identity() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - import trading_framework.core.domain.processing as old_processing - import trading_framework.core.domain.types as old_types + 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 @@ -19,16 +19,16 @@ def test_legacy_and_new_nested_modules_share_identity() -> None: def test_legacy_and_new_symbols_share_identity() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) - from trading_framework.core.domain.configuration import ( + from tradingchassis_core.core.domain.configuration import ( CoreConfiguration as OldCoreConfiguration, ) - from trading_framework.core.domain.types import ( + from tradingchassis_core.core.domain.types import ( ControlTimeEvent as OldControlTimeEvent, ) - from trading_framework.core.domain.types import ( + from tradingchassis_core.core.domain.types import ( MarketEvent as OldMarketEvent, ) - from trading_framework.core.domain.types import ( + from tradingchassis_core.core.domain.types import ( OrderSubmittedEvent as OldOrderSubmittedEvent, ) diff --git a/tests/semantics/models/test_models_against_schemas.py b/tests/semantics/models/test_models_against_schemas.py index 4b1328e..6a44694 100644 --- a/tests/semantics/models/test_models_against_schemas.py +++ b/tests/semantics/models/test_models_against_schemas.py @@ -43,7 +43,7 @@ def load_schema(name: str) -> dict: """ global SCHEMA_REGISTRY - root = Path(__file__).parent.parent.parent.parent # /workspaces/trading-framework + root = Path(__file__).parent.parent.parent.parent name = "tradingchassis_core/core/schemas/" + name schema_path = root / name diff --git a/trading_framework/__init__.py b/trading_framework/__init__.py deleted file mode 100644 index b2d6614..0000000 --- a/trading_framework/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Compatibility import shim for the legacy trading_framework package. - -This package is kept for one transition window and redirects imports to -tradingchassis_core. -""" - -from __future__ import annotations - -import importlib -import importlib.abc -import importlib.machinery -import importlib.util -import sys -import warnings - -_OLD_ROOT = "trading_framework" -_NEW_ROOT = "tradingchassis_core" - - -def _to_new_name(fullname: str) -> str | None: - if fullname == _OLD_ROOT: - return _NEW_ROOT - if fullname.startswith(f"{_OLD_ROOT}."): - return f"{_NEW_ROOT}{fullname[len(_OLD_ROOT):]}" - return None - - -class _LegacyAliasLoader(importlib.abc.Loader): - def __init__(self, old_name: str, new_name: str) -> None: - self._old_name = old_name - self._new_name = new_name - - def create_module(self, spec: importlib.machinery.ModuleSpec) -> object: - module = importlib.import_module(self._new_name) - sys.modules[self._old_name] = module - return module - - def exec_module(self, module: object) -> None: - return None - - -class _LegacyAliasFinder(importlib.abc.MetaPathFinder): - def find_spec( - self, - fullname: str, - path: object | None, - target: object | None = None, - ) -> importlib.machinery.ModuleSpec | None: - if fullname == _OLD_ROOT: - return None - new_name = _to_new_name(fullname) - if new_name is None: - return None - - new_spec = importlib.util.find_spec(new_name) - if new_spec is None: - return None - - is_package = new_spec.submodule_search_locations is not None - spec = importlib.machinery.ModuleSpec( - name=fullname, - loader=_LegacyAliasLoader(old_name=fullname, new_name=new_name), - is_package=is_package, - ) - if is_package: - spec.submodule_search_locations = list(new_spec.submodule_search_locations or []) - spec.origin = f"alias:{new_name}" - return spec - - -def _install_alias_finder() -> None: - for finder in sys.meta_path: - if isinstance(finder, _LegacyAliasFinder): - return - sys.meta_path.insert(0, _LegacyAliasFinder()) - - -_install_alias_finder() -warnings.warn( - "trading_framework is deprecated; import from tradingchassis_core instead.", - DeprecationWarning, - stacklevel=2, -) - -_new_pkg = importlib.import_module(_NEW_ROOT) -__all__ = list(getattr(_new_pkg, "__all__", [])) - -for _name in __all__: - globals()[_name] = getattr(_new_pkg, _name) From 030b857589973f1fe8fe014490a2064374b75221 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 22:24:13 +0000 Subject: [PATCH 58/61] m2 p9 s4.1: remove active legacy naming references --- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- README.md | 12 ++++++------ SECURITY.md | 2 +- docs/event-stream-cursor-characterization-v1.md | 2 +- pyproject.toml | 2 +- .../models/test_import_compatibility_shim.py | 4 ++-- tradingchassis_core/__init__.py | 4 ++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 7428e03..4b5f5ed 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -9,7 +9,7 @@ Clear and concise description of the issue. ## Environment -- Trading Framework version: +- Core (`tradingchassis-core`) version: - Python version: - Execution mode (local / cloud): - Strategy used: diff --git a/README.md b/README.md index 589b01a..2ce714a 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ explicit risk management, order state machines, queue semantics, and research or ## 🧠 What is this? This project wraps the open-source `hftbacktest` engine and extends it -into a structured trading framework. +into a structured core. While `hftbacktest` provides a high-performance event-driven simulation -core, this framework adds the missing layers required for realistic +core, this core adds the missing layers required for realistic research and strategy development: - Explicit order state machine @@ -93,15 +93,15 @@ For runnable backtests and runtime entrypoints, use `core-runtime` (the runtime/ A reproducible development environment is provided via a dev container. ```bash -git clone https://github.com/trading-engineering/trading-framework -cd trading-framework +git clone https://github.com/TradingChassis/core +cd core ``` Open in an IDE supporting Dev Containers, reopen in container, then: ```bash cd ../core-runtime -python trading_runtime/local/backtest.py --config trading_runtime/local/local.json +python -m core_runtime.local.backtest --config core_runtime/local/local.json ``` No manual `pip install` required inside the container. @@ -180,7 +180,7 @@ core-runtime/tests/data/scripts/ ## ⚙️ Configuration Execution is driven by explicit configuration files -(see `core-runtime/trading_runtime/local/local.json` for a runnable example). +(see `core-runtime/core_runtime/local/local.json` for a runnable example). Configurations define: diff --git a/SECURITY.md b/SECURITY.md index a88424c..9e224c4 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,7 +54,7 @@ Security-related dependency updates are prioritized. ## Responsible Usage -This framework is intended for research and controlled environments. +This code is intended for research and controlled environments. Users are responsible for: diff --git a/docs/event-stream-cursor-characterization-v1.md b/docs/event-stream-cursor-characterization-v1.md index e35ad05..ceeec8a 100644 --- a/docs/event-stream-cursor-characterization-v1.md +++ b/docs/event-stream-cursor-characterization-v1.md @@ -38,7 +38,7 @@ redefine existing contracts. ## Current runtime cursor behavior (characterized) Current behavior is implemented in -`core-runtime/trading_runtime/backtest/engine/strategy_runner.py`. +`core-runtime/core_runtime/backtest/engine/strategy_runner.py`. `ESCC-04` - Runtime runner owns an `EventStreamCursor` instance. diff --git a/pyproject.toml b/pyproject.toml index 7c257cc..4589da3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=69", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "core" +name = "tradingchassis-core" version = "0.1.0" description = "Deterministic, event-driven core, with explicit risk management, order state machines, queue semantics, and research orchestration." readme = "README.md" diff --git a/tests/semantics/models/test_import_compatibility_shim.py b/tests/semantics/models/test_import_compatibility_shim.py index 8c4f7b8..320e8bb 100644 --- a/tests/semantics/models/test_import_compatibility_shim.py +++ b/tests/semantics/models/test_import_compatibility_shim.py @@ -3,7 +3,7 @@ import warnings -def test_legacy_and_new_nested_modules_share_identity() -> None: +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 @@ -16,7 +16,7 @@ def test_legacy_and_new_nested_modules_share_identity() -> None: assert old_processing is new_processing -def test_legacy_and_new_symbols_share_identity() -> None: +def test_symbols_share_identity_across_import_sites() -> None: with warnings.catch_warnings(): warnings.simplefilter("ignore", DeprecationWarning) from tradingchassis_core.core.domain.configuration import ( diff --git a/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 15462d4..4eae49c 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -13,7 +13,7 @@ # ---------------------------------------------------------------------- # # Backtest engine/runtime code is runtime-owned and has moved to the -# `trading-runtime` repository (import from `trading_runtime.backtest.*`). +# 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 ( @@ -83,6 +83,6 @@ # ---------------------------------------------------------------------- try: - __version__ = version("trading-framework") + __version__ = version("tradingchassis-core") except PackageNotFoundError: __version__ = "0.0.0" From 09b364cd00c424b1756cd181c3fee6ab33a04827 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Sun, 3 May 2026 22:39:21 +0000 Subject: [PATCH 59/61] m2 p9 s5.1: rewrite README that matches the current architecture and naming --- README.md | 305 +++++++++++++++++++----------------------------------- 1 file changed, 108 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index 2ce714a..5d98c01 100644 --- a/README.md +++ b/README.md @@ -1,281 +1,192 @@ -# 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, event-driven core for TradingChassis infrastructure project, -built on top of [hftbacktest](https://github.com/nkaz001/hftbacktest), and extended with -explicit risk management, order state machines, queue semantics, and research orchestration. +Deterministic semantic Core library for TradingChassis. ---- - -## 🧠 What is this? - -This project wraps the open-source `hftbacktest` engine and extends it -into a structured core. - -While `hftbacktest` provides a high-performance event-driven simulation -core, this core adds the missing layers required for realistic -research and strategy development: - -- Explicit order state machine -- Risk engine with enforceable constraints -- Queue and rate-limit semantics -- Venue abstraction (backtest + live ready) -- Deterministic execution guarantees -- Experiment orchestration (segments, sweeps) -- Schema-validated domain events - -The result is a layered trading architecture. +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 does it solve? - -Backtesting setups tend to: - -- Ignore realistic order lifecycle behavior -- Have no explicit risk enforcement -- Mix strategy logic with execution logic -- Lack deterministic event modeling -- Do not scale to research workflows +## Overview -This Core solves those problems by introducing: +Core is a library, not a runtime shell. -- Clear domain boundaries -- Explicit state transitions -- Risk-first execution gating -- Deterministic event pipelines -- Research-grade orchestration - -It enables realistic simulation while remaining extensible toward live -trading. +- 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 --- -## 🏗 Architecture Overview - -The system is structured into clear layers with every layer being -exchangeable: - -Strategy\ -↓\ -Risk Engine\ -↓\ -Venue Abstraction\ -↓\ -Backtest or Execution Engine - -Internally: +## What Core is -- `hftbacktest` remains timestamp-atomic and event-driven. -- The strategy layer operates state-based per timestamp. -- The runner orchestrates event processing deterministically. +Core provides: -Core modules: - -- `core/` -- domain models, state machine, risk engine, events -- `strategies/` -- base strategy interfaces -- `tests/` -- semantic invariant validation -- `scripts/` -- development helper scripts +- 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 --- -## 🚀 Quickstart - -This repository (`core`) is the **library-only** semantic core. +## What Core is not -For runnable backtests and runtime entrypoints, use `core-runtime` (the runtime/backtesting repository). +Core does not provide: -### Option 1 – Recommended: Dev Container +- local/cluster runtime entrypoints +- Kubernetes or Argo orchestration +- runtime image/deployment plumbing +- full runtime ingress, replay, or storage infrastructure -A reproducible development environment is provided via a dev container. +Those responsibilities live in Core Runtime (`core-runtime`). -```bash -git clone https://github.com/TradingChassis/core -cd core -``` +--- -Open in an IDE supporting Dev Containers, reopen in container, then: +## Current semantic status -```bash -cd ../core-runtime -python -m core_runtime.local.backtest --config core_runtime/local/local.json -``` +The transitional semantic upgrade milestone is closed. -No manual `pip install` required inside the container. +Core remains the canonical semantic library, and current runtime usage focuses on canonical +`MarketEvent`, `OrderSubmittedEvent`, and `ControlTimeEvent` paths. -### Option 2 – Local Python Environment +Compatibility/deferred runtime capabilities still exist and are intentionally not described here as +fully complete canonical coverage. -Python 3.11.x is required. +--- -```bash -pip install -e . -``` +## 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 --- -## ▶️ Execution Modes +## Canonical boundary -### Local Mode +Core guarantees deterministic semantics and reusable contracts. -Local execution is provided by `core-runtime`. +Runtimes supply environment-specific concerns such as: -### Cloud / Entrypoint Mode - -Runtime/backtesting entrypoints and orchestration live in `core-runtime`. +- ingress wiring +- adapter implementations +- orchestration entrypoints +- persistence/replay infrastructure --- -## 📊 Data Requirements - -The backtest engine expects structured, event-driven market data -compatible with `hftbacktest`. +## Canonical vs compatibility artifacts -Key assumptions: +At the Core level: -- Timestamp-based atomic event processing -- Deterministic event ordering -- Preprocessed market events -- No implicit reconstruction during runtime +- Canonical artifacts are semantic models and deterministic processing contracts +- Compatibility artifacts are transitional runtime-facing paths maintained for migration parity -Example synthetic datasets are provided in: +The runtime-level capability matrix is documented in `core-runtime/README.md`. -``` -core-runtime/tests/data/parts/ -``` - -Example parts: - -``` -core-runtime/tests/data/parts/part-000.npz -core-runtime/tests/data/parts/part-001.npz -core-runtime/tests/data/parts/part-002.npz -``` +--- -### Result Artifacts +## Package and import names -Backtest runs produce deterministic result artifacts stored in: +- Human-facing concept name: Core +- Distribution/project name: `tradingchassis-core` +- Python import package: `tradingchassis_core` -``` -core-runtime/tests/data/results/ -``` +Install: -Generated files may include: - -``` -core-runtime/tests/data/results/stats.npz -core-runtime/tests/data/results/events.json +```bash +python -m pip install -e . ``` -Helper scripts for generating and inspecting synthetic datasets are located in: +Install with dev extras: -``` -core-runtime/tests/data/scripts/ +```bash +python -m pip install -e ".[dev]" ``` --- -## ⚙️ Configuration - -Execution is driven by explicit configuration files -(see `core-runtime/core_runtime/local/local.json` for a runnable example). - -Configurations define: +## Repository structure -- Data sources -- Risk constraints -- Strategy parameters -- Execution settings - -All configuration is explicit and validated. +```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 +``` --- -## 🔒 Deterministic Execution - -The framework enforces: +## Development setup -- Explicit state transitions -- No hidden side effects -- Ordered event processing -- Reproducible backtest runs +Requirements: -Semantic invariants are verified via dedicated test suites. +- Python 3.11+ -Run tests with: +Recommended local setup: ```bash -pytest +python -m pip install -e ".[dev]" ``` --- -## 🧪 Research & Orchestration +## Test commands -The backtest layer includes: +From the `core` repository root: -- Segment-based execution -- Parameter sweeps -- Experiment finalization entrypoints -- Metrics export hooks compatible with [Prometheus](https://prometheus.io) -- Logging integration compatible with [MLflow](https://mlflow.org) - -This enables structured research workflows. - -Metrics and experiment tracking are designed to integrate naturally into Kubernetes-based deployments, but the core framework does not require a specific monitoring or tracking backend. - ---- - -## 📦 Scope - -This repository focuses on: - -- Realistic backtesting -- Uniform core domain logic -- Risk-aware backtesting and execution -- Deterministic research workflows - -It does not include: +```bash +python -m pytest +``` -- Data collection pipelines -- Production-grade OMS cloud infrastructure +From a monorepo parent containing `core/`: -Live exchange connectivity and production-grade OMS software infrastructure -are under active development and not yet feature-complete. +```bash +python -m pytest -q core/tests +``` --- -## 🎯 Design Principles - -- Determinism over convenience -- Explicit state modeling -- No hidden side effects -- Risk-first architecture -- Clear domain boundaries +## Relationship to Core Runtime ---- +Core Runtime (`core-runtime`) provides runtime execution around Core, including: -## 📌 Project Status +- local hftbacktest-backed execution entrypoints +- Argo/runtime orchestration entrypoints +- runtime configuration and environment wiring +- local output artifacts under `.runtime/local/results/` -- Backtest stack: maturing -- Live adapters: under development -- Cloud execution: experimental +Core provides the deterministic semantics those runtime paths consume. --- -## 👥 Who is this for? +## Documentation index -- Quant developers building systematic strategies -- Engineers interested in event-driven architectures -- Researchers requiring deterministic simulations -- Contributors exploring structured trading systems -- Reviewers evaluating system design and architecture depth +- Terminology source of truth: `docs/docs/00-guides/terminology.md` +- Runtime capabilities and entrypoints: `core-runtime/README.md` --- -## 🏷️ Versioning +## License and versioning -This project follows the MIT license and semantic versioning. -Initial public release: `v0.1.0` +MIT licensed. Versioning follows semantic versioning. From 9a9af5e359e8a4764dcbc1761d1a52a293c3eea3 Mon Sep 17 00:00:00 2001 From: bxvtr Date: Tue, 5 May 2026 08:40:24 +0000 Subject: [PATCH 60/61] m2 p10 s1: determine whether the `core` repository is actually usable and semantically aligned with the architecture described in the `docs` repository --- .../test_public_canonical_api_surface.py | 48 +++++++++++++++++++ tradingchassis_core/__init__.py | 15 ++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/semantics/models/test_public_canonical_api_surface.py diff --git a/tests/semantics/models/test_public_canonical_api_surface.py b/tests/semantics/models/test_public_canonical_api_surface.py new file mode 100644 index 0000000..ee5973f --- /dev/null +++ b/tests/semantics/models/test_public_canonical_api_surface.py @@ -0,0 +1,48 @@ +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/tradingchassis_core/__init__.py b/tradingchassis_core/__init__.py index 4eae49c..e4b0063 100644 --- a/tradingchassis_core/__init__.py +++ b/tradingchassis_core/__init__.py @@ -8,6 +8,16 @@ from importlib.metadata import PackageNotFoundError, version +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, +) + # ---------------------------------------------------------------------- # Backtest Engine API # ---------------------------------------------------------------------- @@ -73,6 +83,11 @@ "stable_slot_order_id", "EngineContext", "GateDecision", + "CoreConfiguration", + "ProcessingPosition", + "EventStreamEntry", + "process_event_entry", + "fold_event_stream_entries", # Version "__version__", From 10b09aac06881b0f8f942e08104a09d86ba2d2ca Mon Sep 17 00:00:00 2001 From: bxvtr Date: Tue, 5 May 2026 08:48:29 +0000 Subject: [PATCH 61/61] m2 p10 s2: make the existing canonical Core processing API discoverable and stable for downstream users such as Core Runtime --- tests/semantics/models/test_public_canonical_api_surface.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/semantics/models/test_public_canonical_api_surface.py b/tests/semantics/models/test_public_canonical_api_surface.py index ee5973f..0244701 100644 --- a/tests/semantics/models/test_public_canonical_api_surface.py +++ b/tests/semantics/models/test_public_canonical_api_surface.py @@ -1,3 +1,5 @@ +"""Public import-surface contract for canonical core processing APIs.""" + from __future__ import annotations import tradingchassis_core as tc