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,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