From 2f9e73414ccae9be300aafd6cb3b1553e6955596 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Sat, 25 Jul 2026 14:55:20 +0000 Subject: [PATCH 1/4] feat: add native event lifecycle command compiler --- __init__.py | 5 +- backends/native_event.py | 33 ++- core/__init__.py | 5 +- core/order_compiler.py | 214 +++++++++++++++++- core/orders.py | 145 ++++++++++++ docs/endpoint.md | 35 ++- docs/order_fill_policies.md | 9 + ...hase30a_native_event_lifecycle_contract.py | 157 +++++++++++++ upgrade/implement.md | 164 ++++++++++++++ 9 files changed, 759 insertions(+), 8 deletions(-) create mode 100644 tests/test_phase30a_native_event_lifecycle_contract.py diff --git a/__init__.py b/__init__.py index e2e5298..adf2c65 100644 --- a/__init__.py +++ b/__init__.py @@ -90,7 +90,7 @@ from .adapters.nautilus import NautilusBacktestEngine from .core.types import BacktestResult from .core.results import BacktestResultV2, OptionBacktestResult -from .core.orders import BasketIntent, Fill, OrderIntent, Trade +from .core.orders import BasketIntent, Fill, OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent, Trade from .core.basket import FrozenBasketPlan, build_frozen_basket_orders from .core.execution_depth import ( NautilusExecutionDepthConfig, @@ -528,6 +528,9 @@ "MarginModel", "MarginModelKind", "OmsMode", + "OrderAction", + "OrderActivationPolicy", + "OrderCommand", "OrderIntent", "OrderSide", "OrderType", diff --git a/backends/native_event.py b/backends/native_event.py index 71638d3..3d5431e 100644 --- a/backends/native_event.py +++ b/backends/native_event.py @@ -40,8 +40,13 @@ build_arbitrage_order_plan, ) from ..core.basket import build_frozen_basket_orders -from ..core.order_compiler import CompiledOrderArrays, compile_order_intents -from ..core.orders import Fill, OrderIntent +from ..core.order_compiler import ( + CompiledOrderArrays, + CompiledOrderCommandArrays, + compile_order_commands, + compile_order_intents, +) +from ..core.orders import Fill, OrderCommand, OrderIntent from ..core.preprocessor import ( PreparedMarketArrays, align_series, @@ -142,6 +147,30 @@ def compile_orders( symbol_list = list(symbols) if symbols is not None else list(dict.fromkeys(order.symbol for order in orders)) return compile_order_intents(idx=idx, orders=orders, symbol_to_col={s: j for j, s in enumerate(symbol_list)}) + @staticmethod + def compile_order_commands( + datetime_index: Union[pd.DatetimeIndex, pd.Series], + commands: Sequence[OrderCommand], + symbols: Optional[Sequence[str]] = None, + ) -> CompiledOrderCommandArrays: + """ + Compile lifecycle commands for the native-event v2 contract. + + Phase 30A exposes this helper for adapters and strategy services. It + does not route commands into the v1 matching kernel; the v2 lifecycle + kernel is a later phase. + """ + idx = validate_datetime(datetime_index) + if symbols is None: + symbol_list = list(dict.fromkeys(command.symbol for command in commands if command.symbol is not None)) + else: + symbol_list = list(symbols) + return compile_order_commands( + idx=idx, + commands=commands, + symbol_to_col={s: j for j, s in enumerate(symbol_list)}, + ) + def run_orders( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], diff --git a/core/__init__.py b/core/__init__.py index 167f557..16e6ff8 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -3,7 +3,7 @@ from .vectorized import _engine_units_v2 from .types import BacktestResult from .results import BacktestResultV2 -from .orders import BasketIntent, Fill, OrderIntent, Trade +from .orders import BasketIntent, Fill, OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent, Trade from .basket import FrozenBasketPlan, build_frozen_basket_orders from .execution_depth import ( NautilusExecutionDepthConfig, @@ -131,6 +131,9 @@ "MarginModelKind", "NautilusExecutionDepthConfig", "OmsMode", + "OrderAction", + "OrderActivationPolicy", + "OrderCommand", "OrderIntent", "OrderSide", "OrderType", diff --git a/core/order_compiler.py b/core/order_compiler.py index fedc637..0322532 100644 --- a/core/order_compiler.py +++ b/core/order_compiler.py @@ -17,16 +17,29 @@ from .event import ( ORDER_TYPE_LIMIT, ORDER_TYPE_MARKET, + ORDER_TYPE_STOP_LIMIT, + ORDER_TYPE_STOP_MARKET, TIF_FOK, TIF_GTC, TIF_GTD, TIF_IOC, ) -from .orders import OrderIntent +from .orders import OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent from .preprocessor import MarketDataSignature, market_data_signature from .schema import OrderSide, OrderType, TimeInForce +COMMAND_ACTION_PLACE = 0 +COMMAND_ACTION_CANCEL = 1 +COMMAND_ACTION_REPLACE = 2 +COMMAND_ACTION_AMEND = 3 +COMMAND_ACTION_CANCEL_ALL = 4 + +ACTIVATION_IMMEDIATE = 0 +ACTIVATION_ON_PARENT_FIRST_FILL = 1 +ACTIVATION_ON_PARENT_FULL_FILL = 2 + + @dataclass(frozen=True) class CompiledOrderArrays: index_signature: MarketDataSignature @@ -46,6 +59,44 @@ def n_orders(self) -> int: return int(len(self.original_index)) +@dataclass(frozen=True) +class CompiledOrderCommandArrays: + """ + Array contract for native-event lifecycle commands. + + This v2 compiler is intentionally separate from `CompiledOrderArrays` so + the legacy v1 kernel remains byte-for-byte compatible with old endpoints. + """ + + index_signature: MarketDataSignature + symbols: Tuple[str, ...] + sorted_commands: Tuple[Tuple[int, OrderCommand], ...] + command_ptr: np.ndarray + command_bar: np.ndarray + command_action: np.ndarray + command_symbol: np.ndarray + command_side: np.ndarray + command_type: np.ndarray + command_qty: np.ndarray + command_price: np.ndarray + command_trigger_price: np.ndarray + command_tif: np.ndarray + command_reduce_only: np.ndarray + command_order_id: np.ndarray + command_target_order_id: np.ndarray + command_parent_order_id: np.ndarray + command_group_id: np.ndarray + command_oco_group_id: np.ndarray + command_activation: np.ndarray + command_expires_bar: np.ndarray + original_index: np.ndarray + id_values: Tuple[str, ...] + + @property + def n_commands(self) -> int: + return int(len(self.original_index)) + + def compile_order_intents( idx: pd.DatetimeIndex, orders: Sequence[OrderIntent], @@ -120,6 +171,114 @@ def compile_order_intents( ) +def compile_order_commands( + idx: pd.DatetimeIndex, + commands: Sequence[OrderCommand], + symbol_to_col: Dict[str, int], +) -> CompiledOrderCommandArrays: + """ + Compile lifecycle commands into contiguous arrays for native-event v2. + + The compiler validates timestamps/symbols, keeps a stable command order + within each bar, and maps sparse string IDs to dense integer codes. No fill + or accounting logic is performed here; this is only the deterministic input + contract for a lifecycle kernel or adapter. + """ + n_commands = len(commands) + command_bar_unsorted = np.zeros(n_commands, dtype=np.int64) + action_unsorted = np.zeros(n_commands, dtype=np.int64) + symbol_unsorted = np.full(n_commands, -1, dtype=np.int64) + side_unsorted = np.zeros(n_commands, dtype=np.int64) + type_unsorted = np.full(n_commands, -1, dtype=np.int64) + qty_unsorted = np.zeros(n_commands, dtype=np.float64) + price_unsorted = np.zeros(n_commands, dtype=np.float64) + trigger_unsorted = np.zeros(n_commands, dtype=np.float64) + tif_unsorted = np.full(n_commands, TIF_GTC, dtype=np.int64) + reduce_only_unsorted = np.zeros(n_commands, dtype=np.int64) + order_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + target_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + parent_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + group_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + oco_id_unsorted = np.full(n_commands, -1, dtype=np.int64) + activation_unsorted = np.zeros(n_commands, dtype=np.int64) + expires_bar_unsorted = np.full(n_commands, -1, dtype=np.int64) + original_unsorted = np.arange(n_commands, dtype=np.int64) + + id_map: Dict[str, int] = {} + idx_ns = idx.view("int64") + ts_ns = np.zeros(n_commands, dtype=np.int64) + for k, command in enumerate(commands): + ts_ns[k] = _timestamp_ns(command.timestamp) + action_unsorted[k] = _action_code(command.action) + if command.symbol is not None: + if command.symbol not in symbol_to_col: + raise ValueError(f"command symbol {command.symbol!r} is not in symbols") + symbol_unsorted[k] = symbol_to_col[command.symbol] + if command.side is not None: + side_unsorted[k] = _side_code(command.side) + if command.order_type is not None: + type_unsorted[k] = _command_order_type_code(command.order_type) + if command.qty is not None: + qty_unsorted[k] = float(command.qty) + price_unsorted[k] = 0.0 if command.price is None else float(command.price) + trigger_unsorted[k] = 0.0 if command.trigger_price is None else float(command.trigger_price) + tif_unsorted[k] = _tif_code(command.tif) + reduce_only_unsorted[k] = 1 if command.reduce_only else 0 + order_id_unsorted[k] = _id_code(command.order_id, id_map) + target_id_unsorted[k] = _id_code(command.target_order_id, id_map) + parent_id_unsorted[k] = _id_code(command.parent_order_id, id_map) + group_id_unsorted[k] = _id_code(command.group_id, id_map) + oco_id_unsorted[k] = _id_code(command.oco_group_id, id_map) + activation_unsorted[k] = _activation_code(command.activation_policy) + if command.expires_at is not None: + expires_bar_unsorted[k] = int(np.searchsorted(idx_ns, _timestamp_ns(command.expires_at), side="left")) + + command_bar_unsorted = np.searchsorted(idx_ns, ts_ns, side="left").astype(np.int64) + if n_commands > 0 and int(command_bar_unsorted.max()) >= len(idx): + raise ValueError("command timestamp is after the available data") + order_sort = np.argsort(command_bar_unsorted, kind="stable") + + command_bar = np.ascontiguousarray(command_bar_unsorted[order_sort], dtype=np.int64) + command_ptr = np.zeros(len(idx) + 1, dtype=np.int64) + if n_commands > 0: + counts = np.bincount(command_bar + 1, minlength=len(idx) + 1) + command_ptr[:] = np.cumsum(counts, dtype=np.int64) + + original_index = np.ascontiguousarray(original_unsorted[order_sort], dtype=np.int64) + sorted_commands = tuple((int(orig_idx), commands[int(orig_idx)]) for orig_idx in original_index) + id_values = tuple(sorted(id_map, key=id_map.get)) + return CompiledOrderCommandArrays( + index_signature=market_data_signature(idx, list(symbol_to_col.keys())), + symbols=tuple(symbol_to_col.keys()), + sorted_commands=sorted_commands, + command_ptr=command_ptr, + command_bar=np.ascontiguousarray(command_bar, dtype=np.int64), + command_action=np.ascontiguousarray(action_unsorted[order_sort], dtype=np.int64), + command_symbol=np.ascontiguousarray(symbol_unsorted[order_sort], dtype=np.int64), + command_side=np.ascontiguousarray(side_unsorted[order_sort], dtype=np.int64), + command_type=np.ascontiguousarray(type_unsorted[order_sort], dtype=np.int64), + command_qty=np.ascontiguousarray(qty_unsorted[order_sort], dtype=np.float64), + command_price=np.ascontiguousarray(price_unsorted[order_sort], dtype=np.float64), + command_trigger_price=np.ascontiguousarray(trigger_unsorted[order_sort], dtype=np.float64), + command_tif=np.ascontiguousarray(tif_unsorted[order_sort], dtype=np.int64), + command_reduce_only=np.ascontiguousarray(reduce_only_unsorted[order_sort], dtype=np.int64), + command_order_id=np.ascontiguousarray(order_id_unsorted[order_sort], dtype=np.int64), + command_target_order_id=np.ascontiguousarray(target_id_unsorted[order_sort], dtype=np.int64), + command_parent_order_id=np.ascontiguousarray(parent_id_unsorted[order_sort], dtype=np.int64), + command_group_id=np.ascontiguousarray(group_id_unsorted[order_sort], dtype=np.int64), + command_oco_group_id=np.ascontiguousarray(oco_id_unsorted[order_sort], dtype=np.int64), + command_activation=np.ascontiguousarray(activation_unsorted[order_sort], dtype=np.int64), + command_expires_bar=np.ascontiguousarray(expires_bar_unsorted[order_sort], dtype=np.int64), + original_index=original_index, + id_values=id_values, + ) + + +def order_intents_to_commands(orders: Sequence[OrderIntent]) -> Tuple[OrderCommand, ...]: + """Convert legacy intents to immediate PLACE lifecycle commands.""" + return tuple(OrderCommand.from_intent(order) for order in orders) + + def _side_code(side: OrderSide) -> int: return 1 if side is OrderSide.BUY else -1 @@ -132,6 +291,18 @@ def _order_type_code(order_type: OrderType) -> int: raise NotImplementedError(f"unsupported order_type={order_type!r}") +def _command_order_type_code(order_type: OrderType) -> int: + if order_type is OrderType.MARKET: + return ORDER_TYPE_MARKET + if order_type is OrderType.LIMIT: + return ORDER_TYPE_LIMIT + if order_type is OrderType.STOP_MARKET: + return ORDER_TYPE_STOP_MARKET + if order_type is OrderType.STOP_LIMIT: + return ORDER_TYPE_STOP_LIMIT + raise NotImplementedError(f"unsupported order_type={order_type!r}") + + def _tif_code(tif: TimeInForce) -> int: if tif is TimeInForce.GTC: return TIF_GTC @@ -142,3 +313,44 @@ def _tif_code(tif: TimeInForce) -> int: if tif is TimeInForce.GTD: return TIF_GTD raise NotImplementedError(f"unsupported tif={tif!r}") + + +def _action_code(action: OrderAction) -> int: + if action is OrderAction.PLACE: + return COMMAND_ACTION_PLACE + if action is OrderAction.CANCEL: + return COMMAND_ACTION_CANCEL + if action is OrderAction.REPLACE: + return COMMAND_ACTION_REPLACE + if action is OrderAction.AMEND: + return COMMAND_ACTION_AMEND + if action is OrderAction.CANCEL_ALL: + return COMMAND_ACTION_CANCEL_ALL + raise NotImplementedError(f"unsupported action={action!r}") + + +def _activation_code(policy: OrderActivationPolicy) -> int: + if policy is OrderActivationPolicy.IMMEDIATE: + return ACTIVATION_IMMEDIATE + if policy is OrderActivationPolicy.ON_PARENT_FIRST_FILL: + return ACTIVATION_ON_PARENT_FIRST_FILL + if policy is OrderActivationPolicy.ON_PARENT_FULL_FILL: + return ACTIVATION_ON_PARENT_FULL_FILL + raise NotImplementedError(f"unsupported activation_policy={policy!r}") + + +def _id_code(value: str | None, id_map: Dict[str, int]) -> int: + if value is None or value == "": + return -1 + if value not in id_map: + id_map[value] = len(id_map) + return id_map[value] + + +def _timestamp_ns(value: object) -> int: + ts = pd.Timestamp(value) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + return int(ts.value) diff --git a/core/orders.py b/core/orders.py index 17f3537..ff01e95 100644 --- a/core/orders.py +++ b/core/orders.py @@ -7,11 +7,30 @@ from __future__ import annotations from dataclasses import dataclass, field +from enum import Enum from typing import Dict, Optional from .schema import LiquiditySide, OrderSide, OrderType, TimeInForce +class OrderAction(str, Enum): + """Lifecycle command consumed by the native-event v2 compiler.""" + + PLACE = "place" + CANCEL = "cancel" + REPLACE = "replace" + AMEND = "amend" + CANCEL_ALL = "cancel_all" + + +class OrderActivationPolicy(str, Enum): + """When a placed child order becomes eligible for matching.""" + + IMMEDIATE = "immediate" + ON_PARENT_FIRST_FILL = "on_parent_first_fill" + ON_PARENT_FULL_FILL = "on_parent_full_fill" + + @dataclass(frozen=True) class OrderIntent: timestamp: object @@ -44,6 +63,120 @@ def signed_qty(self) -> float: return self.qty * self.side.sign +@dataclass(frozen=True) +class OrderCommand: + """ + Canonical order-lifecycle command for native-event v2 and adapters. + + `OrderIntent` remains the backwards-compatible shorthand for an immediate + PLACE command. Phase 30A only defines and compiles this contract; lifecycle + matching is wired into a dedicated v2 engine phase. + """ + + timestamp: object + action: OrderAction = OrderAction.PLACE + symbol: Optional[str] = None + side: Optional[OrderSide] = None + order_type: Optional[OrderType] = None + qty: Optional[float] = None + price: Optional[float] = None + trigger_price: Optional[float] = None + tif: TimeInForce = TimeInForce.GTC + reduce_only: bool = False + order_id: Optional[str] = None + target_order_id: Optional[str] = None + parent_order_id: Optional[str] = None + group_id: Optional[str] = None + oco_group_id: Optional[str] = None + activation_policy: OrderActivationPolicy = OrderActivationPolicy.IMMEDIATE + expires_at: Optional[object] = None + tag: Optional[str] = None + metadata: Dict = field(default_factory=dict) + + def __post_init__(self) -> None: + action = _normalize_order_action(self.action) + object.__setattr__(self, "action", action) + + activation = _normalize_activation_policy(self.activation_policy) + object.__setattr__(self, "activation_policy", activation) + + if action in (OrderAction.PLACE, OrderAction.REPLACE): + if not self.symbol: + raise ValueError(f"{action.value} command requires symbol") + if self.side is None: + raise ValueError(f"{action.value} command requires side") + if self.order_type is None: + raise ValueError(f"{action.value} command requires order_type") + if self.qty is None or self.qty <= 0.0: + raise ValueError(f"{action.value} command requires qty > 0") + if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT): + if self.price is None or self.price <= 0.0: + raise ValueError("limit commands require price > 0") + if self.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT): + if self.trigger_price is None or self.trigger_price <= 0.0: + raise ValueError("stop commands require trigger_price > 0") + if action is OrderAction.REPLACE and not self.target_order_id: + raise ValueError("replace command requires target_order_id") + elif action in (OrderAction.CANCEL, OrderAction.AMEND): + if not self.target_order_id: + raise ValueError(f"{action.value} command requires target_order_id") + if action is OrderAction.AMEND: + if self.qty is not None and self.qty <= 0.0: + raise ValueError("amend qty must be > 0") + if self.price is not None and self.price <= 0.0: + raise ValueError("amend price must be > 0") + if self.trigger_price is not None and self.trigger_price <= 0.0: + raise ValueError("amend trigger_price must be > 0") + elif action is OrderAction.CANCEL_ALL: + pass + else: + raise NotImplementedError(f"unsupported order action={action!r}") + + @classmethod + def from_intent(cls, intent: OrderIntent) -> "OrderCommand": + return cls( + timestamp=intent.timestamp, + action=OrderAction.PLACE, + symbol=intent.symbol, + side=intent.side, + order_type=intent.order_type, + qty=float(intent.qty), + price=intent.price, + trigger_price=intent.trigger_price, + tif=intent.tif, + reduce_only=intent.reduce_only, + order_id=intent.order_id, + tag=intent.tag, + metadata=dict(intent.metadata), + ) + + def to_intent(self) -> OrderIntent: + if self.action is not OrderAction.PLACE: + raise ValueError("only place commands can be converted to OrderIntent") + if self.symbol is None or self.side is None or self.order_type is None or self.qty is None: + raise ValueError("place command is incomplete") + return OrderIntent( + timestamp=self.timestamp, + symbol=self.symbol, + side=self.side, + order_type=self.order_type, + qty=float(self.qty), + price=self.price, + trigger_price=self.trigger_price, + tif=self.tif, + reduce_only=self.reduce_only, + order_id=self.order_id, + tag=self.tag, + metadata=dict(self.metadata), + ) + + @property + def signed_qty(self) -> float: + if self.side is None or self.qty is None: + return 0.0 + return float(self.qty) * self.side.sign + + @dataclass(frozen=True) class BasketIntent: timestamp: object @@ -113,3 +246,15 @@ def __post_init__(self) -> None: raise ValueError("avg_entry and avg_exit must be > 0") if self.fees < 0.0: raise ValueError("fees must be >= 0") + + +def _normalize_order_action(action: OrderAction | str) -> OrderAction: + if isinstance(action, OrderAction): + return action + return OrderAction(str(action)) + + +def _normalize_activation_policy(policy: OrderActivationPolicy | str) -> OrderActivationPolicy: + if isinstance(policy, OrderActivationPolicy): + return policy + return OrderActivationPolicy(str(policy)) diff --git a/docs/endpoint.md b/docs/endpoint.md index a323570..7f607ae 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -521,17 +521,46 @@ Input requirement: - `orders`: list of `OrderIntent`; - `symbols`: should contain the symbols used by the orders. -Order fields: +`OrderIntent` fields: - `timestamp`: bar timestamp; - `symbol`: instrument name; - `side`: `OrderSide.BUY` or `OrderSide.SELL`; -- `order_type`: `MARKET`, `LIMIT`, `STOP_MARKET`, or `STOP_LIMIT`; +- `order_type`: `MARKET` or `LIMIT` on the current native-event v1 route; - `qty`: positive quantity; - `price`: required for limit orders; -- `trigger_price`: required for stop orders; - `tif`: `GTC`, `IOC`, `FOK`, or `GTD`. +Lifecycle-v2 contract: + +```python +from quantbt import OrderAction, OrderCommand + +commands = [ + OrderCommand( + timestamp=df.index[10], + action=OrderAction.PLACE, + symbol="ETHUSDT", + side=OrderSide.BUY, + order_type=OrderType.STOP_LIMIT, + qty=3.0, + price=1795.0, + trigger_price=1800.0, + order_id="entry-stop-limit", + oco_group_id="eth-grid-1", + ), + OrderCommand( + timestamp=df.index[12], + action=OrderAction.CANCEL, + target_order_id="entry-stop-limit", + ), +] +``` + +Phase 30A compiles `OrderCommand` tapes for the upcoming native-event v2 +lifecycle engine. Existing endpoint execution remains on `OrderIntent` v1 until +the v2 route is explicitly enabled. + Execution rules: - market orders fill on the bar close with slippage; diff --git a/docs/order_fill_policies.md b/docs/order_fill_policies.md index 4a34898..b17a445 100644 --- a/docs/order_fill_policies.md +++ b/docs/order_fill_policies.md @@ -20,6 +20,13 @@ Sizing modes: `NativeEventBackend` and `BacktestEngineV2(backend="native_event")` consume explicit `OrderIntent` records. +Phase 30 adds `OrderCommand` as the lifecycle-v2 contract. `OrderIntent` +remains the stable immediate-place shorthand used by existing endpoints. +`OrderCommand` can express place/cancel/replace/amend/cancel-all, parent-child +activation, OCO groups, stop trigger fields, reduce-only flags, and GTD expiry. +Phase 30A only compiles this command tape; full lifecycle matching is wired in +the later native-event v2 kernel. + Rules: - market orders fill at current close; @@ -35,6 +42,8 @@ Rules: Current limitation: - partial fills are not yet modeled; fills are full-size or rejected/canceled. +- the v1 kernel executes market and limit orders; stop and linked lifecycle + commands require the opt-in v2 lifecycle route once Phase 30B/30C is complete. ## DCA Ladder diff --git a/tests/test_phase30a_native_event_lifecycle_contract.py b/tests/test_phase30a_native_event_lifecycle_contract.py new file mode 100644 index 0000000..6a80d41 --- /dev/null +++ b/tests/test_phase30a_native_event_lifecycle_contract.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import pandas as pd +import pytest + +from quantbt import NativeEventBackend, OrderAction, OrderActivationPolicy, OrderCommand +from quantbt.core.event import ORDER_TYPE_STOP_LIMIT, ORDER_TYPE_STOP_MARKET, TIF_GTC, TIF_IOC +from quantbt.core.order_compiler import ( + COMMAND_ACTION_CANCEL, + COMMAND_ACTION_PLACE, + COMMAND_ACTION_REPLACE, + compile_order_commands, + order_intents_to_commands, +) +from quantbt.core.orders import OrderIntent +from quantbt.core.schema import OrderSide, OrderType, TimeInForce + + +def _idx() -> pd.DatetimeIndex: + return pd.date_range("2024-01-01", periods=5, freq="1h", tz="UTC") + + +def test_order_command_from_intent_preserves_legacy_order_fields(): + idx = _idx() + intent = OrderIntent( + timestamp=idx[1], + symbol="BTCUSDT", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=0.25, + price=40_000.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="legacy-entry", + tag="legacy", + metadata={"source": "test"}, + ) + + command = order_intents_to_commands([intent])[0] + + assert command.action is OrderAction.PLACE + assert command.symbol == intent.symbol + assert command.signed_qty == intent.signed_qty + assert command.to_intent() == intent + + +def test_order_command_validation_rejects_incomplete_lifecycle_commands(): + idx = _idx() + with pytest.raises(ValueError, match="place command requires symbol"): + OrderCommand(timestamp=idx[1], side=OrderSide.BUY, order_type=OrderType.MARKET, qty=1.0) + + with pytest.raises(ValueError, match="cancel command requires target_order_id"): + OrderCommand(timestamp=idx[1], action=OrderAction.CANCEL) + + with pytest.raises(ValueError, match="replace command requires target_order_id"): + OrderCommand( + timestamp=idx[1], + action=OrderAction.REPLACE, + symbol="BTCUSDT", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=41_000.0, + ) + + +def test_compile_order_commands_stable_sorts_and_preserves_lifecycle_fields(): + idx = _idx() + commands = [ + OrderCommand( + timestamp=idx[2], + action=OrderAction.PLACE, + symbol="BTCUSDT", + side=OrderSide.BUY, + order_type=OrderType.STOP_MARKET, + qty=0.5, + trigger_price=40_500.0, + tif=TimeInForce.IOC, + order_id="entry-stop", + group_id="grid-1", + ), + OrderCommand( + timestamp=idx[1], + action=OrderAction.PLACE, + symbol="BTCUSDT", + side=OrderSide.SELL, + order_type=OrderType.STOP_LIMIT, + qty=0.5, + price=41_000.0, + trigger_price=40_900.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="tp-stop-limit", + parent_order_id="entry-stop", + oco_group_id="bracket-1", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + expires_at=idx[4], + ), + OrderCommand( + timestamp=idx[2], + action=OrderAction.CANCEL, + target_order_id="tp-stop-limit", + ), + ] + + compiled = compile_order_commands(idx, commands, {"BTCUSDT": 0}) + + assert compiled.n_commands == 3 + assert compiled.original_index.tolist() == [1, 0, 2] + assert compiled.command_ptr.tolist() == [0, 0, 1, 3, 3, 3] + assert compiled.command_action.tolist() == [ + COMMAND_ACTION_PLACE, + COMMAND_ACTION_PLACE, + COMMAND_ACTION_CANCEL, + ] + assert compiled.command_type[0] == ORDER_TYPE_STOP_LIMIT + assert compiled.command_type[1] == ORDER_TYPE_STOP_MARKET + assert compiled.command_tif[0] == TIF_GTC + assert compiled.command_tif[1] == TIF_IOC + assert compiled.command_reduce_only[0] == 1 + assert compiled.command_trigger_price[0] == 40_900.0 + assert compiled.command_expires_bar[0] == 4 + assert "entry-stop" in compiled.id_values + assert "tp-stop-limit" in compiled.id_values + assert compiled.command_target_order_id[2] == compiled.command_order_id[0] + + +def test_backend_compile_order_commands_helper_matches_core_compiler(): + idx = _idx() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="ETHUSDT", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + order_id="entry", + ), + OrderCommand( + timestamp=idx[2], + action=OrderAction.REPLACE, + target_order_id="entry", + symbol="ETHUSDT", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=2_000.0, + order_id="entry-replaced", + ), + ] + + helper = NativeEventBackend.compile_order_commands(idx, commands, symbols=["ETHUSDT"]) + manual = compile_order_commands(idx, commands, {"ETHUSDT": 0}) + + assert helper.symbols == manual.symbols + assert helper.command_action.tolist() == [COMMAND_ACTION_PLACE, COMMAND_ACTION_REPLACE] + assert helper.command_price.tolist() == manual.command_price.tolist() diff --git a/upgrade/implement.md b/upgrade/implement.md index ef60437..4e6d3ae 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3174,6 +3174,170 @@ Technical debt after Phase 17.6: --- +## Phase 30 - Native Event Lifecycle Upgrade + +Status: active on branch `feat/30-native-event-lifecycle`. + +Urgent goal: + +- Upgrade `native_event` from a static market/limit replay kernel into a + deterministic OHLC order-lifecycle engine. +- Preserve strategy separation: alpha/research code still emits signals or + order commands; the backend owns order state, fills, PnL, fees, margin, + liquidation, and audit reports. +- Keep old endpoint behavior stable while adding a v2 lifecycle path. + +Scope: + +- `quantbt.core.orders`; +- `quantbt.core.order_compiler`; +- `quantbt.core.event`; +- `quantbt.backends.native_event`; +- `quantbt.adapters.nautilus`; +- endpoint/docs/tests only where needed to expose the new contract. + +Non-goals for Phase 30: + +- Do not move alpha/feature logic into the backend. +- Do not silently change `OrderIntent` v1 market/limit replay semantics. +- Do not claim exchange-native OCO/L2 queue behavior until parity tests exist. + +### Phase 30A - Command Contract And Compiler V2 + +Status: completed. + +Plan: + +- Add canonical lifecycle command objects: + - `OrderAction.PLACE`; + - `OrderAction.CANCEL`; + - `OrderAction.REPLACE`; + - `OrderAction.AMEND`; + - `OrderAction.CANCEL_ALL`. +- Keep `OrderIntent` as the backwards-compatible shorthand for immediate + `PLACE`. +- Add lifecycle fields required by v2: + - `order_id`; + - `target_order_id`; + - `parent_order_id`; + - `group_id`; + - `oco_group_id`; + - `activation_policy`; + - `expires_at`; + - `reduce_only`; + - `trigger_price`. +- Add `compile_order_commands(...)` to pack lifecycle commands into contiguous + NumPy arrays without running execution logic. +- Preserve `compile_order_intents(...)` and `_engine_event_v1` unchanged for + old endpoint parity. +- Add focused tests for validation, stable sorting, ID mapping, stop fields, + reduce-only flags, parent/OCO metadata, and backend helper exposure. + +Exit criteria: + +- New command contract imports from `quantbt`. +- Compiler v2 supports market, limit, stop-market, stop-limit command payloads. +- Old native-event market/limit tests still pass unchanged. +- No endpoint default behavior changes. + +Implemented: + +- Added `OrderAction`, `OrderActivationPolicy`, and `OrderCommand`. +- Preserved `OrderIntent` as the compatibility shorthand for immediate place + commands. +- Added `order_intents_to_commands(...)` and `compile_order_commands(...)`. +- Added `CompiledOrderCommandArrays` with packed fields for: + - action; + - symbol; + - side; + - order type; + - quantity; + - limit price; + - trigger price; + - TIF; + - reduce-only; + - order/target/parent/group/OCO IDs; + - activation policy; + - expiry bar; + - original command index. +- Exposed `NativeEventBackend.compile_order_commands(...)`. +- Exported the new command contract from `quantbt` and `quantbt.core`. +- Documented the distinction between v1 `OrderIntent` execution and v2 command + tape compilation. + +Latest tests: + +- Phase 30A command contract tests: `4 passed`. +- Native-event v1 parity/performance tests: `11 passed`. +- Full non-real regression: `399 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 30A: + +- `OrderCommand` tapes are compiled but not yet executed by a lifecycle kernel. +- Stop-market/stop-limit payloads are packed for v2, while v1 still executes + only market/limit orders. +- OCO, parent-child activation, cancel/replace/amend, GTD expiry, and + reduce-only clipping are contract-ready but require Phase 30B execution + tests before production use. + +### Phase 30B - Native Event Lifecycle Kernel V2 + +Status: planned. + +Plan: + +- Add an active-order registry in a v2 Numba kernel. +- Implement deterministic lifecycle transitions: + - place; + - cancel; + - replace; + - amend; + - cancel-all; + - GTD expiry; + - reduce-only clipping; + - stop-market and stop-limit trigger activation; + - OCO sibling cancellation; + - parent-child activation on first/full fill. +- Emit lifecycle audit artifacts: + - order event log; + - final active-order snapshot; + - status/reject/cancel/fill report; + - engine version metadata. +- Keep v1 as compatibility route until v2 parity is explicitly accepted. + +Exit criteria: + +- Domain tests cover bracket/OCO, DCA/grid entry/exit, cancel/replace/amend, + reduce-only, stop triggers, GTD expiry, parent-child activation, and margin + rejection. +- v1 compatibility tests still pass. +- v2 metadata makes lifecycle behavior transparent enough for Nautilus parity. + +### Phase 30C - Endpoint, Nautilus Adapter, And Structured Package Parity + +Status: planned. + +Plan: + +- Expose an opt-in native-event v2 route through endpoint/backends without + breaking existing calls. +- Compile structured bracket, DCA/grid, basket, and arbitrage packages into + lifecycle commands where order state matters. +- Align Nautilus adapter inputs around the same canonical command contract. +- Add parity tests between: + - native-event v2 and old v1 for simple market/limit cases; + - native-event v2 and Nautilus for single-symbol explicit order packages; + - structured package preflight and lifecycle execution reports. + +Exit criteria: + +- Endpoint docs show how to pass `OrderIntent` vs `OrderCommand`. +- Legacy endpoints remain stable. +- Package-level reports include fills, cancels, rejects, and linked-order + status for stakeholder audit. + +--- + ## Backend Selection Guide Use `native_vectorized` when: From 3f15822cfbfb8646eae74f45d9d7ea1e7c5bf9a0 Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Sat, 25 Jul 2026 15:21:13 +0000 Subject: [PATCH 2/4] feat: add native event lifecycle kernel --- backends/native_event.py | 472 ++++++++++++++- core/event.py | 543 ++++++++++++++++++ docs/endpoint.md | 27 +- docs/order_fill_policies.md | 23 +- ..._phase30b_native_event_lifecycle_kernel.py | 314 ++++++++++ upgrade/implement.md | 48 +- 6 files changed, 1418 insertions(+), 9 deletions(-) create mode 100644 tests/test_phase30b_native_event_lifecycle_kernel.py diff --git a/backends/native_event.py b/backends/native_event.py index 3d5431e..78886ad 100644 --- a/backends/native_event.py +++ b/backends/native_event.py @@ -20,6 +20,7 @@ TIF_GTD, TIF_IOC, _engine_event_v1, + _engine_event_v2, ) from ..core.constraints import build_quantity_constraints, quantize_signed_quantity from ..core.arbitrage import ( @@ -46,7 +47,7 @@ compile_order_commands, compile_order_intents, ) -from ..core.orders import Fill, OrderCommand, OrderIntent +from ..core.orders import Fill, OrderAction, OrderCommand, OrderIntent from ..core.preprocessor import ( PreparedMarketArrays, align_series, @@ -69,6 +70,19 @@ ) +def _event_type_name(event_type: int) -> str: + return { + 0: "place", + 1: "cancel", + 2: "replace", + 3: "amend", + 4: "fill", + 5: "expire", + 6: "activate", + 7: "reject", + }.get(int(event_type), "unknown") + + @dataclass(frozen=True) class NativeEventConfig: account: AccountConfig @@ -171,6 +185,257 @@ def compile_order_commands( symbol_to_col={s: j for j, s in enumerate(symbol_list)}, ) + def run_order_commands( + self, + datetime_index: Union[pd.DatetimeIndex, pd.Series], + commands: Sequence[OrderCommand], + closes: Dict[str, pd.Series], + highs: Optional[Dict[str, pd.Series]] = None, + lows: Optional[Dict[str, pd.Series]] = None, + funding_rate: Union[float, pd.Series, Dict] = 0.0, + contract_size: Union[float, Dict[str, float]] = 1.0, + leverage: Optional[Union[float, Dict[str, float]]] = None, + fee_rate: Optional[Union[float, Dict[str, float]]] = None, + symbols: Optional[List[str]] = None, + market_arrays: Optional[PreparedMarketArrays] = None, + compiled_commands: Optional[CompiledOrderCommandArrays] = None, + instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None, + qty_step: Optional[Union[float, Dict[str, float]]] = None, + lot_size: Optional[Union[float, Dict[str, float]]] = None, + slot_size: Optional[Union[float, Dict[str, float]]] = None, + min_qty: Optional[Union[float, Dict[str, float]]] = None, + min_notional: Optional[Union[float, Dict[str, float]]] = None, + ) -> BacktestResultV2: + """ + Execute Phase 30B lifecycle `OrderCommand` tapes through event v2. + + This is intentionally opt-in. Existing `run_orders(OrderIntent...)` + remains routed to event v1 until endpoint parity is promoted in a later + phase. + """ + idx = validate_datetime(datetime_index) + if symbols is None: + symbol_list = list(closes.keys()) + else: + symbol_list = list(symbols) + + if market_arrays is None: + market_arrays = self.prepare_market_arrays( + datetime_index=idx, + closes=closes, + highs=highs, + lows=lows, + funding_rate=funding_rate, + symbols=symbol_list, + ) + elif market_arrays.signature != self._market_signature(idx, symbol_list): + raise ValueError("prepared market arrays do not match datetime_index/symbols") + + contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0) + constraints = build_quantity_constraints( + symbol_list, + instruments=instruments, + qty_step=qty_step, + lot_size=lot_size, + slot_size=slot_size, + min_qty=min_qty, + min_notional=min_notional, + ) + effective_commands, quantity_preflight = self._apply_command_quantity_constraints( + idx=idx, + commands=commands, + closes=market_arrays.closes, + symbol_list=symbol_list, + contract_sizes=contract_sizes, + constraints=constraints, + ) + if quantity_preflight["changed_count"] or quantity_preflight["dropped_count"]: + compiled_commands = None + commands = tuple(effective_commands) + else: + effective_commands = tuple(commands) + + if compiled_commands is None: + compiled_commands = self.compile_order_commands( + datetime_index=idx, + commands=effective_commands, + symbols=symbol_list, + ) + elif ( + compiled_commands.index_signature != market_arrays.signature + or compiled_commands.symbols != tuple(symbol_list) + ): + raise ValueError("compiled commands do not match prepared market arrays") + + leverages = self._per_symbol_array( + self.config.account.leverage if leverage is None else leverage, + symbol_list, + default=self.config.account.leverage, + ) + fee_rates = self._per_symbol_array( + self.config.fee_rate if fee_rate is None else fee_rate, + symbol_list, + default=0.0, + ) + + ( + equity_arr, + pos_arr, + fee_arr, + turnover_arr, + funding_arr, + init_margin_arr, + maint_margin_arr, + rejected_bar, + canceled_bar, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + event_count, + event_bar, + event_command, + event_type, + event_status, + event_related_command, + liq_flag, + liq_idx, + liq_reason, + ) = _engine_event_v2( + n_bars=len(idx), + n_syms=len(symbol_list), + n_commands=compiled_commands.n_commands, + n_ids=len(compiled_commands.id_values), + command_ptr=compiled_commands.command_ptr, + command_action=compiled_commands.command_action, + command_symbol=compiled_commands.command_symbol, + command_side=compiled_commands.command_side, + command_type=compiled_commands.command_type, + command_qty=compiled_commands.command_qty, + command_price=compiled_commands.command_price, + command_trigger_price=compiled_commands.command_trigger_price, + command_tif=compiled_commands.command_tif, + command_reduce_only=compiled_commands.command_reduce_only, + command_order_id=compiled_commands.command_order_id, + command_target_order_id=compiled_commands.command_target_order_id, + command_parent_order_id=compiled_commands.command_parent_order_id, + command_group_id=compiled_commands.command_group_id, + command_oco_group_id=compiled_commands.command_oco_group_id, + command_activation=compiled_commands.command_activation, + command_expires_bar=compiled_commands.command_expires_bar, + highs=market_arrays.highs, + lows=market_arrays.lows, + closes=market_arrays.closes, + funding_rates=market_arrays.funding, + is_funding_bar=market_arrays.is_funding_bar, + init_capital=self.config.account.initial_capital, + leverages=leverages, + maint_ratio=self.config.account.maintenance_ratio, + fee_rates=fee_rates, + contract_sizes=contract_sizes, + slippage=self.config.execution.slippage_rate, + use_funding=bool(self.config.use_funding), + ) + + fills = self._build_fills( + compiled_commands.sorted_commands, + idx, + fill_bar, + fill_qty, + fill_price, + fill_fee, + ) + equity = pd.Series(equity_arr, index=idx, name="equity") + positions = pd.DataFrame( + {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + close_df = pd.DataFrame( + {f"Close_{s}": market_arrays.closes[:, j] for j, s in enumerate(symbol_list)}, + index=idx, + ) + diagnostics = pd.DataFrame( + { + "turnover": turnover_arr, + "rejected_orders": rejected_bar, + "canceled_orders": canceled_bar, + }, + index=idx, + ) + command_report = self._build_command_report( + compiled_commands, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + ) + order_events = self._build_order_events( + idx=idx, + compiled_commands=compiled_commands, + event_count=int(event_count), + event_bar=event_bar, + event_command=event_command, + event_type=event_type, + event_status=event_status, + event_related_command=event_related_command, + ) + active_orders = command_report[ + (command_report["active"] == True) | (command_report["waiting_parent"] == True) # noqa: E712 + ].copy() + + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=positions, + closes=close_df, + symbols=symbol_list, + initial_capital=self.config.account.initial_capital, + leverage=float(np.mean(leverages)), + liquidated=bool(liq_flag), + liquidation_bar=int(liq_idx), + orders=self._commands_to_order_intents(compiled_commands.sorted_commands), + fills=tuple(fills), + fees=pd.Series(fee_arr, index=idx, name="fees"), + funding=pd.Series(funding_arr, index=idx, name="funding"), + margin=pd.DataFrame( + { + "initial_margin": init_margin_arr, + "maintenance_margin": maint_margin_arr, + }, + index=idx, + ), + diagnostics=diagnostics, + metadata={ + "backend": "native_event", + "engine": "event_v2_lifecycle", + "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list), + "slippage_bps": self.config.execution.slippage_bps, + "order_report": command_report, + "command_report": command_report, + "order_events": order_events, + "active_orders": active_orders, + "id_values": compiled_commands.id_values, + "quantity_constraints": constraints.as_dict(), + "quantity_preflight": quantity_preflight, + "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)), + "liquidation_reason": int(liq_reason), + }, + ) + def run_orders( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], @@ -424,6 +689,211 @@ def _apply_order_quantity_constraints( out.append(order) return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + @staticmethod + def _apply_command_quantity_constraints( + *, + idx: pd.DatetimeIndex, + commands: Sequence[OrderCommand], + closes: np.ndarray, + symbol_list: List[str], + contract_sizes: np.ndarray, + constraints, + ) -> tuple[tuple[OrderCommand, ...], Dict]: + if not constraints.enabled: + return tuple(commands), {"changed_count": 0, "dropped_count": 0, "dropped_orders": []} + sym_to_col = {symbol: j for j, symbol in enumerate(symbol_list)} + changed = 0 + dropped = [] + out: list[OrderCommand] = [] + idx_ns = idx.view("int64") + for command_idx, command in enumerate(commands): + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE) or command.symbol is None: + out.append(command) + continue + if command.symbol not in sym_to_col: + raise ValueError(f"command symbol {command.symbol!r} is not in symbols") + col = sym_to_col[command.symbol] + ts = pd.Timestamp(command.timestamp) + if ts.tz is None: + ts = ts.tz_localize("UTC") + else: + ts = ts.tz_convert("UTC") + bar = int(np.searchsorted(idx_ns, ts.value, side="left")) + if bar >= len(idx): + bar = len(idx) - 1 + price = float(command.price) if command.price is not None else float(closes[bar, col]) + signed = command.signed_qty + q = abs( + quantize_signed_quantity( + signed, + price, + float(contract_sizes[col]), + float(constraints.qty_step[col]), + float(constraints.min_qty[col]), + float(constraints.min_notional[col]), + ) + ) + if q <= 0.0: + dropped.append( + { + "original_index": command_idx, + "symbol": command.symbol, + "requested_qty": None if command.qty is None else float(command.qty), + } + ) + continue + if command.qty is not None and abs(q - float(command.qty)) > 1e-12: + changed += 1 + out.append( + OrderCommand( + timestamp=command.timestamp, + action=command.action, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=q, + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + target_order_id=command.target_order_id, + parent_order_id=command.parent_order_id, + group_id=command.group_id, + oco_group_id=command.oco_group_id, + activation_policy=command.activation_policy, + expires_at=command.expires_at, + tag=command.tag, + metadata={ + **command.metadata, + "requested_qty": float(command.qty), + "quantity_quantized": True, + }, + ) + ) + else: + out.append(command) + return tuple(out), {"changed_count": changed, "dropped_count": len(dropped), "dropped_orders": dropped} + + @staticmethod + def _build_command_report( + compiled_commands: CompiledOrderCommandArrays, + command_status: np.ndarray, + reject_code: np.ndarray, + fill_bar: np.ndarray, + fill_qty: np.ndarray, + fill_price: np.ndarray, + fill_fee: np.ndarray, + active: np.ndarray, + waiting_parent: np.ndarray, + working_qty: np.ndarray, + working_price: np.ndarray, + working_trigger: np.ndarray, + ) -> pd.DataFrame: + rows = [] + for sorted_idx, (original_idx, command) in enumerate(compiled_commands.sorted_commands): + rows.append( + { + "original_index": int(original_idx), + "sorted_index": int(sorted_idx), + "timestamp": command.timestamp, + "action": command.action.value, + "symbol": command.symbol, + "side": None if command.side is None else command.side.value, + "order_type": None if command.order_type is None else command.order_type.value, + "order_id": command.order_id, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "activation_policy": command.activation_policy.value, + "status": int(command_status[sorted_idx]), + "reject_code": int(reject_code[sorted_idx]), + "fill_bar": int(fill_bar[sorted_idx]), + "fill_qty": float(fill_qty[sorted_idx]), + "fill_price": float(fill_price[sorted_idx]), + "fill_fee": float(fill_fee[sorted_idx]), + "active": bool(active[sorted_idx]), + "waiting_parent": bool(waiting_parent[sorted_idx]), + "working_qty": float(working_qty[sorted_idx]), + "working_price": float(working_price[sorted_idx]), + "working_trigger_price": float(working_trigger[sorted_idx]), + "reduce_only": bool(command.reduce_only), + "tag": command.tag, + } + ) + if not rows: + return pd.DataFrame() + return pd.DataFrame(rows).sort_values("original_index", kind="stable").reset_index(drop=True) + + @staticmethod + def _build_order_events( + *, + idx: pd.DatetimeIndex, + compiled_commands: CompiledOrderCommandArrays, + event_count: int, + event_bar: np.ndarray, + event_command: np.ndarray, + event_type: np.ndarray, + event_status: np.ndarray, + event_related_command: np.ndarray, + ) -> pd.DataFrame: + rows = [] + for n in range(event_count): + command_idx = int(event_command[n]) + related_idx = int(event_related_command[n]) + original_idx = -1 + related_original_idx = -1 + command = None + if 0 <= command_idx < len(compiled_commands.sorted_commands): + original_idx = int(compiled_commands.sorted_commands[command_idx][0]) + command = compiled_commands.sorted_commands[command_idx][1] + if 0 <= related_idx < len(compiled_commands.sorted_commands): + related_original_idx = int(compiled_commands.sorted_commands[related_idx][0]) + bar = int(event_bar[n]) + rows.append( + { + "timestamp": idx[bar] if 0 <= bar < len(idx) else pd.NaT, + "bar": bar, + "sorted_index": command_idx, + "original_index": original_idx, + "event_type": int(event_type[n]), + "event_name": _event_type_name(int(event_type[n])), + "status": int(event_status[n]), + "related_sorted_index": related_idx, + "related_original_index": related_original_idx, + "order_id": None if command is None else command.order_id, + "target_order_id": None if command is None else command.target_order_id, + "oco_group_id": None if command is None else command.oco_group_id, + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _commands_to_order_intents(sorted_commands) -> tuple[OrderIntent, ...]: + orders: list[OrderIntent] = [] + for _, command in sorted_commands: + if command.action in (OrderAction.PLACE, OrderAction.REPLACE): + if command.symbol is None or command.side is None or command.order_type is None or command.qty is None: + continue + orders.append( + OrderIntent( + timestamp=command.timestamp, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=float(command.qty), + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + tag=command.tag, + metadata=dict(command.metadata), + ) + ) + return tuple(orders) + def run_basket( self, datetime_index: Union[pd.DatetimeIndex, pd.Series], diff --git a/core/event.py b/core/event.py index 1e5ecb2..5f660f5 100644 --- a/core/event.py +++ b/core/event.py @@ -31,12 +31,35 @@ REJECT_NONE = 0 REJECT_INSUFFICIENT_MARGIN = 1 REJECT_UNSUPPORTED_ORDER_TYPE = 2 +REJECT_UNKNOWN_ORDER = 3 +REJECT_INVALID_AMEND = 4 +REJECT_REDUCE_ONLY_NO_POSITION = 5 +REJECT_UNSUPPORTED_ACTION = 6 LIQ_NONE = 0 LIQ_INTRABAR = 1 LIQ_AFTER_FUNDING = 2 LIQ_AFTER_ORDER = 3 +COMMAND_ACTION_PLACE = 0 +COMMAND_ACTION_CANCEL = 1 +COMMAND_ACTION_REPLACE = 2 +COMMAND_ACTION_AMEND = 3 +COMMAND_ACTION_CANCEL_ALL = 4 + +ACTIVATION_IMMEDIATE = 0 +ACTIVATION_ON_PARENT_FIRST_FILL = 1 +ACTIVATION_ON_PARENT_FULL_FILL = 2 + +ORDER_EVENT_PLACE = 0 +ORDER_EVENT_CANCEL = 1 +ORDER_EVENT_REPLACE = 2 +ORDER_EVENT_AMEND = 3 +ORDER_EVENT_FILL = 4 +ORDER_EVENT_EXPIRE = 5 +ORDER_EVENT_ACTIVATE = 6 +ORDER_EVENT_REJECT = 7 + @njit(cache=True) def _event_close_margin( @@ -308,3 +331,523 @@ def _engine_event_v1( liq_idx, liq_reason, ) + + +@njit(cache=True) +def _record_order_event( + event_count: int, + event_bar: np.ndarray, + event_command: np.ndarray, + event_type: np.ndarray, + event_status: np.ndarray, + event_related_command: np.ndarray, + bar: int, + command_idx: int, + event_code: int, + status: int, + related_command_idx: int, +): + if event_count < event_bar.shape[0]: + event_bar[event_count] = bar + event_command[event_count] = command_idx + event_type[event_count] = event_code + event_status[event_count] = status + event_related_command[event_count] = related_command_idx + return event_count + 1 + return event_count + + +@njit(cache=True) +def _event_margin_required( + n_syms: int, + current_pos: np.ndarray, + closes: np.ndarray, + contract_sizes: np.ndarray, + leverages: np.ndarray, + maint_ratio: float, + i: int, + sym: int, + delta: float, + exec_price: float, + fee_cost: float, +): + cur_im, _ = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + cs = contract_sizes[sym] + c = closes[i, sym] + old_im = abs(current_pos[sym]) * c * cs / leverages[sym] + new_im = abs(current_pos[sym] + delta) * exec_price * cs / leverages[sym] + margin_delta = new_im - old_im + required = fee_cost + if margin_delta > 0.0: + required += margin_delta + return required, cur_im + + +@njit(cache=True) +def _event_v2_touched_price( + otype: int, + side: int, + price: float, + trigger_price: float, + high: float, + low: float, + close: float, + slippage: float, +): + touched = False + exec_price = close + if otype == ORDER_TYPE_MARKET: + touched = True + exec_price = close * (1.0 + slippage if side > 0 else 1.0 - slippage) + elif otype == ORDER_TYPE_LIMIT: + if side > 0 and low <= price: + touched = True + exec_price = price + elif side < 0 and high >= price: + touched = True + exec_price = price + elif otype == ORDER_TYPE_STOP_MARKET: + if side > 0 and high >= trigger_price: + touched = True + exec_price = trigger_price * (1.0 + slippage) + elif side < 0 and low <= trigger_price: + touched = True + exec_price = trigger_price * (1.0 - slippage) + elif otype == ORDER_TYPE_STOP_LIMIT: + if side > 0 and high >= trigger_price and low <= price: + touched = True + exec_price = price + elif side < 0 and low <= trigger_price and high >= price: + touched = True + exec_price = price + return touched, exec_price + + +@njit(cache=True) +def _engine_event_v2( + n_bars: int, + n_syms: int, + n_commands: int, + n_ids: int, + command_ptr: np.ndarray, + command_action: np.ndarray, + command_symbol: np.ndarray, + command_side: np.ndarray, + command_type: np.ndarray, + command_qty: np.ndarray, + command_price: np.ndarray, + command_trigger_price: np.ndarray, + command_tif: np.ndarray, + command_reduce_only: np.ndarray, + command_order_id: np.ndarray, + command_target_order_id: np.ndarray, + command_parent_order_id: np.ndarray, + command_group_id: np.ndarray, + command_oco_group_id: np.ndarray, + command_activation: np.ndarray, + command_expires_bar: np.ndarray, + highs: np.ndarray, + lows: np.ndarray, + closes: np.ndarray, + funding_rates: np.ndarray, + is_funding_bar: np.ndarray, + init_capital: float, + leverages: np.ndarray, + maint_ratio: float, + fee_rates: np.ndarray, + contract_sizes: np.ndarray, + slippage: float, + use_funding: bool, +): + equity_curve = np.zeros(n_bars, dtype=np.float64) + pos_out = np.zeros((n_bars, n_syms), dtype=np.float64) + fee_arr = np.zeros(n_bars, dtype=np.float64) + turnover_arr = np.zeros(n_bars, dtype=np.float64) + funding_arr = np.zeros(n_bars, dtype=np.float64) + init_margin = np.zeros(n_bars, dtype=np.float64) + maint_margin = np.zeros(n_bars, dtype=np.float64) + rejected_bar = np.zeros(n_bars, dtype=np.int64) + canceled_bar = np.zeros(n_bars, dtype=np.int64) + + command_status = np.full(n_commands, ORDER_STATUS_PENDING, dtype=np.int64) + reject_code = np.zeros(n_commands, dtype=np.int64) + fill_bar = np.full(n_commands, -1, dtype=np.int64) + fill_qty = np.zeros(n_commands, dtype=np.float64) + fill_price = np.zeros(n_commands, dtype=np.float64) + fill_fee = np.zeros(n_commands, dtype=np.float64) + + active = np.zeros(n_commands, dtype=np.int64) + waiting_parent = np.zeros(n_commands, dtype=np.int64) + working_qty = np.copy(command_qty) + working_price = np.copy(command_price) + working_trigger = np.copy(command_trigger_price) + id_to_slot = np.full(n_ids, -1, dtype=np.int64) + + max_events = n_commands * 8 + n_bars + event_bar = np.full(max_events, -1, dtype=np.int64) + event_command = np.full(max_events, -1, dtype=np.int64) + event_type = np.full(max_events, -1, dtype=np.int64) + event_status = np.full(max_events, -1, dtype=np.int64) + event_related_command = np.full(max_events, -1, dtype=np.int64) + event_count = 0 + + current_pos = np.zeros(n_syms, dtype=np.float64) + equity = init_capital + liq_flag = False + liq_idx = -1 + liq_reason = LIQ_NONE + + equity_curve[0] = equity + + for i in range(1, n_bars): + if liq_flag: + equity_curve[i] = 0.0 + for s in range(n_syms): + pos_out[i, s] = 0.0 + continue + + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + equity += p * (closes[i, s] - closes[i - 1, s]) * contract_sizes[s] + + if _event_liquidated( + n_syms, equity, current_pos, highs, lows, closes, + contract_sizes, maint_ratio, i + ): + liq_flag = True + liq_idx = i + liq_reason = LIQ_INTRABAR + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + if is_funding_bar[i] and use_funding: + for s in range(n_syms): + p = current_pos[s] + if p != 0.0: + cost = p * closes[i, s] * contract_sizes[s] * funding_rates[i, s] + equity -= cost + funding_arr[i] += cost + + _, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_FUNDING + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + # Expire active GTD orders before processing the current bar. + for oid in range(n_commands): + if active[oid] == 1 and command_status[oid] == ORDER_STATUS_PENDING: + exp_bar = command_expires_bar[oid] + if exp_bar >= 0 and i >= exp_bar: + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_EXPIRE, ORDER_STATUS_CANCELED, -1, + ) + + # Apply lifecycle commands submitted for this bar. + for k in range(command_ptr[i], command_ptr[i + 1]): + action = command_action[k] + if action == COMMAND_ACTION_PLACE: + oid_code = command_order_id[k] + if oid_code >= 0 and oid_code < n_ids: + id_to_slot[oid_code] = k + if command_activation[k] == ACTIVATION_IMMEDIATE: + active[k] = 1 + else: + waiting_parent[k] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_PLACE, ORDER_STATUS_PENDING, -1, + ) + elif action == COMMAND_ACTION_REPLACE: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + oid_code = command_order_id[k] + if oid_code >= 0 and oid_code < n_ids: + id_to_slot[oid_code] = k + if target_code >= 0 and target_code < n_ids: + id_to_slot[target_code] = k + active[k] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REPLACE, ORDER_STATUS_PENDING, target, + ) + elif action == COMMAND_ACTION_CANCEL: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + command_status[k] = ORDER_STATUS_FILLED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_CANCEL, ORDER_STATUS_FILLED, target, + ) + elif action == COMMAND_ACTION_AMEND: + target_code = command_target_order_id[k] + target = -1 + if target_code >= 0 and target_code < n_ids: + target = id_to_slot[target_code] + if target < 0 or command_status[target] != ORDER_STATUS_PENDING: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNKNOWN_ORDER + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, target, + ) + else: + if command_qty[k] > 0.0: + working_qty[target] = command_qty[k] + if command_price[k] > 0.0: + working_price[target] = command_price[k] + if command_trigger_price[k] > 0.0: + working_trigger[target] = command_trigger_price[k] + command_status[k] = ORDER_STATUS_FILLED + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_AMEND, ORDER_STATUS_FILLED, target, + ) + elif action == COMMAND_ACTION_CANCEL_ALL: + for target in range(n_commands): + if active[target] == 1 and command_status[target] == ORDER_STATUS_PENDING: + if command_symbol[k] < 0 or command_symbol[k] == command_symbol[target]: + active[target] = 0 + waiting_parent[target] = 0 + command_status[target] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + command_status[k] = ORDER_STATUS_FILLED + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, k, + ORDER_EVENT_CANCEL, ORDER_STATUS_FILLED, -1, + ) + else: + command_status[k] = ORDER_STATUS_REJECTED + reject_code[k] = REJECT_UNSUPPORTED_ACTION + rejected_bar[i] += 1 + + # Match active order slots. Children activated by an earlier parent fill + # can fill in the same bar if they appear later in command order. + for oid in range(n_commands): + if active[oid] != 1 or command_status[oid] != ORDER_STATUS_PENDING: + continue + action = command_action[oid] + if action != COMMAND_ACTION_PLACE and action != COMMAND_ACTION_REPLACE: + continue + + sym = command_symbol[oid] + side = command_side[oid] + otype = command_type[oid] + tif = command_tif[oid] + + touched, exec_price = _event_v2_touched_price( + otype, side, working_price[oid], working_trigger[oid], + highs[i, sym], lows[i, sym], closes[i, sym], slippage, + ) + + if not touched: + if tif == TIF_GTC or tif == TIF_GTD: + continue + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, -1, + ) + continue + + qty = working_qty[oid] + if command_reduce_only[oid] == 1: + current = current_pos[sym] + if current == 0.0 or (current > 0.0 and side > 0) or (current < 0.0 and side < 0): + active[oid] = 0 + command_status[oid] = ORDER_STATUS_CANCELED + reject_code[oid] = REJECT_REDUCE_ONLY_NO_POSITION + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, -1, + ) + continue + max_reduce = abs(current) + if qty > max_reduce: + qty = max_reduce + + delta = qty * side + cs = contract_sizes[sym] + c = closes[i, sym] + trade_notional = abs(delta) * exec_price * cs + fee_cost = trade_notional * fee_rates[sym] + + required, cur_im = _event_margin_required( + n_syms, current_pos, closes, contract_sizes, leverages, + maint_ratio, i, sym, delta, exec_price, fee_cost, + ) + if required > equity - cur_im: + active[oid] = 0 + command_status[oid] = ORDER_STATUS_REJECTED + reject_code[oid] = REJECT_INSUFFICIENT_MARGIN + rejected_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_REJECT, ORDER_STATUS_REJECTED, -1, + ) + continue + + equity += delta * (c - exec_price) * cs - fee_cost + current_pos[sym] += delta + + active[oid] = 0 + command_status[oid] = ORDER_STATUS_FILLED + fill_bar[oid] = i + fill_qty[oid] = qty + fill_price[oid] = exec_price + fill_fee[oid] = fee_cost + fee_arr[i] += fee_cost + turnover_arr[i] += trade_notional + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, oid, + ORDER_EVENT_FILL, ORDER_STATUS_FILLED, -1, + ) + + order_id = command_order_id[oid] + for child in range(n_commands): + if waiting_parent[child] == 1 and command_parent_order_id[child] == order_id: + if ( + command_activation[child] == ACTIVATION_ON_PARENT_FIRST_FILL + or command_activation[child] == ACTIVATION_ON_PARENT_FULL_FILL + ): + waiting_parent[child] = 0 + active[child] = 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, child, + ORDER_EVENT_ACTIVATE, ORDER_STATUS_PENDING, oid, + ) + + oco_group = command_oco_group_id[oid] + if oco_group >= 0: + for sibling in range(n_commands): + if sibling != oid and active[sibling] == 1 and command_status[sibling] == ORDER_STATUS_PENDING: + if command_oco_group_id[sibling] == oco_group: + active[sibling] = 0 + waiting_parent[sibling] = 0 + command_status[sibling] = ORDER_STATUS_CANCELED + canceled_bar[i] += 1 + event_count = _record_order_event( + event_count, event_bar, event_command, event_type, + event_status, event_related_command, i, sibling, + ORDER_EVENT_CANCEL, ORDER_STATUS_CANCELED, oid, + ) + + close_im, close_mm = _event_close_margin( + n_syms, current_pos, closes, contract_sizes, leverages, maint_ratio, i + ) + + if close_mm > 0.0 and equity <= close_mm: + liq_flag = True + liq_idx = i + liq_reason = LIQ_AFTER_ORDER + equity = 0.0 + for s in range(n_syms): + current_pos[s] = 0.0 + pos_out[i, s] = 0.0 + equity_curve[i] = 0.0 + continue + + for s in range(n_syms): + pos_out[i, s] = current_pos[s] + init_margin[i] = close_im + maint_margin[i] = close_mm + equity_curve[i] = equity + + return ( + equity_curve, + pos_out, + fee_arr, + turnover_arr, + funding_arr, + init_margin, + maint_margin, + rejected_bar, + canceled_bar, + command_status, + reject_code, + fill_bar, + fill_qty, + fill_price, + fill_fee, + active, + waiting_parent, + working_qty, + working_price, + working_trigger, + event_count, + event_bar, + event_command, + event_type, + event_status, + event_related_command, + liq_flag, + liq_idx, + liq_reason, + ) diff --git a/docs/endpoint.md b/docs/endpoint.md index 7f607ae..65fd48a 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -557,9 +557,30 @@ commands = [ ] ``` -Phase 30A compiles `OrderCommand` tapes for the upcoming native-event v2 -lifecycle engine. Existing endpoint execution remains on `OrderIntent` v1 until -the v2 route is explicitly enabled. +Phase 30B executes `OrderCommand` tapes through +`NativeEventBackend.run_order_commands(...)`. Existing endpoint execution +remains on `OrderIntent` v1 until the v2 route is explicitly promoted. + +```python +from quantbt import AccountConfig, NativeEventBackend, NativeEventConfig + +backend = NativeEventBackend( + NativeEventConfig(account=AccountConfig(initial_capital=100_000, leverage=5)) +) + +result = backend.run_order_commands( + datetime_index=df.index, + commands=commands, + closes={"ETHUSDT": df["close"]}, + highs={"ETHUSDT": df["high"]}, + lows={"ETHUSDT": df["low"]}, + symbols=["ETHUSDT"], +) + +result.metadata["command_report"] +result.metadata["order_events"] +result.metadata["active_orders"] +``` Execution rules: diff --git a/docs/order_fill_policies.md b/docs/order_fill_policies.md index b17a445..d30d77d 100644 --- a/docs/order_fill_policies.md +++ b/docs/order_fill_policies.md @@ -24,8 +24,8 @@ Phase 30 adds `OrderCommand` as the lifecycle-v2 contract. `OrderIntent` remains the stable immediate-place shorthand used by existing endpoints. `OrderCommand` can express place/cancel/replace/amend/cancel-all, parent-child activation, OCO groups, stop trigger fields, reduce-only flags, and GTD expiry. -Phase 30A only compiles this command tape; full lifecycle matching is wired in -the later native-event v2 kernel. +Use `NativeEventBackend.run_order_commands(...)` for the opt-in v2 lifecycle +route. Rules: @@ -42,8 +42,23 @@ Rules: Current limitation: - partial fills are not yet modeled; fills are full-size or rejected/canceled. -- the v1 kernel executes market and limit orders; stop and linked lifecycle - commands require the opt-in v2 lifecycle route once Phase 30B/30C is complete. +- the v1 endpoint route executes market and limit orders; +- stop and linked lifecycle commands require the opt-in v2 lifecycle backend + route until endpoint wiring is promoted. + +Lifecycle-v2 rules: + +- place activates an order immediately unless a parent activation policy is set; +- cancel cancels a pending active or waiting order by `target_order_id`; +- replace cancels the target slot and creates a new executable slot; +- amend updates working quantity, limit price, or trigger price; +- GTD expiry cancels active orders before the expiry bar is matched; +- reduce-only exits are clipped to the current opposite position and canceled + as no-op when no opposite position exists; +- OCO siblings sharing `oco_group_id` are canceled after the first sibling fill; +- stop-market orders trigger from high/low and fill at trigger price plus + slippage; +- stop-limit orders require both trigger touch and limit touch in the bar. ## DCA Ladder diff --git a/tests/test_phase30b_native_event_lifecycle_kernel.py b/tests/test_phase30b_native_event_lifecycle_kernel.py new file mode 100644 index 0000000..da12d1b --- /dev/null +++ b/tests/test_phase30b_native_event_lifecycle_kernel.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import pandas as pd + +from quantbt import ( + AccountConfig, + ExecutionConfig, + NativeEventBackend, + NativeEventConfig, + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderSide, + OrderType, + TimeInForce, +) +from quantbt.core.event import ( + ORDER_STATUS_CANCELED, + ORDER_STATUS_FILLED, + ORDER_STATUS_PENDING, + REJECT_REDUCE_ONLY_NO_POSITION, +) +from quantbt.core.order_compiler import order_intents_to_commands +from quantbt.core.orders import OrderIntent + + +def _backend(initial_capital: float = 10_000.0, leverage: float = 10.0) -> NativeEventBackend: + return NativeEventBackend( + NativeEventConfig( + account=AccountConfig(initial_capital=initial_capital, leverage=leverage), + execution=ExecutionConfig(slippage_bps=0.0), + fee_rate=0.0, + use_funding=False, + ) + ) + + +def _market(): + idx = pd.date_range("2024-01-01", periods=6, freq="1h", tz="UTC") + close = pd.Series([100.0, 100.0, 103.0, 108.0, 96.0, 100.0], index=idx) + high = pd.Series([100.0, 101.0, 106.0, 111.0, 100.0, 101.0], index=idx) + low = pd.Series([100.0, 99.0, 98.0, 94.0, 89.0, 99.0], index=idx) + return idx, {"BTC": close}, {"BTC": high}, {"BTC": low} + + +def test_event_v2_cancel_prevents_later_gtc_limit_fill(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=90.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand(timestamp=idx[2], action=OrderAction.CANCEL, target_order_id="entry"), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_CANCELED + assert int(report.iloc[1]["status"]) == ORDER_STATUS_FILLED + assert result.positions["Position_BTC"].iloc[-1] == 0.0 + assert "cancel" in set(result.metadata["order_events"]["event_name"]) + + +def test_event_v2_replace_cancels_old_slot_and_fills_replacement(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=90.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=idx[2], + action=OrderAction.REPLACE, + target_order_id="entry", + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=99.0, + tif=TimeInForce.GTC, + order_id="entry-r1", + ), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 1 + assert result.fills[0].order_id == "entry-r1" + assert result.fills[0].price == 99.0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_CANCELED + assert int(report.iloc[1]["status"]) == ORDER_STATUS_FILLED + + +def test_event_v2_amend_updates_working_limit_before_matching(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=90.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand(timestamp=idx[2], action=OrderAction.AMEND, target_order_id="entry", price=99.0), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 1 + assert result.fills[0].order_id == "entry" + assert result.fills[0].price == 99.0 + assert float(report.iloc[0]["working_price"]) == 99.0 + assert int(report.iloc[1]["status"]) == ORDER_STATUS_FILLED + + +def test_event_v2_stop_market_uses_high_low_trigger_and_trigger_fill_price(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_MARKET, + qty=1.0, + trigger_price=105.0, + tif=TimeInForce.GTC, + order_id="breakout", + ) + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + + assert len(result.fills) == 1 + assert result.fills[0].timestamp == idx[2] + assert result.fills[0].price == 105.0 + assert result.positions["Position_BTC"].iloc[2] == 1.0 + + +def test_event_v2_parent_child_bracket_activates_and_oco_cancels_sibling(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ), + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=110.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="take-profit", + parent_order_id="entry", + oco_group_id="bracket", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + ), + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.STOP_MARKET, + qty=1.0, + trigger_price=93.0, + tif=TimeInForce.GTC, + reduce_only=True, + order_id="stop-loss", + parent_order_id="entry", + oco_group_id="bracket", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + ), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + events = result.metadata["order_events"] + + assert [fill.order_id for fill in result.fills] == ["entry", "take-profit"] + assert result.positions["Position_BTC"].iloc[-1] == 0.0 + assert int(report.iloc[1]["status"]) == ORDER_STATUS_FILLED + assert int(report.iloc[2]["status"]) == ORDER_STATUS_CANCELED + assert "activate" in set(events["event_name"]) + assert "cancel" in set(events["event_name"]) + + +def test_event_v2_reduce_only_without_opposite_position_cancels_noop(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=1.0, + reduce_only=True, + order_id="bad-reduce", + ) + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_CANCELED + assert int(report.iloc[0]["reject_code"]) == REJECT_REDUCE_ONLY_NO_POSITION + + +def test_event_v2_gtd_expires_before_later_touch(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=90.0, + tif=TimeInForce.GTD, + expires_at=idx[3], + order_id="gtd-entry", + ) + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_CANCELED + assert "expire" in set(result.metadata["order_events"]["event_name"]) + assert int(report.iloc[0]["fill_bar"]) == -1 + assert result.metadata["active_orders"].empty + + +def test_event_v2_unfilled_gtc_remains_active_in_snapshot(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=50.0, + tif=TimeInForce.GTC, + order_id="deep-bid", + ) + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_PENDING + assert bool(report.iloc[0]["active"]) is True + assert len(result.metadata["active_orders"]) == 1 + + +def test_event_v2_matches_v1_for_simple_market_and_limit_intents(): + idx, close, high, low = _market() + orders = [ + OrderIntent( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="market-entry", + ), + OrderIntent( + timestamp=idx[3], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=110.0, + tif=TimeInForce.GTC, + order_id="limit-exit", + ), + ] + backend = _backend() + + v1 = backend.run_orders(idx, orders, close, high, low) + v2 = backend.run_order_commands(idx, order_intents_to_commands(orders), close, high, low) + + pd.testing.assert_series_equal(v2.equity, v1.equity) + pd.testing.assert_frame_equal(v2.positions, v1.positions) + assert [fill.price for fill in v2.fills] == [fill.price for fill in v1.fills] diff --git a/upgrade/implement.md b/upgrade/implement.md index 4e6d3ae..7dc4232 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3282,7 +3282,7 @@ Technical debt after Phase 30A: ### Phase 30B - Native Event Lifecycle Kernel V2 -Status: planned. +Status: completed. Plan: @@ -3313,6 +3313,52 @@ Exit criteria: - v1 compatibility tests still pass. - v2 metadata makes lifecycle behavior transparent enough for Nautilus parity. +Implemented: + +- Added `_engine_event_v2(...)` as an opt-in Numba lifecycle kernel. +- Added active-order registry arrays and dense ID lookup. +- Implemented deterministic lifecycle commands: + - place; + - cancel; + - replace; + - amend; + - cancel-all. +- Implemented order-state behavior: + - parent-child activation; + - OCO sibling cancellation; + - reduce-only no-op cancellation and quantity clipping; + - stop-market trigger fills; + - stop-limit trigger plus limit-touch fills; + - GTD expiry before matching; + - IOC/FOK cancellation when not touched; + - margin rejection using the same account model as v1. +- Added `NativeEventBackend.run_order_commands(...)`. +- Added lifecycle audit metadata: + - `command_report`; + - `order_report`; + - `order_events`; + - `active_orders`; + - `id_values`; + - quantity preflight. +- Preserved `run_orders(...)` on event v1 for existing endpoint parity. + +Latest tests: + +- Phase 30A/30B lifecycle tests: `13 passed`. +- Native-event v1 parity/performance tests: `11 passed`. +- Simple market/limit v1-v2 parity test: passed inside Phase 30B suite. +- Full non-real regression: `408 passed, 1 skipped, 3 warnings`. + +Technical debt after Phase 30B: + +- Endpoint route still defaults to v1 `OrderIntent`; Phase 30C will expose + lifecycle v2 through endpoint/backends more ergonomically. +- Structured DCA/grid, bracket, basket, and arbitrage packages are not yet + automatically compiled into `OrderCommand` tapes. +- Partial fills, queue priority, latency, and L2 depth remain outside this + kernel; current v2 behavior is deterministic OHLC lifecycle simulation. +- Nautilus parity for command tapes remains Phase 30C. + ### Phase 30C - Endpoint, Nautilus Adapter, And Structured Package Parity Status: planned. From 3d02f0c40193d4f0bf6793ae792a240a9dc8903c Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Sun, 26 Jul 2026 03:18:51 +0000 Subject: [PATCH 3/4] test: complete native event lifecycle coverage --- core/event.py | 5 +- ..._phase30b_native_event_lifecycle_kernel.py | 164 ++++++++++++++++++ upgrade/implement.md | 21 ++- 3 files changed, 187 insertions(+), 3 deletions(-) diff --git a/core/event.py b/core/event.py index 5f660f5..6e4cd7b 100644 --- a/core/event.py +++ b/core/event.py @@ -663,7 +663,10 @@ def _engine_event_v2( ) elif action == COMMAND_ACTION_CANCEL_ALL: for target in range(n_commands): - if active[target] == 1 and command_status[target] == ORDER_STATUS_PENDING: + if ( + (active[target] == 1 or waiting_parent[target] == 1) + and command_status[target] == ORDER_STATUS_PENDING + ): if command_symbol[k] < 0 or command_symbol[k] == command_symbol[target]: active[target] = 0 waiting_parent[target] = 0 diff --git a/tests/test_phase30b_native_event_lifecycle_kernel.py b/tests/test_phase30b_native_event_lifecycle_kernel.py index da12d1b..0215232 100644 --- a/tests/test_phase30b_native_event_lifecycle_kernel.py +++ b/tests/test_phase30b_native_event_lifecycle_kernel.py @@ -18,6 +18,8 @@ ORDER_STATUS_CANCELED, ORDER_STATUS_FILLED, ORDER_STATUS_PENDING, + ORDER_STATUS_REJECTED, + REJECT_INSUFFICIENT_MARGIN, REJECT_REDUCE_ONLY_NO_POSITION, ) from quantbt.core.order_compiler import order_intents_to_commands @@ -155,6 +157,68 @@ def test_event_v2_stop_market_uses_high_low_trigger_and_trigger_fill_price(): assert result.positions["Position_BTC"].iloc[2] == 1.0 +def test_event_v2_stop_limit_requires_trigger_and_limit_touch(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.STOP_LIMIT, + qty=1.0, + price=104.0, + trigger_price=105.0, + tif=TimeInForce.GTC, + order_id="stop-limit-entry", + ) + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + + assert len(result.fills) == 1 + assert result.fills[0].timestamp == idx[2] + assert result.fills[0].price == 104.0 + assert result.positions["Position_BTC"].iloc[2] == 1.0 + + +def test_event_v2_cancel_all_cancels_active_and_waiting_child_orders(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=50.0, + tif=TimeInForce.GTC, + order_id="deep-bid", + ), + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.LIMIT, + qty=1.0, + price=150.0, + tif=TimeInForce.GTC, + order_id="waiting-child", + parent_order_id="missing-parent", + activation_policy=OrderActivationPolicy.ON_PARENT_FIRST_FILL, + ), + OrderCommand(timestamp=idx[2], action=OrderAction.CANCEL_ALL, symbol="BTC"), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_CANCELED + assert int(report.iloc[1]["status"]) == ORDER_STATUS_CANCELED + assert int(report.iloc[2]["status"]) == ORDER_STATUS_FILLED + assert result.metadata["active_orders"].empty + + def test_event_v2_parent_child_bracket_activates_and_oco_cancels_sibling(): idx, close, high, low = _market() commands = [ @@ -231,6 +295,106 @@ def test_event_v2_reduce_only_without_opposite_position_cancels_noop(): assert int(report.iloc[0]["reject_code"]) == REJECT_REDUCE_ONLY_NO_POSITION +def test_event_v2_reduce_only_clips_to_existing_position_size(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ), + OrderCommand( + timestamp=idx[2], + symbol="BTC", + side=OrderSide.SELL, + order_type=OrderType.MARKET, + qty=3.0, + tif=TimeInForce.IOC, + reduce_only=True, + order_id="oversized-exit", + ), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + + assert [fill.qty for fill in result.fills] == [1.0, 1.0] + assert result.positions["Position_BTC"].iloc[2] == 0.0 + assert result.positions["Position_BTC"].iloc[-1] == 0.0 + + +def test_event_v2_rejects_order_above_buying_power(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=500.0, + tif=TimeInForce.IOC, + order_id="too-large", + ) + ] + + result = _backend(leverage=1.0).run_order_commands(idx, commands, close, high, low) + report = result.metadata["command_report"].sort_values("original_index") + + assert len(result.fills) == 0 + assert int(report.iloc[0]["status"]) == ORDER_STATUS_REJECTED + assert int(report.iloc[0]["reject_code"]) == REJECT_INSUFFICIENT_MARGIN + assert result.positions["Position_BTC"].iloc[-1] == 0.0 + + +def test_event_v2_dca_ladder_style_limits_fill_at_grid_prices(): + idx, close, high, low = _market() + commands = [ + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="base", + group_id="dca-grid", + ), + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.5, + price=95.0, + tif=TimeInForce.GTC, + order_id="safety-1", + group_id="dca-grid", + ), + OrderCommand( + timestamp=idx[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=2.0, + price=90.0, + tif=TimeInForce.GTC, + order_id="safety-2", + group_id="dca-grid", + ), + ] + + result = _backend().run_order_commands(idx, commands, close, high, low) + + assert [fill.order_id for fill in result.fills] == ["base", "safety-1", "safety-2"] + assert [fill.price for fill in result.fills] == [100.0, 95.0, 90.0] + assert result.fills[1].timestamp == idx[3] + assert result.fills[2].timestamp == idx[4] + assert result.positions["Position_BTC"].iloc[-1] == 4.5 + + def test_event_v2_gtd_expires_before_later_touch(): idx, close, high, low = _market() commands = [ diff --git a/upgrade/implement.md b/upgrade/implement.md index 7dc4232..ad56936 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3344,10 +3344,27 @@ Implemented: Latest tests: -- Phase 30A/30B lifecycle tests: `13 passed`. +- Phase 30A/30B lifecycle tests: `18 passed`. - Native-event v1 parity/performance tests: `11 passed`. - Simple market/limit v1-v2 parity test: passed inside Phase 30B suite. -- Full non-real regression: `408 passed, 1 skipped, 3 warnings`. +- Full non-real regression: `413 passed, 1 skipped, 3 warnings`. + +Additional locked domain cases: + +- cancel prevents later GTC fill; +- replace cancels old slot and fills replacement; +- amend updates working limit before matching; +- stop-market trigger fill; +- stop-limit trigger plus limit-touch fill; +- cancel-all cancels active and parent-waiting orders; +- parent-child bracket activation with OCO sibling cancel; +- reduce-only no-op cancel without opposite position; +- reduce-only clipping to existing position size; +- margin rejection above buying power; +- DCA/grid-style base plus safety limit fills at grid prices; +- GTD expiry before later touch; +- unfilled GTC active snapshot; +- v1-v2 market/limit parity. Technical debt after Phase 30B: From 2567650f5fc3e973a8a328d00156aad6cef2d83c Mon Sep 17 00:00:00 2001 From: BobbyAxerol Date: Sun, 26 Jul 2026 03:31:53 +0000 Subject: [PATCH 4/4] feat: expose native event lifecycle endpoints --- __init__.py | 12 +- core/__init__.py | 12 +- core/orders.py | 60 ++++- docs/endpoint.md | 48 +++- docs/order_fill_policies.md | 9 +- endpoint.py | 159 +++++++++++-- engines.py | 99 +++++++- ...hase30c_native_event_endpoint_lifecycle.py | 214 ++++++++++++++++++ upgrade/implement.md | 54 ++++- 9 files changed, 627 insertions(+), 40 deletions(-) create mode 100644 tests/test_phase30c_native_event_endpoint_lifecycle.py diff --git a/__init__.py b/__init__.py index adf2c65..e12e58c 100644 --- a/__init__.py +++ b/__init__.py @@ -90,7 +90,16 @@ from .adapters.nautilus import NautilusBacktestEngine from .core.types import BacktestResult from .core.results import BacktestResultV2, OptionBacktestResult -from .core.orders import BasketIntent, Fill, OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent, Trade +from .core.orders import ( + BasketIntent, + Fill, + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderIntent, + Trade, + order_intents_to_lifecycle_commands, +) from .core.basket import FrozenBasketPlan, build_frozen_basket_orders from .core.execution_depth import ( NautilusExecutionDepthConfig, @@ -557,6 +566,7 @@ "build_quantity_constraints", "build_dca_grid_order_plan", "build_frozen_basket_orders", + "order_intents_to_lifecycle_commands", "normalize_portfolio_mode", "normalize_portfolio_sizing_mode", "normalize_rebalance_policy", diff --git a/core/__init__.py b/core/__init__.py index 16e6ff8..9dbc687 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -3,7 +3,16 @@ from .vectorized import _engine_units_v2 from .types import BacktestResult from .results import BacktestResultV2 -from .orders import BasketIntent, Fill, OrderAction, OrderActivationPolicy, OrderCommand, OrderIntent, Trade +from .orders import ( + BasketIntent, + Fill, + OrderAction, + OrderActivationPolicy, + OrderCommand, + OrderIntent, + Trade, + order_intents_to_lifecycle_commands, +) from .basket import FrozenBasketPlan, build_frozen_basket_orders from .execution_depth import ( NautilusExecutionDepthConfig, @@ -159,6 +168,7 @@ "build_bracket_order_plan", "build_dca_grid_order_plan", "build_frozen_basket_orders", + "order_intents_to_lifecycle_commands", "round_down_to_step", "SUPPORTED_DEPTH_MODELS", "l2_replay_available", diff --git a/core/orders.py b/core/orders.py index ff01e95..fa4fff4 100644 --- a/core/orders.py +++ b/core/orders.py @@ -8,7 +8,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Dict, Optional +from typing import Dict, Optional, Sequence, Tuple from .schema import LiquiditySide, OrderSide, OrderType, TimeInForce @@ -258,3 +258,61 @@ def _normalize_activation_policy(policy: OrderActivationPolicy | str) -> OrderAc if isinstance(policy, OrderActivationPolicy): return policy return OrderActivationPolicy(str(policy)) + + +def order_intents_to_lifecycle_commands( + orders: Sequence[OrderIntent], + *, + linked_metadata: bool = True, +) -> Tuple[OrderCommand, ...]: + """ + Convert `OrderIntent` records into lifecycle-v2 `OrderCommand` records. + + Structured package builders already carry parent/OCO information in + metadata. This helper lifts those fields into the explicit command contract + while preserving all old order intent fields for compatibility. + """ + commands = [] + tag_to_id = {} + for idx, order in enumerate(orders): + order_id = order.order_id or order.tag or f"order-{idx}" + tag_to_id[order.tag] = order_id + + for idx, order in enumerate(orders): + metadata = dict(order.metadata) + order_id = order.order_id or order.tag or f"order-{idx}" + parent_id = None + oco_group_id = None + activation = OrderActivationPolicy.IMMEDIATE + group_id = None + if linked_metadata: + group_id = metadata.get("group_id") or metadata.get("package_id") or metadata.get("arb_id") + leg_role = str(metadata.get("leg_role", "")).lower().strip() + if order.reduce_only or leg_role in {"take_profit", "stop_loss", "exit"}: + oco_group_id = metadata.get("oco_group_id") + parent_ref = metadata.get("parent_order_id") or metadata.get("parent_tag") + if parent_ref is not None: + parent_id = tag_to_id.get(parent_ref, str(parent_ref)) + activation = OrderActivationPolicy.ON_PARENT_FIRST_FILL + commands.append( + OrderCommand( + timestamp=order.timestamp, + action=OrderAction.PLACE, + symbol=order.symbol, + side=order.side, + order_type=order.order_type, + qty=float(order.qty), + price=order.price, + trigger_price=order.trigger_price, + tif=order.tif, + reduce_only=order.reduce_only, + order_id=order_id, + parent_order_id=parent_id, + group_id=None if group_id is None else str(group_id), + oco_group_id=None if oco_group_id is None else str(oco_group_id), + activation_policy=activation, + tag=order.tag, + metadata=metadata, + ) + ) + return tuple(commands) diff --git a/docs/endpoint.md b/docs/endpoint.md index 65fd48a..b25d0fc 100644 --- a/docs/endpoint.md +++ b/docs/endpoint.md @@ -557,9 +557,10 @@ commands = [ ] ``` -Phase 30B executes `OrderCommand` tapes through -`NativeEventBackend.run_order_commands(...)`. Existing endpoint execution -remains on `OrderIntent` v1 until the v2 route is explicitly promoted. +Phase 30C exposes this through `QuantBTEndpoint.native_event_lifecycle(...)` +and through `QuantBTEndpoint.orders(..., event_engine_version="v2")`. Existing +`QuantBTEndpoint.orders(...)` calls still default to the v1 `OrderIntent` +route. ```python from quantbt import AccountConfig, NativeEventBackend, NativeEventConfig @@ -582,6 +583,23 @@ result.metadata["order_events"] result.metadata["active_orders"] ``` +Endpoint equivalent: + +```python +bt = QuantBTEndpoint.native_event_lifecycle( + initial_capital=100_000, + leverage=5, + fee_rate=0.0002, + use_funding=False, +) + +result = bt.simulate( + data=df, + order_commands=commands, + symbols=["ETHUSDT"], +) +``` + Execution rules: - market orders fill on the bar close with slippage; @@ -635,6 +653,30 @@ route cleanly. It preserves TIF, reduce-only, and tags in the Nautilus order reports. DCA/grid, bracket/OCO, basket, portfolio and arbitrage packages remain higher-level adapters that compile into this explicit-order replay path. +Lifecycle commands with Nautilus: + +- `QuantBTEndpoint.orders(backend="nautilus")` accepts `order_commands`; +- executable `PLACE` and `REPLACE` commands are converted to Nautilus package + `OrderIntent` payloads; +- native lifecycle-only actions such as `CANCEL` and `AMEND` remain audited by + native-event v2 and are not exchange-native Nautilus command objects yet. + +Native structured lifecycle endpoints: + +```python +bt = QuantBTEndpoint.native_event_bracket_orders( + spec=bracket_spec, + initial_capital=100_000, + leverage=5, +) +result = bt.simulate(data=df) +result.metadata["command_report"] +result.metadata["order_events"] + +grid_bt = QuantBTEndpoint.native_event_dca_grid(spec=dca_grid_spec) +grid_result = grid_bt.simulate(data=df) +``` + Package execution-depth preflight: ```python diff --git a/docs/order_fill_policies.md b/docs/order_fill_policies.md index d30d77d..762f9a0 100644 --- a/docs/order_fill_policies.md +++ b/docs/order_fill_policies.md @@ -24,8 +24,8 @@ Phase 30 adds `OrderCommand` as the lifecycle-v2 contract. `OrderIntent` remains the stable immediate-place shorthand used by existing endpoints. `OrderCommand` can express place/cancel/replace/amend/cancel-all, parent-child activation, OCO groups, stop trigger fields, reduce-only flags, and GTD expiry. -Use `NativeEventBackend.run_order_commands(...)` for the opt-in v2 lifecycle -route. +Use `NativeEventBackend.run_order_commands(...)` or +`QuantBTEndpoint.native_event_lifecycle(...)` for the opt-in v2 lifecycle route. Rules: @@ -42,9 +42,8 @@ Rules: Current limitation: - partial fills are not yet modeled; fills are full-size or rejected/canceled. -- the v1 endpoint route executes market and limit orders; -- stop and linked lifecycle commands require the opt-in v2 lifecycle backend - route until endpoint wiring is promoted. +- the default endpoint route executes v1 market and limit orders; +- stop and linked lifecycle commands require the opt-in v2 lifecycle route. Lifecycle-v2 rules: diff --git a/endpoint.py b/endpoint.py index b3b392a..a725dc7 100644 --- a/endpoint.py +++ b/endpoint.py @@ -43,7 +43,7 @@ NautilusExecutionDepthConfig, simulate_nautilus_order_package_depth, ) -from .core.orders import OrderIntent +from .core.orders import OrderCommand, OrderIntent, order_intents_to_lifecycle_commands from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce from .core.structured_orders import ( @@ -177,6 +177,7 @@ class EndpointConfig: basket: Optional[BasketSpec] = None arbitrage_spec: object = None structured_order_spec: object = None + event_engine_version: str = "v1" symbols: Optional[Sequence[str]] = None dca_kwargs: Dict = field(default_factory=dict) nautilus_config: object = None @@ -298,6 +299,25 @@ def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": """ return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs)) + @classmethod + def native_event_lifecycle(cls, **kwargs) -> "QuantBTEndpoint": + """ + Create an explicit native-event v2 lifecycle endpoint. + + Use `simulate(..., order_commands=[OrderCommand(...), ...])` for + cancel/replace/amend/OCO/parent/stop/GTD lifecycle simulations. Passing + legacy `orders=[OrderIntent(...)]` is also accepted and converted to + immediate PLACE commands. + """ + return cls( + _config_from_kwargs( + mode="orders", + backend="native_event", + event_engine_version="v2", + **kwargs, + ) + ) + @classmethod def options( cls, @@ -396,6 +416,49 @@ def nautilus_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwar ) ) + @classmethod + def native_event_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a native-event v2 DCA/grid lifecycle endpoint. + + The structured package is compiled into `OrderCommand` records so base, + safety orders, reduce-only exits, and OCO metadata are audited in + `command_report` and `order_events`. + """ + if spec is None: + spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec)) + return cls( + _config_from_kwargs( + mode="native_event_dca_grid", + backend="native_event", + event_engine_version="v2", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + + @classmethod + def native_event_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint": + """ + Create a native-event v2 bracket/OCO lifecycle endpoint. + + Entry, take-profit, and stop-loss legs are linked through parent/OCO + command fields and simulated by the deterministic OHLC lifecycle kernel. + """ + if spec is None: + spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec)) + return cls( + _config_from_kwargs( + mode="native_event_bracket_orders", + backend="native_event", + event_engine_version="v2", + structured_order_spec=spec, + symbols=[spec.symbol], + **kwargs, + ) + ) + @classmethod def basket(cls, basket: Optional[BasketSpec] = None, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint": """ @@ -580,6 +643,13 @@ def nautilus_support_matrix() -> Dict[str, Dict[str, str]]: "order_types": "market, limit, stop_market, stop_limit", "notes": "preserves TIF, reduce_only, tags, price and trigger_price where Nautilus supports them", }, + "lifecycle_commands": { + "status": "supported_native_event_adapter_aligned", + "endpoint": "QuantBTEndpoint.native_event_lifecycle(...) or QuantBTEndpoint.orders(event_engine_version='v2', ...)", + "scope": "native-event v2 command lifecycle; Nautilus package adapter accepts executable PLACE/REPLACE payloads", + "order_types": "market, limit, stop_market, stop_limit plus cancel/replace/amend/cancel_all in native-event v2", + "notes": "Nautilus command path is payload-aligned, not exchange-native cancel/amend parity yet", + }, "dca_grid": { "status": "experimental", "endpoint": "QuantBTEndpoint.nautilus_dca_grid(...)", @@ -820,6 +890,7 @@ def backtest( signal_col: Optional[str] = None, positions: Optional[Union[pd.DataFrame, SeriesMap]] = None, orders: Optional[Sequence[OrderIntent]] = None, + order_commands: Optional[Sequence[OrderCommand]] = None, basket: Optional[BasketSpec] = None, closes: Optional[SeriesMap] = None, highs: Optional[SeriesMap] = None, @@ -910,8 +981,14 @@ def backtest( if mode in ("single_signal", "pct_equity", "signal_notional", "dca_ladder", "nautilus_validation"): return self._run_single(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index, symbols=symbols) if mode == "orders": - return self._run_orders(data=data, orders=orders, datetime_index=datetime_index, symbols=symbols) - if mode in ("nautilus_dca_grid", "nautilus_bracket_orders"): + return self._run_orders( + data=data, + orders=orders, + order_commands=order_commands, + datetime_index=datetime_index, + symbols=symbols, + ) + if mode in ("nautilus_dca_grid", "nautilus_bracket_orders", "native_event_dca_grid", "native_event_bracket_orders"): return self._run_structured_orders(data=data, datetime_index=datetime_index, symbols=symbols) if mode == "basket": return self._run_basket( @@ -1197,16 +1274,21 @@ def _run_single(self, data, signal, signal_col, datetime_index, symbols): self._store_result(self.engine.result) return self.result - def _run_orders(self, data, orders, datetime_index, symbols): - if not orders: - raise ValueError("orders endpoint requires orders=[OrderIntent(...), ...]") + def _run_orders(self, data, orders, order_commands, datetime_index, symbols): + if not orders and not order_commands: + raise ValueError("orders endpoint requires orders=[OrderIntent(...)] or order_commands=[OrderCommand(...)]") frame, idx, _ = _normalize_single_data(data=data, signal=pd.Series(0.0, index=_infer_index(data, datetime_index)), signal_col=None, datetime_index=datetime_index) backend = _resolve_backend(self.config) + event_version = str(self.config.event_engine_version).lower().strip() + if order_commands is not None: + event_version = "v2" self.engine = BacktestEngineV2( data=frame, symbols=list(symbols or self.config.symbols or ["asset"]), backend=backend, orders=orders, + order_commands=order_commands, + event_engine_version=event_version, account=self.config.account, execution=self.config.execution, fee_rate=self.config.v2_fee_rate, @@ -1238,21 +1320,56 @@ def _run_structured_orders(self, data, datetime_index, symbols): else: raise TypeError(f"unsupported structured_order_spec={type(spec).__name__}") - result = self._run_nautilus_package_orders( - data={spec.symbol: frame}, - orders=plan.orders, - symbols=[spec.symbol], - params={ - "input_mode": plan.package_type, - "structured_order_plan": plan, - "structured_order_table": plan.order_table, - "package_id": plan.package_id, - "package_type": plan.package_type, - "package_metadata": plan.metadata, - "order_count_input": len(plan.orders), - }, - ) - result.metadata["engine"] = f"nautilus_{plan.package_type}" + params = { + "input_mode": plan.package_type, + "structured_order_plan": plan, + "structured_order_table": plan.order_table, + "package_id": plan.package_id, + "package_type": plan.package_type, + "package_metadata": plan.metadata, + "order_count_input": len(plan.orders), + } + backend = _resolve_backend(self.config) + if backend == "native_event": + commands = order_intents_to_lifecycle_commands(plan.orders) + self.engine = BacktestEngineV2( + data=frame, + symbols=[spec.symbol], + backend="native_event", + order_commands=commands, + event_engine_version="v2", + account=self.config.account, + execution=self.config.execution, + fee_rate=self.config.v2_fee_rate, + use_funding=self.config.use_funding, + funding_rate=self.config.funding_rate, + contract_size=self.config.contract_size, + instruments=self.config.instruments, + qty_step=self.config.qty_step, + lot_size=self.config.lot_size, + slot_size=self.config.slot_size, + min_qty=self.config.min_qty, + min_notional=self.config.min_notional, + ) + result = self.engine.result + result.metadata.update( + { + **params, + "engine": f"event_v2_{plan.package_type}", + "lifecycle_command_count": len(commands), + "lifecycle_commands": commands, + } + ) + elif backend == "nautilus": + result = self._run_nautilus_package_orders( + data={spec.symbol: frame}, + orders=plan.orders, + symbols=[spec.symbol], + params=params, + ) + result.metadata["engine"] = f"nautilus_{plan.package_type}" + else: + raise ValueError(f"structured order endpoints require backend='native_event' or 'nautilus', got {backend!r}") self._store_result(result) return self.result diff --git a/engines.py b/engines.py index 39211bf..9fd7ae7 100644 --- a/engines.py +++ b/engines.py @@ -23,7 +23,7 @@ NativeVectorizedBackend, NativeVectorizedConfig, ) -from .core.orders import OrderIntent +from .core.orders import OrderAction, OrderCommand, OrderIntent, order_intents_to_lifecycle_commands from .core.preprocessor import validate_datetime from .core.results import BacktestResultV2, OptionBacktestResult from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce @@ -65,6 +65,8 @@ def __init__( positions: Optional[Union[pd.Series, SeriesMap]] = None, target_units: Optional[Union[pd.Series, SeriesMap]] = None, orders: Optional[Sequence[OrderIntent]] = None, + order_commands: Optional[Sequence[OrderCommand]] = None, + event_engine_version: str = "v1", datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None, closes: Optional[SeriesMap] = None, highs: Optional[SeriesMap] = None, @@ -101,6 +103,8 @@ def __init__( self.positions = positions self.target_units = target_units self.orders = tuple(orders or ()) + self.order_commands = tuple(order_commands or ()) + self.event_engine_version = str(event_engine_version).lower().strip() self.datetime_index = datetime_index self.closes = closes self.highs = highs @@ -224,6 +228,44 @@ def _run_native_event(self) -> BacktestResultV2: min_notional=self.min_notional, ) + if self.order_commands or self.event_engine_version in {"v2", "event_v2", "lifecycle", "lifecycle_v2"}: + commands = self.order_commands + if not commands and self.orders: + commands = order_intents_to_lifecycle_commands(self.orders) + if not commands: + raw_positions = self.positions if self.positions is not None else self.signals + if raw_positions is None: + raise ValueError("native_event v2 requires order_commands, orders, signals, positions, or a basket") + generated_orders = tuple( + _build_market_rebalance_orders( + datetime_index=idx, + positions=_as_series_map(raw_positions, symbols), + closes=closes, + alloc_per_trade=self.alloc_per_trade, + hedge_type=self.hedge_type, + use_pyramiding=self.use_pyramiding, + symbols=symbols, + ) + ) + commands = order_intents_to_lifecycle_commands(generated_orders) + return backend.run_order_commands( + datetime_index=idx, + commands=commands, + closes=closes, + highs=highs, + lows=lows, + funding_rate=self.funding_rate, + contract_size=self.contract_size, + leverage=self.leverage, + symbols=symbols, + instruments=self.instruments, + qty_step=self.qty_step, + lot_size=self.lot_size, + slot_size=self.slot_size, + min_qty=self.min_qty, + min_notional=self.min_notional, + ) + orders = self.orders if not orders: raw_positions = self.positions if self.positions is not None else self.signals @@ -265,8 +307,13 @@ def _run_nautilus(self) -> BacktestResultV2: trade_notional = self.alloc_per_trade if not isinstance(self.alloc_per_trade, dict) else next( iter(self.alloc_per_trade.values()) ) - if self.orders: + if self.orders or self.order_commands: idx, closes, highs, lows, symbols = self._market_data() + package_orders = self.orders + input_mode = "explicit_orders" + if self.order_commands: + package_orders = _commands_to_package_order_intents(self.order_commands) + input_mode = "lifecycle_commands" data = _frames_for_nautilus( data=self.data, datetime_index=idx, @@ -292,14 +339,17 @@ def _run_nautilus(self) -> BacktestResultV2: ) else: config = replace(config, **updates) + package_params = { + "input_mode": input_mode, + "order_count_input": int(len(package_orders)), + } + if self.order_commands: + package_params["command_count_input"] = int(len(self.order_commands)) return NautilusBacktestEngine(config).run_order_packages( data=data, - orders=self.orders, + orders=package_orders, symbols=symbols, - params={ - "input_mode": "explicit_orders", - "order_count_input": int(len(self.orders)), - }, + params=package_params, ) data = _single_frame(self.data) @@ -819,6 +869,41 @@ def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, return {s: float(value) for s in symbols} +def _commands_to_package_order_intents(commands: Sequence[OrderCommand]) -> Tuple[OrderIntent, ...]: + orders: List[OrderIntent] = [] + for command in commands: + if command.action not in (OrderAction.PLACE, OrderAction.REPLACE): + continue + if command.symbol is None or command.side is None or command.order_type is None or command.qty is None: + continue + metadata = { + **dict(command.metadata), + "command_action": command.action.value, + "target_order_id": command.target_order_id, + "parent_order_id": command.parent_order_id, + "group_id": command.group_id, + "oco_group_id": command.oco_group_id, + "activation_policy": command.activation_policy.value, + } + orders.append( + OrderIntent( + timestamp=command.timestamp, + symbol=command.symbol, + side=command.side, + order_type=command.order_type, + qty=float(command.qty), + price=command.price, + trigger_price=command.trigger_price, + tif=command.tif, + reduce_only=command.reduce_only, + order_id=command.order_id, + tag=command.tag, + metadata=metadata, + ) + ) + return tuple(orders) + + def _first_signal(value: Optional[Union[pd.Series, SeriesMap]]) -> Optional[pd.Series]: if value is None: return None diff --git a/tests/test_phase30c_native_event_endpoint_lifecycle.py b/tests/test_phase30c_native_event_endpoint_lifecycle.py new file mode 100644 index 0000000..9bc9ebd --- /dev/null +++ b/tests/test_phase30c_native_event_endpoint_lifecycle.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import pandas as pd + +from quantbt import ( + AccountConfig, + BacktestResultV2, + BracketOrderSpec, + DcaGridSpec, + OrderAction, + OrderCommand, + OrderIntent, + OrderSide, + OrderType, + QuantBTEndpoint, + TimeInForce, +) +from quantbt.core.event import ORDER_STATUS_CANCELED, ORDER_STATUS_FILLED + + +def _bars() -> pd.DataFrame: + idx = pd.date_range("2024-01-01", periods=6, freq="1h", tz="UTC") + return pd.DataFrame( + { + "open": [100.0, 100.0, 103.0, 108.0, 96.0, 100.0], + "high": [100.0, 101.0, 106.0, 111.0, 100.0, 101.0], + "low": [100.0, 99.0, 98.0, 94.0, 89.0, 99.0], + "close": [100.0, 100.0, 103.0, 108.0, 96.0, 100.0], + "volume": 1_000.0, + }, + index=idx, + ) + + +def test_endpoint_native_event_lifecycle_accepts_order_commands(): + df = _bars() + commands = [ + OrderCommand( + timestamp=df.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + qty=1.0, + price=90.0, + tif=TimeInForce.GTC, + order_id="entry", + ), + OrderCommand( + timestamp=df.index[2], + action=OrderAction.AMEND, + target_order_id="entry", + price=99.0, + ), + ] + + endpoint = QuantBTEndpoint.native_event_lifecycle( + initial_capital=10_000.0, + leverage=10.0, + use_funding=False, + ) + result = endpoint.simulate(data=df, order_commands=commands, symbols=["BTC"]) + + assert result.metadata["engine"] == "event_v2_lifecycle" + assert len(result.fills) == 1 + assert result.fills[0].order_id == "entry" + assert result.fills[0].price == 99.0 + assert "amend" in set(result.metadata["order_events"]["event_name"]) + assert "command_report" in result.metadata + + +def test_endpoint_orders_v2_converts_legacy_intents_to_lifecycle_place_commands(): + df = _bars() + orders = [ + OrderIntent( + timestamp=df.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + tif=TimeInForce.IOC, + order_id="entry", + ) + ] + + endpoint = QuantBTEndpoint.orders( + backend="native_event", + event_engine_version="v2", + initial_capital=10_000.0, + leverage=10.0, + use_funding=False, + ) + result = endpoint.simulate(data=df, orders=orders, symbols=["BTC"]) + + assert result.metadata["engine"] == "event_v2_lifecycle" + assert len(result.fills) == 1 + assert result.fills[0].order_id == "entry" + + +def test_endpoint_native_event_bracket_uses_parent_oco_lifecycle(): + df = _bars() + spec = BracketOrderSpec( + package_id="bracket-1", + entry_timestamp=df.index[1], + symbol="BTC", + side=OrderSide.BUY, + qty=1.0, + entry_order_type=OrderType.MARKET, + entry_tif=TimeInForce.IOC, + take_profit_price=110.0, + stop_loss_price=93.0, + ) + + endpoint = QuantBTEndpoint.native_event_bracket_orders( + spec=spec, + initial_capital=10_000.0, + leverage=10.0, + use_funding=False, + ) + result = endpoint.simulate(data=df) + report = result.metadata["command_report"].sort_values("original_index") + + assert result.metadata["engine"] == "event_v2_bracket_oco" + assert [fill.order_id for fill in result.fills] == ["bracket-1:entry", "bracket-1:take-profit"] + assert int(report.iloc[1]["status"]) == ORDER_STATUS_FILLED + assert int(report.iloc[2]["status"]) == ORDER_STATUS_CANCELED + assert "activate" in set(result.metadata["order_events"]["event_name"]) + + +def test_endpoint_native_event_dca_grid_reports_grid_fills_and_oco_cancel(): + df = _bars() + spec = DcaGridSpec( + package_id="dca-1", + entry_timestamp=df.index[1], + symbol="BTC", + side=OrderSide.BUY, + base_qty=1.0, + safety_order_count=2, + safety_qty=1.0, + step_pct=0.05, + take_profit_price=110.0, + stop_loss_price=88.0, + ) + + endpoint = QuantBTEndpoint.native_event_dca_grid( + spec=spec, + initial_capital=10_000.0, + leverage=10.0, + use_funding=False, + ) + result = endpoint.simulate(data=df) + + fill_ids = [fill.order_id for fill in result.fills] + assert "dca-1:base" in fill_ids + assert "dca-1:safety-1" in fill_ids + assert "dca-1:safety-2" in fill_ids + assert result.metadata["engine"] == "event_v2_dca_grid" + assert result.metadata["lifecycle_command_count"] >= 5 + assert not result.metadata["command_report"].empty + + +def test_endpoint_orders_nautilus_accepts_order_commands_via_package_payload(monkeypatch): + import quantbt.adapters.nautilus as nautilus_module + + df = _bars() + captured = {} + + class FakeNautilusBacktestEngine: + def __init__(self, config): + captured["config"] = config + + def run_order_packages(self, data, orders, symbols, params=None): + captured["orders"] = tuple(orders) + captured["symbols"] = list(symbols) + captured["params"] = dict(params or {}) + idx = next(iter(data.values())).index + equity = pd.Series(10_000.0, index=idx, name="equity") + positions = pd.DataFrame({"Position_BTC": 0.0}, index=idx) + closes = pd.DataFrame({"Close_BTC": data["BTC"]["close"]}, index=idx) + return BacktestResultV2( + equity=equity, + returns=equity.pct_change().fillna(0.0), + positions=positions, + closes=closes, + symbols=["BTC"], + initial_capital=10_000.0, + metadata={"backend": "nautilus", "engine": "fake"}, + ) + + monkeypatch.setattr(nautilus_module, "NautilusBacktestEngine", FakeNautilusBacktestEngine) + + endpoint = QuantBTEndpoint.orders( + backend="nautilus", + initial_capital=10_000.0, + use_funding=False, + ) + commands = [ + OrderCommand( + timestamp=df.index[1], + symbol="BTC", + side=OrderSide.BUY, + order_type=OrderType.MARKET, + qty=1.0, + order_id="entry", + ), + OrderCommand(timestamp=df.index[2], action=OrderAction.CANCEL, target_order_id="entry"), + ] + + endpoint.simulate(data=df, order_commands=commands, symbols=["BTC"]) + + assert captured["params"]["input_mode"] == "lifecycle_commands" + assert captured["params"]["command_count_input"] == 2 + assert len(captured["orders"]) == 1 + assert captured["orders"][0].order_id == "entry" + assert captured["orders"][0].metadata["command_action"] == "place" diff --git a/upgrade/implement.md b/upgrade/implement.md index ad56936..6aa1e7b 100644 --- a/upgrade/implement.md +++ b/upgrade/implement.md @@ -3378,7 +3378,7 @@ Technical debt after Phase 30B: ### Phase 30C - Endpoint, Nautilus Adapter, And Structured Package Parity -Status: planned. +Status: completed. Plan: @@ -3399,6 +3399,58 @@ Exit criteria: - Package-level reports include fills, cancels, rejects, and linked-order status for stakeholder audit. +Implemented: + +- Added endpoint-level lifecycle route: + - `QuantBTEndpoint.native_event_lifecycle(...)`; + - `QuantBTEndpoint.orders(event_engine_version="v2", ...)`; + - `simulate(..., order_commands=[OrderCommand(...), ...])`. +- Added `BacktestEngineV2` support for: + - `order_commands`; + - `event_engine_version`; + - native-event v2 lifecycle execution; + - legacy `OrderIntent` to lifecycle `PLACE` conversion when v2 is requested. +- Added structured native-event v2 endpoints: + - `QuantBTEndpoint.native_event_dca_grid(...)`; + - `QuantBTEndpoint.native_event_bracket_orders(...)`. +- Added canonical metadata converter: + - `order_intents_to_lifecycle_commands(...)`; + - lifts parent/OCO/package metadata into explicit command fields; + - limits OCO linkage to reduce-only/exit legs so base/safety orders are not + accidentally canceled. +- Aligned Nautilus adapter payloads: + - `QuantBTEndpoint.orders(backend="nautilus")` accepts `order_commands`; + - executable `PLACE` and `REPLACE` commands are converted into Nautilus + package `OrderIntent` payloads; + - legacy Nautilus `orders=[OrderIntent(...)]` params remain unchanged. +- Updated endpoint/order-fill docs and Nautilus support matrix. + +Latest tests: + +- Phase 30A/30B/30C lifecycle suites: `23 passed`. +- Endpoint/Nautilus compatibility subsets: `50 passed`. +- Native-event v1 parity/performance subset: `11 passed`. +- Full non-real regression: `418 passed, 1 skipped, 3 warnings`. + +Final Phase 30 conclusion: + +- Native-event v2 is usable for deterministic OHLC lifecycle research and + package audit through opt-in endpoint/backend routes. +- Existing v1 `OrderIntent` endpoint behavior remains stable and default. +- Structured DCA/grid and bracket/OCO packages can now run through native-event + v2 with command reports and event logs. +- Nautilus adapter is command-payload aligned for executable package orders, + but exchange-native cancel/amend command parity remains future work. + +Remaining out-of-scope debt: + +- Partial fills, queue priority, latency, and L2 depth are still handled by + separate depth/preflight approximations, not the v2 OHLC kernel. +- Nautilus parity for true cancel/replace/amend lifecycle requires a dedicated + Nautilus strategy upgrade and real Nautilus package runs. +- Portfolio/arbitrage package command conversion can be deepened later where + linked lifecycle state materially changes the strategy behavior. + --- ## Backend Selection Guide