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,600 @@
1
+ """
2
+ Snapshot-level option package execution.
3
+
4
+ Phase 4 is an execution simulator on a prepared option tape. It is intentionally
5
+ not the final multi-currency ledger, margin, expiry, or Nautilus adapter.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import Enum
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ import pandas as pd
15
+
16
+ from ..core.orders import Fill, OrderIntent
17
+ from ..core.schema import LiquiditySide, OrderSide, OrderType, TimeInForce
18
+ from .packages import OptionPackageExecutionPolicy, OptionPackageIntent, compile_option_package_orders
19
+ from .tape import PreparedOptionTape
20
+
21
+
22
+ class OptionLimitFidelity(str, Enum):
23
+ CROSS_ONLY = "cross_only"
24
+ MAKER_TOUCH = "maker_touch"
25
+
26
+
27
+ class OptionDepthFidelity(str, Enum):
28
+ TOP_OF_BOOK = "top_of_book"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class OptionExecutionConfig:
33
+ initial_cash: float = 0.0
34
+ fee_rate: float = 0.0
35
+ allow_partial_fill: bool = True
36
+ max_quote_age_ns: Optional[int] = None
37
+ limit_fidelity: OptionLimitFidelity = OptionLimitFidelity.CROSS_ONLY
38
+ depth_fidelity: OptionDepthFidelity = OptionDepthFidelity.TOP_OF_BOOK
39
+ metadata: Dict = field(default_factory=dict)
40
+
41
+ def __post_init__(self) -> None:
42
+ object.__setattr__(self, "limit_fidelity", _coerce_enum(OptionLimitFidelity, self.limit_fidelity, "limit_fidelity"))
43
+ object.__setattr__(self, "depth_fidelity", _coerce_enum(OptionDepthFidelity, self.depth_fidelity, "depth_fidelity"))
44
+ if self.fee_rate < 0.0:
45
+ raise ValueError("fee_rate must be >= 0")
46
+ if self.max_quote_age_ns is not None and self.max_quote_age_ns < 0:
47
+ raise ValueError("max_quote_age_ns must be >= 0")
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class OptionPackageExecutionResult:
52
+ fills: Tuple[Fill, ...]
53
+ order_report: pd.DataFrame
54
+ package_report: pd.DataFrame
55
+ cash: float
56
+ positions: Dict[str, float]
57
+ margin_report: Dict
58
+ metadata: Dict = field(default_factory=dict)
59
+
60
+
61
+ @dataclass
62
+ class _ExecutionState:
63
+ cash: float
64
+ positions: Dict[str, float]
65
+
66
+ def copy(self) -> "_ExecutionState":
67
+ return _ExecutionState(cash=float(self.cash), positions=dict(self.positions))
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class _OrderEvaluation:
72
+ fill: Optional[Fill]
73
+ row: Dict
74
+ cash_delta: float
75
+ position_delta: float
76
+
77
+
78
+ _ORDER_REPORT_COLUMNS = [
79
+ "package_id",
80
+ "order_id",
81
+ "symbol",
82
+ "side",
83
+ "order_type",
84
+ "tif",
85
+ "requested_qty",
86
+ "filled_qty",
87
+ "residual_qty",
88
+ "fill_price",
89
+ "fee",
90
+ "cash_delta",
91
+ "status",
92
+ "reject_reason",
93
+ "liquidity",
94
+ "snapshot_timestamp_ns",
95
+ "decision_timestamp_ns",
96
+ "row_index",
97
+ "depth_fidelity",
98
+ "limit_fidelity",
99
+ "residual_risk",
100
+ "atomicity",
101
+ ]
102
+
103
+ _PACKAGE_REPORT_COLUMNS = [
104
+ "package_id",
105
+ "execution_policy",
106
+ "status",
107
+ "reject_reason",
108
+ "requested_orders",
109
+ "filled_orders",
110
+ "partial_orders",
111
+ "cash_before",
112
+ "cash_after",
113
+ "net_cash_delta",
114
+ "gross_premium",
115
+ "debit",
116
+ "credit",
117
+ "max_debit",
118
+ "min_credit",
119
+ "atomicity",
120
+ "exchange_combo",
121
+ "block_trade_style",
122
+ "depth_fidelity",
123
+ ]
124
+
125
+
126
+ def execute_option_package(
127
+ package: OptionPackageIntent,
128
+ tape: PreparedOptionTape,
129
+ *,
130
+ config: Optional[OptionExecutionConfig] = None,
131
+ positions: Optional[Dict[str, float]] = None,
132
+ compiled_orders: Optional[Tuple[OrderIntent, ...]] = None,
133
+ ) -> OptionPackageExecutionResult:
134
+ """Execute one option package against the latest observable tape snapshot."""
135
+ cfg = config or OptionExecutionConfig()
136
+ state = _ExecutionState(cash=float(cfg.initial_cash), positions=dict(positions or {}))
137
+ orders = tuple(compiled_orders) if compiled_orders is not None else compile_option_package_orders(package)
138
+ policy = package.execution_policy
139
+ if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE:
140
+ return _execute_atomic_all_or_none(package, orders, tape, cfg, state)
141
+ if policy is OptionPackageExecutionPolicy.BEST_EFFORT:
142
+ return _execute_best_effort(package, orders, tape, cfg, state)
143
+ if policy is OptionPackageExecutionPolicy.SEQUENTIAL:
144
+ return _execute_sequential(package, orders, tape, cfg, state)
145
+ if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY:
146
+ return _execute_hedge_after_primary(package, orders, tape, cfg, state)
147
+ if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY:
148
+ return _execute_rebalance_only(package, orders, tape, cfg, state)
149
+ raise ValueError(f"unsupported option execution policy: {policy}")
150
+
151
+
152
+ def _execute_atomic_all_or_none(
153
+ package: OptionPackageIntent,
154
+ orders: Tuple[OrderIntent, ...],
155
+ tape: PreparedOptionTape,
156
+ cfg: OptionExecutionConfig,
157
+ state: _ExecutionState,
158
+ ) -> OptionPackageExecutionResult:
159
+ trial = state.copy()
160
+ evaluations = [_evaluate_order(order, tape, cfg, trial, package.package_id) for order in orders]
161
+ all_full = all(row.row["status"] == "filled" for row in evaluations)
162
+ guard_ok, guard_reason = _package_cash_guard(package, evaluations)
163
+ if not all_full or not guard_ok:
164
+ reason = guard_reason or "atomic_all_or_none_unfilled_leg"
165
+ rows = [_rejected_row(ev.row, reason) for ev in evaluations]
166
+ return _final_result(package, cfg, state, state, [], rows, "rejected", reason)
167
+ fills = []
168
+ for ev in evaluations:
169
+ _apply_evaluation(trial, ev)
170
+ fills.append(ev.fill)
171
+ return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], "filled", "")
172
+
173
+
174
+ def _execute_best_effort(
175
+ package: OptionPackageIntent,
176
+ orders: Tuple[OrderIntent, ...],
177
+ tape: PreparedOptionTape,
178
+ cfg: OptionExecutionConfig,
179
+ state: _ExecutionState,
180
+ ) -> OptionPackageExecutionResult:
181
+ trial = state.copy()
182
+ fills: List[Fill] = []
183
+ evaluations = []
184
+ for order in orders:
185
+ ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
186
+ evaluations.append(ev)
187
+ if ev.fill is not None:
188
+ _apply_evaluation(trial, ev)
189
+ fills.append(ev.fill)
190
+ guard_ok, guard_reason = _package_cash_guard(package, evaluations)
191
+ if not guard_ok:
192
+ rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
193
+ return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
194
+ status = _package_status(evaluations)
195
+ return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
196
+
197
+
198
+ def _execute_sequential(
199
+ package: OptionPackageIntent,
200
+ orders: Tuple[OrderIntent, ...],
201
+ tape: PreparedOptionTape,
202
+ cfg: OptionExecutionConfig,
203
+ state: _ExecutionState,
204
+ ) -> OptionPackageExecutionResult:
205
+ trial = state.copy()
206
+ fills: List[Fill] = []
207
+ evaluations = []
208
+ stopped = False
209
+ for order in orders:
210
+ if stopped:
211
+ row = _base_skipped_row(package.package_id, order, "sequential_previous_leg_failed", cfg)
212
+ evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0))
213
+ continue
214
+ ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
215
+ evaluations.append(ev)
216
+ if ev.fill is not None:
217
+ _apply_evaluation(trial, ev)
218
+ fills.append(ev.fill)
219
+ if ev.row["status"] not in {"filled", "partial"}:
220
+ stopped = True
221
+ guard_ok, guard_reason = _package_cash_guard(package, evaluations)
222
+ if not guard_ok:
223
+ rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
224
+ return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
225
+ status = _package_status(evaluations)
226
+ return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
227
+
228
+
229
+ def _execute_hedge_after_primary(
230
+ package: OptionPackageIntent,
231
+ orders: Tuple[OrderIntent, ...],
232
+ tape: PreparedOptionTape,
233
+ cfg: OptionExecutionConfig,
234
+ state: _ExecutionState,
235
+ ) -> OptionPackageExecutionResult:
236
+ trial = state.copy()
237
+ fills: List[Fill] = []
238
+ evaluations = []
239
+ primary = next((order for order in orders if order.metadata.get("option_leg_role") == "primary"), orders[0])
240
+ hedge_orders = tuple(order for order in orders if order is not primary)
241
+ primary_ev = _evaluate_order(primary, tape, cfg, trial, package.package_id)
242
+ evaluations.append(primary_ev)
243
+ if primary_ev.row["status"] != "filled":
244
+ rows = [primary_ev.row] + [_base_skipped_row(package.package_id, order, "primary_not_filled", cfg) for order in hedge_orders]
245
+ return _final_result(package, cfg, state, state, [], rows, "rejected", "primary_not_filled")
246
+ _apply_evaluation(trial, primary_ev)
247
+ fills.append(primary_ev.fill)
248
+ for order in hedge_orders:
249
+ ev = _evaluate_order(order, tape, cfg, trial, package.package_id)
250
+ evaluations.append(ev)
251
+ if ev.fill is not None:
252
+ _apply_evaluation(trial, ev)
253
+ fills.append(ev.fill)
254
+ guard_ok, guard_reason = _package_cash_guard(package, evaluations)
255
+ if not guard_ok:
256
+ rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
257
+ return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
258
+ status = _package_status(evaluations)
259
+ return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
260
+
261
+
262
+ def _execute_rebalance_only(
263
+ package: OptionPackageIntent,
264
+ orders: Tuple[OrderIntent, ...],
265
+ tape: PreparedOptionTape,
266
+ cfg: OptionExecutionConfig,
267
+ state: _ExecutionState,
268
+ ) -> OptionPackageExecutionResult:
269
+ trial = state.copy()
270
+ fills: List[Fill] = []
271
+ evaluations = []
272
+ for order in orders:
273
+ target_signed = float(order.side.sign) * float(order.qty)
274
+ current = float(trial.positions.get(order.symbol, 0.0))
275
+ delta = target_signed - current
276
+ if abs(delta) <= 1e-12:
277
+ row = _base_skipped_row(package.package_id, order, "already_at_target", cfg)
278
+ row["status"] = "no_op"
279
+ evaluations.append(_OrderEvaluation(fill=None, row=row, cash_delta=0.0, position_delta=0.0))
280
+ continue
281
+ adjusted = OrderIntent(
282
+ timestamp=order.timestamp,
283
+ symbol=order.symbol,
284
+ side=OrderSide.BUY if delta > 0 else OrderSide.SELL,
285
+ order_type=order.order_type,
286
+ qty=abs(delta),
287
+ price=order.price,
288
+ tif=order.tif,
289
+ tag=order.tag,
290
+ metadata={**order.metadata, "rebalance_target_signed_qty": target_signed, "rebalance_current_qty": current},
291
+ )
292
+ ev = _evaluate_order(adjusted, tape, cfg, trial, package.package_id)
293
+ evaluations.append(ev)
294
+ if ev.fill is not None:
295
+ _apply_evaluation(trial, ev)
296
+ fills.append(ev.fill)
297
+ guard_ok, guard_reason = _package_cash_guard(package, evaluations)
298
+ if not guard_ok:
299
+ rows = [_rejected_row(ev.row, guard_reason) for ev in evaluations]
300
+ return _final_result(package, cfg, state, state, [], rows, "rejected", guard_reason)
301
+ status = _package_status(evaluations)
302
+ return _final_result(package, cfg, state, trial, fills, [ev.row for ev in evaluations], status, "")
303
+
304
+
305
+ def _evaluate_order(
306
+ order: OrderIntent,
307
+ tape: PreparedOptionTape,
308
+ cfg: OptionExecutionConfig,
309
+ state: _ExecutionState,
310
+ package_id: str,
311
+ ) -> _OrderEvaluation:
312
+ snapshot_index = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns)
313
+ rows = tape.snapshot_slice(snapshot_index)
314
+ row_index = _find_row_index(tape, rows, order.symbol)
315
+ if row_index is None:
316
+ return _OrderEvaluation(None, _base_rejected_row(package_id, order, "instrument_not_listed_at_snapshot", cfg), 0.0, 0.0)
317
+ fill_price, liquidity, fillable, reason = _fill_price(order, tape, row_index, cfg)
318
+ if not fillable:
319
+ return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, 0.0, "open", reason, liquidity), 0.0, 0.0)
320
+ available = _available_qty(order, tape, row_index)
321
+ fill_qty = min(float(order.qty), available)
322
+ residual = float(order.qty) - fill_qty
323
+ if fill_qty <= 0.0:
324
+ return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "no_top_of_book_size", liquidity), 0.0, 0.0)
325
+ if residual > 1e-12 and order.tif is TimeInForce.FOK:
326
+ return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "fok_insufficient_size", liquidity), 0.0, 0.0)
327
+ if residual > 1e-12 and order.tif is TimeInForce.IOC:
328
+ if not cfg.allow_partial_fill:
329
+ return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "rejected", "ioc_partial_not_allowed", liquidity), 0.0, 0.0)
330
+ status = "partial"
331
+ reason = "ioc_residual_canceled"
332
+ elif residual > 1e-12 and order.tif is TimeInForce.GTC:
333
+ if not cfg.allow_partial_fill:
334
+ return _OrderEvaluation(None, _row_from_order(package_id, order, tape, row_index, cfg, 0.0, fill_price, "open", "gtc_waiting_for_size", liquidity), 0.0, 0.0)
335
+ status = "partial"
336
+ reason = "gtc_residual_open"
337
+ else:
338
+ status = "filled"
339
+ reason = ""
340
+ fee = fill_qty * fill_price * cfg.fee_rate
341
+ cash_delta = fill_qty * fill_price - fee if order.side is OrderSide.SELL else -(fill_qty * fill_price + fee)
342
+ position_delta = order.side.sign * fill_qty
343
+ fill = Fill(
344
+ timestamp=tape.timestamp_ns[snapshot_index],
345
+ symbol=order.symbol,
346
+ side=order.side,
347
+ qty=fill_qty,
348
+ price=fill_price,
349
+ fee=fee,
350
+ liquidity=liquidity,
351
+ order_id=order.order_id,
352
+ metadata={**order.metadata, "option_row_index": int(row_index), "package_id": package_id},
353
+ )
354
+ row = _row_from_order(package_id, order, tape, row_index, cfg, fill_qty, fill_price, status, reason, liquidity, fee=fee, cash_delta=cash_delta)
355
+ return _OrderEvaluation(fill, row, cash_delta, position_delta)
356
+
357
+
358
+ def _fill_price(
359
+ order: OrderIntent,
360
+ tape: PreparedOptionTape,
361
+ row_index: int,
362
+ cfg: OptionExecutionConfig,
363
+ ) -> tuple[float, LiquiditySide, bool, str]:
364
+ bid = float(tape.bid_price[row_index])
365
+ ask = float(tape.ask_price[row_index])
366
+ if order.order_type is OrderType.MARKET:
367
+ return (ask if order.side is OrderSide.BUY else bid), LiquiditySide.TAKER, True, ""
368
+ if order.order_type is not OrderType.LIMIT:
369
+ return float("nan"), LiquiditySide.TAKER, False, "unsupported_option_order_type"
370
+ limit = float(order.price)
371
+ if cfg.limit_fidelity is OptionLimitFidelity.CROSS_ONLY:
372
+ if order.side is OrderSide.BUY and limit >= ask:
373
+ return ask, LiquiditySide.TAKER, True, ""
374
+ if order.side is OrderSide.SELL and limit <= bid:
375
+ return bid, LiquiditySide.TAKER, True, ""
376
+ return limit, LiquiditySide.MAKER, False, "limit_not_crossed"
377
+ if order.side is OrderSide.BUY and limit >= bid:
378
+ return min(limit, ask), LiquiditySide.MAKER if limit < ask else LiquiditySide.TAKER, True, "maker_touch_simulated"
379
+ if order.side is OrderSide.SELL and limit <= ask:
380
+ return max(limit, bid), LiquiditySide.MAKER if limit > bid else LiquiditySide.TAKER, True, "maker_touch_simulated"
381
+ return limit, LiquiditySide.MAKER, False, "limit_not_touched"
382
+
383
+
384
+ def _available_qty(order: OrderIntent, tape: PreparedOptionTape, row_index: int) -> float:
385
+ return float(tape.ask_size[row_index] if order.side is OrderSide.BUY else tape.bid_size[row_index])
386
+
387
+
388
+ def _find_row_index(tape: PreparedOptionTape, rows: slice, symbol: str) -> Optional[int]:
389
+ for idx in range(rows.start, rows.stop):
390
+ if tape.instrument_id[idx] == symbol:
391
+ return idx
392
+ return None
393
+
394
+
395
+ def _apply_evaluation(state: _ExecutionState, evaluation: _OrderEvaluation) -> None:
396
+ if evaluation.fill is None:
397
+ return
398
+ state.cash += float(evaluation.cash_delta)
399
+ state.positions[evaluation.fill.symbol] = state.positions.get(evaluation.fill.symbol, 0.0) + evaluation.position_delta
400
+
401
+
402
+ def _package_cash_guard(package: OptionPackageIntent, evaluations: List[_OrderEvaluation]) -> tuple[bool, str]:
403
+ net_cash_delta = sum(ev.cash_delta for ev in evaluations if ev.fill is not None)
404
+ debit = max(-net_cash_delta, 0.0)
405
+ credit = max(net_cash_delta, 0.0)
406
+ if package.max_debit is not None and debit > float(package.max_debit) + 1e-12:
407
+ return False, "max_debit_exceeded"
408
+ if package.min_credit is not None and credit + 1e-12 < float(package.min_credit):
409
+ return False, "min_credit_not_met"
410
+ return True, ""
411
+
412
+
413
+ def _final_result(
414
+ package: OptionPackageIntent,
415
+ cfg: OptionExecutionConfig,
416
+ initial_state: _ExecutionState,
417
+ final_state: _ExecutionState,
418
+ fills: List[Optional[Fill]],
419
+ rows: List[Dict],
420
+ package_status: str,
421
+ reject_reason: str,
422
+ ) -> OptionPackageExecutionResult:
423
+ concrete_fills = tuple(fill for fill in fills if fill is not None)
424
+ order_report = pd.DataFrame(rows, columns=_ORDER_REPORT_COLUMNS)
425
+ filled_orders = int((order_report["status"] == "filled").sum()) if not order_report.empty else 0
426
+ partial_orders = int((order_report["status"] == "partial").sum()) if not order_report.empty else 0
427
+ net_cash_delta = float(final_state.cash - initial_state.cash)
428
+ gross_premium = float(order_report["filled_qty"].mul(order_report["fill_price"]).sum()) if not order_report.empty else 0.0
429
+ package_report = pd.DataFrame(
430
+ [
431
+ {
432
+ "package_id": package.package_id,
433
+ "execution_policy": package.execution_policy.value,
434
+ "status": package_status,
435
+ "reject_reason": reject_reason,
436
+ "requested_orders": len(package.legs),
437
+ "filled_orders": filled_orders,
438
+ "partial_orders": partial_orders,
439
+ "cash_before": initial_state.cash,
440
+ "cash_after": final_state.cash,
441
+ "net_cash_delta": net_cash_delta,
442
+ "gross_premium": gross_premium,
443
+ "debit": max(-net_cash_delta, 0.0),
444
+ "credit": max(net_cash_delta, 0.0),
445
+ "max_debit": package.max_debit,
446
+ "min_credit": package.min_credit,
447
+ "atomicity": _atomicity_for_report(package.execution_policy),
448
+ "exchange_combo": False,
449
+ "block_trade_style": False,
450
+ "depth_fidelity": cfg.depth_fidelity.value,
451
+ }
452
+ ],
453
+ columns=_PACKAGE_REPORT_COLUMNS,
454
+ )
455
+ positions = {symbol: qty for symbol, qty in final_state.positions.items() if abs(qty) > 1e-12}
456
+ return OptionPackageExecutionResult(
457
+ fills=concrete_fills,
458
+ order_report=order_report,
459
+ package_report=package_report,
460
+ cash=float(final_state.cash),
461
+ positions=positions,
462
+ margin_report={
463
+ "phase": "phase4_snapshot_execution",
464
+ "margin_model": "not_implemented_until_phase5",
465
+ "gross_premium": gross_premium,
466
+ "position_count": len(positions),
467
+ },
468
+ metadata={
469
+ "backend": "native_option_phase4",
470
+ "execution_scope": "snapshot_package_execution",
471
+ "depth_fidelity": cfg.depth_fidelity.value,
472
+ "limit_fidelity": cfg.limit_fidelity.value,
473
+ "atomicity": _atomicity_for_report(package.execution_policy),
474
+ **cfg.metadata,
475
+ },
476
+ )
477
+
478
+
479
+ def _package_status(evaluations: List[_OrderEvaluation]) -> str:
480
+ statuses = [ev.row["status"] for ev in evaluations]
481
+ if statuses and all(status == "filled" for status in statuses):
482
+ return "filled"
483
+ if any(status == "partial" for status in statuses):
484
+ return "partial"
485
+ if any(status == "filled" for status in statuses):
486
+ return "partial"
487
+ if any(status == "open" for status in statuses):
488
+ return "open"
489
+ return "rejected"
490
+
491
+
492
+ def _row_from_order(
493
+ package_id: str,
494
+ order: OrderIntent,
495
+ tape: PreparedOptionTape,
496
+ row_index: int,
497
+ cfg: OptionExecutionConfig,
498
+ filled_qty: float,
499
+ fill_price: float,
500
+ status: str,
501
+ reject_reason: str,
502
+ liquidity: LiquiditySide,
503
+ *,
504
+ fee: float = 0.0,
505
+ cash_delta: float = 0.0,
506
+ ) -> Dict:
507
+ snapshot_idx = tape.snapshot_index_at_or_before(int(order.timestamp), max_quote_age_ns=cfg.max_quote_age_ns)
508
+ return {
509
+ "package_id": package_id,
510
+ "order_id": order.order_id,
511
+ "symbol": order.symbol,
512
+ "side": order.side.value,
513
+ "order_type": order.order_type.value,
514
+ "tif": order.tif.value,
515
+ "requested_qty": float(order.qty),
516
+ "filled_qty": float(filled_qty),
517
+ "residual_qty": max(float(order.qty) - float(filled_qty), 0.0),
518
+ "fill_price": float(fill_price),
519
+ "fee": float(fee),
520
+ "cash_delta": float(cash_delta),
521
+ "status": status,
522
+ "reject_reason": reject_reason,
523
+ "liquidity": liquidity.value,
524
+ "snapshot_timestamp_ns": int(tape.timestamp_ns[snapshot_idx]),
525
+ "decision_timestamp_ns": int(order.timestamp),
526
+ "row_index": int(row_index),
527
+ "depth_fidelity": cfg.depth_fidelity.value,
528
+ "limit_fidelity": cfg.limit_fidelity.value,
529
+ "residual_risk": bool(status == "partial"),
530
+ "atomicity": order.metadata.get("atomicity", ""),
531
+ }
532
+
533
+
534
+ def _base_rejected_row(package_id: str, order: OrderIntent, reason: str, cfg: OptionExecutionConfig) -> Dict:
535
+ return _base_skipped_row(package_id, order, reason, cfg, status="rejected")
536
+
537
+
538
+ def _base_skipped_row(
539
+ package_id: str,
540
+ order: OrderIntent,
541
+ reason: str,
542
+ cfg: OptionExecutionConfig,
543
+ *,
544
+ status: str = "skipped",
545
+ ) -> Dict:
546
+ return {
547
+ "package_id": package_id,
548
+ "order_id": order.order_id,
549
+ "symbol": order.symbol,
550
+ "side": order.side.value,
551
+ "order_type": order.order_type.value,
552
+ "tif": order.tif.value,
553
+ "requested_qty": float(order.qty),
554
+ "filled_qty": 0.0,
555
+ "residual_qty": float(order.qty),
556
+ "fill_price": float("nan"),
557
+ "fee": 0.0,
558
+ "cash_delta": 0.0,
559
+ "status": status,
560
+ "reject_reason": reason,
561
+ "liquidity": "",
562
+ "snapshot_timestamp_ns": 0,
563
+ "decision_timestamp_ns": int(order.timestamp),
564
+ "row_index": -1,
565
+ "depth_fidelity": cfg.depth_fidelity.value,
566
+ "limit_fidelity": cfg.limit_fidelity.value,
567
+ "residual_risk": False,
568
+ "atomicity": order.metadata.get("atomicity", ""),
569
+ }
570
+
571
+
572
+ def _rejected_row(row: Dict, reason: str) -> Dict:
573
+ rejected = dict(row)
574
+ rejected["status"] = "rejected"
575
+ rejected["reject_reason"] = reason
576
+ rejected["filled_qty"] = 0.0
577
+ rejected["residual_qty"] = rejected["requested_qty"]
578
+ rejected["fee"] = 0.0
579
+ rejected["cash_delta"] = 0.0
580
+ rejected["residual_risk"] = False
581
+ return rejected
582
+
583
+
584
+ def _atomicity_for_report(policy: OptionPackageExecutionPolicy) -> str:
585
+ if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE:
586
+ return "simulated_atomic_all_or_none"
587
+ if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY:
588
+ return "simulated_primary_then_hedge"
589
+ if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY:
590
+ return "simulated_rebalance_only"
591
+ return f"simulated_{policy.value}"
592
+
593
+
594
+ def _coerce_enum(enum_cls, value, field_name: str):
595
+ if isinstance(value, enum_cls):
596
+ return value
597
+ try:
598
+ return enum_cls(str(value))
599
+ except ValueError as exc:
600
+ raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc