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,796 @@
1
+ """
2
+ Native option backend facade.
3
+
4
+ This backend wires the Phase 1-6 option components into the common QuantBT
5
+ result contract. It does not attempt to be a venue-exact options exchange; the
6
+ venue-specific gaps stay explicit in reports and metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ import hashlib
13
+ from typing import Dict, Iterable, Mapping, Optional, Sequence
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from ..core.results import OptionBacktestResult
19
+ from ..core.schema import AccountConfig, ExecutionConfig
20
+ from ..options.cache import OptionPreparedRunCache
21
+ from ..options.execution import OptionExecutionConfig, execute_option_package
22
+ from ..options.fees import OptionFeeResult, OptionFeeSchedule, calculate_option_fee
23
+ from ..options.hedging import OptionHedgeConfig, run_delta_hedge_path
24
+ from ..options.ledger import OptionLedger
25
+ from ..options.lifecycle import OptionSettlementRepresentation, settle_option_expiry
26
+ from ..options.margin import OptionMarginConfig, OptionMarginRequirement, calculate_option_margin
27
+ from ..options.packages import OptionPackageIntent
28
+ from ..options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
29
+ from ..options.tape import PreparedOptionTape, prepare_option_tape
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class NativeOptionConfig:
34
+ account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0))
35
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
36
+ option_execution: OptionExecutionConfig = field(default_factory=OptionExecutionConfig)
37
+ margin: OptionMarginConfig = field(default_factory=OptionMarginConfig)
38
+ fee_schedule: Optional[OptionFeeSchedule] = None
39
+ reporting_currency: str = "USD"
40
+ initial_balances: Optional[Dict[str, float]] = None
41
+ conversion_rates: Dict[str, float] = field(default_factory=dict)
42
+ settle_expired: bool = False
43
+ max_spread_bps: Optional[float] = None
44
+ max_source_latency_ns: Optional[int] = None
45
+ random_seed: Optional[int] = 42
46
+ metadata: Dict = field(default_factory=dict)
47
+
48
+ def __post_init__(self) -> None:
49
+ object.__setattr__(self, "reporting_currency", str(self.reporting_currency).upper())
50
+ if self.account.initial_capital <= 0.0:
51
+ raise ValueError("account.initial_capital must be > 0")
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class OptionSettlementEvent:
56
+ symbol: str
57
+ timestamp_ns: int
58
+ settlement_price: float
59
+ representation: Optional[OptionSettlementRepresentation] = None
60
+
61
+
62
+ class NativeOptionBackend:
63
+ """Array-first native option backend returning `OptionBacktestResult`."""
64
+
65
+ def __init__(self, config: Optional[NativeOptionConfig] = None):
66
+ self.config = config or NativeOptionConfig()
67
+
68
+ def run(
69
+ self,
70
+ *,
71
+ chain: pd.DataFrame,
72
+ instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
73
+ packages: Sequence[OptionPackageIntent] = (),
74
+ prepared_tape: Optional[PreparedOptionTape] = None,
75
+ prepared_cache: Optional[OptionPreparedRunCache] = None,
76
+ underlying: Optional[pd.DataFrame | pd.Series] = None,
77
+ hedge_policy: Optional[OptionHedgeConfig] = None,
78
+ net_option_delta: Optional[pd.Series] = None,
79
+ settlement_events: Optional[Sequence[OptionSettlementEvent | Mapping]] = None,
80
+ conversion_rates: Optional[Dict[str, float]] = None,
81
+ reporting_currency: Optional[str] = None,
82
+ ) -> OptionBacktestResult:
83
+ registry = _normalize_registry(instruments)
84
+ if prepared_cache is not None:
85
+ prepared_cache.validate(registry)
86
+ tape = prepared_cache.tape
87
+ else:
88
+ tape = prepared_tape or prepare_option_tape(
89
+ chain,
90
+ registry,
91
+ max_spread_bps=self.config.max_spread_bps,
92
+ max_source_latency_ns=self.config.max_source_latency_ns,
93
+ )
94
+ tape.validate_compatible(registry_signature=registry.signature)
95
+ rates = {**self.config.conversion_rates, **(conversion_rates or {})}
96
+ report_ccy = str(reporting_currency or self.config.reporting_currency).upper()
97
+ if report_ccy not in rates:
98
+ rates[report_ccy] = 1.0
99
+
100
+ ledger = OptionLedger.from_cash(self.config.initial_balances or {report_ccy: self.config.account.initial_capital})
101
+ instrument_map = registry.by_symbol
102
+ packages_sorted = tuple(sorted(packages or (), key=lambda package: int(package.timestamp_ns)))
103
+ order_reports = []
104
+ package_reports = []
105
+ applied_fills = []
106
+ snapshots = []
107
+
108
+ snapshots.append(_snapshot_state(tape, 0, ledger, instrument_map, rates, report_ccy, "initial"))
109
+ for package in packages_sorted:
110
+ pkg_result = execute_option_package(
111
+ package,
112
+ tape,
113
+ config=self.config.option_execution,
114
+ positions={symbol: position.qty for symbol, position in ledger.positions.items()},
115
+ compiled_orders=prepared_cache.compile_package(package) if prepared_cache is not None else None,
116
+ )
117
+ order_reports.append(pkg_result.order_report)
118
+ package_reports.append(pkg_result.package_report)
119
+ for fill in pkg_result.fills:
120
+ instrument = instrument_map[fill.symbol]
121
+ fee = _option_fee(fill, instrument, tape, self.config.fee_schedule)
122
+ ledger.apply_fill(fill, instrument, fee=fee, timestamp_ns=int(fill.timestamp))
123
+ applied_fills.append((fill, fee))
124
+ snap_idx = tape.snapshot_index_at_or_before(int(package.timestamp_ns))
125
+ snapshots.append(_snapshot_state(tape, snap_idx, ledger, instrument_map, rates, report_ccy, package.package_id))
126
+
127
+ settlements = []
128
+ for event in _normalize_settlement_events(settlement_events):
129
+ instrument = instrument_map[event.symbol]
130
+ settlement = settle_option_expiry(
131
+ ledger,
132
+ instrument,
133
+ timestamp_ns=int(event.timestamp_ns),
134
+ settlement_price=float(event.settlement_price),
135
+ representation=event.representation,
136
+ )
137
+ settlements.append(settlement)
138
+ snap_idx = min(tape.snapshot_count - 1, max(0, np.searchsorted(tape.timestamp_ns, int(event.timestamp_ns), side="right") - 1))
139
+ snapshots.append(_snapshot_state(tape, int(snap_idx), ledger, instrument_map, rates, report_ccy, f"settlement:{event.symbol}"))
140
+
141
+ if self.config.settle_expired:
142
+ last_ts = int(tape.timestamp_ns[-1])
143
+ marks = _snapshot_marks(tape, tape.snapshot_count - 1)
144
+ underlyings = _snapshot_underlyings(tape, tape.snapshot_count - 1)
145
+ for symbol, position in list(ledger.positions.items()):
146
+ instrument = instrument_map[symbol]
147
+ if position.is_flat or int(instrument.expiry_ns) > last_ts:
148
+ continue
149
+ settlement = settle_option_expiry(
150
+ ledger,
151
+ instrument,
152
+ timestamp_ns=last_ts,
153
+ settlement_price=underlyings.get(instrument.underlying_id, marks.get(symbol, 0.0)),
154
+ )
155
+ settlements.append(settlement)
156
+ snapshots.append(_snapshot_state(tape, tape.snapshot_count - 1, ledger, instrument_map, rates, report_ccy, "auto_settlement"))
157
+
158
+ final_snapshot_idx = tape.snapshot_count - 1
159
+ final_marks = _snapshot_marks(tape, final_snapshot_idx)
160
+ final_underlyings = _snapshot_underlyings(tape, final_snapshot_idx)
161
+ margin = calculate_option_margin(
162
+ ledger,
163
+ instrument_map,
164
+ final_marks,
165
+ final_underlyings,
166
+ config=self.config.margin,
167
+ reporting_currency=report_ccy,
168
+ conversion_rates=rates,
169
+ )
170
+ snapshots.append(_snapshot_state(tape, final_snapshot_idx, ledger, instrument_map, rates, report_ccy, "final"))
171
+
172
+ result = _build_result(
173
+ tape=tape,
174
+ registry=registry,
175
+ ledger=ledger,
176
+ account=self.config.account,
177
+ report_ccy=report_ccy,
178
+ conversion_rates=rates,
179
+ snapshots=snapshots,
180
+ fills_with_fees=applied_fills,
181
+ order_report=_concat(order_reports),
182
+ package_report=_concat(package_reports),
183
+ settlements=settlements,
184
+ margin=margin,
185
+ metadata={
186
+ "backend": "native_option",
187
+ "engine": "native_option",
188
+ "phase": "phase7_backend_endpoint_result",
189
+ "package_count": len(packages_sorted),
190
+ "fill_count": len(applied_fills),
191
+ "settlement_count": len(settlements),
192
+ "venue_exact_margin": bool(margin.venue_exact),
193
+ "reporting_currency": report_ccy,
194
+ "prepared_cache_used": prepared_cache is not None,
195
+ "package_cache_size": 0 if prepared_cache is None else prepared_cache.package_cache_size,
196
+ "fee_schedule_id": "execution_fee_rate"
197
+ if self.config.fee_schedule is None
198
+ else self.config.fee_schedule.schedule_id,
199
+ "limit_fidelity": self.config.option_execution.limit_fidelity.value,
200
+ "depth_fidelity": self.config.option_execution.depth_fidelity.value,
201
+ "random_seed": self.config.random_seed,
202
+ **self.config.metadata,
203
+ },
204
+ )
205
+ if hedge_policy is not None:
206
+ result = _attach_delta_hedge_contract(
207
+ result,
208
+ tape=tape,
209
+ registry=registry,
210
+ underlying=underlying,
211
+ hedge_policy=hedge_policy,
212
+ net_option_delta=net_option_delta,
213
+ account=self.config.account,
214
+ report_ccy=report_ccy,
215
+ )
216
+ return result
217
+
218
+
219
+ def _normalize_registry(
220
+ instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
221
+ ) -> OptionInstrumentRegistry:
222
+ if isinstance(instruments, OptionInstrumentRegistry):
223
+ return instruments
224
+ if isinstance(instruments, Mapping):
225
+ return OptionInstrumentRegistry.from_iterable(instruments.values())
226
+ return OptionInstrumentRegistry.from_iterable(tuple(instruments))
227
+
228
+
229
+ def _normalize_settlement_events(events: Optional[Sequence[OptionSettlementEvent | Mapping]]) -> tuple[OptionSettlementEvent, ...]:
230
+ if not events:
231
+ return ()
232
+ out = []
233
+ for event in events:
234
+ if isinstance(event, OptionSettlementEvent):
235
+ out.append(event)
236
+ else:
237
+ out.append(
238
+ OptionSettlementEvent(
239
+ symbol=str(event["symbol"]),
240
+ timestamp_ns=int(event["timestamp_ns"]),
241
+ settlement_price=float(event["settlement_price"]),
242
+ representation=event.get("representation"),
243
+ )
244
+ )
245
+ return tuple(out)
246
+
247
+
248
+ def _option_fee(fill, instrument: OptionInstrumentSpec, tape: PreparedOptionTape, schedule: Optional[OptionFeeSchedule]) -> Optional[OptionFeeResult]:
249
+ schedule = schedule or fill.metadata.get("option_fee_schedule")
250
+ if schedule is None:
251
+ return None
252
+ if not isinstance(schedule, OptionFeeSchedule):
253
+ return None
254
+ row_index = int(fill.metadata.get("option_row_index", -1))
255
+ if row_index < 0:
256
+ return None
257
+ reference = float(tape.index_price[row_index] if np.isfinite(tape.index_price[row_index]) else tape.forward_price[row_index])
258
+ return calculate_option_fee(fill, instrument, schedule, reference_price=reference)
259
+
260
+
261
+ def _snapshot_state(
262
+ tape: PreparedOptionTape,
263
+ snapshot_idx: int,
264
+ ledger: OptionLedger,
265
+ instruments: Dict[str, OptionInstrumentSpec],
266
+ conversion_rates: Dict[str, float],
267
+ report_ccy: str,
268
+ label: str,
269
+ ) -> Dict:
270
+ marks = _snapshot_marks(tape, snapshot_idx)
271
+ equity = ledger.equity(conversion_rates=conversion_rates, marks=marks, instruments=instruments, reporting_currency=report_ccy)
272
+ return {
273
+ "timestamp_ns": int(tape.timestamp_ns[snapshot_idx]),
274
+ "label": label,
275
+ "equity": float(equity),
276
+ "cash": dict(ledger.cash),
277
+ "positions": {symbol: position.qty for symbol, position in ledger.positions.items()},
278
+ "marks": marks,
279
+ }
280
+
281
+
282
+ def _snapshot_marks(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]:
283
+ rows = tape.snapshot_slice(snapshot_idx)
284
+ return {tape.instrument_id[idx]: float(tape.mark_price[idx]) for idx in range(rows.start, rows.stop)}
285
+
286
+
287
+ def _snapshot_underlyings(tape: PreparedOptionTape, snapshot_idx: int) -> Dict[str, float]:
288
+ rows = tape.snapshot_slice(snapshot_idx)
289
+ out = {}
290
+ registry = tape.registry.by_symbol
291
+ for idx in range(rows.start, rows.stop):
292
+ symbol = tape.instrument_id[idx]
293
+ instrument = registry[symbol]
294
+ price = float(tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx])
295
+ out[instrument.underlying_id] = price
296
+ out[symbol] = price
297
+ return out
298
+
299
+
300
+ def _build_result(
301
+ *,
302
+ tape: PreparedOptionTape,
303
+ registry: OptionInstrumentRegistry,
304
+ ledger: OptionLedger,
305
+ account: AccountConfig,
306
+ report_ccy: str,
307
+ conversion_rates: Dict[str, float],
308
+ snapshots: Sequence[Dict],
309
+ fills_with_fees: Sequence[tuple],
310
+ order_report: pd.DataFrame,
311
+ package_report: pd.DataFrame,
312
+ settlements: Sequence,
313
+ margin: OptionMarginRequirement,
314
+ metadata: Dict,
315
+ ) -> OptionBacktestResult:
316
+ index = pd.DatetimeIndex(pd.to_datetime([snap["timestamp_ns"] for snap in snapshots], utc=True)).tz_convert(None)
317
+ equity = pd.Series([snap["equity"] for snap in snapshots], index=index, name="equity")
318
+ if len(equity.index) != len(set(equity.index)):
319
+ offsets = pd.to_timedelta(np.arange(len(equity)), unit="ns")
320
+ equity.index = pd.DatetimeIndex(equity.index + offsets)
321
+ returns = equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
322
+
323
+ symbols = list(registry.symbols)
324
+ positions = pd.DataFrame(
325
+ [{f"Position_{symbol}": snap["positions"].get(symbol, 0.0) for symbol in symbols} for snap in snapshots],
326
+ index=equity.index,
327
+ columns=[f"Position_{symbol}" for symbol in symbols],
328
+ )
329
+ closes = pd.DataFrame(
330
+ [{f"Close_{symbol}": snap["marks"].get(symbol, np.nan) for symbol in symbols} for snap in snapshots],
331
+ index=equity.index,
332
+ columns=[f"Close_{symbol}" for symbol in symbols],
333
+ ).ffill()
334
+ cash_report = _cash_report(snapshots, equity.index)
335
+ marks_report = _marks_report(tape)
336
+ greeks_report = _greeks_report(tape)
337
+ fills_report = _fills_report(fills_with_fees)
338
+ settlements_report = _settlements_report(settlements)
339
+ attribution_report = _attribution_report(ledger, account, equity.iloc[-1], report_ccy, conversion_rates)
340
+ run_manifest = {
341
+ "backend": "native_option",
342
+ "result_contract": "OptionBacktestResult",
343
+ "symbols": symbols,
344
+ "snapshot_count": int(tape.snapshot_count),
345
+ "row_count": int(tape.row_count),
346
+ "initial_capital": float(account.initial_capital),
347
+ "final_equity": float(equity.iloc[-1]),
348
+ "reporting_currency": report_ccy,
349
+ "data_hash": _chain_data_hash(marks_report),
350
+ "registry_signature_hash": _stable_hash(repr(registry.signature.signature)),
351
+ "convention_versions": sorted(
352
+ {instrument.convention_version for instrument in registry.instruments if instrument.convention_version}
353
+ ),
354
+ "fee_schedule": metadata.get("fee_schedule_id", "execution_fee_rate"),
355
+ "margin_model": str(getattr(margin.model, "value", margin.model)),
356
+ "pricing_model": "observed_chain_bid_ask_mark",
357
+ "deterministic_replay": True,
358
+ "random_seed": metadata.get("random_seed"),
359
+ "fidelity_manifest": {
360
+ "tape": "prepared_csr_option_chain",
361
+ "execution": "top_of_book_bbo",
362
+ "limit_fidelity": metadata.get("limit_fidelity"),
363
+ "depth_fidelity": metadata.get("depth_fidelity"),
364
+ "margin": str(getattr(margin.model, "value", margin.model)),
365
+ "venue_exact_margin": bool(margin.venue_exact),
366
+ "prepared_cache_used": bool(metadata.get("prepared_cache_used", False)),
367
+ },
368
+ "option_reports": [
369
+ "fills_report",
370
+ "packages_report",
371
+ "cash_report",
372
+ "marks_report",
373
+ "greeks_report",
374
+ "settlements_report",
375
+ "margin_report",
376
+ "attribution_report",
377
+ ],
378
+ }
379
+ result_metadata = {
380
+ **metadata,
381
+ "order_report": order_report,
382
+ "fills_report": fills_report,
383
+ "packages_report": package_report,
384
+ "cash_report": cash_report,
385
+ "marks_report": marks_report,
386
+ "greeks_report": greeks_report,
387
+ "settlements_report": settlements_report,
388
+ "margin_report": margin.detail_report,
389
+ "attribution_report": attribution_report,
390
+ "run_manifest": run_manifest,
391
+ "ledger_event_report": ledger.event_report(),
392
+ "equity_identity": ledger.equity_identity_report(
393
+ conversion_rates=conversion_rates,
394
+ marks=_snapshot_marks(tape, tape.snapshot_count - 1),
395
+ instruments=registry.by_symbol,
396
+ reporting_currency=report_ccy,
397
+ ),
398
+ }
399
+ fees = pd.Series(0.0, index=equity.index, name="fees")
400
+ if len(fees) > 0:
401
+ fees.iloc[-1] = float(sum((fee.fee if fee is not None else fill.fee) for fill, fee in fills_with_fees))
402
+ return OptionBacktestResult(
403
+ equity=equity,
404
+ returns=returns,
405
+ positions=positions,
406
+ closes=closes,
407
+ symbols=symbols,
408
+ initial_capital=float(account.initial_capital),
409
+ leverage=float(account.leverage),
410
+ liquidated=False,
411
+ fills=tuple(fill for fill, _ in fills_with_fees),
412
+ fees=fees,
413
+ margin=margin.detail_report,
414
+ diagnostics=package_report,
415
+ metadata=result_metadata,
416
+ fills_report=fills_report,
417
+ packages_report=package_report,
418
+ cash_report=cash_report,
419
+ marks_report=marks_report,
420
+ greeks_report=greeks_report,
421
+ settlements_report=settlements_report,
422
+ margin_report=margin.detail_report,
423
+ attribution_report=attribution_report,
424
+ run_manifest=run_manifest,
425
+ )
426
+
427
+
428
+ def _concat(frames: Iterable[pd.DataFrame]) -> pd.DataFrame:
429
+ items = [frame for frame in frames if frame is not None and not frame.empty]
430
+ return pd.concat(items, ignore_index=True) if items else pd.DataFrame()
431
+
432
+
433
+ def _chain_data_hash(frame: pd.DataFrame) -> str:
434
+ if frame.empty:
435
+ return "0"
436
+ hashed = pd.util.hash_pandas_object(frame.sort_index(axis=1), index=False).to_numpy(dtype="uint64")
437
+ return str(int(hashed.sum(dtype="uint64")))
438
+
439
+
440
+ def _stable_hash(value: str) -> str:
441
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
442
+
443
+
444
+ def _attach_delta_hedge_contract(
445
+ result: OptionBacktestResult,
446
+ *,
447
+ tape: PreparedOptionTape,
448
+ registry: OptionInstrumentRegistry,
449
+ underlying: Optional[pd.DataFrame | pd.Series],
450
+ hedge_policy: OptionHedgeConfig,
451
+ net_option_delta: Optional[pd.Series],
452
+ account: AccountConfig,
453
+ report_ccy: str,
454
+ ) -> OptionBacktestResult:
455
+ path_timestamps = np.concatenate((np.array([int(tape.timestamp_ns[0]) - 1], dtype=np.int64), tape.timestamp_ns.astype(np.int64)))
456
+ index = _datetime_index_from_ns(path_timestamps)
457
+ option_equity, positions, closes, fees = _linear_quote_option_path(
458
+ result,
459
+ tape,
460
+ registry,
461
+ account,
462
+ report_ccy,
463
+ index,
464
+ path_timestamps,
465
+ )
466
+ deltas = _normalize_net_delta(net_option_delta, result.greeks_report, positions, registry, index)
467
+ prices, underlying_source = _normalize_underlying_prices(underlying, tape, index)
468
+
469
+ hedge = run_delta_hedge_path(
470
+ timestamps_ns=list(path_timestamps),
471
+ underlying_prices=prices.to_numpy(dtype=np.float64),
472
+ net_option_deltas=deltas.to_numpy(dtype=np.float64),
473
+ config=hedge_policy,
474
+ )
475
+ hedge_report = hedge.hedge_report.copy()
476
+ hedge_report.index = index
477
+ cumulative_hedge = pd.Series(
478
+ hedge_report["cumulative_hedge_pnl"].to_numpy(dtype=np.float64),
479
+ index=index,
480
+ name="hedge_pnl",
481
+ )
482
+ combined = (option_equity + cumulative_hedge).rename("equity")
483
+ combined_returns = combined.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
484
+
485
+ result.option_equity = option_equity
486
+ result.hedge_report = hedge_report
487
+ result.combined_equity = combined
488
+ result.combined_returns = combined_returns
489
+ result.equity = combined
490
+ result.returns = combined_returns
491
+ result.positions = positions
492
+ result.closes = closes
493
+ result.fees = fees
494
+ result.metadata["option_equity"] = option_equity
495
+ result.metadata["hedge_report"] = hedge_report
496
+ result.metadata["combined_equity"] = combined
497
+ result.metadata["combined_returns"] = combined_returns
498
+ result.metadata["delta_hedge_contract"] = {
499
+ "enabled": True,
500
+ "underlying_source": underlying_source,
501
+ "policy": hedge_policy.policy.value,
502
+ "target_delta": float(hedge_policy.target_delta),
503
+ "final_hedge_qty": float(hedge.final_hedge_qty),
504
+ "hedge_pnl": float(hedge.hedge_pnl),
505
+ "hedge_rebalances": int(hedge_report["should_rebalance"].sum()) if not hedge_report.empty else 0,
506
+ "option_path_method": result.metadata.get("option_path_method", "linear_quote_replay"),
507
+ }
508
+ result.run_manifest["delta_hedge"] = result.metadata["delta_hedge_contract"]
509
+ result.run_manifest["final_equity"] = float(combined.iloc[-1])
510
+ result.metadata["run_manifest"] = result.run_manifest
511
+ return result
512
+
513
+
514
+ def _linear_quote_option_path(
515
+ result: OptionBacktestResult,
516
+ tape: PreparedOptionTape,
517
+ registry: OptionInstrumentRegistry,
518
+ account: AccountConfig,
519
+ report_ccy: str,
520
+ index: pd.DatetimeIndex,
521
+ path_timestamps: np.ndarray,
522
+ ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame, pd.Series]:
523
+ symbols = list(registry.symbols)
524
+ linear_quote_exact = all(
525
+ instrument.premium_currency.upper() == report_ccy and instrument.settlement_currency.upper() == report_ccy
526
+ for instrument in registry.instruments
527
+ )
528
+ if not linear_quote_exact:
529
+ option_equity = result.equity.reindex(index).ffill().bfill().rename("option_equity")
530
+ positions = result.positions.reindex(index).ffill().fillna(0.0)
531
+ closes = result.closes.reindex(index).ffill().bfill()
532
+ fees = result.fees.reindex(index).fillna(0.0)
533
+ result.metadata["option_path_method"] = "event_equity_reindexed_non_quote_currency"
534
+ return option_equity, positions, closes, fees
535
+
536
+ cash = float(account.initial_capital)
537
+ pos = {symbol: 0.0 for symbol in symbols}
538
+ fills = result.fills_report.sort_values("timestamp") if not result.fills_report.empty else pd.DataFrame()
539
+ fill_idx = 0
540
+ equity_rows = []
541
+ position_rows = []
542
+ close_rows = []
543
+ fee_values = []
544
+ mark_by_ts_symbol = _mark_lookup(tape)
545
+
546
+ for ts, dt in zip(path_timestamps, index):
547
+ snap_idx = max(0, int(np.searchsorted(tape.timestamp_ns, int(ts), side="right") - 1))
548
+ fee_at_ts = 0.0
549
+ while not fills.empty and fill_idx < len(fills) and int(fills.iloc[fill_idx]["timestamp"]) <= int(ts):
550
+ row = fills.iloc[fill_idx]
551
+ qty = float(row["qty"])
552
+ price = float(row["price"])
553
+ fee = float(row.get("applied_fee", row.get("execution_fee", 0.0)))
554
+ symbol = str(row["symbol"])
555
+ side = str(row["side"]).lower()
556
+ if side == "buy":
557
+ cash -= qty * price + fee
558
+ pos[symbol] = pos.get(symbol, 0.0) + qty
559
+ else:
560
+ cash += qty * price - fee
561
+ pos[symbol] = pos.get(symbol, 0.0) - qty
562
+ fee_at_ts += fee
563
+ fill_idx += 1
564
+ mark_ts = int(tape.timestamp_ns[snap_idx])
565
+ marks = {symbol: mark_by_ts_symbol.get((mark_ts, symbol), np.nan) for symbol in symbols}
566
+ marked_value = sum(pos.get(symbol, 0.0) * marks[symbol] for symbol in symbols if np.isfinite(marks[symbol]))
567
+ equity_rows.append(cash + marked_value)
568
+ position_rows.append({f"Position_{symbol}": pos.get(symbol, 0.0) for symbol in symbols})
569
+ close_rows.append({f"Close_{symbol}": marks[symbol] for symbol in symbols})
570
+ fee_values.append(fee_at_ts)
571
+
572
+ option_equity = pd.Series(equity_rows, index=index, name="option_equity")
573
+ positions = pd.DataFrame(position_rows, index=index).fillna(0.0)
574
+ closes = pd.DataFrame(close_rows, index=index).ffill().bfill()
575
+ fees = pd.Series(fee_values, index=index, name="fees")
576
+ result.metadata["option_path_method"] = "linear_quote_replay"
577
+ return option_equity, positions, closes, fees
578
+
579
+
580
+ def _normalize_net_delta(
581
+ net_option_delta: Optional[pd.Series],
582
+ greeks_report: pd.DataFrame,
583
+ positions: pd.DataFrame,
584
+ registry: OptionInstrumentRegistry,
585
+ index: pd.DatetimeIndex,
586
+ ) -> pd.Series:
587
+ if net_option_delta is not None:
588
+ series = _coerce_series_index(net_option_delta, "net_option_delta")
589
+ return series.reindex(index).ffill().bfill().fillna(0.0).rename("net_option_delta")
590
+ if greeks_report.empty:
591
+ return pd.Series(0.0, index=index, name="net_option_delta")
592
+ greeks = greeks_report.copy()
593
+ greeks["datetime"] = pd.to_datetime(greeks["timestamp_ns"], utc=True).dt.tz_convert(None)
594
+ delta = greeks.pivot_table(index="datetime", columns="instrument_id", values="delta", aggfunc="last").reindex(index).ffill()
595
+ total = pd.Series(0.0, index=index, name="net_option_delta")
596
+ instruments = registry.by_symbol
597
+ for symbol in registry.symbols:
598
+ pos_col = f"Position_{symbol}"
599
+ if pos_col not in positions or symbol not in delta:
600
+ continue
601
+ multiplier = float(instruments[symbol].multiplier)
602
+ contribution = pd.Series(
603
+ positions[pos_col].to_numpy(dtype=np.float64) * delta[symbol].fillna(0.0).to_numpy(dtype=np.float64) * multiplier,
604
+ index=index,
605
+ )
606
+ total = total.add(contribution, fill_value=0.0)
607
+ return total.fillna(0.0).rename("net_option_delta")
608
+
609
+
610
+ def _normalize_underlying_prices(
611
+ underlying: Optional[pd.DataFrame | pd.Series],
612
+ tape: PreparedOptionTape,
613
+ index: pd.DatetimeIndex,
614
+ ) -> tuple[pd.Series, str]:
615
+ if underlying is None:
616
+ tape_index = _datetime_index_from_ns(tape.timestamp_ns.astype(np.int64))
617
+ base = pd.Series(
618
+ [_snapshot_underlying_price(tape, i) for i in range(tape.snapshot_count)],
619
+ index=tape_index,
620
+ name="underlying_price",
621
+ )
622
+ return _align_price_series(base, index), "option_chain_index_price"
623
+ if isinstance(underlying, pd.Series):
624
+ series = _coerce_series_index(underlying, "underlying_price")
625
+ return _align_price_series(series, index), "underlying_series"
626
+ if not isinstance(underlying, pd.DataFrame):
627
+ raise TypeError("underlying must be a pandas Series or DataFrame")
628
+ frame = underlying.copy()
629
+ if "timestamp_ns" in frame.columns:
630
+ idx = pd.to_datetime(frame["timestamp_ns"].astype("int64"), utc=True).dt.tz_convert(None)
631
+ elif "time" in frame.columns:
632
+ idx = pd.to_datetime(frame["time"], utc=True, errors="coerce").dt.tz_convert(None)
633
+ elif isinstance(frame.index, pd.DatetimeIndex):
634
+ idx = pd.DatetimeIndex(pd.to_datetime(frame.index, utc=True)).tz_convert(None)
635
+ else:
636
+ raise ValueError("underlying DataFrame requires timestamp_ns, time, or DatetimeIndex")
637
+ column = "close" if "close" in frame.columns else ("price" if "price" in frame.columns else None)
638
+ if column is None:
639
+ raise ValueError("underlying DataFrame requires close or price column")
640
+ series = pd.Series(pd.to_numeric(frame[column], errors="raise").to_numpy(dtype=np.float64), index=idx, name="underlying_price")
641
+ return _align_price_series(series, index), f"underlying_dataframe:{column}"
642
+
643
+
644
+ def _align_price_series(series: pd.Series, index: pd.DatetimeIndex) -> pd.Series:
645
+ out = series.sort_index()
646
+ out = out[~out.index.duplicated(keep="last")]
647
+ out = out.reindex(index).ffill().bfill()
648
+ if out.isna().any() or bool((out <= 0.0).any()):
649
+ raise ValueError("underlying prices must align to option tape and be finite > 0")
650
+ return out.rename("underlying_price")
651
+
652
+
653
+ def _coerce_series_index(series: pd.Series, name: str) -> pd.Series:
654
+ out = series.copy()
655
+ if not isinstance(out.index, pd.DatetimeIndex):
656
+ out.index = pd.to_datetime(out.index, utc=True)
657
+ else:
658
+ out.index = pd.DatetimeIndex(pd.to_datetime(out.index, utc=True))
659
+ out.index = out.index.tz_convert(None)
660
+ out = pd.to_numeric(out, errors="raise").astype("float64")
661
+ out.name = name
662
+ return out
663
+
664
+
665
+ def _datetime_index_from_ns(timestamps_ns: np.ndarray) -> pd.DatetimeIndex:
666
+ return pd.DatetimeIndex(pd.to_datetime(timestamps_ns, utc=True)).tz_convert(None)
667
+
668
+
669
+ def _mark_lookup(tape: PreparedOptionTape) -> Dict[tuple[int, str], float]:
670
+ out: Dict[tuple[int, str], float] = {}
671
+ for snap_idx, ts in enumerate(tape.timestamp_ns):
672
+ slc = tape.snapshot_slice(snap_idx)
673
+ for idx in range(slc.start, slc.stop):
674
+ out[(int(ts), tape.instrument_id[idx])] = float(tape.mark_price[idx])
675
+ return out
676
+
677
+
678
+ def _snapshot_underlying_price(tape: PreparedOptionTape, snapshot_idx: int) -> float:
679
+ rows = tape.snapshot_slice(snapshot_idx)
680
+ for idx in range(rows.start, rows.stop):
681
+ price = tape.index_price[idx] if np.isfinite(tape.index_price[idx]) else tape.forward_price[idx]
682
+ if np.isfinite(price) and price > 0.0:
683
+ return float(price)
684
+ raise ValueError("option tape snapshot has no finite underlying/index price")
685
+
686
+
687
+ def _cash_report(snapshots: Sequence[Dict], index: pd.DatetimeIndex) -> pd.DataFrame:
688
+ currencies = sorted({currency for snap in snapshots for currency in snap["cash"]})
689
+ return pd.DataFrame(
690
+ [{currency: snap["cash"].get(currency, 0.0) for currency in currencies} for snap in snapshots],
691
+ index=index,
692
+ columns=currencies,
693
+ )
694
+
695
+
696
+ def _marks_report(tape: PreparedOptionTape) -> pd.DataFrame:
697
+ rows = []
698
+ for snap_idx, ts in enumerate(tape.timestamp_ns):
699
+ slc = tape.snapshot_slice(snap_idx)
700
+ for idx in range(slc.start, slc.stop):
701
+ rows.append(
702
+ {
703
+ "timestamp_ns": int(ts),
704
+ "instrument_id": tape.instrument_id[idx],
705
+ "bid_price": float(tape.bid_price[idx]),
706
+ "ask_price": float(tape.ask_price[idx]),
707
+ "mark_price": float(tape.mark_price[idx]),
708
+ "index_price": float(tape.index_price[idx]),
709
+ "forward_price": float(tape.forward_price[idx]),
710
+ "bid_size": float(tape.bid_size[idx]),
711
+ "ask_size": float(tape.ask_size[idx]),
712
+ }
713
+ )
714
+ return pd.DataFrame(rows)
715
+
716
+
717
+ def _greeks_report(tape: PreparedOptionTape) -> pd.DataFrame:
718
+ return pd.DataFrame(
719
+ {
720
+ "timestamp_ns": np.repeat(tape.timestamp_ns, np.diff(tape.row_ptr)),
721
+ "instrument_id": tape.instrument_id,
722
+ "mark_iv": tape.mark_iv,
723
+ "bid_iv": tape.bid_iv,
724
+ "ask_iv": tape.ask_iv,
725
+ "delta": tape.delta,
726
+ "gamma": tape.gamma,
727
+ "vega": tape.vega,
728
+ "theta": tape.theta,
729
+ }
730
+ )
731
+
732
+
733
+ def _fills_report(fills_with_fees: Sequence[tuple]) -> pd.DataFrame:
734
+ rows = []
735
+ for fill, fee in fills_with_fees:
736
+ rows.append(
737
+ {
738
+ "timestamp": fill.timestamp,
739
+ "symbol": fill.symbol,
740
+ "side": fill.side.value,
741
+ "qty": float(fill.qty),
742
+ "price": float(fill.price),
743
+ "notional": float(fill.notional),
744
+ "execution_fee": float(fill.fee),
745
+ "applied_fee": float(fee.fee if fee is not None else fill.fee),
746
+ "fee_currency": fee.currency if fee is not None else "",
747
+ "liquidity": fill.liquidity.value,
748
+ "order_id": fill.order_id,
749
+ "package_id": fill.metadata.get("package_id"),
750
+ }
751
+ )
752
+ return pd.DataFrame(rows)
753
+
754
+
755
+ def _settlements_report(settlements: Sequence) -> pd.DataFrame:
756
+ return pd.DataFrame(
757
+ [
758
+ {
759
+ "timestamp_ns": item.timestamp_ns,
760
+ "symbol": item.symbol,
761
+ "settlement_price": item.settlement_price,
762
+ "payoff_per_unit": item.payoff_per_unit,
763
+ "cashflow": item.cashflow,
764
+ "settlement_currency": item.settlement_currency,
765
+ "representation": item.representation.value,
766
+ "itm": item.itm,
767
+ "position_closed": item.position_closed,
768
+ }
769
+ for item in settlements
770
+ ]
771
+ )
772
+
773
+
774
+ def _attribution_report(
775
+ ledger: OptionLedger,
776
+ account: AccountConfig,
777
+ final_equity: float,
778
+ report_ccy: str,
779
+ conversion_rates: Dict[str, float],
780
+ ) -> pd.DataFrame:
781
+ rows = []
782
+ for currency, amount in ledger.cash.items():
783
+ rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan))
784
+ rows.append({"bucket": "cash", "currency": currency, "amount": float(amount), "reporting_value": float(amount) * rate})
785
+ for currency, fee in ledger.fees.items():
786
+ rate = 1.0 if currency == report_ccy else float(conversion_rates.get(currency, np.nan))
787
+ rows.append({"bucket": "fees", "currency": currency, "amount": -float(fee), "reporting_value": -float(fee) * rate})
788
+ rows.append(
789
+ {
790
+ "bucket": "total",
791
+ "currency": report_ccy,
792
+ "amount": float(final_equity - account.initial_capital),
793
+ "reporting_value": float(final_equity - account.initial_capital),
794
+ }
795
+ )
796
+ return pd.DataFrame(rows)