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,227 @@
1
+ """
2
+ Option domain schema.
3
+
4
+ These objects are deliberately dependency-free and do not import Nautilus. They
5
+ describe instrument conventions and registry signatures; they do not perform
6
+ pricing, execution, or ledger accounting.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Dict, Iterable, Optional, Tuple
14
+
15
+ from ..core.schema import AssetType, InstrumentSpec
16
+
17
+
18
+ class OptionKind(str, Enum):
19
+ CALL = "call"
20
+ PUT = "put"
21
+
22
+
23
+ class ExerciseStyle(str, Enum):
24
+ EUROPEAN = "european"
25
+ AMERICAN = "american"
26
+
27
+
28
+ class PremiumConvention(str, Enum):
29
+ LINEAR_QUOTE = "linear_quote"
30
+ INVERSE_BASE = "inverse_base"
31
+ QUANTO = "quanto"
32
+
33
+
34
+ class SettlementStyle(str, Enum):
35
+ CASH = "cash"
36
+ FUTURE_THEN_CASH = "future_then_cash"
37
+ PHYSICAL = "physical"
38
+
39
+
40
+ class OptionDecisionFillPolicy(str, Enum):
41
+ NEXT_SNAPSHOT = "next_snapshot"
42
+ SAME_SNAPSHOT_AFTER_SIGNAL = "same_snapshot_after_signal"
43
+ NEXT_BAR_OPEN = "next_bar_open"
44
+ EXPLICIT_EVENT_SEQUENCE = "explicit_event_sequence"
45
+
46
+
47
+ @dataclass(frozen=True, kw_only=True)
48
+ class OptionInstrumentSpec(InstrumentSpec):
49
+ """
50
+ Option instrument definition with explicit quote/settlement conventions.
51
+
52
+ `contract_size` remains the generic QuantBT multiplier field. `multiplier`
53
+ is kept as an option-domain alias for readability; both must match.
54
+
55
+ `lot_size` remains the generic QuantBT quantity increment field. `qty_step`
56
+ is kept as an option-domain alias because options venues usually describe
57
+ order precision this way. If either is supplied, both are normalized to the
58
+ same value.
59
+ """
60
+
61
+ asset_type: AssetType = AssetType.OPTION
62
+ venue: str
63
+ underlying_id: str
64
+ underlying_index_id: str
65
+ option_kind: OptionKind
66
+ exercise_style: ExerciseStyle
67
+ premium_convention: PremiumConvention
68
+ settlement_style: SettlementStyle
69
+ strike: float
70
+ expiry_ns: int
71
+ settlement_currency: str
72
+ premium_currency: str
73
+ quote_currency: str
74
+ multiplier: float = 1.0
75
+ qty_step: float = 0.0
76
+ settlement_time_ns: Optional[int] = None
77
+ fee_schedule_id: str = ""
78
+ margin_schedule_id: str = ""
79
+ convention_version: str = ""
80
+ metadata: Dict = field(default_factory=dict)
81
+
82
+ def __post_init__(self) -> None:
83
+ object.__setattr__(self, "asset_type", _coerce_enum(AssetType, self.asset_type, "asset_type"))
84
+ object.__setattr__(self, "option_kind", _coerce_enum(OptionKind, self.option_kind, "option_kind"))
85
+ object.__setattr__(self, "exercise_style", _coerce_enum(ExerciseStyle, self.exercise_style, "exercise_style"))
86
+ object.__setattr__(
87
+ self,
88
+ "premium_convention",
89
+ _coerce_enum(PremiumConvention, self.premium_convention, "premium_convention"),
90
+ )
91
+ object.__setattr__(
92
+ self,
93
+ "settlement_style",
94
+ _coerce_enum(SettlementStyle, self.settlement_style, "settlement_style"),
95
+ )
96
+ super().__post_init__()
97
+ if self.asset_type is not AssetType.OPTION:
98
+ raise ValueError("OptionInstrumentSpec.asset_type must be OPTION")
99
+ if not self.venue:
100
+ raise ValueError("venue is required")
101
+ if not self.underlying_id or not self.underlying_index_id:
102
+ raise ValueError("underlying identifiers are required")
103
+ if self.strike <= 0.0:
104
+ raise ValueError("strike must be > 0")
105
+ if int(self.expiry_ns) <= 0:
106
+ raise ValueError("expiry_ns must be > 0")
107
+ object.__setattr__(self, "expiry_ns", int(self.expiry_ns))
108
+ if self.settlement_time_ns is not None and int(self.settlement_time_ns) <= 0:
109
+ raise ValueError("settlement_time_ns must be > 0")
110
+ if self.settlement_time_ns is not None:
111
+ object.__setattr__(self, "settlement_time_ns", int(self.settlement_time_ns))
112
+ if self.multiplier <= 0.0:
113
+ raise ValueError("multiplier must be > 0")
114
+ if abs(float(self.multiplier) - float(self.contract_size)) > 1e-15:
115
+ raise ValueError("multiplier must match contract_size")
116
+ if self.qty_step < 0.0:
117
+ raise ValueError("qty_step must be >= 0")
118
+ _normalize_quantity_step_alias(self)
119
+ for field_name in ("settlement_currency", "premium_currency", "quote_currency"):
120
+ value = getattr(self, field_name)
121
+ if not value:
122
+ raise ValueError(f"{field_name} is required")
123
+ object.__setattr__(self, field_name, str(value).upper())
124
+ object.__setattr__(self, "venue", str(self.venue).lower().strip())
125
+ object.__setattr__(self, "underlying_id", str(self.underlying_id).strip())
126
+ object.__setattr__(self, "underlying_index_id", str(self.underlying_index_id).strip())
127
+ _validate_convention_currency_contract(self)
128
+
129
+ @property
130
+ def convention_signature_tuple(self) -> Tuple:
131
+ return (
132
+ self.symbol,
133
+ self.venue,
134
+ self.underlying_id,
135
+ self.option_kind.value,
136
+ self.exercise_style.value,
137
+ self.premium_convention.value,
138
+ self.settlement_style.value,
139
+ float(self.strike),
140
+ int(self.expiry_ns),
141
+ self.premium_currency,
142
+ self.settlement_currency,
143
+ self.quote_currency,
144
+ float(self.multiplier),
145
+ float(self.qty_step),
146
+ self.fee_schedule_id,
147
+ self.margin_schedule_id,
148
+ self.convention_version,
149
+ )
150
+
151
+
152
+ @dataclass(frozen=True)
153
+ class InstrumentRegistrySignature:
154
+ count: int
155
+ symbols: Tuple[str, ...]
156
+ convention_versions: Tuple[str, ...]
157
+ signature: Tuple[Tuple, ...]
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class OptionInstrumentRegistry:
162
+ instruments: Tuple[OptionInstrumentSpec, ...]
163
+
164
+ def __post_init__(self) -> None:
165
+ if not self.instruments:
166
+ raise ValueError("OptionInstrumentRegistry requires at least one instrument")
167
+ symbols = [instrument.symbol for instrument in self.instruments]
168
+ if len(symbols) != len(set(symbols)):
169
+ raise ValueError("option instrument symbols must be unique")
170
+
171
+ @classmethod
172
+ def from_iterable(cls, instruments: Iterable[OptionInstrumentSpec]) -> "OptionInstrumentRegistry":
173
+ return cls(tuple(instruments))
174
+
175
+ @property
176
+ def symbols(self) -> Tuple[str, ...]:
177
+ return tuple(instrument.symbol for instrument in self.instruments)
178
+
179
+ @property
180
+ def by_symbol(self) -> Dict[str, OptionInstrumentSpec]:
181
+ return {instrument.symbol: instrument for instrument in self.instruments}
182
+
183
+ @property
184
+ def signature(self) -> InstrumentRegistrySignature:
185
+ ordered = tuple(sorted((instrument.convention_signature_tuple for instrument in self.instruments), key=lambda row: row[0]))
186
+ return InstrumentRegistrySignature(
187
+ count=len(ordered),
188
+ symbols=tuple(row[0] for row in ordered),
189
+ convention_versions=tuple(row[-1] for row in ordered),
190
+ signature=ordered,
191
+ )
192
+
193
+
194
+ def _coerce_enum(enum_cls, value, field_name: str):
195
+ if isinstance(value, enum_cls):
196
+ return value
197
+ try:
198
+ return enum_cls(str(value))
199
+ except ValueError as exc:
200
+ raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc
201
+
202
+
203
+ def _validate_convention_currency_contract(spec: OptionInstrumentSpec) -> None:
204
+ if spec.premium_convention is PremiumConvention.INVERSE_BASE:
205
+ if spec.premium_currency != spec.settlement_currency:
206
+ raise ValueError("inverse options require premium_currency == settlement_currency")
207
+ if spec.quote_currency == spec.premium_currency:
208
+ raise ValueError("inverse options require quote_currency distinct from premium currency")
209
+ elif spec.premium_convention is PremiumConvention.LINEAR_QUOTE:
210
+ if spec.premium_currency != spec.quote_currency:
211
+ raise ValueError("linear quote options require premium_currency == quote_currency")
212
+ if spec.settlement_style is SettlementStyle.PHYSICAL:
213
+ raise ValueError("linear quote options cannot use physical settlement in Phase 1 schema")
214
+ elif spec.premium_convention is PremiumConvention.QUANTO:
215
+ if spec.premium_currency == spec.settlement_currency == spec.quote_currency:
216
+ raise ValueError("quanto options require at least one distinct premium/settlement/quote currency")
217
+
218
+
219
+ def _normalize_quantity_step_alias(spec: OptionInstrumentSpec) -> None:
220
+ lot_size = float(spec.lot_size)
221
+ qty_step = float(spec.qty_step)
222
+ if lot_size > 0.0 and qty_step > 0.0 and abs(lot_size - qty_step) > 1e-15:
223
+ raise ValueError("qty_step must match lot_size when both are provided")
224
+ if qty_step <= 0.0 and lot_size > 0.0:
225
+ object.__setattr__(spec, "qty_step", lot_size)
226
+ elif lot_size <= 0.0 and qty_step > 0.0:
227
+ object.__setattr__(spec, "lot_size", qty_step)
@@ -0,0 +1,281 @@
1
+ """
2
+ No-lookahead option selectors.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from typing import Optional, Union
9
+
10
+ import numpy as np
11
+
12
+ from .schema import OptionKind
13
+ from .tape import PreparedOptionTape, YEAR_NS
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class OptionSelectionFilters:
18
+ option_kind: Optional[Union[OptionKind, str]] = None
19
+ min_bid_size: float = 0.0
20
+ min_ask_size: float = 0.0
21
+ max_spread_bps: Optional[float] = None
22
+ min_open_interest: float = 0.0
23
+ min_volume: float = 0.0
24
+ min_dte_days: Optional[float] = None
25
+ max_dte_days: Optional[float] = None
26
+ min_moneyness: Optional[float] = None
27
+ max_moneyness: Optional[float] = None
28
+ require_mark_iv: bool = False
29
+ require_delta: bool = False
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class OptionSelection:
34
+ row_index: int
35
+ snapshot_index: int
36
+ snapshot_timestamp_ns: int
37
+ decision_timestamp_ns: int
38
+ instrument_id: str
39
+ instrument_code: int
40
+ option_kind: OptionKind
41
+ expiry_ns: int
42
+ strike: float
43
+ dte_years: float
44
+ moneyness: float
45
+ bid_price: float
46
+ ask_price: float
47
+ mark_price: float
48
+ mid_price: float
49
+ mark_iv: float
50
+ delta: float
51
+ score: float
52
+
53
+
54
+ def select_atm_option(
55
+ tape: PreparedOptionTape,
56
+ decision_timestamp_ns: int,
57
+ *,
58
+ filters: Optional[OptionSelectionFilters] = None,
59
+ max_quote_age_ns: Optional[int] = None,
60
+ ) -> OptionSelection:
61
+ """Select the listed option closest to ATM at the observable snapshot."""
62
+ return _select_min_score(
63
+ tape,
64
+ decision_timestamp_ns,
65
+ filters=filters,
66
+ max_quote_age_ns=max_quote_age_ns,
67
+ score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - 1.0),
68
+ )
69
+
70
+
71
+ def select_target_delta_option(
72
+ tape: PreparedOptionTape,
73
+ decision_timestamp_ns: int,
74
+ *,
75
+ target_delta: float,
76
+ filters: Optional[OptionSelectionFilters] = None,
77
+ max_quote_age_ns: Optional[int] = None,
78
+ ) -> OptionSelection:
79
+ """Select the option with observable delta closest to `target_delta`."""
80
+ base_filters = _merge_require_delta(filters)
81
+ target = float(target_delta)
82
+ if not np.isfinite(target):
83
+ raise ValueError("target_delta must be finite")
84
+ return _select_min_score(
85
+ tape,
86
+ decision_timestamp_ns,
87
+ filters=base_filters,
88
+ max_quote_age_ns=max_quote_age_ns,
89
+ score_fn=lambda rows: np.abs(tape.delta[rows] - target),
90
+ )
91
+
92
+
93
+ def select_target_dte_option(
94
+ tape: PreparedOptionTape,
95
+ decision_timestamp_ns: int,
96
+ *,
97
+ target_dte_days: float,
98
+ filters: Optional[OptionSelectionFilters] = None,
99
+ max_quote_age_ns: Optional[int] = None,
100
+ ) -> OptionSelection:
101
+ """Select the option with expiry closest to target DTE at the snapshot."""
102
+ target_years = _positive_days(target_dte_days, "target_dte_days") / 365.0
103
+ return _select_min_score(
104
+ tape,
105
+ decision_timestamp_ns,
106
+ filters=filters,
107
+ max_quote_age_ns=max_quote_age_ns,
108
+ score_fn=lambda rows: np.abs(_dte_years(tape, rows, decision_timestamp_ns) - target_years),
109
+ )
110
+
111
+
112
+ def select_target_moneyness_option(
113
+ tape: PreparedOptionTape,
114
+ decision_timestamp_ns: int,
115
+ *,
116
+ target_moneyness: float,
117
+ filters: Optional[OptionSelectionFilters] = None,
118
+ max_quote_age_ns: Optional[int] = None,
119
+ ) -> OptionSelection:
120
+ """Select the option with strike/forward closest to target moneyness."""
121
+ target = float(target_moneyness)
122
+ if not np.isfinite(target) or target <= 0.0:
123
+ raise ValueError("target_moneyness must be finite and > 0")
124
+ return _select_min_score(
125
+ tape,
126
+ decision_timestamp_ns,
127
+ filters=filters,
128
+ max_quote_age_ns=max_quote_age_ns,
129
+ score_fn=lambda rows: np.abs(tape.strike[rows] / tape.forward_price[rows] - target),
130
+ )
131
+
132
+
133
+ def available_option_rows(
134
+ tape: PreparedOptionTape,
135
+ decision_timestamp_ns: int,
136
+ *,
137
+ filters: Optional[OptionSelectionFilters] = None,
138
+ max_quote_age_ns: Optional[int] = None,
139
+ ) -> np.ndarray:
140
+ """Return global row indexes listed and tradable at the observable snapshot."""
141
+ snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns)
142
+ rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64)
143
+ mask = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters())
144
+ return rows[mask]
145
+
146
+
147
+ def _select_min_score(
148
+ tape: PreparedOptionTape,
149
+ decision_timestamp_ns: int,
150
+ *,
151
+ filters: Optional[OptionSelectionFilters],
152
+ max_quote_age_ns: Optional[int],
153
+ score_fn,
154
+ ) -> OptionSelection:
155
+ snapshot_idx = tape.snapshot_index_at_or_before(decision_timestamp_ns, max_quote_age_ns=max_quote_age_ns)
156
+ rows = np.arange(tape.row_ptr[snapshot_idx], tape.row_ptr[snapshot_idx + 1], dtype=np.int64)
157
+ filtered = _filter_mask(tape, rows, int(decision_timestamp_ns), filters or OptionSelectionFilters())
158
+ candidates = rows[filtered]
159
+ if len(candidates) == 0:
160
+ raise ValueError("no option candidates pass filters at observable snapshot")
161
+ scores = np.asarray(score_fn(candidates), dtype=np.float64)
162
+ valid_scores = np.isfinite(scores)
163
+ if not bool(valid_scores.any()):
164
+ raise ValueError("no option candidates have finite selector score")
165
+ candidates = candidates[valid_scores]
166
+ scores = scores[valid_scores]
167
+ local_idx = int(np.argmin(scores))
168
+ return _build_selection(tape, int(candidates[local_idx]), snapshot_idx, int(decision_timestamp_ns), float(scores[local_idx]))
169
+
170
+
171
+ def _filter_mask(
172
+ tape: PreparedOptionTape,
173
+ rows: np.ndarray,
174
+ decision_timestamp_ns: int,
175
+ filters: OptionSelectionFilters,
176
+ ) -> np.ndarray:
177
+ if len(rows) == 0:
178
+ return np.zeros(0, dtype=bool)
179
+ mask = np.ones(len(rows), dtype=bool)
180
+ if filters.option_kind is not None:
181
+ kind = _coerce_kind(filters.option_kind)
182
+ mask &= tape.option_kind_code[rows] == (0 if kind is OptionKind.CALL else 1)
183
+ mask &= tape.expiry_ns[rows] > int(decision_timestamp_ns)
184
+ mask &= tape.bid_size[rows] >= float(filters.min_bid_size)
185
+ mask &= tape.ask_size[rows] >= float(filters.min_ask_size)
186
+ mask &= tape.open_interest[rows] >= float(filters.min_open_interest)
187
+ mask &= tape.volume[rows] >= float(filters.min_volume)
188
+ if filters.max_spread_bps is not None:
189
+ mid = 0.5 * (tape.bid_price[rows] + tape.ask_price[rows])
190
+ spread_bps = np.divide(
191
+ tape.ask_price[rows] - tape.bid_price[rows],
192
+ mid,
193
+ out=np.full(len(rows), np.inf, dtype=np.float64),
194
+ where=mid > 0.0,
195
+ ) * 10_000.0
196
+ mask &= spread_bps <= float(filters.max_spread_bps)
197
+ dte_days = _dte_years(tape, rows, decision_timestamp_ns) * 365.0
198
+ if filters.min_dte_days is not None:
199
+ mask &= dte_days >= float(filters.min_dte_days)
200
+ if filters.max_dte_days is not None:
201
+ mask &= dte_days <= float(filters.max_dte_days)
202
+ moneyness = tape.strike[rows] / tape.forward_price[rows]
203
+ if filters.min_moneyness is not None:
204
+ mask &= moneyness >= float(filters.min_moneyness)
205
+ if filters.max_moneyness is not None:
206
+ mask &= moneyness <= float(filters.max_moneyness)
207
+ if filters.require_mark_iv:
208
+ mask &= np.isfinite(tape.mark_iv[rows])
209
+ if filters.require_delta:
210
+ mask &= np.isfinite(tape.delta[rows])
211
+ return mask
212
+
213
+
214
+ def _build_selection(
215
+ tape: PreparedOptionTape,
216
+ row_index: int,
217
+ snapshot_index: int,
218
+ decision_timestamp_ns: int,
219
+ score: float,
220
+ ) -> OptionSelection:
221
+ mid = 0.5 * (float(tape.bid_price[row_index]) + float(tape.ask_price[row_index]))
222
+ kind = OptionKind.CALL if int(tape.option_kind_code[row_index]) == 0 else OptionKind.PUT
223
+ return OptionSelection(
224
+ row_index=row_index,
225
+ snapshot_index=snapshot_index,
226
+ snapshot_timestamp_ns=int(tape.timestamp_ns[snapshot_index]),
227
+ decision_timestamp_ns=int(decision_timestamp_ns),
228
+ instrument_id=tape.instrument_id[row_index],
229
+ instrument_code=int(tape.instrument_code[row_index]),
230
+ option_kind=kind,
231
+ expiry_ns=int(tape.expiry_ns[row_index]),
232
+ strike=float(tape.strike[row_index]),
233
+ dte_years=float((int(tape.expiry_ns[row_index]) - int(decision_timestamp_ns)) / YEAR_NS),
234
+ moneyness=float(tape.strike[row_index] / tape.forward_price[row_index]),
235
+ bid_price=float(tape.bid_price[row_index]),
236
+ ask_price=float(tape.ask_price[row_index]),
237
+ mark_price=float(tape.mark_price[row_index]),
238
+ mid_price=mid,
239
+ mark_iv=float(tape.mark_iv[row_index]),
240
+ delta=float(tape.delta[row_index]),
241
+ score=float(score),
242
+ )
243
+
244
+
245
+ def _dte_years(tape: PreparedOptionTape, rows: np.ndarray, decision_timestamp_ns: int) -> np.ndarray:
246
+ return (tape.expiry_ns[rows].astype(np.float64) - float(decision_timestamp_ns)) / float(YEAR_NS)
247
+
248
+
249
+ def _merge_require_delta(filters: Optional[OptionSelectionFilters]) -> OptionSelectionFilters:
250
+ if filters is None:
251
+ return OptionSelectionFilters(require_delta=True)
252
+ return OptionSelectionFilters(
253
+ option_kind=filters.option_kind,
254
+ min_bid_size=filters.min_bid_size,
255
+ min_ask_size=filters.min_ask_size,
256
+ max_spread_bps=filters.max_spread_bps,
257
+ min_open_interest=filters.min_open_interest,
258
+ min_volume=filters.min_volume,
259
+ min_dte_days=filters.min_dte_days,
260
+ max_dte_days=filters.max_dte_days,
261
+ min_moneyness=filters.min_moneyness,
262
+ max_moneyness=filters.max_moneyness,
263
+ require_mark_iv=filters.require_mark_iv,
264
+ require_delta=True,
265
+ )
266
+
267
+
268
+ def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind:
269
+ if isinstance(option_kind, OptionKind):
270
+ return option_kind
271
+ try:
272
+ return OptionKind(str(option_kind).lower())
273
+ except ValueError as exc:
274
+ raise ValueError("option_kind must be call or put") from exc
275
+
276
+
277
+ def _positive_days(value: float, name: str) -> float:
278
+ out = float(value)
279
+ if not np.isfinite(out) or out <= 0.0:
280
+ raise ValueError(f"{name} must be finite and > 0")
281
+ return out