quantbt-engine 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,613 @@
1
+ """
2
+ Execution-depth preflight for Nautilus-style package validation.
3
+
4
+ This module is intentionally dependency-free from NautilusTrader. It gives
5
+ QuantBT a deterministic, fast, auditable preflight layer for package orders
6
+ before a heavier event backend is used.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, replace
12
+ from typing import Dict, Iterable, Optional, Sequence, Tuple
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from .orders import OrderIntent
18
+ from .schema import OrderSide, OrderType
19
+
20
+
21
+ SUPPORTED_DEPTH_MODELS = ("ohlcv_volume_cap", "synthetic_book", "l2_replay")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NautilusExecutionDepthConfig:
26
+ """
27
+ Optional execution-depth policy for package-order validation.
28
+
29
+ Defaults are intentionally conservative and mostly observational: existing
30
+ endpoints are unchanged unless callers explicitly run this preflight layer.
31
+ """
32
+
33
+ all_or_none_packages: bool = False
34
+ all_or_none_package_types: Tuple[str, ...] = ("basket_package", "arbitrage_package")
35
+ allow_partial_fills: bool = False
36
+ max_participation_rate: Optional[float] = None
37
+ queue_ahead_qty: float = 0.0
38
+ latency_bars: int = 0
39
+ depth_model: str = "ohlcv_volume_cap"
40
+ synthetic_spread_bps: float = 2.0
41
+ synthetic_level_spacing_bps: Optional[float] = None
42
+ synthetic_levels: int = 5
43
+ synthetic_base_depth_qty: Optional[float] = None
44
+ synthetic_base_depth_notional: Optional[float] = None
45
+ synthetic_depth_slope: float = 0.0
46
+ activate_oco_after_entry_fill: bool = True
47
+ cancel_oco_sibling_on_first_exit_fill: bool = True
48
+ cap_reduce_only_to_position: bool = True
49
+ metadata: Dict = field(default_factory=dict)
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.depth_model not in SUPPORTED_DEPTH_MODELS:
53
+ raise ValueError(f"depth_model must be one of {SUPPORTED_DEPTH_MODELS}")
54
+ if self.max_participation_rate is not None and not 0.0 <= self.max_participation_rate <= 1.0:
55
+ raise ValueError("max_participation_rate must be in [0, 1]")
56
+ if self.queue_ahead_qty < 0.0:
57
+ raise ValueError("queue_ahead_qty must be >= 0")
58
+ if self.latency_bars < 0:
59
+ raise ValueError("latency_bars must be >= 0")
60
+ if self.synthetic_spread_bps < 0.0:
61
+ raise ValueError("synthetic_spread_bps must be >= 0")
62
+ if self.synthetic_level_spacing_bps is not None and self.synthetic_level_spacing_bps < 0.0:
63
+ raise ValueError("synthetic_level_spacing_bps must be >= 0")
64
+ if self.synthetic_levels <= 0:
65
+ raise ValueError("synthetic_levels must be > 0")
66
+ if self.synthetic_base_depth_qty is not None and self.synthetic_base_depth_qty <= 0.0:
67
+ raise ValueError("synthetic_base_depth_qty must be > 0")
68
+ if self.synthetic_base_depth_notional is not None and self.synthetic_base_depth_notional <= 0.0:
69
+ raise ValueError("synthetic_base_depth_notional must be > 0")
70
+ if self.synthetic_depth_slope < -1.0:
71
+ raise ValueError("synthetic_depth_slope must be >= -1")
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class PackageDepthPreflightResult:
76
+ orders: Tuple[OrderIntent, ...]
77
+ order_report: pd.DataFrame
78
+ package_report: pd.DataFrame
79
+ metadata: Dict = field(default_factory=dict)
80
+
81
+
82
+ def l2_replay_available(provider: object = None) -> bool:
83
+ """
84
+ Return whether a real L2 replay provider is configured.
85
+
86
+ QuantBT intentionally does not synthesize Level-3 venue claims. A provider
87
+ must expose venue snapshots, incremental book updates, and trade prints.
88
+ """
89
+ required = ("snapshots", "updates", "trades")
90
+ return provider is not None and all(hasattr(provider, name) for name in required)
91
+
92
+
93
+ def simulate_nautilus_order_package_depth(
94
+ orders: Sequence[OrderIntent],
95
+ data: Dict[str, pd.DataFrame],
96
+ config: Optional[NautilusExecutionDepthConfig] = None,
97
+ ) -> PackageDepthPreflightResult:
98
+ """
99
+ Simulate lightweight package execution constraints on OHLCV bars.
100
+
101
+ This is not a full matching engine. It is a deterministic package preflight
102
+ for domain checks that Nautilus package routes need before deeper adapter
103
+ integration: touch eligibility, latency, queue/volume caps, partial fills,
104
+ reduce-only caps, OCO sibling cancellation, and all-or-none package reject.
105
+ """
106
+ cfg = config or NautilusExecutionDepthConfig()
107
+ if cfg.depth_model == "l2_replay":
108
+ raise NotImplementedError(
109
+ "depth_model='l2_replay' requires real venue L2 snapshots, incremental updates, "
110
+ "trade prints, and a provider adapter. Use depth_model='synthetic_book' for deterministic stress tests."
111
+ )
112
+ if not orders:
113
+ return PackageDepthPreflightResult(
114
+ orders=tuple(),
115
+ order_report=_empty_order_report(),
116
+ package_report=_empty_package_report(),
117
+ metadata={"accepted_orders": 0, "input_orders": 0},
118
+ )
119
+
120
+ frames = _normalize_data(data)
121
+ states = _State()
122
+ accepted: list[OrderIntent] = []
123
+ rows: list[Dict] = []
124
+ package_rows: list[Dict] = []
125
+
126
+ planned = [_PlannedOrder(order=order, effective_timestamp=_effective_timestamp(order, frames, cfg)) for order in orders]
127
+ planned.sort(key=lambda item: (item.effective_timestamp.value if item.effective_timestamp is not None else np.iinfo(np.int64).max))
128
+
129
+ groups: Dict[Tuple[pd.Timestamp, str], list[_PlannedOrder]] = {}
130
+ singles: list[_PlannedOrder] = []
131
+ for item in planned:
132
+ package_id = _package_id(item.order)
133
+ package_type = _package_type(item.order)
134
+ if cfg.all_or_none_packages and package_type in set(cfg.all_or_none_package_types) and package_id:
135
+ key = (item.effective_timestamp or _utc_timestamp(item.order.timestamp), package_id)
136
+ groups.setdefault(key, []).append(item)
137
+ else:
138
+ singles.append(item)
139
+
140
+ timeline = sorted(
141
+ [(key[0], "group", key, values) for key, values in groups.items()]
142
+ + [(item.effective_timestamp or _utc_timestamp(item.order.timestamp), "single", None, [item]) for item in singles],
143
+ key=lambda value: value[0].value,
144
+ )
145
+
146
+ for _, kind, group_key, items in timeline:
147
+ if kind == "group":
148
+ trial = states.copy()
149
+ trial_rows: list[Dict] = []
150
+ trial_orders: list[OrderIntent] = []
151
+ for item in items:
152
+ evaluated = _evaluate_order(item, frames, cfg, trial)
153
+ trial_rows.append(evaluated.row)
154
+ if evaluated.accepted_order is not None:
155
+ trial_orders.append(evaluated.accepted_order)
156
+ group_ok = bool(trial_orders) and all(row["status"] == "filled" for row in trial_rows)
157
+ package_id = group_key[1] if group_key else ""
158
+ if group_ok:
159
+ states = trial
160
+ accepted.extend(trial_orders)
161
+ rows.extend(trial_rows)
162
+ package_rows.append(_package_row(package_id, items, "accepted", "all_or_none_filled"))
163
+ else:
164
+ for row in trial_rows:
165
+ rejected = dict(row)
166
+ rejected["status"] = "rejected"
167
+ rejected["reject_reason"] = "all_or_none_package_rejected"
168
+ rejected["filled_qty"] = 0.0
169
+ rows.append(rejected)
170
+ package_rows.append(_package_row(package_id, items, "rejected", "all_or_none_package_rejected"))
171
+ continue
172
+
173
+ item = items[0]
174
+ evaluated = _evaluate_order(item, frames, cfg, states)
175
+ rows.append(evaluated.row)
176
+ if evaluated.accepted_order is not None:
177
+ accepted.append(evaluated.accepted_order)
178
+
179
+ order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS)
180
+ package_report = pd.DataFrame(package_rows, columns=_PACKAGE_REPORT_COLUMNS)
181
+ metadata = {
182
+ "input_orders": int(len(orders)),
183
+ "accepted_orders": int(len(accepted)),
184
+ "rejected_orders": int((order_report["status"] == "rejected").sum()) if not order_report.empty else 0,
185
+ "partial_orders": int((order_report["status"] == "partial").sum()) if not order_report.empty else 0,
186
+ "canceled_orders": int((order_report["status"] == "canceled").sum()) if not order_report.empty else 0,
187
+ "latency_bars": int(cfg.latency_bars),
188
+ "allow_partial_fills": bool(cfg.allow_partial_fills),
189
+ "all_or_none_packages": bool(cfg.all_or_none_packages),
190
+ "depth_model": str(cfg.depth_model),
191
+ "supported_depth_models": SUPPORTED_DEPTH_MODELS,
192
+ **cfg.metadata,
193
+ }
194
+ return PackageDepthPreflightResult(
195
+ orders=tuple(accepted),
196
+ order_report=order_report,
197
+ package_report=package_report,
198
+ metadata=metadata,
199
+ )
200
+
201
+
202
+ @dataclass
203
+ class _State:
204
+ position: Dict[str, float] = field(default_factory=dict)
205
+ filled_tags: set[str] = field(default_factory=set)
206
+ canceled_oco_groups: set[str] = field(default_factory=set)
207
+ filled_oco_groups: set[str] = field(default_factory=set)
208
+
209
+ def copy(self) -> "_State":
210
+ return _State(
211
+ position=dict(self.position),
212
+ filled_tags=set(self.filled_tags),
213
+ canceled_oco_groups=set(self.canceled_oco_groups),
214
+ filled_oco_groups=set(self.filled_oco_groups),
215
+ )
216
+
217
+
218
+ @dataclass(frozen=True)
219
+ class _PlannedOrder:
220
+ order: OrderIntent
221
+ effective_timestamp: Optional[pd.Timestamp]
222
+
223
+
224
+ @dataclass(frozen=True)
225
+ class _EvaluatedOrder:
226
+ row: Dict
227
+ accepted_order: Optional[OrderIntent]
228
+
229
+
230
+ @dataclass(frozen=True)
231
+ class _DepthFill:
232
+ fillable: bool
233
+ fill_price: float
234
+ reason: str
235
+ available_qty: float
236
+ levels_consumed: int = 0
237
+ participation_cap_qty: float = np.nan
238
+
239
+
240
+ _ORDER_REPORT_COLUMNS = [
241
+ "timestamp",
242
+ "effective_timestamp",
243
+ "symbol",
244
+ "side",
245
+ "order_type",
246
+ "qty",
247
+ "filled_qty",
248
+ "fill_price",
249
+ "status",
250
+ "reject_reason",
251
+ "package_id",
252
+ "package_type",
253
+ "leg_role",
254
+ "oco_group_id",
255
+ "latency_bars",
256
+ "available_qty",
257
+ "depth_model",
258
+ "levels_consumed",
259
+ "spread_bps",
260
+ "queue_ahead_qty",
261
+ "participation_cap_qty",
262
+ "requested_notional",
263
+ "filled_notional",
264
+ ]
265
+
266
+ _PACKAGE_REPORT_COLUMNS = ["package_id", "package_type", "timestamp", "orders", "status", "reason"]
267
+
268
+
269
+ def _evaluate_order(
270
+ item: _PlannedOrder,
271
+ frames: Dict[str, pd.DataFrame],
272
+ cfg: NautilusExecutionDepthConfig,
273
+ state: _State,
274
+ ) -> _EvaluatedOrder:
275
+ order = item.order
276
+ ts = item.effective_timestamp
277
+ base = _base_row(order, ts, cfg)
278
+ if ts is None:
279
+ return _reject(base, "latency_out_of_range")
280
+ if order.symbol not in frames:
281
+ return _reject(base, "missing_symbol_data")
282
+ frame = frames[order.symbol]
283
+ if ts not in frame.index:
284
+ return _reject(base, "timestamp_not_in_data")
285
+ if _is_oco_exit(order) and cfg.activate_oco_after_entry_fill:
286
+ parent_tag = order.metadata.get("parent_tag")
287
+ if parent_tag and parent_tag not in state.filled_tags:
288
+ return _reject(base, "parent_entry_not_filled")
289
+ oco_group = order.metadata.get("oco_group_id")
290
+ if oco_group and oco_group in state.canceled_oco_groups:
291
+ row = {**base, "status": "canceled", "reject_reason": "oco_sibling_already_filled"}
292
+ return _EvaluatedOrder(row=row, accepted_order=None)
293
+
294
+ bar = frame.loc[ts]
295
+ depth_fill = _evaluate_depth_fill(order, bar, cfg)
296
+ if not depth_fill.fillable:
297
+ return _reject(base, depth_fill.reason)
298
+
299
+ available = depth_fill.available_qty
300
+ requested = float(order.qty)
301
+ reduce_only_capped = False
302
+ if order.reduce_only and cfg.cap_reduce_only_to_position:
303
+ current = float(state.position.get(order.symbol, 0.0))
304
+ if current == 0.0 or np.sign(current) == order.side.sign:
305
+ return _reject({**base, "available_qty": available}, "reduce_only_no_opposite_position")
306
+ available = min(available, abs(current))
307
+ reduce_only_capped = available < requested
308
+
309
+ if available <= 0.0:
310
+ return _reject({**base, "available_qty": available}, "no_queue_capacity")
311
+ filled_qty = min(requested, available)
312
+ if filled_qty < requested and not cfg.allow_partial_fills and not reduce_only_capped:
313
+ return _reject({**base, "available_qty": available}, "insufficient_queue_capacity")
314
+
315
+ status = "partial" if filled_qty < requested else "filled"
316
+ accepted_order = order
317
+ if filled_qty != requested or ts != _utc_timestamp(order.timestamp):
318
+ metadata = {
319
+ **order.metadata,
320
+ "depth_original_qty": requested,
321
+ "depth_effective_timestamp": ts,
322
+ "depth_status": status,
323
+ "depth_model": cfg.depth_model,
324
+ }
325
+ accepted_order = replace(order, timestamp=ts, qty=float(filled_qty), metadata=metadata)
326
+
327
+ _commit_fill(state, accepted_order)
328
+ if accepted_order.tag:
329
+ state.filled_tags.add(accepted_order.tag)
330
+ if oco_group and _is_oco_exit(order) and cfg.cancel_oco_sibling_on_first_exit_fill:
331
+ state.filled_oco_groups.add(str(oco_group))
332
+ state.canceled_oco_groups.add(str(oco_group))
333
+
334
+ row = {
335
+ **base,
336
+ "filled_qty": float(filled_qty),
337
+ "fill_price": float(depth_fill.fill_price),
338
+ "status": status,
339
+ "reject_reason": "",
340
+ "available_qty": float(available),
341
+ "levels_consumed": int(depth_fill.levels_consumed),
342
+ "participation_cap_qty": float(depth_fill.participation_cap_qty),
343
+ "requested_notional": float(requested * depth_fill.fill_price),
344
+ "filled_notional": float(filled_qty * depth_fill.fill_price),
345
+ }
346
+ return _EvaluatedOrder(row=row, accepted_order=accepted_order)
347
+
348
+
349
+ def _commit_fill(state: _State, order: OrderIntent) -> None:
350
+ current = float(state.position.get(order.symbol, 0.0))
351
+ delta = float(order.qty) * order.side.sign
352
+ if order.reduce_only and current != 0.0 and np.sign(current) != order.side.sign:
353
+ delta = np.sign(delta) * min(abs(delta), abs(current))
354
+ state.position[order.symbol] = current + delta
355
+
356
+
357
+ def _fillability(order: OrderIntent, bar: pd.Series) -> tuple[bool, float, str]:
358
+ high = float(bar["high"])
359
+ low = float(bar["low"])
360
+ close = float(bar["close"])
361
+ if order.order_type is OrderType.MARKET:
362
+ return True, close, ""
363
+ if order.order_type is OrderType.LIMIT:
364
+ price = float(order.price)
365
+ touched = low <= price if order.side is OrderSide.BUY else high >= price
366
+ return touched, price, "" if touched else "limit_not_touched"
367
+ if order.order_type is OrderType.STOP_MARKET:
368
+ trigger = float(order.trigger_price)
369
+ touched = high >= trigger if order.side is OrderSide.BUY else low <= trigger
370
+ return touched, trigger, "" if touched else "stop_not_triggered"
371
+ if order.order_type is OrderType.STOP_LIMIT:
372
+ trigger = float(order.trigger_price)
373
+ price = float(order.price)
374
+ triggered = high >= trigger if order.side is OrderSide.BUY else low <= trigger
375
+ touched = low <= price if order.side is OrderSide.BUY else high >= price
376
+ ok = triggered and touched
377
+ return ok, price, "" if ok else "stop_limit_not_triggered_or_touched"
378
+ return False, np.nan, "unsupported_order_type"
379
+
380
+
381
+ def _evaluate_depth_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill:
382
+ if cfg.depth_model == "synthetic_book":
383
+ return _synthetic_book_fill(order, bar, cfg)
384
+
385
+ fillable, fill_price, reason = _fillability(order, bar)
386
+ if not fillable:
387
+ return _DepthFill(False, fill_price, reason, 0.0)
388
+ available = _available_qty(order, bar, cfg)
389
+ participation_cap = _participation_cap_qty(bar, cfg)
390
+ return _DepthFill(
391
+ fillable=True,
392
+ fill_price=float(fill_price),
393
+ reason="",
394
+ available_qty=float(available),
395
+ levels_consumed=1,
396
+ participation_cap_qty=participation_cap,
397
+ )
398
+
399
+
400
+ def _synthetic_book_fill(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> _DepthFill:
401
+ eligible, executable_price, reason = _fillability(order, bar)
402
+ if not eligible:
403
+ return _DepthFill(False, executable_price, reason, 0.0)
404
+
405
+ close = float(bar["close"])
406
+ if not np.isfinite(close) or close <= 0.0:
407
+ return _DepthFill(False, np.nan, "invalid_close_for_synthetic_book", 0.0)
408
+
409
+ levels = _synthetic_book_levels(order, close, cfg)
410
+ if order.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT):
411
+ limit_price = float(order.price)
412
+ if order.side is OrderSide.BUY:
413
+ levels = tuple((price, qty) for price, qty in levels if price <= limit_price)
414
+ else:
415
+ levels = tuple((price, qty) for price, qty in levels if price >= limit_price)
416
+
417
+ participation_cap = _participation_cap_qty(bar, cfg)
418
+ requested = float(order.qty)
419
+ target_qty = min(requested, participation_cap) if np.isfinite(participation_cap) else requested
420
+ if target_qty <= 0.0:
421
+ return _DepthFill(True, executable_price, "", 0.0, participation_cap_qty=participation_cap)
422
+
423
+ remaining_queue = float(cfg.queue_ahead_qty)
424
+ remaining = target_qty
425
+ filled = 0.0
426
+ notional = 0.0
427
+ consumed = 0
428
+ for price, level_qty in levels:
429
+ qty_after_queue = float(level_qty)
430
+ if remaining_queue > 0.0:
431
+ queue_take = min(qty_after_queue, remaining_queue)
432
+ qty_after_queue -= queue_take
433
+ remaining_queue -= queue_take
434
+ if qty_after_queue <= 0.0:
435
+ consumed += 1
436
+ continue
437
+ take = min(remaining, qty_after_queue)
438
+ if take <= 0.0:
439
+ break
440
+ filled += take
441
+ notional += take * float(price)
442
+ remaining -= take
443
+ consumed += 1
444
+ if remaining <= 1e-15:
445
+ break
446
+
447
+ if filled <= 0.0:
448
+ return _DepthFill(True, executable_price, "", 0.0, levels_consumed=consumed, participation_cap_qty=participation_cap)
449
+ return _DepthFill(
450
+ fillable=True,
451
+ fill_price=float(notional / filled),
452
+ reason="",
453
+ available_qty=float(filled),
454
+ levels_consumed=int(consumed),
455
+ participation_cap_qty=participation_cap,
456
+ )
457
+
458
+
459
+ def _synthetic_book_levels(
460
+ order: OrderIntent,
461
+ reference_price: float,
462
+ cfg: NautilusExecutionDepthConfig,
463
+ ) -> Tuple[Tuple[float, float], ...]:
464
+ half_spread = reference_price * float(cfg.synthetic_spread_bps) / 20_000.0
465
+ spacing_bps = cfg.synthetic_level_spacing_bps
466
+ if spacing_bps is None:
467
+ spacing_bps = max(float(cfg.synthetic_spread_bps), 1.0)
468
+ spacing = reference_price * float(spacing_bps) / 10_000.0
469
+
470
+ if cfg.synthetic_base_depth_qty is not None:
471
+ base_qty = float(cfg.synthetic_base_depth_qty)
472
+ elif cfg.synthetic_base_depth_notional is not None:
473
+ base_qty = float(cfg.synthetic_base_depth_notional) / reference_price
474
+ else:
475
+ base_qty = float(order.qty)
476
+
477
+ out: list[Tuple[float, float]] = []
478
+ for level in range(int(cfg.synthetic_levels)):
479
+ if order.side is OrderSide.BUY:
480
+ price = reference_price + half_spread + level * spacing
481
+ else:
482
+ price = reference_price - half_spread - level * spacing
483
+ qty_multiplier = max(0.0, 1.0 + float(cfg.synthetic_depth_slope) * level)
484
+ out.append((float(price), float(base_qty * qty_multiplier)))
485
+ return tuple(out)
486
+
487
+
488
+ def _available_qty(order: OrderIntent, bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float:
489
+ if cfg.max_participation_rate is None:
490
+ return float(order.qty)
491
+ volume = float(bar.get("volume", 0.0))
492
+ capacity = max(0.0, volume * float(cfg.max_participation_rate) - float(cfg.queue_ahead_qty))
493
+ return min(float(order.qty), capacity)
494
+
495
+
496
+ def _participation_cap_qty(bar: pd.Series, cfg: NautilusExecutionDepthConfig) -> float:
497
+ if cfg.max_participation_rate is None:
498
+ return np.nan
499
+ volume = float(bar.get("volume", 0.0))
500
+ return max(0.0, volume * float(cfg.max_participation_rate))
501
+
502
+
503
+ def _effective_timestamp(
504
+ order: OrderIntent,
505
+ frames: Dict[str, pd.DataFrame],
506
+ cfg: NautilusExecutionDepthConfig,
507
+ ) -> Optional[pd.Timestamp]:
508
+ ts = _utc_timestamp(order.timestamp)
509
+ if cfg.latency_bars == 0:
510
+ return ts
511
+ frame = frames.get(order.symbol)
512
+ if frame is None or frame.empty:
513
+ return None
514
+ index = frame.index
515
+ pos = index.searchsorted(ts)
516
+ if pos >= len(index) or index[pos] != ts:
517
+ return None
518
+ target = pos + int(cfg.latency_bars)
519
+ if target >= len(index):
520
+ return None
521
+ return pd.Timestamp(index[target])
522
+
523
+
524
+ def _normalize_data(data: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
525
+ out = {}
526
+ for symbol, frame in data.items():
527
+ df = frame.copy()
528
+ if not isinstance(df.index, pd.DatetimeIndex):
529
+ df.index = pd.to_datetime(df.index)
530
+ df.index = df.index.tz_localize("UTC") if df.index.tz is None else df.index.tz_convert("UTC")
531
+ rename = {col: str(col).lower() for col in df.columns}
532
+ df = df.rename(columns=rename)
533
+ if "close" not in df:
534
+ raise ValueError(f"data for {symbol!r} must include close")
535
+ for col in ("open", "high", "low", "volume"):
536
+ if col not in df:
537
+ df[col] = df["close"] if col != "volume" else 0.0
538
+ out[symbol] = df[["open", "high", "low", "close", "volume"]].sort_index()
539
+ return out
540
+
541
+
542
+ def _base_row(order: OrderIntent, effective_timestamp: Optional[pd.Timestamp], cfg: NautilusExecutionDepthConfig) -> Dict:
543
+ return {
544
+ "timestamp": _utc_timestamp(order.timestamp),
545
+ "effective_timestamp": effective_timestamp,
546
+ "symbol": order.symbol,
547
+ "side": order.side.value if isinstance(order.side, OrderSide) else str(order.side),
548
+ "order_type": order.order_type.value if isinstance(order.order_type, OrderType) else str(order.order_type),
549
+ "qty": float(order.qty),
550
+ "filled_qty": 0.0,
551
+ "fill_price": np.nan,
552
+ "status": "pending",
553
+ "reject_reason": "",
554
+ "package_id": _package_id(order),
555
+ "package_type": _package_type(order),
556
+ "leg_role": order.metadata.get("leg_role"),
557
+ "oco_group_id": order.metadata.get("oco_group_id"),
558
+ "latency_bars": int(cfg.latency_bars),
559
+ "available_qty": np.nan,
560
+ "depth_model": str(cfg.depth_model),
561
+ "levels_consumed": 0,
562
+ "spread_bps": float(cfg.synthetic_spread_bps) if cfg.depth_model == "synthetic_book" else np.nan,
563
+ "queue_ahead_qty": float(cfg.queue_ahead_qty),
564
+ "participation_cap_qty": np.nan,
565
+ "requested_notional": np.nan,
566
+ "filled_notional": 0.0,
567
+ }
568
+
569
+
570
+ def _reject(base: Dict, reason: str) -> _EvaluatedOrder:
571
+ return _EvaluatedOrder(row={**base, "status": "rejected", "reject_reason": reason}, accepted_order=None)
572
+
573
+
574
+ def _package_row(package_id: str, items: Sequence[_PlannedOrder], status: str, reason: str) -> Dict:
575
+ first = items[0].order if items else None
576
+ ts = _utc_timestamp(first.timestamp) if first is not None else pd.NaT
577
+ return {
578
+ "package_id": package_id,
579
+ "package_type": _package_type(first) if first is not None else "",
580
+ "timestamp": ts,
581
+ "orders": int(len(items)),
582
+ "status": status,
583
+ "reason": reason,
584
+ }
585
+
586
+
587
+ def _is_oco_exit(order: OrderIntent) -> bool:
588
+ return order.metadata.get("leg_role") in {"take_profit", "stop_loss"} and bool(order.metadata.get("oco_group_id"))
589
+
590
+
591
+ def _package_id(order: Optional[OrderIntent]) -> str:
592
+ if order is None:
593
+ return ""
594
+ return str(order.metadata.get("package_id") or order.metadata.get("basket_id") or order.metadata.get("arb_id") or "")
595
+
596
+
597
+ def _package_type(order: Optional[OrderIntent]) -> str:
598
+ if order is None:
599
+ return ""
600
+ return str(order.metadata.get("package_type") or order.metadata.get("structured_type") or "")
601
+
602
+
603
+ def _utc_timestamp(value) -> pd.Timestamp:
604
+ ts = pd.Timestamp(value)
605
+ return ts.tz_localize("UTC") if ts.tz is None else ts.tz_convert("UTC")
606
+
607
+
608
+ def _empty_order_report() -> pd.DataFrame:
609
+ return pd.DataFrame(columns=_ORDER_REPORT_COLUMNS)
610
+
611
+
612
+ def _empty_package_report() -> pd.DataFrame:
613
+ return pd.DataFrame(columns=_PACKAGE_REPORT_COLUMNS)