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,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option margin and liquidation approximations.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import Dict, Optional, Protocol, Tuple
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from ..core.orders import Fill
|
|
14
|
+
from ..core.schema import LiquiditySide, OrderSide
|
|
15
|
+
from .ledger import OptionLedger
|
|
16
|
+
from .schema import OptionInstrumentSpec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OptionMarginModel(str, Enum):
|
|
20
|
+
LONG_PREMIUM_ONLY = "long_premium_only"
|
|
21
|
+
STANDARD_VENUE_APPROX = "standard_venue_approx"
|
|
22
|
+
SCENARIO_PM_APPROX = "scenario_pm_approx"
|
|
23
|
+
NO_MARGIN_RESEARCH = "no_margin_research"
|
|
24
|
+
EXTERNAL_VALIDATOR = "external_validator"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExternalOptionMarginValidator(Protocol):
|
|
28
|
+
def calculate_margin(
|
|
29
|
+
self,
|
|
30
|
+
ledger: OptionLedger,
|
|
31
|
+
instruments: Dict[str, OptionInstrumentSpec],
|
|
32
|
+
marks: Dict[str, float],
|
|
33
|
+
underlying_prices: Dict[str, float],
|
|
34
|
+
reporting_currency: str,
|
|
35
|
+
conversion_rates: Dict[str, float],
|
|
36
|
+
) -> "OptionMarginRequirement":
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class OptionMarginConfig:
|
|
42
|
+
model: OptionMarginModel = OptionMarginModel.STANDARD_VENUE_APPROX
|
|
43
|
+
maintenance_ratio: float = 0.20
|
|
44
|
+
long_option_margin_rate: float = 1.0
|
|
45
|
+
short_option_margin_rate: float = 0.15
|
|
46
|
+
scenario_shocks: Tuple[float, ...] = (-0.20, -0.10, 0.0, 0.10, 0.20)
|
|
47
|
+
liquidation_fee_rate: float = 0.0
|
|
48
|
+
metadata: Dict = field(default_factory=dict)
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
object.__setattr__(self, "model", _coerce_model(self.model))
|
|
52
|
+
if self.maintenance_ratio < 0.0:
|
|
53
|
+
raise ValueError("maintenance_ratio must be >= 0")
|
|
54
|
+
if self.long_option_margin_rate < 0.0 or self.short_option_margin_rate < 0.0:
|
|
55
|
+
raise ValueError("margin rates must be >= 0")
|
|
56
|
+
if self.liquidation_fee_rate < 0.0:
|
|
57
|
+
raise ValueError("liquidation_fee_rate must be >= 0")
|
|
58
|
+
if not self.scenario_shocks:
|
|
59
|
+
raise ValueError("scenario_shocks cannot be empty")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class OptionMarginRequirement:
|
|
64
|
+
initial_margin: float
|
|
65
|
+
maintenance_margin: float
|
|
66
|
+
model: OptionMarginModel
|
|
67
|
+
venue_exact: bool
|
|
68
|
+
reporting_currency: str
|
|
69
|
+
detail_report: pd.DataFrame
|
|
70
|
+
metadata: Dict = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class OptionLiquidationAudit:
|
|
75
|
+
breached: bool
|
|
76
|
+
breach_reason: str
|
|
77
|
+
equity_before: float
|
|
78
|
+
maintenance_margin: float
|
|
79
|
+
equity_after: float
|
|
80
|
+
final_cash: Dict[str, float]
|
|
81
|
+
final_positions: Dict[str, float]
|
|
82
|
+
liquidation_orders: pd.DataFrame
|
|
83
|
+
metadata: Dict = field(default_factory=dict)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def calculate_option_margin(
|
|
87
|
+
ledger: OptionLedger,
|
|
88
|
+
instruments: Dict[str, OptionInstrumentSpec],
|
|
89
|
+
marks: Dict[str, float],
|
|
90
|
+
underlying_prices: Dict[str, float],
|
|
91
|
+
*,
|
|
92
|
+
config: Optional[OptionMarginConfig] = None,
|
|
93
|
+
reporting_currency: str = "USD",
|
|
94
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
95
|
+
external_validator: Optional[ExternalOptionMarginValidator] = None,
|
|
96
|
+
) -> OptionMarginRequirement:
|
|
97
|
+
cfg = config or OptionMarginConfig()
|
|
98
|
+
rates = conversion_rates or {}
|
|
99
|
+
if cfg.model is OptionMarginModel.EXTERNAL_VALIDATOR:
|
|
100
|
+
if external_validator is None:
|
|
101
|
+
raise ValueError("external_validator is required for external margin model")
|
|
102
|
+
return external_validator.calculate_margin(ledger, instruments, marks, underlying_prices, reporting_currency, rates)
|
|
103
|
+
rows = []
|
|
104
|
+
total_initial = 0.0
|
|
105
|
+
for symbol, position in ledger.positions.items():
|
|
106
|
+
if position.is_flat:
|
|
107
|
+
continue
|
|
108
|
+
instrument = instruments.get(symbol)
|
|
109
|
+
if instrument is None:
|
|
110
|
+
raise ValueError(f"missing instrument for {symbol}")
|
|
111
|
+
mark = _positive_map_value(marks, symbol, "mark")
|
|
112
|
+
conversion = _conversion_rate(instrument.premium_currency, rates, reporting_currency)
|
|
113
|
+
qty = float(position.qty)
|
|
114
|
+
abs_qty = abs(qty)
|
|
115
|
+
long_value = max(qty, 0.0) * mark * instrument.multiplier * conversion
|
|
116
|
+
short_abs_value = max(-qty, 0.0) * mark * instrument.multiplier * conversion
|
|
117
|
+
underlying = _underlying_price(instrument, underlying_prices)
|
|
118
|
+
underlying_notional = abs_qty * underlying * instrument.multiplier * _conversion_rate(instrument.quote_currency, rates, reporting_currency)
|
|
119
|
+
if cfg.model is OptionMarginModel.NO_MARGIN_RESEARCH:
|
|
120
|
+
requirement = 0.0
|
|
121
|
+
reason = "research_no_margin"
|
|
122
|
+
elif cfg.model is OptionMarginModel.LONG_PREMIUM_ONLY:
|
|
123
|
+
requirement = long_value * cfg.long_option_margin_rate
|
|
124
|
+
reason = "long_premium_only"
|
|
125
|
+
elif cfg.model is OptionMarginModel.STANDARD_VENUE_APPROX:
|
|
126
|
+
requirement = long_value * cfg.long_option_margin_rate + max(short_abs_value, underlying_notional * cfg.short_option_margin_rate)
|
|
127
|
+
reason = "standard_short_notional_approx"
|
|
128
|
+
elif cfg.model is OptionMarginModel.SCENARIO_PM_APPROX:
|
|
129
|
+
requirement = _scenario_requirement(position_qty=qty, mark=mark, underlying=underlying, instrument=instrument, cfg=cfg, conversion=conversion)
|
|
130
|
+
reason = "scenario_pm_approx"
|
|
131
|
+
else:
|
|
132
|
+
raise ValueError(f"unsupported margin model: {cfg.model}")
|
|
133
|
+
total_initial += requirement
|
|
134
|
+
rows.append(
|
|
135
|
+
{
|
|
136
|
+
"symbol": symbol,
|
|
137
|
+
"qty": qty,
|
|
138
|
+
"mark": mark,
|
|
139
|
+
"underlying_price": underlying,
|
|
140
|
+
"premium_currency": instrument.premium_currency,
|
|
141
|
+
"requirement": float(requirement),
|
|
142
|
+
"reason": reason,
|
|
143
|
+
"venue_exact": False,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
maintenance = total_initial * cfg.maintenance_ratio
|
|
147
|
+
ledger.margin_locked[str(reporting_currency).upper()] = float(total_initial)
|
|
148
|
+
return OptionMarginRequirement(
|
|
149
|
+
initial_margin=float(total_initial),
|
|
150
|
+
maintenance_margin=float(maintenance),
|
|
151
|
+
model=cfg.model,
|
|
152
|
+
venue_exact=False,
|
|
153
|
+
reporting_currency=str(reporting_currency).upper(),
|
|
154
|
+
detail_report=pd.DataFrame(rows),
|
|
155
|
+
metadata={"venue_exact": False, **cfg.metadata},
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def liquidate_option_positions(
|
|
160
|
+
ledger: OptionLedger,
|
|
161
|
+
instruments: Dict[str, OptionInstrumentSpec],
|
|
162
|
+
*,
|
|
163
|
+
bid_prices: Dict[str, float],
|
|
164
|
+
ask_prices: Dict[str, float],
|
|
165
|
+
margin_requirement: OptionMarginRequirement,
|
|
166
|
+
conversion_rates: Dict[str, float],
|
|
167
|
+
reporting_currency: str = "USD",
|
|
168
|
+
timestamp_ns: int,
|
|
169
|
+
fee_rate: float = 0.0,
|
|
170
|
+
) -> OptionLiquidationAudit:
|
|
171
|
+
"""Liquidate all option positions with adverse bid/ask prices if breached."""
|
|
172
|
+
equity_before = ledger.equity(
|
|
173
|
+
conversion_rates=conversion_rates,
|
|
174
|
+
marks=_marks_from_bbo(bid_prices, ask_prices),
|
|
175
|
+
instruments=instruments,
|
|
176
|
+
reporting_currency=reporting_currency,
|
|
177
|
+
)
|
|
178
|
+
if equity_before >= margin_requirement.maintenance_margin:
|
|
179
|
+
return OptionLiquidationAudit(
|
|
180
|
+
breached=False,
|
|
181
|
+
breach_reason="equity_above_maintenance",
|
|
182
|
+
equity_before=float(equity_before),
|
|
183
|
+
maintenance_margin=float(margin_requirement.maintenance_margin),
|
|
184
|
+
equity_after=float(equity_before),
|
|
185
|
+
final_cash=dict(ledger.cash),
|
|
186
|
+
final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat},
|
|
187
|
+
liquidation_orders=pd.DataFrame(),
|
|
188
|
+
metadata={"venue_exact": margin_requirement.venue_exact},
|
|
189
|
+
)
|
|
190
|
+
rows = []
|
|
191
|
+
for symbol, position in list(ledger.positions.items()):
|
|
192
|
+
if position.is_flat:
|
|
193
|
+
continue
|
|
194
|
+
instrument = instruments.get(symbol)
|
|
195
|
+
if instrument is None:
|
|
196
|
+
raise ValueError(f"missing instrument for {symbol}")
|
|
197
|
+
if position.qty > 0.0:
|
|
198
|
+
side = OrderSide.SELL
|
|
199
|
+
price = _positive_map_value(bid_prices, symbol, "bid")
|
|
200
|
+
else:
|
|
201
|
+
side = OrderSide.BUY
|
|
202
|
+
price = _positive_map_value(ask_prices, symbol, "ask")
|
|
203
|
+
qty = abs(float(position.qty))
|
|
204
|
+
fee = qty * price * instrument.multiplier * float(fee_rate)
|
|
205
|
+
fill = Fill(
|
|
206
|
+
timestamp=int(timestamp_ns),
|
|
207
|
+
symbol=symbol,
|
|
208
|
+
side=side,
|
|
209
|
+
qty=qty,
|
|
210
|
+
price=price,
|
|
211
|
+
fee=fee,
|
|
212
|
+
liquidity=LiquiditySide.TAKER,
|
|
213
|
+
metadata={"liquidation": True, "adverse_bid_ask": True},
|
|
214
|
+
)
|
|
215
|
+
ledger.apply_fill(fill, instrument, timestamp_ns=timestamp_ns)
|
|
216
|
+
rows.append(
|
|
217
|
+
{
|
|
218
|
+
"timestamp_ns": int(timestamp_ns),
|
|
219
|
+
"symbol": symbol,
|
|
220
|
+
"side": side.value,
|
|
221
|
+
"qty": qty,
|
|
222
|
+
"price": price,
|
|
223
|
+
"fee": fee,
|
|
224
|
+
"reason": "maintenance_margin_breach",
|
|
225
|
+
"adverse_bid_ask": True,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
equity_after = ledger.equity(
|
|
229
|
+
conversion_rates=conversion_rates,
|
|
230
|
+
marks=_marks_from_bbo(bid_prices, ask_prices),
|
|
231
|
+
instruments=instruments,
|
|
232
|
+
reporting_currency=reporting_currency,
|
|
233
|
+
)
|
|
234
|
+
return OptionLiquidationAudit(
|
|
235
|
+
breached=True,
|
|
236
|
+
breach_reason="maintenance_margin_breach",
|
|
237
|
+
equity_before=float(equity_before),
|
|
238
|
+
maintenance_margin=float(margin_requirement.maintenance_margin),
|
|
239
|
+
equity_after=float(equity_after),
|
|
240
|
+
final_cash=dict(ledger.cash),
|
|
241
|
+
final_positions={symbol: pos.qty for symbol, pos in ledger.positions.items() if not pos.is_flat},
|
|
242
|
+
liquidation_orders=pd.DataFrame(rows),
|
|
243
|
+
metadata={
|
|
244
|
+
"venue_exact": margin_requirement.venue_exact,
|
|
245
|
+
"liquidation_sequence": "all_positions_adverse_bid_ask",
|
|
246
|
+
"fee_rate": float(fee_rate),
|
|
247
|
+
},
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _scenario_requirement(
|
|
252
|
+
*,
|
|
253
|
+
position_qty: float,
|
|
254
|
+
mark: float,
|
|
255
|
+
underlying: float,
|
|
256
|
+
instrument: OptionInstrumentSpec,
|
|
257
|
+
cfg: OptionMarginConfig,
|
|
258
|
+
conversion: float,
|
|
259
|
+
) -> float:
|
|
260
|
+
if position_qty >= 0.0:
|
|
261
|
+
return abs(position_qty) * mark * instrument.multiplier * conversion * cfg.long_option_margin_rate
|
|
262
|
+
worst_loss = 0.0
|
|
263
|
+
base_value = mark
|
|
264
|
+
for shock in cfg.scenario_shocks:
|
|
265
|
+
shocked_mark = max(mark * (1.0 + abs(float(shock)) * underlying / max(underlying, 1e-12)), 0.0)
|
|
266
|
+
pnl = float(position_qty) * (shocked_mark - base_value) * instrument.multiplier * conversion
|
|
267
|
+
worst_loss = max(worst_loss, -pnl)
|
|
268
|
+
floor = abs(position_qty) * underlying * instrument.multiplier * conversion * cfg.short_option_margin_rate
|
|
269
|
+
return max(worst_loss, floor)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _underlying_price(instrument: OptionInstrumentSpec, underlying_prices: Dict[str, float]) -> float:
|
|
273
|
+
if instrument.underlying_id in underlying_prices:
|
|
274
|
+
return _positive_map_value(underlying_prices, instrument.underlying_id, "underlying")
|
|
275
|
+
return _positive_map_value(underlying_prices, instrument.symbol, "underlying")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _marks_from_bbo(bid_prices: Dict[str, float], ask_prices: Dict[str, float]) -> Dict[str, float]:
|
|
279
|
+
symbols = set(bid_prices).union(ask_prices)
|
|
280
|
+
return {symbol: 0.5 * (_positive_map_value(bid_prices, symbol, "bid") + _positive_map_value(ask_prices, symbol, "ask")) for symbol in symbols}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _positive_map_value(values: Dict[str, float], key: str, label: str) -> float:
|
|
284
|
+
if key not in values:
|
|
285
|
+
raise ValueError(f"missing {label} for {key}")
|
|
286
|
+
value = float(values[key])
|
|
287
|
+
if value <= 0.0:
|
|
288
|
+
raise ValueError(f"{label} for {key} must be > 0")
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _conversion_rate(currency: str, conversion_rates: Dict[str, float], reporting_currency: str) -> float:
|
|
293
|
+
ccy = str(currency).upper()
|
|
294
|
+
report = str(reporting_currency).upper()
|
|
295
|
+
if ccy == report:
|
|
296
|
+
return 1.0
|
|
297
|
+
if ccy not in conversion_rates:
|
|
298
|
+
raise ValueError(f"missing conversion rate for {ccy}->{report}")
|
|
299
|
+
rate = float(conversion_rates[ccy])
|
|
300
|
+
if rate <= 0.0:
|
|
301
|
+
raise ValueError(f"conversion rate for {ccy}->{report} must be > 0")
|
|
302
|
+
return rate
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _coerce_model(value) -> OptionMarginModel:
|
|
306
|
+
if isinstance(value, OptionMarginModel):
|
|
307
|
+
return value
|
|
308
|
+
try:
|
|
309
|
+
return OptionMarginModel(str(value))
|
|
310
|
+
except ValueError as exc:
|
|
311
|
+
raise ValueError("invalid option margin model") from exc
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option package intents and compiler.
|
|
3
|
+
|
|
4
|
+
This layer turns option-domain package legs into QuantBT `OrderIntent` leaves.
|
|
5
|
+
It does not execute orders or maintain a ledger.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Dict, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from ..core.orders import OrderIntent
|
|
15
|
+
from ..core.schema import OrderSide, OrderType, TimeInForce
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OptionPackageExecutionPolicy(str, Enum):
|
|
19
|
+
ATOMIC_ALL_OR_NONE = "atomic_all_or_none"
|
|
20
|
+
BEST_EFFORT = "best_effort"
|
|
21
|
+
SEQUENTIAL = "sequential"
|
|
22
|
+
HEDGE_AFTER_PRIMARY = "hedge_after_primary"
|
|
23
|
+
REBALANCE_ONLY = "rebalance_only"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class OptionPackageLeg:
|
|
28
|
+
"""
|
|
29
|
+
One option leg inside a package.
|
|
30
|
+
|
|
31
|
+
`side` owns direction. `ratio` is always positive and scales from package
|
|
32
|
+
quantity, so callers cannot hide direction in a negative ratio.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
instrument_id: str
|
|
36
|
+
side: Union[OrderSide, str]
|
|
37
|
+
ratio: float
|
|
38
|
+
order_type: Union[OrderType, str] = OrderType.MARKET
|
|
39
|
+
limit_price: Optional[float] = None
|
|
40
|
+
tif: Union[TimeInForce, str] = TimeInForce.FOK
|
|
41
|
+
role: str = "leg"
|
|
42
|
+
tag: Optional[str] = None
|
|
43
|
+
metadata: Dict = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
def __post_init__(self) -> None:
|
|
46
|
+
object.__setattr__(self, "side", _coerce_enum(OrderSide, self.side, "side"))
|
|
47
|
+
object.__setattr__(self, "order_type", _coerce_enum(OrderType, self.order_type, "order_type"))
|
|
48
|
+
object.__setattr__(self, "tif", _coerce_enum(TimeInForce, self.tif, "tif"))
|
|
49
|
+
if not self.instrument_id:
|
|
50
|
+
raise ValueError("instrument_id is required")
|
|
51
|
+
if self.ratio <= 0.0:
|
|
52
|
+
raise ValueError("ratio must be > 0; side owns direction")
|
|
53
|
+
if self.order_type not in (OrderType.MARKET, OrderType.LIMIT):
|
|
54
|
+
raise ValueError("Phase 4 option package legs support market and limit orders only")
|
|
55
|
+
if self.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT):
|
|
56
|
+
if self.limit_price is None or self.limit_price <= 0.0:
|
|
57
|
+
raise ValueError("limit option legs require limit_price > 0")
|
|
58
|
+
if not self.role:
|
|
59
|
+
raise ValueError("role is required")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class OptionPackageIntent:
|
|
64
|
+
timestamp_ns: int
|
|
65
|
+
package_id: str
|
|
66
|
+
legs: Tuple[OptionPackageLeg, ...]
|
|
67
|
+
quantity: float = 1.0
|
|
68
|
+
execution_policy: Union[OptionPackageExecutionPolicy, str] = OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE
|
|
69
|
+
max_debit: Optional[float] = None
|
|
70
|
+
min_credit: Optional[float] = None
|
|
71
|
+
tag: Optional[str] = None
|
|
72
|
+
metadata: Dict = field(default_factory=dict)
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
object.__setattr__(
|
|
76
|
+
self,
|
|
77
|
+
"execution_policy",
|
|
78
|
+
_coerce_enum(OptionPackageExecutionPolicy, self.execution_policy, "execution_policy"),
|
|
79
|
+
)
|
|
80
|
+
object.__setattr__(self, "timestamp_ns", int(self.timestamp_ns))
|
|
81
|
+
object.__setattr__(self, "legs", tuple(self.legs))
|
|
82
|
+
if self.timestamp_ns <= 0:
|
|
83
|
+
raise ValueError("timestamp_ns must be > 0")
|
|
84
|
+
if not self.package_id:
|
|
85
|
+
raise ValueError("package_id is required")
|
|
86
|
+
if len(self.legs) == 0:
|
|
87
|
+
raise ValueError("OptionPackageIntent requires at least one leg")
|
|
88
|
+
if self.quantity <= 0.0:
|
|
89
|
+
raise ValueError("quantity must be > 0")
|
|
90
|
+
if self.max_debit is not None and self.max_debit < 0.0:
|
|
91
|
+
raise ValueError("max_debit must be >= 0")
|
|
92
|
+
if self.min_credit is not None and self.min_credit < 0.0:
|
|
93
|
+
raise ValueError("min_credit must be >= 0")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compile_option_package_orders(package: OptionPackageIntent) -> Tuple[OrderIntent, ...]:
|
|
97
|
+
"""Compile an option package to `OrderIntent` leaves with package metadata."""
|
|
98
|
+
orders = []
|
|
99
|
+
atomicity = _atomicity_label(package.execution_policy)
|
|
100
|
+
for leg_index, leg in enumerate(package.legs):
|
|
101
|
+
metadata = {
|
|
102
|
+
**leg.metadata,
|
|
103
|
+
"package_id": package.package_id,
|
|
104
|
+
"package_type": "option_package",
|
|
105
|
+
"option_package_id": package.package_id,
|
|
106
|
+
"option_leg_index": int(leg_index),
|
|
107
|
+
"option_leg_ratio": float(leg.ratio),
|
|
108
|
+
"option_leg_role": leg.role,
|
|
109
|
+
"option_execution_policy": package.execution_policy.value,
|
|
110
|
+
"atomicity": atomicity,
|
|
111
|
+
"exchange_combo": False,
|
|
112
|
+
"block_trade_style": False,
|
|
113
|
+
"simulated_atomicity": package.execution_policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE,
|
|
114
|
+
}
|
|
115
|
+
qty = float(package.quantity) * float(leg.ratio)
|
|
116
|
+
order = OrderIntent(
|
|
117
|
+
timestamp=package.timestamp_ns,
|
|
118
|
+
symbol=leg.instrument_id,
|
|
119
|
+
side=leg.side,
|
|
120
|
+
order_type=leg.order_type,
|
|
121
|
+
qty=qty,
|
|
122
|
+
price=leg.limit_price,
|
|
123
|
+
tif=leg.tif,
|
|
124
|
+
tag=leg.tag or package.tag,
|
|
125
|
+
metadata=metadata,
|
|
126
|
+
)
|
|
127
|
+
orders.append(order)
|
|
128
|
+
return tuple(orders)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _atomicity_label(policy: OptionPackageExecutionPolicy) -> str:
|
|
132
|
+
if policy is OptionPackageExecutionPolicy.ATOMIC_ALL_OR_NONE:
|
|
133
|
+
return "simulated_all_or_none"
|
|
134
|
+
if policy is OptionPackageExecutionPolicy.HEDGE_AFTER_PRIMARY:
|
|
135
|
+
return "simulated_primary_then_hedge"
|
|
136
|
+
if policy is OptionPackageExecutionPolicy.REBALANCE_ONLY:
|
|
137
|
+
return "simulated_rebalance_only"
|
|
138
|
+
return f"simulated_{policy.value}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _coerce_enum(enum_cls, value, field_name: str):
|
|
142
|
+
if isinstance(value, enum_cls):
|
|
143
|
+
return value
|
|
144
|
+
try:
|
|
145
|
+
return enum_cls(str(value))
|
|
146
|
+
except ValueError as exc:
|
|
147
|
+
raise ValueError(f"{field_name} must be one of {[item.value for item in enum_cls]}") from exc
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Option pricing primitives.
|
|
3
|
+
|
|
4
|
+
Phase 2 intentionally keeps pricing deterministic and scalar. Execution,
|
|
5
|
+
margin, expiry, and ledger accounting are added in later phases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
from .schema import OptionKind
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
Number = Union[int, float]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def black76_price(
|
|
20
|
+
forward: Number,
|
|
21
|
+
strike: Number,
|
|
22
|
+
time_to_expiry: Number,
|
|
23
|
+
volatility: Number,
|
|
24
|
+
option_kind: Union[OptionKind, str],
|
|
25
|
+
*,
|
|
26
|
+
discount: Number = 1.0,
|
|
27
|
+
) -> float:
|
|
28
|
+
"""Return linear Black-76 option value in quote currency per 1 underlying."""
|
|
29
|
+
kind = _coerce_kind(option_kind)
|
|
30
|
+
fwd, strike_, tau, vol, df = _validate_inputs(forward, strike, time_to_expiry, volatility, discount)
|
|
31
|
+
intrinsic = black76_intrinsic(fwd, strike_, kind, discount=df)
|
|
32
|
+
if tau <= 0.0 or vol <= 0.0:
|
|
33
|
+
return intrinsic
|
|
34
|
+
d1, d2 = black76_d1_d2(fwd, strike_, tau, vol)
|
|
35
|
+
if kind is OptionKind.CALL:
|
|
36
|
+
return df * (fwd * normal_cdf(d1) - strike_ * normal_cdf(d2))
|
|
37
|
+
return df * (strike_ * normal_cdf(-d2) - fwd * normal_cdf(-d1))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def black76_intrinsic(
|
|
41
|
+
forward: Number,
|
|
42
|
+
strike: Number,
|
|
43
|
+
option_kind: Union[OptionKind, str],
|
|
44
|
+
*,
|
|
45
|
+
discount: Number = 1.0,
|
|
46
|
+
) -> float:
|
|
47
|
+
"""Return discounted intrinsic value in quote currency."""
|
|
48
|
+
kind = _coerce_kind(option_kind)
|
|
49
|
+
fwd = _positive_float(forward, "forward")
|
|
50
|
+
strike_ = _positive_float(strike, "strike")
|
|
51
|
+
df = _positive_float(discount, "discount")
|
|
52
|
+
if kind is OptionKind.CALL:
|
|
53
|
+
return df * max(fwd - strike_, 0.0)
|
|
54
|
+
return df * max(strike_ - fwd, 0.0)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def black76_parity_value(forward: Number, strike: Number, *, discount: Number = 1.0) -> float:
|
|
58
|
+
"""Return theoretical linear call-put parity value: C - P."""
|
|
59
|
+
fwd = _positive_float(forward, "forward")
|
|
60
|
+
strike_ = _positive_float(strike, "strike")
|
|
61
|
+
df = _positive_float(discount, "discount")
|
|
62
|
+
return df * (fwd - strike_)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def black76_parity_residual(
|
|
66
|
+
call_price: Number,
|
|
67
|
+
put_price: Number,
|
|
68
|
+
forward: Number,
|
|
69
|
+
strike: Number,
|
|
70
|
+
*,
|
|
71
|
+
discount: Number = 1.0,
|
|
72
|
+
) -> float:
|
|
73
|
+
"""Return residual of linear Black-76 put-call parity."""
|
|
74
|
+
return float(call_price) - float(put_price) - black76_parity_value(forward, strike, discount=discount)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def inverse_black76_price_base(
|
|
78
|
+
forward: Number,
|
|
79
|
+
strike: Number,
|
|
80
|
+
time_to_expiry: Number,
|
|
81
|
+
volatility: Number,
|
|
82
|
+
option_kind: Union[OptionKind, str],
|
|
83
|
+
*,
|
|
84
|
+
discount: Number = 1.0,
|
|
85
|
+
) -> float:
|
|
86
|
+
"""
|
|
87
|
+
Return inverse option value in base settlement currency.
|
|
88
|
+
|
|
89
|
+
The Phase 2 convention prices inverse BTC/ETH options as the corresponding
|
|
90
|
+
forward Black-76 quote-currency option divided by forward. This gives the
|
|
91
|
+
expiry payoff shape `max(S-K, 0) / S` for calls and `max(K-S, 0) / S` for
|
|
92
|
+
puts, and locks inverse parity to `DF * (1 - K/F)`.
|
|
93
|
+
"""
|
|
94
|
+
fwd = _positive_float(forward, "forward")
|
|
95
|
+
return black76_price(fwd, strike, time_to_expiry, volatility, option_kind, discount=discount) / fwd
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def inverse_black76_intrinsic_base(
|
|
99
|
+
forward: Number,
|
|
100
|
+
strike: Number,
|
|
101
|
+
option_kind: Union[OptionKind, str],
|
|
102
|
+
*,
|
|
103
|
+
discount: Number = 1.0,
|
|
104
|
+
) -> float:
|
|
105
|
+
"""Return inverse intrinsic value in base settlement currency."""
|
|
106
|
+
fwd = _positive_float(forward, "forward")
|
|
107
|
+
return black76_intrinsic(fwd, strike, option_kind, discount=discount) / fwd
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def inverse_black76_parity_value_base(forward: Number, strike: Number, *, discount: Number = 1.0) -> float:
|
|
111
|
+
"""Return inverse call-put parity value in base settlement currency."""
|
|
112
|
+
fwd = _positive_float(forward, "forward")
|
|
113
|
+
strike_ = _positive_float(strike, "strike")
|
|
114
|
+
df = _positive_float(discount, "discount")
|
|
115
|
+
return df * (1.0 - strike_ / fwd)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def inverse_black76_parity_residual_base(
|
|
119
|
+
call_price_base: Number,
|
|
120
|
+
put_price_base: Number,
|
|
121
|
+
forward: Number,
|
|
122
|
+
strike: Number,
|
|
123
|
+
*,
|
|
124
|
+
discount: Number = 1.0,
|
|
125
|
+
) -> float:
|
|
126
|
+
"""Return residual of inverse put-call parity in base settlement currency."""
|
|
127
|
+
return (
|
|
128
|
+
float(call_price_base)
|
|
129
|
+
- float(put_price_base)
|
|
130
|
+
- inverse_black76_parity_value_base(forward, strike, discount=discount)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def black76_d1_d2(forward: Number, strike: Number, time_to_expiry: Number, volatility: Number) -> tuple[float, float]:
|
|
135
|
+
fwd = _positive_float(forward, "forward")
|
|
136
|
+
strike_ = _positive_float(strike, "strike")
|
|
137
|
+
tau = _non_negative_float(time_to_expiry, "time_to_expiry")
|
|
138
|
+
vol = _non_negative_float(volatility, "volatility")
|
|
139
|
+
if tau <= 0.0 or vol <= 0.0:
|
|
140
|
+
raise ValueError("d1/d2 require time_to_expiry > 0 and volatility > 0")
|
|
141
|
+
vol_sqrt_t = vol * math.sqrt(tau)
|
|
142
|
+
d1 = (math.log(fwd / strike_) + 0.5 * vol * vol * tau) / vol_sqrt_t
|
|
143
|
+
return d1, d1 - vol_sqrt_t
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normal_pdf(x: Number) -> float:
|
|
147
|
+
value = float(x)
|
|
148
|
+
return math.exp(-0.5 * value * value) / math.sqrt(2.0 * math.pi)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def normal_cdf(x: Number) -> float:
|
|
152
|
+
return 0.5 * (1.0 + math.erf(float(x) / math.sqrt(2.0)))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _coerce_kind(option_kind: Union[OptionKind, str]) -> OptionKind:
|
|
156
|
+
if isinstance(option_kind, OptionKind):
|
|
157
|
+
return option_kind
|
|
158
|
+
try:
|
|
159
|
+
return OptionKind(str(option_kind).lower())
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
raise ValueError("option_kind must be call or put") from exc
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _validate_inputs(
|
|
165
|
+
forward: Number,
|
|
166
|
+
strike: Number,
|
|
167
|
+
time_to_expiry: Number,
|
|
168
|
+
volatility: Number,
|
|
169
|
+
discount: Number,
|
|
170
|
+
) -> tuple[float, float, float, float, float]:
|
|
171
|
+
return (
|
|
172
|
+
_positive_float(forward, "forward"),
|
|
173
|
+
_positive_float(strike, "strike"),
|
|
174
|
+
_non_negative_float(time_to_expiry, "time_to_expiry"),
|
|
175
|
+
_non_negative_float(volatility, "volatility"),
|
|
176
|
+
_positive_float(discount, "discount"),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _positive_float(value: Number, name: str) -> float:
|
|
181
|
+
out = float(value)
|
|
182
|
+
if not math.isfinite(out) or out <= 0.0:
|
|
183
|
+
raise ValueError(f"{name} must be finite and > 0")
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _non_negative_float(value: Number, name: str) -> float:
|
|
188
|
+
out = float(value)
|
|
189
|
+
if not math.isfinite(out) or out < 0.0:
|
|
190
|
+
raise ValueError(f"{name} must be finite and >= 0")
|
|
191
|
+
return out
|