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