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,249 @@
1
+ """Option strategy adapters.
2
+
3
+ Adapters live above the option execution engine. They convert observable
4
+ option-chain snapshots into package intents and audit tables. They do not own
5
+ fills, premium accounting, margin, settlement, or PnL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import Dict, Optional, Sequence
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from ..core.schema import OrderSide
17
+ from .hedging import OptionHedgeConfig
18
+ from .packages import OptionPackageIntent, OptionPackageLeg
19
+ from .schema import OptionInstrumentRegistry
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class OptionStrategyRun:
24
+ """Package-level strategy output consumed by `QuantBTEndpoint.options`."""
25
+
26
+ packages: tuple[OptionPackageIntent, ...]
27
+ hedge_policy: Optional[OptionHedgeConfig] = None
28
+ selected_contracts: pd.DataFrame = field(default_factory=pd.DataFrame)
29
+ metadata: Dict = field(default_factory=dict)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class GammaScalpingConfig:
34
+ """Configuration for a simple ATM straddle gamma-scalping adapter."""
35
+
36
+ side: str = "long"
37
+ quantity: float = 1.0
38
+ min_dte_days: float = 2.0
39
+ max_dte_days: float = 45.0
40
+ roll_dte_days: float = 2.0
41
+ max_spread_bps: Optional[float] = None
42
+ min_bid_size: float = 0.0
43
+ min_ask_size: float = 0.0
44
+ min_volume: float = 0.0
45
+ min_open_interest: float = 0.0
46
+ hedge_policy: Optional[OptionHedgeConfig] = None
47
+ metadata: Dict = field(default_factory=dict)
48
+
49
+ def __post_init__(self) -> None:
50
+ side = str(self.side).lower().strip()
51
+ if side not in {"long", "short"}:
52
+ raise ValueError("GammaScalpingConfig.side must be long or short")
53
+ object.__setattr__(self, "side", side)
54
+ if self.quantity <= 0.0:
55
+ raise ValueError("GammaScalpingConfig.quantity must be > 0")
56
+ if self.min_dte_days < 0.0 or self.max_dte_days <= 0.0:
57
+ raise ValueError("DTE bounds must be non-negative and max_dte_days > 0")
58
+ if self.min_dte_days > self.max_dte_days:
59
+ raise ValueError("min_dte_days must be <= max_dte_days")
60
+ if self.roll_dte_days < 0.0:
61
+ raise ValueError("roll_dte_days must be >= 0")
62
+ for name in ("min_bid_size", "min_ask_size", "min_volume", "min_open_interest"):
63
+ if getattr(self, name) < 0.0:
64
+ raise ValueError(f"{name} must be >= 0")
65
+ if self.max_spread_bps is not None and self.max_spread_bps < 0.0:
66
+ raise ValueError("max_spread_bps must be >= 0")
67
+
68
+
69
+ def build_gamma_scalping_strategy_run(
70
+ chain: pd.DataFrame,
71
+ instruments: OptionInstrumentRegistry,
72
+ config: Optional[GammaScalpingConfig] = None,
73
+ ) -> OptionStrategyRun:
74
+ """
75
+ Build open/roll/close straddle packages from observable chain snapshots.
76
+
77
+ Selection is snapshot-local: at each decision timestamp, the adapter only
78
+ inspects rows with that exact `timestamp_ns`. The selected pair is the
79
+ valid same-expiry same-strike call/put closest to the observed index price.
80
+ """
81
+ cfg = config or GammaScalpingConfig()
82
+ frame = _canonical_strategy_frame(chain)
83
+ valid_symbols = set(instruments.symbols)
84
+ frame = frame[frame["instrument_id"].isin(valid_symbols)].copy()
85
+ if frame.empty:
86
+ raise ValueError("gamma scalping adapter found no chain rows matching instrument registry")
87
+
88
+ timestamps = [int(ts) for ts in sorted(frame["timestamp_ns"].unique())]
89
+ packages: list[OptionPackageIntent] = []
90
+ selected_rows: list[dict] = []
91
+ active: Optional[dict] = None
92
+
93
+ for ts in timestamps:
94
+ is_last = ts == timestamps[-1]
95
+ if active is not None:
96
+ dte = (int(active["expiry_ns"]) - ts) / _DAY_NS
97
+ if dte <= cfg.roll_dte_days or is_last:
98
+ if _has_quotes(frame, ts, (active["call_id"], active["put_id"])):
99
+ packages.append(_straddle_package(ts, active, cfg, action="close"))
100
+ selected_rows.append({**active, "timestamp_ns": ts, "action": "close", "dte_days": float(dte)})
101
+ active = None
102
+ if is_last:
103
+ break
104
+
105
+ if active is None and not is_last:
106
+ selection = _select_atm_pair(frame, ts, cfg)
107
+ if selection is None:
108
+ continue
109
+ packages.append(_straddle_package(ts, selection, cfg, action="open"))
110
+ dte = (int(selection["expiry_ns"]) - ts) / _DAY_NS
111
+ selected_rows.append({**selection, "timestamp_ns": ts, "action": "open", "dte_days": float(dte)})
112
+ active = selection
113
+
114
+ if active is not None:
115
+ ts = timestamps[-1]
116
+ if _has_quotes(frame, ts, (active["call_id"], active["put_id"])):
117
+ packages.append(_straddle_package(ts, active, cfg, action="close"))
118
+ selected_rows.append(
119
+ {
120
+ **active,
121
+ "timestamp_ns": ts,
122
+ "action": "close",
123
+ "dte_days": float((int(active["expiry_ns"]) - ts) / _DAY_NS),
124
+ }
125
+ )
126
+
127
+ selected = pd.DataFrame(selected_rows)
128
+ return OptionStrategyRun(
129
+ packages=tuple(packages),
130
+ hedge_policy=cfg.hedge_policy,
131
+ selected_contracts=selected,
132
+ metadata={
133
+ "strategy": "gamma_scalping",
134
+ "side": cfg.side,
135
+ "quantity": float(cfg.quantity),
136
+ "package_count": len(packages),
137
+ "selection_count": len(selected),
138
+ **cfg.metadata,
139
+ },
140
+ )
141
+
142
+
143
+ def _canonical_strategy_frame(chain: pd.DataFrame) -> pd.DataFrame:
144
+ required = {
145
+ "timestamp_ns",
146
+ "instrument_id",
147
+ "expiry_ns",
148
+ "strike",
149
+ "option_kind",
150
+ "bid_price",
151
+ "ask_price",
152
+ "bid_size",
153
+ "ask_size",
154
+ "index_price",
155
+ }
156
+ missing = sorted(required.difference(chain.columns))
157
+ if missing:
158
+ raise ValueError(f"gamma scalping chain missing columns: {missing}")
159
+ frame = chain.copy()
160
+ for column in ("timestamp_ns", "expiry_ns"):
161
+ frame[column] = pd.to_numeric(frame[column], errors="raise").astype("int64")
162
+ for column in ("strike", "bid_price", "ask_price", "bid_size", "ask_size", "index_price"):
163
+ frame[column] = pd.to_numeric(frame[column], errors="raise").astype("float64")
164
+ if "volume" not in frame:
165
+ frame["volume"] = 0.0
166
+ if "open_interest" not in frame:
167
+ frame["open_interest"] = 0.0
168
+ frame["volume"] = pd.to_numeric(frame["volume"], errors="coerce").fillna(0.0).astype("float64")
169
+ frame["open_interest"] = pd.to_numeric(frame["open_interest"], errors="coerce").fillna(0.0).astype("float64")
170
+ frame["option_kind"] = frame["option_kind"].astype(str).str.lower().str.strip()
171
+ return frame.sort_values(["timestamp_ns", "expiry_ns", "strike", "option_kind", "instrument_id"]).reset_index(drop=True)
172
+
173
+
174
+ def _select_atm_pair(frame: pd.DataFrame, timestamp_ns: int, cfg: GammaScalpingConfig) -> Optional[dict]:
175
+ snap = frame[frame["timestamp_ns"] == int(timestamp_ns)].copy()
176
+ if snap.empty:
177
+ return None
178
+ snap = snap[(snap["bid_price"] > 0.0) & (snap["ask_price"] > 0.0) & (snap["ask_price"] >= snap["bid_price"])]
179
+ snap = snap[(snap["bid_size"] >= cfg.min_bid_size) & (snap["ask_size"] >= cfg.min_ask_size)]
180
+ snap = snap[(snap["volume"] >= cfg.min_volume) & (snap["open_interest"] >= cfg.min_open_interest)]
181
+ dte = (snap["expiry_ns"] - int(timestamp_ns)) / _DAY_NS
182
+ snap = snap[(dte >= cfg.min_dte_days) & (dte <= cfg.max_dte_days)]
183
+ if cfg.max_spread_bps is not None:
184
+ mid = 0.5 * (snap["bid_price"] + snap["ask_price"])
185
+ spread_bps = np.where(mid > 0.0, (snap["ask_price"] - snap["bid_price"]) / mid * 10_000.0, np.inf)
186
+ snap = snap[spread_bps <= float(cfg.max_spread_bps)]
187
+ if snap.empty:
188
+ return None
189
+
190
+ spot = float(snap["index_price"].median())
191
+ pair_groups = snap.groupby(["expiry_ns", "strike"])
192
+ candidates = []
193
+ for (expiry_ns, strike), group in pair_groups:
194
+ kinds = set(group["option_kind"])
195
+ if kinds != {"call", "put"}:
196
+ continue
197
+ call = group[group["option_kind"] == "call"].iloc[0]
198
+ put = group[group["option_kind"] == "put"].iloc[0]
199
+ candidates.append(
200
+ {
201
+ "expiry_ns": int(expiry_ns),
202
+ "strike": float(strike),
203
+ "spot": spot,
204
+ "call_id": str(call["instrument_id"]),
205
+ "put_id": str(put["instrument_id"]),
206
+ "call_delta": float(call.get("delta", np.nan)),
207
+ "put_delta": float(put.get("delta", np.nan)),
208
+ "distance": abs(float(strike) - spot),
209
+ "dte_days": float((int(expiry_ns) - int(timestamp_ns)) / _DAY_NS),
210
+ }
211
+ )
212
+ if not candidates:
213
+ return None
214
+ return min(candidates, key=lambda row: (row["distance"], row["dte_days"]))
215
+
216
+
217
+ def _straddle_package(timestamp_ns: int, selection: dict, cfg: GammaScalpingConfig, *, action: str) -> OptionPackageIntent:
218
+ if action == "open":
219
+ side = OrderSide.BUY if cfg.side == "long" else OrderSide.SELL
220
+ elif action == "close":
221
+ side = OrderSide.SELL if cfg.side == "long" else OrderSide.BUY
222
+ else:
223
+ raise ValueError("action must be open or close")
224
+ return OptionPackageIntent(
225
+ timestamp_ns=int(timestamp_ns),
226
+ package_id=f"gamma-{action}:{selection['call_id']}:{selection['put_id']}:{timestamp_ns}",
227
+ legs=(
228
+ OptionPackageLeg(selection["call_id"], side, 1.0, role=f"{action}_call"),
229
+ OptionPackageLeg(selection["put_id"], side, 1.0, role=f"{action}_put"),
230
+ ),
231
+ quantity=float(cfg.quantity),
232
+ tag=f"gamma_scalping_{action}",
233
+ metadata={
234
+ "strategy": "gamma_scalping",
235
+ "action": action,
236
+ "side": cfg.side,
237
+ "strike": float(selection["strike"]),
238
+ "expiry_ns": int(selection["expiry_ns"]),
239
+ "spot": float(selection["spot"]),
240
+ },
241
+ )
242
+
243
+
244
+ def _has_quotes(frame: pd.DataFrame, timestamp_ns: int, symbols: Sequence[str]) -> bool:
245
+ snap_symbols = set(frame.loc[frame["timestamp_ns"] == int(timestamp_ns), "instrument_id"])
246
+ return all(symbol in snap_symbols for symbol in symbols)
247
+
248
+
249
+ _DAY_NS = 24 * 60 * 60 * 1_000_000_000
@@ -0,0 +1,142 @@
1
+ """
2
+ Minimal option surface diagnostics.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Dict, Iterable, Tuple
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SurfaceDiagnostics:
16
+ positive_total_variance: bool
17
+ no_future_timestamps: bool
18
+ expiries_after_snapshot: bool
19
+ calendar_total_variance_non_decreasing: bool
20
+ butterfly_convexity_checked: bool
21
+ notes: Tuple[str, ...]
22
+
23
+ @property
24
+ def pass_basic(self) -> bool:
25
+ return (
26
+ self.positive_total_variance
27
+ and self.no_future_timestamps
28
+ and self.expiries_after_snapshot
29
+ and self.calendar_total_variance_non_decreasing
30
+ )
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class TotalVarianceSurface:
35
+ timestamp_ns: int
36
+ expiry_ns: np.ndarray
37
+ strike: np.ndarray
38
+ total_variance: np.ndarray
39
+
40
+ def __post_init__(self) -> None:
41
+ timestamp = int(self.timestamp_ns)
42
+ expiry = np.asarray(self.expiry_ns, dtype=np.int64)
43
+ strike = np.asarray(self.strike, dtype=np.float64)
44
+ variance = np.asarray(self.total_variance, dtype=np.float64)
45
+ if timestamp <= 0:
46
+ raise ValueError("timestamp_ns must be > 0")
47
+ if expiry.ndim != 1 or strike.ndim != 1 or variance.ndim != 1:
48
+ raise ValueError("surface arrays must be 1-D")
49
+ if len(expiry) == 0 or len(expiry) != len(strike) or len(expiry) != len(variance):
50
+ raise ValueError("surface arrays must be non-empty and equal length")
51
+ if bool((expiry <= timestamp).any()):
52
+ raise ValueError("surface expiry_ns must be after timestamp_ns")
53
+ if bool((strike <= 0.0).any()):
54
+ raise ValueError("surface strikes must be > 0")
55
+ if bool((~np.isfinite(variance)).any()) or bool((variance < 0.0).any()):
56
+ raise ValueError("total_variance must be finite and >= 0")
57
+ order = np.lexsort((strike, expiry))
58
+ object.__setattr__(self, "timestamp_ns", timestamp)
59
+ object.__setattr__(self, "expiry_ns", expiry[order])
60
+ object.__setattr__(self, "strike", strike[order])
61
+ object.__setattr__(self, "total_variance", variance[order])
62
+
63
+ @classmethod
64
+ def from_snapshot_frame(
65
+ cls,
66
+ frame: pd.DataFrame,
67
+ *,
68
+ timestamp_ns: int,
69
+ volatility_column: str = "mark_iv",
70
+ ) -> "TotalVarianceSurface":
71
+ required = {"timestamp_ns", "expiry_ns", "strike", volatility_column}
72
+ missing = sorted(required.difference(frame.columns))
73
+ if missing:
74
+ raise ValueError(f"surface frame missing required columns: {missing}")
75
+ timestamp = int(timestamp_ns)
76
+ future_rows = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") > timestamp]
77
+ if len(future_rows) > 0:
78
+ raise ValueError("surface calibration cannot include future timestamp rows")
79
+ snapshot = frame.loc[pd.to_numeric(frame["timestamp_ns"], errors="raise").astype("int64") == timestamp].copy()
80
+ if snapshot.empty:
81
+ raise ValueError("surface snapshot has no rows for timestamp_ns")
82
+ expiry = pd.to_numeric(snapshot["expiry_ns"], errors="raise").astype("int64").to_numpy()
83
+ strike = pd.to_numeric(snapshot["strike"], errors="raise").astype("float64").to_numpy()
84
+ vol = pd.to_numeric(snapshot[volatility_column], errors="raise").astype("float64").to_numpy()
85
+ tau_years = (expiry.astype(np.float64) - float(timestamp)) / (365.0 * 24.0 * 60.0 * 60.0 * 1_000_000_000.0)
86
+ total_variance = vol * vol * tau_years
87
+ return cls(timestamp_ns=timestamp, expiry_ns=expiry, strike=strike, total_variance=total_variance)
88
+
89
+ @property
90
+ def expiries(self) -> np.ndarray:
91
+ return np.unique(self.expiry_ns)
92
+
93
+ def interpolate_total_variance(self, *, expiry_ns: int, strike: float) -> float:
94
+ """Interpolate total variance by strike first, then expiry."""
95
+ target_expiry = int(expiry_ns)
96
+ target_strike = float(strike)
97
+ if target_expiry <= self.timestamp_ns:
98
+ raise ValueError("target expiry must be after surface timestamp")
99
+ if target_strike <= 0.0:
100
+ raise ValueError("target strike must be > 0")
101
+ expiries = self.expiries
102
+ per_expiry = np.array([self._interpolate_strike(expiry, target_strike) for expiry in expiries], dtype=np.float64)
103
+ if len(expiries) == 1:
104
+ return float(per_expiry[0])
105
+ return float(np.interp(float(target_expiry), expiries.astype(np.float64), per_expiry))
106
+
107
+ def diagnostics(self) -> SurfaceDiagnostics:
108
+ notes = ["butterfly convexity is placeholder-only in Phase 2"]
109
+ by_strike = _group_by_strike(self.expiry_ns, self.strike, self.total_variance)
110
+ calendar_ok = True
111
+ for rows in by_strike.values():
112
+ rows_sorted = sorted(rows, key=lambda item: item[0])
113
+ variances = np.array([item[1] for item in rows_sorted], dtype=np.float64)
114
+ if len(variances) > 1 and bool((np.diff(variances) < -1e-12).any()):
115
+ calendar_ok = False
116
+ break
117
+ return SurfaceDiagnostics(
118
+ positive_total_variance=bool((self.total_variance >= 0.0).all()),
119
+ no_future_timestamps=True,
120
+ expiries_after_snapshot=bool((self.expiry_ns > self.timestamp_ns).all()),
121
+ calendar_total_variance_non_decreasing=calendar_ok,
122
+ butterfly_convexity_checked=False,
123
+ notes=tuple(notes),
124
+ )
125
+
126
+ def _interpolate_strike(self, expiry_ns: int, strike: float) -> float:
127
+ mask = self.expiry_ns == int(expiry_ns)
128
+ strikes = self.strike[mask]
129
+ variances = self.total_variance[mask]
130
+ if len(strikes) == 0:
131
+ raise ValueError("expiry not found")
132
+ if len(strikes) == 1:
133
+ return float(variances[0])
134
+ order = np.argsort(strikes)
135
+ return float(np.interp(strike, strikes[order], variances[order]))
136
+
137
+
138
+ def _group_by_strike(expiry_ns: Iterable[int], strike: Iterable[float], total_variance: Iterable[float]) -> Dict[float, list[tuple[int, float]]]:
139
+ grouped: Dict[float, list[tuple[int, float]]] = {}
140
+ for expiry, strike_value, variance in zip(expiry_ns, strike, total_variance):
141
+ grouped.setdefault(float(strike_value), []).append((int(expiry), float(variance)))
142
+ return grouped
@@ -0,0 +1,226 @@
1
+ """
2
+ Prepared ragged option tape.
3
+
4
+ The canonical option chain remains long-form. This module compiles validated
5
+ rows into CSR-style arrays so later selectors and execution code can scan the
6
+ listed contracts at each observable snapshot without building a dense
7
+ bar-by-contract matrix.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from typing import Optional, Sequence, Tuple
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from .data import validate_option_chain_frame
19
+ from .schema import InstrumentRegistrySignature, OptionInstrumentRegistry
20
+
21
+
22
+ YEAR_NS = 365 * 24 * 60 * 60 * 1_000_000_000
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OptionTapeSignature:
27
+ row_count: int
28
+ snapshot_count: int
29
+ first_timestamp_ns: int
30
+ last_timestamp_ns: int
31
+ instrument_registry_signature: InstrumentRegistrySignature
32
+ convention_signature: Tuple
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PreparedOptionTape:
37
+ timestamp_ns: np.ndarray
38
+ row_ptr: np.ndarray
39
+ instrument_code: np.ndarray
40
+ instrument_id: Tuple[str, ...]
41
+ expiry_ns: np.ndarray
42
+ strike: np.ndarray
43
+ option_kind_code: np.ndarray
44
+ bid_price: np.ndarray
45
+ bid_size: np.ndarray
46
+ ask_price: np.ndarray
47
+ ask_size: np.ndarray
48
+ mark_price: np.ndarray
49
+ index_price: np.ndarray
50
+ forward_price: np.ndarray
51
+ mark_iv: np.ndarray
52
+ bid_iv: np.ndarray
53
+ ask_iv: np.ndarray
54
+ delta: np.ndarray
55
+ gamma: np.ndarray
56
+ vega: np.ndarray
57
+ theta: np.ndarray
58
+ open_interest: np.ndarray
59
+ volume: np.ndarray
60
+ source_latency_ns: np.ndarray
61
+ registry: OptionInstrumentRegistry
62
+ signature: OptionTapeSignature
63
+
64
+ def __post_init__(self) -> None:
65
+ if self.timestamp_ns.ndim != 1 or self.row_ptr.ndim != 1:
66
+ raise ValueError("timestamp_ns and row_ptr must be 1-D")
67
+ if len(self.row_ptr) != len(self.timestamp_ns) + 1:
68
+ raise ValueError("row_ptr length must equal snapshot_count + 1")
69
+ if len(self.instrument_code) != self.signature.row_count:
70
+ raise ValueError("instrument_code length must match row_count")
71
+ if self.row_ptr[0] != 0 or self.row_ptr[-1] != self.signature.row_count:
72
+ raise ValueError("row_ptr bounds do not match row_count")
73
+ if bool((np.diff(self.row_ptr) < 0).any()):
74
+ raise ValueError("row_ptr must be non-decreasing")
75
+ if bool((np.diff(self.timestamp_ns) <= 0).any()):
76
+ raise ValueError("timestamp_ns must be strictly increasing")
77
+
78
+ @property
79
+ def snapshot_count(self) -> int:
80
+ return len(self.timestamp_ns)
81
+
82
+ @property
83
+ def row_count(self) -> int:
84
+ return len(self.instrument_code)
85
+
86
+ def snapshot_index_at_or_before(self, decision_timestamp_ns: int, *, max_quote_age_ns: Optional[int] = None) -> int:
87
+ decision_ts = int(decision_timestamp_ns)
88
+ idx = int(np.searchsorted(self.timestamp_ns, decision_ts, side="right") - 1)
89
+ if idx < 0:
90
+ raise ValueError("no option snapshot is observable at or before decision_timestamp_ns")
91
+ if max_quote_age_ns is not None and decision_ts - int(self.timestamp_ns[idx]) > int(max_quote_age_ns):
92
+ raise ValueError("latest option snapshot is stale for decision_timestamp_ns")
93
+ return idx
94
+
95
+ def snapshot_slice(self, snapshot_index: int) -> slice:
96
+ idx = int(snapshot_index)
97
+ if idx < 0 or idx >= self.snapshot_count:
98
+ raise IndexError("snapshot_index out of range")
99
+ return slice(int(self.row_ptr[idx]), int(self.row_ptr[idx + 1]))
100
+
101
+ def validate_compatible(
102
+ self,
103
+ *,
104
+ registry_signature: Optional[InstrumentRegistrySignature] = None,
105
+ convention_signature: Optional[Tuple] = None,
106
+ timestamps_ns: Optional[Sequence[int]] = None,
107
+ ) -> None:
108
+ if registry_signature is not None and registry_signature != self.signature.instrument_registry_signature:
109
+ raise ValueError("prepared option tape registry signature mismatch")
110
+ if convention_signature is not None and tuple(convention_signature) != self.signature.convention_signature:
111
+ raise ValueError("prepared option tape convention signature mismatch")
112
+ if timestamps_ns is not None:
113
+ expected = np.asarray(timestamps_ns, dtype=np.int64)
114
+ if len(expected) != len(self.timestamp_ns) or bool((expected != self.timestamp_ns).any()):
115
+ raise ValueError("prepared option tape timestamp mismatch")
116
+
117
+
118
+ def prepare_option_tape(
119
+ chain: pd.DataFrame,
120
+ registry: OptionInstrumentRegistry,
121
+ *,
122
+ max_spread_bps: Optional[float] = None,
123
+ max_source_latency_ns: Optional[int] = None,
124
+ convention_signature: Optional[Tuple] = None,
125
+ ) -> PreparedOptionTape:
126
+ """
127
+ Validate long-form chain rows and compile a CSR-style option tape.
128
+
129
+ `max_source_latency_ns` checks the per-row venue/source latency column when
130
+ present. Decision-time quote age is checked later by selectors because it
131
+ depends on the strategy timestamp.
132
+ """
133
+ canonical = validate_option_chain_frame(chain, max_spread_bps=max_spread_bps)
134
+ registry_symbols = registry.by_symbol
135
+ unknown = sorted(set(canonical["instrument_id"]).difference(registry_symbols))
136
+ if unknown:
137
+ raise ValueError(f"option chain contains instruments not in registry: {unknown}")
138
+ if max_source_latency_ns is not None:
139
+ if max_source_latency_ns < 0:
140
+ raise ValueError("max_source_latency_ns must be >= 0")
141
+ if "source_latency_ns" not in canonical:
142
+ raise ValueError("source_latency_ns is required when max_source_latency_ns is set")
143
+ if bool((canonical["source_latency_ns"].to_numpy(dtype=np.int64) > int(max_source_latency_ns)).any()):
144
+ raise ValueError("option chain contains stale source latency rows")
145
+ _validate_registry_static_fields(canonical, registry)
146
+ timestamps, row_ptr = _build_row_ptr(canonical["timestamp_ns"].to_numpy(dtype=np.int64))
147
+ ids = tuple(canonical["instrument_id"].astype(str).tolist())
148
+ code_map = {symbol: code for code, symbol in enumerate(registry.symbols)}
149
+ instrument_code = np.asarray([code_map[symbol] for symbol in ids], dtype=np.int32)
150
+ kind_code = np.asarray([0 if kind == "call" else 1 for kind in canonical["option_kind"].astype(str)], dtype=np.int8)
151
+ convention_sig = tuple(convention_signature) if convention_signature is not None else registry.signature.signature
152
+ signature = OptionTapeSignature(
153
+ row_count=len(canonical),
154
+ snapshot_count=len(timestamps),
155
+ first_timestamp_ns=int(timestamps[0]),
156
+ last_timestamp_ns=int(timestamps[-1]),
157
+ instrument_registry_signature=registry.signature,
158
+ convention_signature=convention_sig,
159
+ )
160
+ return PreparedOptionTape(
161
+ timestamp_ns=timestamps,
162
+ row_ptr=row_ptr,
163
+ instrument_code=instrument_code,
164
+ instrument_id=ids,
165
+ expiry_ns=canonical["expiry_ns"].to_numpy(dtype=np.int64),
166
+ strike=canonical["strike"].to_numpy(dtype=np.float64),
167
+ option_kind_code=kind_code,
168
+ bid_price=canonical["bid_price"].to_numpy(dtype=np.float64),
169
+ bid_size=canonical["bid_size"].to_numpy(dtype=np.float64),
170
+ ask_price=canonical["ask_price"].to_numpy(dtype=np.float64),
171
+ ask_size=canonical["ask_size"].to_numpy(dtype=np.float64),
172
+ mark_price=canonical["mark_price"].to_numpy(dtype=np.float64),
173
+ index_price=canonical["index_price"].to_numpy(dtype=np.float64),
174
+ forward_price=canonical["forward_price"].to_numpy(dtype=np.float64),
175
+ mark_iv=_float_column(canonical, "mark_iv", default=np.nan),
176
+ bid_iv=_float_column(canonical, "bid_iv", default=np.nan),
177
+ ask_iv=_float_column(canonical, "ask_iv", default=np.nan),
178
+ delta=_float_column(canonical, "delta", default=np.nan),
179
+ gamma=_float_column(canonical, "gamma", default=np.nan),
180
+ vega=_float_column(canonical, "vega", default=np.nan),
181
+ theta=_float_column(canonical, "theta", default=np.nan),
182
+ open_interest=_float_column(canonical, "open_interest", default=0.0),
183
+ volume=_float_column(canonical, "volume", default=0.0),
184
+ source_latency_ns=_int_column(canonical, "source_latency_ns", default=0),
185
+ registry=registry,
186
+ signature=signature,
187
+ )
188
+
189
+
190
+ def _build_row_ptr(timestamp_ns: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
191
+ timestamps, counts = np.unique(timestamp_ns, return_counts=True)
192
+ row_ptr = np.empty(len(timestamps) + 1, dtype=np.int64)
193
+ row_ptr[0] = 0
194
+ row_ptr[1:] = np.cumsum(counts, dtype=np.int64)
195
+ return timestamps.astype(np.int64), row_ptr
196
+
197
+
198
+ def _float_column(frame: pd.DataFrame, column: str, *, default: float) -> np.ndarray:
199
+ if column not in frame:
200
+ return np.full(len(frame), default, dtype=np.float64)
201
+ return frame[column].to_numpy(dtype=np.float64)
202
+
203
+
204
+ def _int_column(frame: pd.DataFrame, column: str, *, default: int) -> np.ndarray:
205
+ if column not in frame:
206
+ return np.full(len(frame), default, dtype=np.int64)
207
+ return frame[column].to_numpy(dtype=np.int64)
208
+
209
+
210
+ def _validate_registry_static_fields(chain: pd.DataFrame, registry: OptionInstrumentRegistry) -> None:
211
+ for row in chain.itertuples(index=False):
212
+ spec = registry.by_symbol[getattr(row, "instrument_id")]
213
+ if int(getattr(row, "expiry_ns")) != int(spec.expiry_ns):
214
+ raise ValueError("option chain expiry_ns does not match registry")
215
+ if abs(float(getattr(row, "strike")) - float(spec.strike)) > 1e-12:
216
+ raise ValueError("option chain strike does not match registry")
217
+ if str(getattr(row, "option_kind")).lower() != spec.option_kind.value:
218
+ raise ValueError("option chain option_kind does not match registry")
219
+ if str(getattr(row, "venue")).lower() != spec.venue:
220
+ raise ValueError("option chain venue does not match registry")
221
+ if str(getattr(row, "underlying_id")).strip() != spec.underlying_id:
222
+ raise ValueError("option chain underlying_id does not match registry")
223
+ if str(getattr(row, "quote_currency")).upper() != spec.quote_currency:
224
+ raise ValueError("option chain quote_currency does not match registry")
225
+ if str(getattr(row, "settlement_currency")).upper() != spec.settlement_currency:
226
+ raise ValueError("option chain settlement_currency does not match registry")
@@ -0,0 +1,33 @@
1
+ """Option package builder templates."""
2
+
3
+ from .packages import (
4
+ butterfly,
5
+ calendar,
6
+ collar,
7
+ condor,
8
+ covered_call,
9
+ long_call,
10
+ long_put,
11
+ risk_reversal,
12
+ short_call,
13
+ short_put,
14
+ straddle,
15
+ strangle,
16
+ vertical,
17
+ )
18
+
19
+ __all__ = [
20
+ "butterfly",
21
+ "calendar",
22
+ "collar",
23
+ "condor",
24
+ "covered_call",
25
+ "long_call",
26
+ "long_put",
27
+ "risk_reversal",
28
+ "short_call",
29
+ "short_put",
30
+ "straddle",
31
+ "strangle",
32
+ "vertical",
33
+ ]