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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- 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
|