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,767 @@
1
+ """
2
+ quantbt.core.arbitrage
3
+ ----------------------
4
+ Phase A/B arbitrage domain schema and executable order-plan helpers.
5
+
6
+ This module intentionally stops short of a full ArbitrageBacktestEngine. It
7
+ defines the public domain objects and deterministic package order planning
8
+ needed by golden tests before engine implementation begins.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from enum import Enum
15
+ from math import floor, isfinite
16
+ from typing import Dict, Optional, Tuple
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from .orders import OrderIntent
22
+ from .preprocessor import align_series, validate_datetime
23
+ from .schema import OrderSide, OrderType, TimeInForce
24
+
25
+
26
+ def _coerce_enum(enum_cls, value):
27
+ if isinstance(value, enum_cls):
28
+ return value
29
+ return enum_cls(value)
30
+
31
+
32
+ class ArbitrageType(str, Enum):
33
+ BASIS = "basis"
34
+ CALENDAR_SPREAD = "calendar_spread"
35
+ FUNDING = "funding"
36
+ STAT_ARB_PAIR = "stat_arb_pair"
37
+ INDEX_BASKET = "index_basket"
38
+ TRIANGULAR = "triangular"
39
+ CROSS_EXCHANGE = "cross_exchange"
40
+ SPOT_PERP_CASH_CARRY = "spot_perp_cash_carry"
41
+ OPTIONS_VOL = "options_vol"
42
+
43
+
44
+ class ContractType(str, Enum):
45
+ LINEAR = "linear"
46
+ INVERSE = "inverse"
47
+ QUANTO = "quanto"
48
+ SPOT = "spot"
49
+ OPTION = "option"
50
+
51
+
52
+ class HedgePolicyKind(str, Enum):
53
+ BASE_QTY_EQUAL = "base_qty_equal"
54
+ DELTA_NEUTRAL = "delta_neutral"
55
+ NOTIONAL_NEUTRAL = "notional_neutral"
56
+ BETA_NEUTRAL = "beta_neutral"
57
+ VEGA_NEUTRAL = "vega_neutral"
58
+
59
+
60
+ class SizingPolicyKind(str, Enum):
61
+ TARGET_NOTIONAL_TO_BASE_QTY = "target_notional_to_base_qty"
62
+ TARGET_GROSS_NOTIONAL = "target_gross_notional"
63
+ TARGET_BASE_QTY = "target_base_qty"
64
+ EQUITY_FRACTION = "equity_fraction"
65
+
66
+
67
+ class PackageExecutionKind(str, Enum):
68
+ ATOMIC_ALL_OR_NONE = "atomic_all_or_none"
69
+ BEST_EFFORT = "best_effort"
70
+ SEQUENTIAL = "sequential"
71
+ HEDGE_AFTER_PRIMARY = "hedge_after_primary"
72
+ REBALANCE_ONLY = "rebalance_only"
73
+
74
+
75
+ class SpreadFormulaKind(str, Enum):
76
+ PRICE_DIFF = "price_diff"
77
+ LOG_RESIDUAL = "log_residual"
78
+ RATIO = "ratio"
79
+ ANNUALIZED_BASIS = "annualized_basis"
80
+ FUNDING_SPREAD = "funding_spread"
81
+ BASKET_RESIDUAL = "basket_residual"
82
+ TRIANGULAR = "triangular"
83
+ OPTIONS_VOL = "options_vol"
84
+ CUSTOM = "custom"
85
+
86
+
87
+ class SignalModelKind(str, Enum):
88
+ EXTERNAL = "external"
89
+ THRESHOLD = "threshold"
90
+ ZSCORE = "zscore"
91
+ CUSTOM = "custom"
92
+
93
+
94
+ class CostModelKind(str, Enum):
95
+ PER_LEG_FEE = "per_leg_fee"
96
+ FLAT_BPS = "flat_bps"
97
+ SPREAD_PLUS_FEE = "spread_plus_fee"
98
+ CUSTOM = "custom"
99
+
100
+
101
+ class CarryModelKind(str, Enum):
102
+ NONE = "none"
103
+ FUNDING = "funding"
104
+ BORROW = "borrow"
105
+ CASH_YIELD = "cash_yield"
106
+ FUNDING_AND_BORROW = "funding_and_borrow"
107
+ CUSTOM = "custom"
108
+
109
+
110
+ class MarginModelKind(str, Enum):
111
+ GROSS = "gross"
112
+ HEDGED_OFFSET = "hedged_offset"
113
+ PORTFOLIO = "portfolio"
114
+ VENUE = "venue"
115
+ CUSTOM = "custom"
116
+
117
+
118
+ class LifecycleModelKind(str, Enum):
119
+ OPEN_ENDED = "open_ended"
120
+ EXPIRY_SETTLEMENT = "expiry_settlement"
121
+ ROLLING = "rolling"
122
+ EXERCISE = "exercise"
123
+ CUSTOM = "custom"
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class SpreadFormula:
128
+ kind: SpreadFormulaKind = SpreadFormulaKind.CUSTOM
129
+ base_symbol: Optional[str] = None
130
+ quote_symbol: Optional[str] = None
131
+ fair_value: Optional[float] = None
132
+ annualization_days: float = 365.0
133
+ metadata: Dict = field(default_factory=dict)
134
+
135
+ def __post_init__(self) -> None:
136
+ object.__setattr__(self, "kind", _coerce_enum(SpreadFormulaKind, self.kind))
137
+ if self.fair_value is not None and not isfinite(float(self.fair_value)):
138
+ raise ValueError("fair_value must be finite")
139
+ if self.annualization_days <= 0.0:
140
+ raise ValueError("annualization_days must be > 0")
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class SignalModel:
145
+ kind: SignalModelKind = SignalModelKind.EXTERNAL
146
+ entry_threshold: Optional[float] = None
147
+ exit_threshold: Optional[float] = None
148
+ lookback: Optional[int] = None
149
+ metadata: Dict = field(default_factory=dict)
150
+
151
+ def __post_init__(self) -> None:
152
+ object.__setattr__(self, "kind", _coerce_enum(SignalModelKind, self.kind))
153
+ if self.lookback is not None and self.lookback <= 0:
154
+ raise ValueError("lookback must be > 0")
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class CostModel:
159
+ kind: CostModelKind = CostModelKind.PER_LEG_FEE
160
+ fee_bps: float = 0.0
161
+ slippage_bps: float = 0.0
162
+ spread_bps: float = 0.0
163
+ metadata: Dict = field(default_factory=dict)
164
+
165
+ def __post_init__(self) -> None:
166
+ object.__setattr__(self, "kind", _coerce_enum(CostModelKind, self.kind))
167
+ if self.fee_bps < 0.0 or self.slippage_bps < 0.0 or self.spread_bps < 0.0:
168
+ raise ValueError("cost bps values must be >= 0")
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class CarryModel:
173
+ kind: CarryModelKind = CarryModelKind.NONE
174
+ funding_interval_hours: Optional[float] = None
175
+ borrow_rate: float = 0.0
176
+ cash_yield: float = 0.0
177
+ metadata: Dict = field(default_factory=dict)
178
+
179
+ def __post_init__(self) -> None:
180
+ object.__setattr__(self, "kind", _coerce_enum(CarryModelKind, self.kind))
181
+ if self.funding_interval_hours is not None and self.funding_interval_hours <= 0.0:
182
+ raise ValueError("funding_interval_hours must be > 0")
183
+ if self.borrow_rate < 0.0:
184
+ raise ValueError("borrow_rate must be >= 0")
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class MarginModel:
189
+ kind: MarginModelKind = MarginModelKind.GROSS
190
+ hedged_margin_offset: float = 0.0
191
+ maintenance_ratio: Optional[float] = None
192
+ metadata: Dict = field(default_factory=dict)
193
+
194
+ def __post_init__(self) -> None:
195
+ object.__setattr__(self, "kind", _coerce_enum(MarginModelKind, self.kind))
196
+ if not 0.0 <= self.hedged_margin_offset <= 1.0:
197
+ raise ValueError("hedged_margin_offset must be in [0, 1]")
198
+ if self.maintenance_ratio is not None and self.maintenance_ratio < 0.0:
199
+ raise ValueError("maintenance_ratio must be >= 0")
200
+
201
+
202
+ @dataclass(frozen=True)
203
+ class LifecycleModel:
204
+ kind: LifecycleModelKind = LifecycleModelKind.OPEN_ENDED
205
+ roll_days_before_expiry: Optional[int] = None
206
+ force_flat_before_expiry: bool = True
207
+ metadata: Dict = field(default_factory=dict)
208
+
209
+ def __post_init__(self) -> None:
210
+ object.__setattr__(self, "kind", _coerce_enum(LifecycleModelKind, self.kind))
211
+ if self.roll_days_before_expiry is not None and self.roll_days_before_expiry < 0:
212
+ raise ValueError("roll_days_before_expiry must be >= 0")
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class ArbitrageLeg:
217
+ symbol: str
218
+ ratio: float
219
+ role: str = "leg"
220
+ venue: Optional[str] = None
221
+ asset_class: str = "future"
222
+ quote_currency: str = "USDT"
223
+ base_currency: Optional[str] = None
224
+ contract_type: ContractType = ContractType.LINEAR
225
+ contract_size: float = 1.0
226
+ qty_step: float = 0.0
227
+ min_qty: float = 0.0
228
+ min_notional: float = 0.0
229
+ tick_size: float = 0.0
230
+ fee_rate: Optional[float] = None
231
+ funding_enabled: bool = False
232
+ expiry: Optional[pd.Timestamp] = None
233
+ settlement_policy: Optional[str] = None
234
+ metadata: Dict = field(default_factory=dict)
235
+
236
+ def __post_init__(self) -> None:
237
+ object.__setattr__(self, "contract_type", _coerce_enum(ContractType, self.contract_type))
238
+ if not self.symbol:
239
+ raise ValueError("symbol is required")
240
+ if not isfinite(float(self.ratio)) or float(self.ratio) == 0.0:
241
+ raise ValueError("ratio must be finite and non-zero")
242
+ if self.contract_size <= 0.0:
243
+ raise ValueError("contract_size must be > 0")
244
+ if self.qty_step < 0.0:
245
+ raise ValueError("qty_step must be >= 0")
246
+ if self.min_qty < 0.0 or self.min_notional < 0.0:
247
+ raise ValueError("min_qty and min_notional must be >= 0")
248
+ if self.tick_size < 0.0:
249
+ raise ValueError("tick_size must be >= 0")
250
+ if self.fee_rate is not None and self.fee_rate < 0.0:
251
+ raise ValueError("fee_rate must be >= 0")
252
+ if self.expiry is not None:
253
+ expiry = pd.Timestamp(self.expiry)
254
+ if expiry.tz is None:
255
+ expiry = expiry.tz_localize("UTC")
256
+ else:
257
+ expiry = expiry.tz_convert("UTC")
258
+ object.__setattr__(self, "expiry", expiry)
259
+ if self.contract_type in (ContractType.LINEAR, ContractType.INVERSE, ContractType.QUANTO):
260
+ if self.asset_class not in ("future", "perp", "derivative", "crypto"):
261
+ raise ValueError("derivative contract legs must use future/perp/derivative asset_class")
262
+
263
+
264
+ @dataclass(frozen=True)
265
+ class HedgePolicy:
266
+ kind: HedgePolicyKind
267
+ freeze_on_entry: bool = True
268
+ rebalance_threshold: Optional[float] = None
269
+ rebalance_interval: Optional[str] = None
270
+ metadata: Dict = field(default_factory=dict)
271
+
272
+ def __post_init__(self) -> None:
273
+ object.__setattr__(self, "kind", _coerce_enum(HedgePolicyKind, self.kind))
274
+ if self.rebalance_threshold is not None and self.rebalance_threshold < 0.0:
275
+ raise ValueError("rebalance_threshold must be >= 0")
276
+
277
+
278
+ @dataclass(frozen=True)
279
+ class SizingPolicy:
280
+ kind: SizingPolicyKind
281
+ notional: Optional[float] = None
282
+ base_qty: Optional[float] = None
283
+ equity_fraction: Optional[float] = None
284
+ reference_symbol: Optional[str] = None
285
+ metadata: Dict = field(default_factory=dict)
286
+
287
+ def __post_init__(self) -> None:
288
+ object.__setattr__(self, "kind", _coerce_enum(SizingPolicyKind, self.kind))
289
+ if self.notional is not None and self.notional <= 0.0:
290
+ raise ValueError("notional must be > 0")
291
+ if self.base_qty is not None and self.base_qty <= 0.0:
292
+ raise ValueError("base_qty must be > 0")
293
+ if self.equity_fraction is not None and self.equity_fraction <= 0.0:
294
+ raise ValueError("equity_fraction must be > 0")
295
+ if self.kind in (SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY, SizingPolicyKind.TARGET_GROSS_NOTIONAL):
296
+ if self.notional is None:
297
+ raise ValueError(f"{self.kind.value} requires notional")
298
+ if self.kind is SizingPolicyKind.TARGET_BASE_QTY and self.base_qty is None:
299
+ raise ValueError("target_base_qty requires base_qty")
300
+
301
+
302
+ @dataclass(frozen=True)
303
+ class ArbExecutionPolicy:
304
+ kind: PackageExecutionKind = PackageExecutionKind.ATOMIC_ALL_OR_NONE
305
+ allow_partial_fill: bool = False
306
+ order_type: OrderType = OrderType.MARKET
307
+ tif: TimeInForce = TimeInForce.IOC
308
+ metadata: Dict = field(default_factory=dict)
309
+
310
+ def __post_init__(self) -> None:
311
+ object.__setattr__(self, "kind", _coerce_enum(PackageExecutionKind, self.kind))
312
+ object.__setattr__(self, "order_type", _coerce_enum(OrderType, self.order_type))
313
+ object.__setattr__(self, "tif", _coerce_enum(TimeInForce, self.tif))
314
+ if self.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE and self.allow_partial_fill:
315
+ raise ValueError("atomic_all_or_none cannot allow partial fills")
316
+ if self.kind is PackageExecutionKind.BEST_EFFORT and not self.allow_partial_fill:
317
+ object.__setattr__(self, "allow_partial_fill", True)
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class ArbitrageSpec:
322
+ arb_id: str
323
+ legs: Tuple[ArbitrageLeg, ...]
324
+ hedge_policy: HedgePolicy
325
+ sizing_policy: SizingPolicy
326
+ spread_formula: SpreadFormula = field(default_factory=SpreadFormula)
327
+ signal_model: SignalModel = field(default_factory=SignalModel)
328
+ cost_model: CostModel = field(default_factory=CostModel)
329
+ carry_model: CarryModel = field(default_factory=CarryModel)
330
+ margin_model: MarginModel = field(default_factory=MarginModel)
331
+ lifecycle_model: LifecycleModel = field(default_factory=LifecycleModel)
332
+ execution_policy: ArbExecutionPolicy = field(default_factory=ArbExecutionPolicy)
333
+ arb_type: ArbitrageType = ArbitrageType.BASIS
334
+ metadata: Dict = field(default_factory=dict)
335
+
336
+ def __post_init__(self) -> None:
337
+ object.__setattr__(self, "arb_type", _coerce_enum(ArbitrageType, self.arb_type))
338
+ if not self.arb_id:
339
+ raise ValueError("arb_id is required")
340
+ if len(self.legs) < 2:
341
+ raise ValueError("arbitrage spec requires at least two legs")
342
+ symbols = [leg.symbol for leg in self.legs]
343
+ if len(set(symbols)) != len(symbols):
344
+ raise ValueError("arbitrage legs must have unique symbols")
345
+ roles = [leg.role for leg in self.legs if leg.role and leg.role != "leg"]
346
+ if len(set(roles)) != len(roles):
347
+ raise ValueError("arbitrage legs must have unique roles")
348
+ if self.sizing_policy.reference_symbol is not None and self.sizing_policy.reference_symbol not in symbols:
349
+ raise ValueError("sizing_policy.reference_symbol must be one of the leg symbols")
350
+ if self.spread_formula.base_symbol is not None and self.spread_formula.base_symbol not in symbols:
351
+ raise ValueError("spread_formula.base_symbol must be one of the leg symbols")
352
+ if self.spread_formula.quote_symbol is not None and self.spread_formula.quote_symbol not in symbols:
353
+ raise ValueError("spread_formula.quote_symbol must be one of the leg symbols")
354
+ if self.lifecycle_model.kind in (LifecycleModelKind.EXPIRY_SETTLEMENT, LifecycleModelKind.ROLLING):
355
+ expiring = [leg for leg in self.legs if leg.expiry is not None]
356
+ if not expiring:
357
+ raise ValueError("expiry lifecycle requires at least one leg expiry")
358
+
359
+
360
+ @dataclass(frozen=True)
361
+ class BasisArbitrageSpec(ArbitrageSpec):
362
+ arb_type: ArbitrageType = ArbitrageType.BASIS
363
+
364
+ def __post_init__(self) -> None:
365
+ super().__post_init__()
366
+ if self.hedge_policy.kind not in (HedgePolicyKind.BASE_QTY_EQUAL, HedgePolicyKind.DELTA_NEUTRAL):
367
+ raise ValueError("BasisArbitrageSpec requires base_qty_equal or delta_neutral hedge policy")
368
+ linear_legs = [leg for leg in self.legs if leg.contract_type is ContractType.LINEAR]
369
+ if len(linear_legs) != len(self.legs):
370
+ # Inverse/quanto support is planned, but Phase A keeps the clean
371
+ # USDM linear basis contract explicit.
372
+ raise NotImplementedError("Phase A BasisArbitrageSpec supports linear legs only")
373
+
374
+
375
+ @dataclass(frozen=True)
376
+ class CalendarSpreadSpec(ArbitrageSpec):
377
+ arb_type: ArbitrageType = ArbitrageType.CALENDAR_SPREAD
378
+
379
+ def __post_init__(self) -> None:
380
+ super().__post_init__()
381
+ expiries = [leg.expiry for leg in self.legs]
382
+ if any(expiry is None for expiry in expiries):
383
+ raise ValueError("CalendarSpreadSpec requires expiry on every leg")
384
+ if len(set(expiries)) < 2:
385
+ raise ValueError("CalendarSpreadSpec requires at least two distinct expiries")
386
+
387
+
388
+ @dataclass(frozen=True)
389
+ class FundingArbitrageSpec(ArbitrageSpec):
390
+ arb_type: ArbitrageType = ArbitrageType.FUNDING
391
+
392
+ def __post_init__(self) -> None:
393
+ super().__post_init__()
394
+ if not any(leg.funding_enabled for leg in self.legs):
395
+ raise ValueError("FundingArbitrageSpec requires at least one funding-enabled leg")
396
+ if self.carry_model.kind not in (CarryModelKind.NONE, CarryModelKind.FUNDING, CarryModelKind.FUNDING_AND_BORROW, CarryModelKind.CUSTOM):
397
+ raise ValueError("FundingArbitrageSpec requires a funding-compatible carry model")
398
+
399
+
400
+ @dataclass(frozen=True)
401
+ class StatArbPairSpec(ArbitrageSpec):
402
+ arb_type: ArbitrageType = ArbitrageType.STAT_ARB_PAIR
403
+
404
+
405
+ @dataclass(frozen=True)
406
+ class IndexBasketArbSpec(ArbitrageSpec):
407
+ arb_type: ArbitrageType = ArbitrageType.INDEX_BASKET
408
+
409
+ def __post_init__(self) -> None:
410
+ super().__post_init__()
411
+ if len(self.legs) < 3:
412
+ raise ValueError("IndexBasketArbSpec requires at least three legs")
413
+ if self.sizing_policy.kind is not SizingPolicyKind.TARGET_GROSS_NOTIONAL:
414
+ raise ValueError("IndexBasketArbSpec requires target_gross_notional sizing")
415
+
416
+
417
+ @dataclass(frozen=True)
418
+ class TriangularArbSpec(ArbitrageSpec):
419
+ arb_type: ArbitrageType = ArbitrageType.TRIANGULAR
420
+
421
+ def __post_init__(self) -> None:
422
+ super().__post_init__()
423
+ if len(self.legs) != 3:
424
+ raise ValueError("TriangularArbSpec requires exactly three legs")
425
+ currencies = []
426
+ for leg in self.legs:
427
+ if not leg.base_currency or not leg.quote_currency:
428
+ raise ValueError("TriangularArbSpec requires base_currency and quote_currency on every leg")
429
+ currencies.append((leg.base_currency, leg.quote_currency))
430
+ unique_currencies = {currency for pair in currencies for currency in pair}
431
+ if len(unique_currencies) != 3:
432
+ raise ValueError("TriangularArbSpec requires exactly three currencies")
433
+
434
+
435
+ @dataclass(frozen=True)
436
+ class CrossExchangeArbSpec(ArbitrageSpec):
437
+ arb_type: ArbitrageType = ArbitrageType.CROSS_EXCHANGE
438
+
439
+ def __post_init__(self) -> None:
440
+ super().__post_init__()
441
+ venues = [leg.venue for leg in self.legs]
442
+ if any(venue is None or venue == "" for venue in venues):
443
+ raise ValueError("CrossExchangeArbSpec requires venue on every leg")
444
+ if len(set(venues)) < 2:
445
+ raise ValueError("CrossExchangeArbSpec requires at least two venues")
446
+
447
+
448
+ @dataclass(frozen=True)
449
+ class SpotPerpCashCarrySpec(ArbitrageSpec):
450
+ arb_type: ArbitrageType = ArbitrageType.SPOT_PERP_CASH_CARRY
451
+
452
+ def __post_init__(self) -> None:
453
+ super().__post_init__()
454
+ has_spot = any(leg.contract_type is ContractType.SPOT for leg in self.legs)
455
+ has_derivative = any(leg.contract_type in (ContractType.LINEAR, ContractType.INVERSE, ContractType.QUANTO) for leg in self.legs)
456
+ if not has_spot or not has_derivative:
457
+ raise ValueError("SpotPerpCashCarrySpec requires at least one spot leg and one derivative leg")
458
+ if not any(leg.funding_enabled for leg in self.legs if leg.contract_type is not ContractType.SPOT):
459
+ raise ValueError("SpotPerpCashCarrySpec requires a funding-enabled derivative leg")
460
+ if self.hedge_policy.kind not in (HedgePolicyKind.BASE_QTY_EQUAL, HedgePolicyKind.DELTA_NEUTRAL):
461
+ raise ValueError("SpotPerpCashCarrySpec requires base_qty_equal or delta_neutral hedge policy")
462
+
463
+
464
+ @dataclass(frozen=True)
465
+ class OptionsVolArbSpec(ArbitrageSpec):
466
+ arb_type: ArbitrageType = ArbitrageType.OPTIONS_VOL
467
+
468
+ def __post_init__(self) -> None:
469
+ super().__post_init__()
470
+ if not any(leg.contract_type is ContractType.OPTION for leg in self.legs):
471
+ raise ValueError("OptionsVolArbSpec requires at least one option leg")
472
+ if self.hedge_policy.kind not in (HedgePolicyKind.VEGA_NEUTRAL, HedgePolicyKind.DELTA_NEUTRAL):
473
+ raise ValueError("OptionsVolArbSpec requires vega_neutral or delta_neutral hedge policy")
474
+
475
+
476
+ @dataclass(frozen=True)
477
+ class PackageRejection:
478
+ timestamp: object
479
+ arb_id: str
480
+ reason: str
481
+ failed_legs: Tuple[str, ...]
482
+ metadata: Dict = field(default_factory=dict)
483
+
484
+
485
+ @dataclass(frozen=True)
486
+ class ArbitragePlan:
487
+ spec: ArbitrageSpec
488
+ orders: Tuple[OrderIntent, ...]
489
+ target_units: pd.DataFrame
490
+ signals: pd.Series
491
+ entry_ratios: pd.DataFrame
492
+ rejections: Tuple[PackageRejection, ...] = ()
493
+ metadata: Dict = field(default_factory=dict)
494
+
495
+ @property
496
+ def rejection_report(self) -> pd.DataFrame:
497
+ rows = [
498
+ {
499
+ "timestamp": rejection.timestamp,
500
+ "arb_id": rejection.arb_id,
501
+ "reason": rejection.reason,
502
+ "failed_legs": ",".join(rejection.failed_legs),
503
+ **rejection.metadata,
504
+ }
505
+ for rejection in self.rejections
506
+ ]
507
+ return pd.DataFrame(rows)
508
+
509
+
510
+ def build_arbitrage_order_plan(
511
+ datetime_index,
512
+ spec: ArbitrageSpec,
513
+ signal: pd.Series,
514
+ closes: Dict[str, pd.Series],
515
+ hedge_ratios: Optional[Dict[str, pd.Series]] = None,
516
+ min_abs_delta: float = 1e-12,
517
+ ) -> ArbitragePlan:
518
+ """
519
+ Convert a scalar arbitrage signal into package leg orders.
520
+
521
+ Phase A behavior is deterministic by design:
522
+
523
+ * units are computed on signal transitions only;
524
+ * units are frozen while signal is unchanged;
525
+ * package precision/min-notional rejects are explicit;
526
+ * atomic policy rejects the whole package;
527
+ * best-effort policy keeps valid legs and records rejected legs.
528
+ """
529
+ if min_abs_delta < 0.0:
530
+ raise ValueError("min_abs_delta must be >= 0")
531
+
532
+ idx = validate_datetime(datetime_index)
533
+ symbols = [leg.symbol for leg in spec.legs]
534
+ if not set(symbols).issubset(closes.keys()):
535
+ missing = sorted(set(symbols) - set(closes.keys()))
536
+ raise ValueError(f"missing closes for arbitrage legs: {missing}")
537
+
538
+ close_dict = align_series(closes, symbols, idx)
539
+ _validate_plan_market_data(close_dict, symbols, idx)
540
+ _reject_unsupported_contract_sizing(spec)
541
+ sig = _align_signal(signal, idx)
542
+ ratio_dict = _build_ratio_series(spec, hedge_ratios, symbols, idx)
543
+
544
+ orders = []
545
+ rejections = []
546
+ current_units = {symbol: 0.0 for symbol in symbols}
547
+ current_signal = 0.0
548
+ target_rows = []
549
+ ratio_rows = []
550
+
551
+ for ts in idx:
552
+ raw_signal = float(sig.loc[ts])
553
+ if abs(raw_signal) < min_abs_delta:
554
+ raw_signal = 0.0
555
+
556
+ changed = abs(raw_signal - current_signal) > min_abs_delta
557
+ if changed:
558
+ target_units = _compute_target_units(spec, raw_signal, symbols, ts, close_dict, ratio_dict)
559
+ failed = _validate_target_units(spec, target_units, ts, close_dict)
560
+ if failed:
561
+ rejections.append(
562
+ PackageRejection(
563
+ timestamp=ts,
564
+ arb_id=spec.arb_id,
565
+ reason="precision_or_min_notional",
566
+ failed_legs=tuple(failed.keys()),
567
+ metadata={"details": failed, "policy": spec.execution_policy.kind.value},
568
+ )
569
+ )
570
+ if spec.execution_policy.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE:
571
+ target_units = dict(current_units)
572
+ elif spec.execution_policy.kind is PackageExecutionKind.BEST_EFFORT:
573
+ for failed_symbol in failed:
574
+ target_units[failed_symbol] = current_units[failed_symbol]
575
+
576
+ for symbol in symbols:
577
+ delta = target_units[symbol] - current_units[symbol]
578
+ if abs(delta) <= min_abs_delta:
579
+ continue
580
+ side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL
581
+ orders.append(
582
+ OrderIntent(
583
+ timestamp=ts,
584
+ symbol=symbol,
585
+ side=side,
586
+ order_type=spec.execution_policy.order_type,
587
+ qty=abs(delta),
588
+ tif=spec.execution_policy.tif,
589
+ tag=spec.arb_id,
590
+ metadata={
591
+ "arb_id": spec.arb_id,
592
+ "arb_type": spec.arb_type.value,
593
+ "package_policy": spec.execution_policy.kind.value,
594
+ "hedge_policy": spec.hedge_policy.kind.value,
595
+ "sizing_policy": spec.sizing_policy.kind.value,
596
+ "target_units": target_units[symbol],
597
+ "previous_units": current_units[symbol],
598
+ },
599
+ )
600
+ )
601
+ current_units[symbol] = target_units[symbol]
602
+
603
+ current_signal = raw_signal
604
+
605
+ target_rows.append({symbol: current_units[symbol] for symbol in symbols})
606
+ ratio_rows.append({symbol: float(ratio_dict[symbol].loc[ts]) for symbol in symbols})
607
+
608
+ return ArbitragePlan(
609
+ spec=spec,
610
+ orders=tuple(orders),
611
+ target_units=pd.DataFrame(target_rows, index=idx),
612
+ signals=sig,
613
+ entry_ratios=pd.DataFrame(ratio_rows, index=idx),
614
+ rejections=tuple(rejections),
615
+ metadata={
616
+ "arb_id": spec.arb_id,
617
+ "arb_type": spec.arb_type.value,
618
+ "execution_policy": spec.execution_policy.kind.value,
619
+ "hedge_policy": spec.hedge_policy.kind.value,
620
+ "sizing_policy": spec.sizing_policy.kind.value,
621
+ },
622
+ )
623
+
624
+
625
+ def round_down_to_step(value: float, step: float) -> float:
626
+ if value < 0.0:
627
+ raise ValueError("value must be >= 0")
628
+ if step < 0.0:
629
+ raise ValueError("step must be >= 0")
630
+ if step == 0.0:
631
+ return value
632
+ return floor((value + 1e-15) / step) * step
633
+
634
+
635
+ def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
636
+ if not isinstance(signal, pd.Series):
637
+ signal = pd.Series(signal, index=idx)
638
+ else:
639
+ signal = signal.copy()
640
+ if isinstance(signal.index, pd.DatetimeIndex):
641
+ signal.index = signal.index.tz_localize("UTC") if signal.index.tz is None else signal.index.tz_convert("UTC")
642
+ return signal.reindex(idx, method="ffill").fillna(0.0).astype(float)
643
+
644
+
645
+ def _build_ratio_series(
646
+ spec: ArbitrageSpec,
647
+ hedge_ratios: Optional[Dict[str, pd.Series]],
648
+ symbols: list[str],
649
+ idx: pd.DatetimeIndex,
650
+ ) -> Dict[str, pd.Series]:
651
+ defaults = {leg.symbol: float(leg.ratio) for leg in spec.legs}
652
+ if hedge_ratios is None:
653
+ return {symbol: pd.Series(defaults[symbol], index=idx, dtype=float) for symbol in symbols}
654
+
655
+ out = {}
656
+ for symbol in symbols:
657
+ value = hedge_ratios.get(symbol, defaults[symbol])
658
+ if isinstance(value, pd.Series):
659
+ series = value.copy()
660
+ if isinstance(series.index, pd.DatetimeIndex):
661
+ series.index = series.index.tz_localize("UTC") if series.index.tz is None else series.index.tz_convert("UTC")
662
+ out[symbol] = series.reindex(idx, method="ffill").fillna(defaults[symbol]).astype(float)
663
+ else:
664
+ out[symbol] = pd.Series(float(value), index=idx, dtype=float)
665
+ return out
666
+
667
+
668
+ def _compute_target_units(
669
+ spec: ArbitrageSpec,
670
+ signal_value: float,
671
+ symbols: list[str],
672
+ timestamp,
673
+ closes: Dict[str, pd.Series],
674
+ ratios: Dict[str, pd.Series],
675
+ ) -> Dict[str, float]:
676
+ if signal_value == 0.0:
677
+ return {symbol: 0.0 for symbol in symbols}
678
+
679
+ side = 1.0 if signal_value > 0.0 else -1.0
680
+ magnitude = abs(signal_value)
681
+ if spec.sizing_policy.kind is SizingPolicyKind.TARGET_BASE_QTY:
682
+ base_qty = float(spec.sizing_policy.base_qty) * magnitude
683
+ elif spec.sizing_policy.kind is SizingPolicyKind.TARGET_NOTIONAL_TO_BASE_QTY:
684
+ reference_symbol = spec.sizing_policy.reference_symbol or symbols[0]
685
+ reference_price = float(closes[reference_symbol].loc[timestamp])
686
+ if reference_price <= 0.0:
687
+ base_qty = 0.0
688
+ else:
689
+ raw_qty = float(spec.sizing_policy.notional) * magnitude / reference_price
690
+ base_qty = _round_to_common_step(raw_qty, spec.legs)
691
+ elif spec.sizing_policy.kind is SizingPolicyKind.TARGET_GROSS_NOTIONAL:
692
+ gross_unit_notional = 0.0
693
+ for symbol in symbols:
694
+ gross_unit_notional += abs(float(ratios[symbol].loc[timestamp])) * float(closes[symbol].loc[timestamp])
695
+ if gross_unit_notional <= 0.0:
696
+ base_qty = 0.0
697
+ else:
698
+ base_qty = float(spec.sizing_policy.notional) * magnitude / gross_unit_notional
699
+ else:
700
+ raise NotImplementedError("equity_fraction sizing is reserved for engine phase")
701
+
702
+ return {
703
+ symbol: base_qty * float(ratios[symbol].loc[timestamp]) * side
704
+ for symbol in symbols
705
+ }
706
+
707
+
708
+ def _validate_plan_market_data(
709
+ closes: Dict[str, pd.Series],
710
+ symbols: list[str],
711
+ idx: pd.DatetimeIndex,
712
+ ) -> None:
713
+ for symbol in symbols:
714
+ prices = pd.to_numeric(closes[symbol], errors="coerce").reindex(idx)
715
+ values = prices.to_numpy(dtype=float)
716
+ bad = prices.isna().to_numpy() | ~np.isfinite(values) | (values <= 0.0)
717
+ if bool(bad.any()):
718
+ first_bad = prices.index[bad][0]
719
+ raise ValueError(
720
+ f"arbitrage closes must be finite and > 0 for {symbol!r}; "
721
+ f"first bad timestamp={first_bad}"
722
+ )
723
+
724
+
725
+ def _reject_unsupported_contract_sizing(spec: ArbitrageSpec) -> None:
726
+ unsupported = [leg.symbol for leg in spec.legs if leg.contract_type in (ContractType.INVERSE, ContractType.QUANTO)]
727
+ if unsupported:
728
+ raise NotImplementedError(
729
+ "inverse/quanto contract sizing is not implemented in arbitrage order planning; "
730
+ f"unsupported legs={unsupported}"
731
+ )
732
+
733
+
734
+ def _round_to_common_step(value: float, legs: Tuple[ArbitrageLeg, ...]) -> float:
735
+ out = value
736
+ for leg in legs:
737
+ out = round_down_to_step(out, leg.qty_step)
738
+ return out
739
+
740
+
741
+ def _validate_target_units(
742
+ spec: ArbitrageSpec,
743
+ target_units: Dict[str, float],
744
+ timestamp,
745
+ closes: Dict[str, pd.Series],
746
+ ) -> Dict[str, Dict[str, float]]:
747
+ failed: Dict[str, Dict[str, float]] = {}
748
+ for leg in spec.legs:
749
+ qty = abs(float(target_units[leg.symbol]))
750
+ if qty == 0.0:
751
+ continue
752
+ price = float(closes[leg.symbol].loc[timestamp])
753
+ notional = qty * price * leg.contract_size
754
+ reasons = {}
755
+ if leg.min_qty > 0.0 and qty < leg.min_qty:
756
+ reasons["min_qty"] = leg.min_qty
757
+ if leg.min_notional > 0.0 and notional < leg.min_notional:
758
+ reasons["min_notional"] = leg.min_notional
759
+ if leg.qty_step > 0.0:
760
+ rounded = round_down_to_step(qty, leg.qty_step)
761
+ if abs(qty - rounded) > 1e-12:
762
+ reasons["qty_step"] = leg.qty_step
763
+ if reasons:
764
+ reasons["qty"] = qty
765
+ reasons["notional"] = notional
766
+ failed[leg.symbol] = reasons
767
+ return failed