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,1299 @@
1
+ """
2
+ quantbt.backends.native_vectorized
3
+ ----------------------------------
4
+ V2 backend facade over Numba vectorized kernels.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Dict, List, Optional, Union
11
+ import warnings
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from ..core.preprocessor import (
17
+ PreparedMarketArrays,
18
+ align_series,
19
+ build_arrays,
20
+ build_market_arrays,
21
+ build_signal_matrix,
22
+ market_data_signature,
23
+ prepare_funding,
24
+ validate_datetime,
25
+ )
26
+ from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix
27
+ from ..core.results import BacktestResultV2
28
+ from ..core.schema import (
29
+ AccountConfig,
30
+ BasketLegSpec,
31
+ BasketSpec,
32
+ ExecutionConfig,
33
+ FillPricePolicy,
34
+ InstrumentSpec,
35
+ SameBarPolicy,
36
+ )
37
+ from ..core.vectorized import _engine_units_v2
38
+ from ..core.arbitrage import (
39
+ ArbitrageSpec,
40
+ ArbitragePlan,
41
+ BasisArbitrageSpec,
42
+ CalendarSpreadSpec,
43
+ CrossExchangeArbSpec,
44
+ FundingArbitrageSpec,
45
+ IndexBasketArbSpec,
46
+ OptionsVolArbSpec,
47
+ PackageExecutionKind,
48
+ PackageRejection,
49
+ SizingPolicyKind,
50
+ SpotPerpCashCarrySpec,
51
+ StatArbPairSpec,
52
+ TriangularArbSpec,
53
+ build_arbitrage_order_plan,
54
+ )
55
+ from ..core.basket import build_frozen_basket_orders
56
+ from ..core.orders import OrderIntent
57
+ from ..core.preprocessor import make_funding_mask
58
+ from ..core.schema import OrderSide
59
+ from ..sizing.fast import scale_signal_notional_matrix
60
+ from ..sizing.modes import compute_target_units
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class NativeVectorizedConfig:
65
+ account: AccountConfig
66
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
67
+ fee_rate: Union[float, Dict[str, float]] = 0.0
68
+ use_funding: bool = True
69
+
70
+ def __post_init__(self) -> None:
71
+ if isinstance(self.fee_rate, dict):
72
+ if any(float(rate) < 0.0 for rate in self.fee_rate.values()):
73
+ raise ValueError("fee_rate must be >= 0")
74
+ elif float(self.fee_rate) < 0.0:
75
+ raise ValueError("fee_rate must be >= 0")
76
+ unsupported = []
77
+ if self.execution.fill_price_policy is not FillPricePolicy.CLOSE:
78
+ unsupported.append(f"fill_price_policy={self.execution.fill_price_policy.value!r}")
79
+ if self.execution.same_bar_policy is not SameBarPolicy.CONSERVATIVE:
80
+ unsupported.append(f"same_bar_policy={self.execution.same_bar_policy.value!r}")
81
+ if self.execution.allow_partial_fill:
82
+ unsupported.append("allow_partial_fill=True")
83
+ if self.execution.min_order_notional > 0.0:
84
+ unsupported.append("min_order_notional")
85
+ if not self.execution.reject_on_insufficient_margin:
86
+ unsupported.append("reject_on_insufficient_margin=False")
87
+ if unsupported:
88
+ raise NotImplementedError(
89
+ "native_vectorized is the close_target_v2 contract and does not support "
90
+ + ", ".join(unsupported)
91
+ )
92
+
93
+
94
+ class NativeVectorizedBackend:
95
+ """
96
+ Fast vectorized backend returning BacktestResultV2 diagnostics.
97
+
98
+ This initial Phase 2 backend consumes pre-scaled target units. Sizing modes
99
+ remain in the existing public wrappers and will be migrated onto this backend
100
+ incrementally.
101
+ """
102
+
103
+ def __init__(self, config: NativeVectorizedConfig):
104
+ self.config = config
105
+
106
+ @staticmethod
107
+ def _close_target_metadata(
108
+ *,
109
+ symbol_list: List[str],
110
+ idx: pd.DatetimeIndex,
111
+ high_low_source: str,
112
+ first_bar_policy: str,
113
+ ) -> Dict:
114
+ signature = market_data_signature(idx, symbol_list)
115
+ return {
116
+ "backend": "native_vectorized",
117
+ "backend_alias": "native_vectorized",
118
+ "engine": "close_target_v2",
119
+ "engine_id": "close_target_v2",
120
+ "kernel_version": "units_v2",
121
+ "execution_contract": {
122
+ "engine_id": "close_target_v2",
123
+ "signal_phase": "bar_close",
124
+ "fill_phase": "same_close",
125
+ "intrabar_exit_model": "none",
126
+ "market_fill_policy": "close",
127
+ "timeline": "mark close[t-1]->close[t], rebalance target at close[t]",
128
+ "accounting_certified": True,
129
+ "execution_generated_by_engine": True,
130
+ },
131
+ "signal_phase": "bar_close",
132
+ "fill_phase": "same_close",
133
+ "intrabar_exit_model": "none",
134
+ "first_bar_target_policy": first_bar_policy,
135
+ "high_low_source": high_low_source,
136
+ "data_signature": signature,
137
+ }
138
+
139
+ @staticmethod
140
+ def _high_low_source(highs, lows) -> str:
141
+ if highs is None and lows is None:
142
+ return "close_fallback_uncertified_intrabar_risk"
143
+ if highs is None:
144
+ return "high_close_fallback_uncertified_intrabar_risk"
145
+ if lows is None:
146
+ return "low_close_fallback_uncertified_intrabar_risk"
147
+ return "provided"
148
+
149
+ @staticmethod
150
+ def _warn_high_low_fallback(high_low_source: str) -> None:
151
+ if high_low_source != "provided":
152
+ warnings.warn(
153
+ "native_vectorized close_target_v2 received missing high/low data and will use close fallback; "
154
+ "intrabar liquidation/risk is uncertified for this run. Pass explicit highs/lows for certified risk.",
155
+ RuntimeWarning,
156
+ stacklevel=3,
157
+ )
158
+
159
+ def prepare_market_arrays(
160
+ self,
161
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
162
+ closes: Dict[str, pd.Series],
163
+ highs: Optional[Dict[str, pd.Series]] = None,
164
+ lows: Optional[Dict[str, pd.Series]] = None,
165
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
166
+ symbols: Optional[List[str]] = None,
167
+ ) -> PreparedMarketArrays:
168
+ """
169
+ Normalize single-symbol or multi-symbol market data once for repeated
170
+ signal-notional scoring loops.
171
+
172
+ The prepared object is a copied ndarray snapshot plus an explicit
173
+ datetime/symbol signature. `run_signals` rejects it when reused against
174
+ a different index or symbol layout.
175
+ """
176
+ idx = validate_datetime(datetime_index)
177
+ symbol_list = symbols or list(closes.keys())
178
+ close_dict = align_series(closes, symbol_list, idx)
179
+ high_low_source = self._high_low_source(highs, lows)
180
+ self._warn_high_low_fallback(high_low_source)
181
+ high_dict = align_series(highs, symbol_list, idx, fallback=close_dict)
182
+ low_dict = align_series(lows, symbol_list, idx, fallback=close_dict)
183
+ funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx)
184
+ market = build_market_arrays(
185
+ symbols=symbol_list,
186
+ idx=idx,
187
+ closes_dict=close_dict,
188
+ highs_dict=high_dict,
189
+ lows_dict=low_dict,
190
+ funding_dict=funding_dict,
191
+ )
192
+ return market
193
+
194
+ def run_target_units(
195
+ self,
196
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
197
+ target_units: Dict[str, pd.Series],
198
+ closes: Dict[str, pd.Series],
199
+ highs: Optional[Dict[str, pd.Series]] = None,
200
+ lows: Optional[Dict[str, pd.Series]] = None,
201
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
202
+ contract_size: Union[float, Dict[str, float]] = 1.0,
203
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
204
+ fee_rate: Optional[Union[float, Dict[str, float]]] = None,
205
+ symbols: Optional[List[str]] = None,
206
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
207
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
208
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
209
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
210
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
211
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
212
+ ) -> BacktestResultV2:
213
+ idx = validate_datetime(datetime_index)
214
+ symbol_list = symbols or list(target_units.keys())
215
+ if set(symbol_list) != set(target_units.keys()) or set(symbol_list) != set(closes.keys()):
216
+ raise ValueError("symbols, target_units, and closes must contain the same keys")
217
+
218
+ close_dict = align_series(closes, symbol_list, idx)
219
+ high_low_source = self._high_low_source(highs, lows)
220
+ self._warn_high_low_fallback(high_low_source)
221
+ high_dict = align_series(highs, symbol_list, idx, fallback=close_dict)
222
+ low_dict = align_series(lows, symbol_list, idx, fallback=close_dict)
223
+ target_dict = align_series(target_units, symbol_list, idx, fill_val=0.0)
224
+ funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx)
225
+
226
+ closes_m, highs_m, lows_m, target_m, funding_m, is_funding = build_arrays(
227
+ symbols=symbol_list,
228
+ idx=idx,
229
+ closes_dict=close_dict,
230
+ highs_dict=high_dict,
231
+ lows_dict=low_dict,
232
+ signals_dict=target_dict,
233
+ funding_dict=funding_dict,
234
+ )
235
+
236
+ return self._run_target_arrays(
237
+ idx=idx,
238
+ symbol_list=symbol_list,
239
+ closes_m=closes_m,
240
+ highs_m=highs_m,
241
+ lows_m=lows_m,
242
+ target_m=target_m,
243
+ funding_m=funding_m,
244
+ is_funding=is_funding,
245
+ contract_size=contract_size,
246
+ leverage=leverage,
247
+ fee_rate=fee_rate,
248
+ instruments=instruments,
249
+ qty_step=qty_step,
250
+ lot_size=lot_size,
251
+ slot_size=slot_size,
252
+ min_qty=min_qty,
253
+ min_notional=min_notional,
254
+ high_low_source=high_low_source,
255
+ )
256
+
257
+ def _run_target_arrays(
258
+ self,
259
+ idx: pd.DatetimeIndex,
260
+ symbol_list: List[str],
261
+ closes_m: np.ndarray,
262
+ highs_m: np.ndarray,
263
+ lows_m: np.ndarray,
264
+ target_m: np.ndarray,
265
+ funding_m: np.ndarray,
266
+ is_funding: np.ndarray,
267
+ contract_size: Union[float, Dict[str, float]] = 1.0,
268
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
269
+ fee_rate: Optional[Union[float, Dict[str, float]]] = None,
270
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
271
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
272
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
273
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
274
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
275
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
276
+ market_arrays: Optional[PreparedMarketArrays] = None,
277
+ raw_signal_matrix: Optional[np.ndarray] = None,
278
+ high_low_source: str = "provided",
279
+ ) -> BacktestResultV2:
280
+ contract_sizes = self._per_symbol_array(contract_size, symbol_list, default=1.0)
281
+ constraints = build_quantity_constraints(
282
+ symbol_list,
283
+ instruments=instruments,
284
+ qty_step=qty_step,
285
+ lot_size=lot_size,
286
+ slot_size=slot_size,
287
+ min_qty=min_qty,
288
+ min_notional=min_notional,
289
+ )
290
+ target_m = quantize_target_units_matrix(target_m, closes_m, contract_sizes, constraints)
291
+ leverages = self._per_symbol_array(
292
+ self.config.account.leverage if leverage is None else leverage,
293
+ symbol_list,
294
+ default=self.config.account.leverage,
295
+ )
296
+ fee_rates = self._per_symbol_array(
297
+ self.config.fee_rate if fee_rate is None else fee_rate,
298
+ symbol_list,
299
+ default=0.0,
300
+ )
301
+
302
+ (
303
+ equity_arr,
304
+ pos_arr,
305
+ fee_arr,
306
+ turnover_arr,
307
+ funding_arr,
308
+ init_margin_arr,
309
+ maint_margin_arr,
310
+ rejected_arr,
311
+ reject_code_arr,
312
+ liq_flag,
313
+ liq_idx,
314
+ liq_reason,
315
+ ) = _engine_units_v2(
316
+ n_bars=len(idx),
317
+ n_syms=len(symbol_list),
318
+ highs=highs_m,
319
+ lows=lows_m,
320
+ closes=closes_m,
321
+ target_units=target_m,
322
+ funding_rates=funding_m,
323
+ is_funding_bar=is_funding,
324
+ init_capital=self.config.account.initial_capital,
325
+ leverages=leverages,
326
+ maint_ratio=self.config.account.maintenance_ratio,
327
+ fee_rates=fee_rates,
328
+ contract_sizes=contract_sizes,
329
+ slippage=self.config.execution.slippage_rate,
330
+ use_funding=bool(self.config.use_funding),
331
+ )
332
+
333
+ equity = pd.Series(equity_arr, index=idx, name="equity")
334
+ returns = equity.pct_change().fillna(0.0)
335
+ positions = pd.DataFrame(
336
+ {f"Position_{s}": pos_arr[:, j] for j, s in enumerate(symbol_list)},
337
+ index=idx,
338
+ )
339
+ close_df = pd.DataFrame(
340
+ {f"Close_{s}": closes_m[:, j] for j, s in enumerate(symbol_list)},
341
+ index=idx,
342
+ )
343
+ fees = pd.Series(fee_arr, index=idx, name="fees")
344
+ funding = pd.Series(funding_arr, index=idx, name="funding")
345
+ margin = pd.DataFrame(
346
+ {
347
+ "initial_margin": init_margin_arr,
348
+ "maintenance_margin": maint_margin_arr,
349
+ },
350
+ index=idx,
351
+ )
352
+ diagnostics = pd.DataFrame(
353
+ {
354
+ "turnover": turnover_arr,
355
+ "rejected_orders": rejected_arr,
356
+ "reject_code": reject_code_arr,
357
+ },
358
+ index=idx,
359
+ )
360
+
361
+ metadata = self._close_target_metadata(
362
+ symbol_list=symbol_list,
363
+ idx=idx,
364
+ high_low_source=high_low_source,
365
+ first_bar_policy="target_units[0]_not_executed; first executable rebalance occurs at bar index 1",
366
+ )
367
+ metadata.update(
368
+ {
369
+ "fee_rate_oneway": self._fee_rate_metadata(fee_rates, symbol_list),
370
+ "slippage_bps": self.config.execution.slippage_bps,
371
+ "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)),
372
+ "liquidation_reason": int(liq_reason),
373
+ "quantity_constraints": constraints.as_dict(),
374
+ }
375
+ )
376
+
377
+ return BacktestResultV2(
378
+ equity=equity,
379
+ returns=returns,
380
+ positions=positions,
381
+ closes=close_df,
382
+ symbols=symbol_list,
383
+ initial_capital=self.config.account.initial_capital,
384
+ leverage=float(np.mean(leverages)),
385
+ liquidated=bool(liq_flag),
386
+ liquidation_bar=int(liq_idx),
387
+ fees=fees,
388
+ funding=funding,
389
+ margin=margin,
390
+ diagnostics=diagnostics,
391
+ metadata=metadata,
392
+ )
393
+
394
+ def run_signals(
395
+ self,
396
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
397
+ positions: Dict[str, pd.Series],
398
+ closes: Dict[str, pd.Series],
399
+ highs: Optional[Dict[str, pd.Series]] = None,
400
+ lows: Optional[Dict[str, pd.Series]] = None,
401
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
402
+ contract_size: Union[float, Dict[str, float]] = 1.0,
403
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
404
+ alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
405
+ hedge_type: str = "signal_notional",
406
+ use_pyramiding: bool = True,
407
+ symbols: Optional[List[str]] = None,
408
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
409
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
410
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
411
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
412
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
413
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
414
+ market_arrays: Optional[PreparedMarketArrays] = None,
415
+ raw_signal_matrix: Optional[np.ndarray] = None,
416
+ ) -> BacktestResultV2:
417
+ """
418
+ Scale raw position signals into target units, then run the V2 kernel.
419
+
420
+ Phase 2 supports target-unit sizing modes here. `%_equity` and
421
+ `dca_ladder` remain on the legacy kernels until their V2 diagnostics
422
+ kernels are added.
423
+ """
424
+ ht = hedge_type.lower().strip()
425
+ if ht in ("%_equity", "pct_equity", "dca_ladder", "dca"):
426
+ raise NotImplementedError(f"NativeVectorizedBackend.run_signals does not yet support hedge_type={hedge_type!r}")
427
+
428
+ idx = validate_datetime(datetime_index)
429
+ symbol_list = symbols or list(positions.keys())
430
+ pos_dict = None if raw_signal_matrix is not None else align_series(positions, symbol_list, idx, fill_val=0.0)
431
+ close_dict = None if market_arrays is not None else align_series(closes, symbol_list, idx)
432
+ high_low_source = "prepared_market_arrays" if market_arrays is not None else self._high_low_source(highs, lows)
433
+ if market_arrays is None:
434
+ self._warn_high_low_fallback(high_low_source)
435
+ alloc = self._per_symbol_mapping(alloc_per_trade, symbol_list, default=100_000.0)
436
+
437
+ if ht in ("signal_notional", "signal"):
438
+ if market_arrays is None:
439
+ high_dict = align_series(highs, symbol_list, idx, fallback=close_dict)
440
+ low_dict = align_series(lows, symbol_list, idx, fallback=close_dict)
441
+ funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx)
442
+ closes_m, highs_m, lows_m, signals_m, funding_m, is_funding = build_arrays(
443
+ symbols=symbol_list,
444
+ idx=idx,
445
+ closes_dict=close_dict,
446
+ highs_dict=high_dict,
447
+ lows_dict=low_dict,
448
+ signals_dict=pos_dict,
449
+ funding_dict=funding_dict,
450
+ )
451
+ else:
452
+ if market_arrays.signature != market_data_signature(idx, symbol_list):
453
+ raise ValueError("prepared market arrays do not match datetime_index/symbols")
454
+ closes_m = market_arrays.closes
455
+ highs_m = market_arrays.highs
456
+ lows_m = market_arrays.lows
457
+ funding_m = market_arrays.funding
458
+ is_funding = market_arrays.is_funding_bar
459
+ if raw_signal_matrix is None:
460
+ signals_m = build_signal_matrix(symbol_list, idx, pos_dict)
461
+ else:
462
+ signals_m = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64)
463
+ if signals_m.shape != closes_m.shape:
464
+ raise ValueError("raw_signal_matrix shape does not match prepared market arrays")
465
+ allocs = np.array([alloc[s] for s in symbol_list], dtype=np.float64)
466
+ target_m = scale_signal_notional_matrix(
467
+ signals=signals_m,
468
+ closes=closes_m,
469
+ allocs=allocs,
470
+ use_pyramiding=use_pyramiding,
471
+ )
472
+ return self._run_target_arrays(
473
+ idx=idx,
474
+ symbol_list=symbol_list,
475
+ closes_m=closes_m,
476
+ highs_m=highs_m,
477
+ lows_m=lows_m,
478
+ target_m=target_m,
479
+ funding_m=funding_m,
480
+ is_funding=is_funding,
481
+ contract_size=contract_size,
482
+ leverage=leverage,
483
+ instruments=instruments,
484
+ qty_step=qty_step,
485
+ lot_size=lot_size,
486
+ slot_size=slot_size,
487
+ min_qty=min_qty,
488
+ min_notional=min_notional,
489
+ high_low_source=high_low_source,
490
+ )
491
+
492
+ if close_dict is None:
493
+ close_dict = {
494
+ symbol: pd.Series(market_arrays.closes[:, j], index=idx, name=symbol)
495
+ for j, symbol in enumerate(symbol_list)
496
+ }
497
+ if pos_dict is None:
498
+ pos_dict = {
499
+ symbol: pd.Series(raw_signal_matrix[:, j], index=idx, name=symbol)
500
+ for j, symbol in enumerate(symbol_list)
501
+ }
502
+ target_units = {
503
+ s: compute_target_units(
504
+ hedge_type=hedge_type,
505
+ signal=pos_dict[s],
506
+ close=close_dict[s],
507
+ alloc=alloc[s],
508
+ use_pyramiding=use_pyramiding,
509
+ )
510
+ for s in symbol_list
511
+ }
512
+
513
+ return self.run_target_units(
514
+ datetime_index=idx,
515
+ target_units=target_units,
516
+ closes=close_dict,
517
+ highs=highs,
518
+ lows=lows,
519
+ funding_rate=funding_rate,
520
+ contract_size=contract_size,
521
+ leverage=leverage,
522
+ symbols=symbol_list,
523
+ instruments=instruments,
524
+ qty_step=qty_step,
525
+ lot_size=lot_size,
526
+ slot_size=slot_size,
527
+ min_qty=min_qty,
528
+ min_notional=min_notional,
529
+ )
530
+
531
+ def run_basis_arbitrage(
532
+ self,
533
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
534
+ spec: BasisArbitrageSpec,
535
+ signal: pd.Series,
536
+ closes: Dict[str, pd.Series],
537
+ highs: Optional[Dict[str, pd.Series]] = None,
538
+ lows: Optional[Dict[str, pd.Series]] = None,
539
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
540
+ contract_size: Optional[Union[float, Dict[str, float]]] = None,
541
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
542
+ hedge_ratios: Optional[Dict[str, pd.Series]] = None,
543
+ ) -> BacktestResultV2:
544
+ if not isinstance(spec, BasisArbitrageSpec):
545
+ raise TypeError("run_basis_arbitrage requires a BasisArbitrageSpec")
546
+ idx = validate_datetime(datetime_index)
547
+ symbols = [leg.symbol for leg in spec.legs]
548
+ close_dict = align_series(closes, symbols, idx)
549
+ plan = build_arbitrage_order_plan(
550
+ datetime_index=idx,
551
+ spec=spec,
552
+ signal=signal,
553
+ closes=close_dict,
554
+ hedge_ratios=hedge_ratios,
555
+ )
556
+ contract_sizes = self._contract_size_for_spec(spec, contract_size)
557
+ fee_rates = self._fee_rate_for_spec(spec)
558
+ plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage)
559
+ basis_funding = self._funding_for_spec(spec, funding_rate)
560
+ target_units = {symbol: plan.target_units[symbol] for symbol in symbols}
561
+
562
+ result = self.run_target_units(
563
+ datetime_index=idx,
564
+ target_units=target_units,
565
+ closes=close_dict,
566
+ highs=highs,
567
+ lows=lows,
568
+ funding_rate=basis_funding,
569
+ contract_size=contract_sizes,
570
+ leverage=leverage,
571
+ fee_rate=fee_rates,
572
+ symbols=symbols,
573
+ )
574
+ funding_dict = prepare_funding(basis_funding if self.config.use_funding else 0.0, symbols, idx)
575
+ leg_pnl_report = self._leg_pnl_report(
576
+ idx=idx,
577
+ symbols=symbols,
578
+ roles={leg.symbol: leg.role for leg in spec.legs},
579
+ result=result,
580
+ closes=close_dict,
581
+ funding=funding_dict,
582
+ contract_sizes=contract_sizes,
583
+ fee_rates=fee_rates,
584
+ )
585
+ package_report = self._package_pnl_report(idx, result, leg_pnl_report)
586
+ result.metadata.update(
587
+ {
588
+ "backend": "native_vectorized",
589
+ "engine": "units_v2_basis_arbitrage",
590
+ "arb_id": spec.arb_id,
591
+ "arb_type": spec.arb_type.value,
592
+ "arbitrage_plan": plan,
593
+ "package_target_units": plan.target_units,
594
+ "package_rejection_report": plan.rejection_report,
595
+ "spread_report": self._basis_spread_report(idx, spec, close_dict, plan.target_units),
596
+ "leg_pnl_report": leg_pnl_report,
597
+ "package_pnl_report": package_report,
598
+ "fee_rate_oneway": fee_rates,
599
+ "contract_size": contract_sizes,
600
+ }
601
+ )
602
+ return result
603
+
604
+ def run_stat_arb_pair_arbitrage(
605
+ self,
606
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
607
+ spec: StatArbPairSpec,
608
+ signal: pd.Series,
609
+ closes: Dict[str, pd.Series],
610
+ highs: Optional[Dict[str, pd.Series]] = None,
611
+ lows: Optional[Dict[str, pd.Series]] = None,
612
+ hedge_ratios: Optional[Dict[str, pd.Series]] = None,
613
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
614
+ contract_size: Optional[Union[float, Dict[str, float]]] = None,
615
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
616
+ ) -> BacktestResultV2:
617
+ if not isinstance(spec, StatArbPairSpec):
618
+ raise TypeError("run_stat_arb_pair_arbitrage requires a StatArbPairSpec")
619
+ idx = validate_datetime(datetime_index)
620
+ symbols = [leg.symbol for leg in spec.legs]
621
+ close_dict = align_series(closes, symbols, idx)
622
+ basket = self._stat_arb_basket_from_spec(spec)
623
+ rebalance_threshold = spec.hedge_policy.rebalance_threshold
624
+ if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None:
625
+ rebalance_threshold = 0.0
626
+ plan = build_frozen_basket_orders(
627
+ datetime_index=idx,
628
+ basket=basket,
629
+ signal=signal,
630
+ closes=close_dict,
631
+ hedge_ratios=hedge_ratios,
632
+ rebalance_threshold=rebalance_threshold,
633
+ )
634
+ contract_sizes = self._contract_size_for_spec(spec, contract_size)
635
+ fee_rates = self._fee_rate_for_spec(spec)
636
+ stat_funding = self._funding_for_spec(spec, funding_rate)
637
+ arb_plan = self._apply_atomic_package_margin_policy(
638
+ idx=idx,
639
+ plan=ArbitragePlan(
640
+ spec=spec,
641
+ orders=plan.orders,
642
+ target_units=plan.target_units,
643
+ signals=plan.signals,
644
+ entry_ratios=plan.entry_ratios,
645
+ rejections=(),
646
+ metadata=plan.metadata,
647
+ ),
648
+ closes=close_dict,
649
+ contract_sizes=contract_sizes,
650
+ fee_rates=fee_rates,
651
+ leverage=leverage,
652
+ )
653
+ target_units = {symbol: arb_plan.target_units[symbol] for symbol in symbols}
654
+
655
+ result = self.run_target_units(
656
+ datetime_index=idx,
657
+ target_units=target_units,
658
+ closes=close_dict,
659
+ highs=highs,
660
+ lows=lows,
661
+ funding_rate=stat_funding,
662
+ contract_size=contract_sizes,
663
+ leverage=leverage,
664
+ fee_rate=fee_rates,
665
+ symbols=symbols,
666
+ )
667
+ funding_dict = prepare_funding(stat_funding if self.config.use_funding else 0.0, symbols, idx)
668
+ leg_pnl_report = self._leg_pnl_report(
669
+ idx=idx,
670
+ symbols=symbols,
671
+ roles=self._stat_arb_roles(spec),
672
+ result=result,
673
+ closes=close_dict,
674
+ funding=funding_dict,
675
+ contract_sizes=contract_sizes,
676
+ fee_rates=fee_rates,
677
+ )
678
+ package_report = self._package_pnl_report(idx, result, leg_pnl_report)
679
+ result.metadata.update(
680
+ {
681
+ "backend": "native_vectorized",
682
+ "engine": "units_v2_stat_arb_pair",
683
+ "arb_id": spec.arb_id,
684
+ "arb_type": spec.arb_type.value,
685
+ "arbitrage_plan": arb_plan,
686
+ "package_target_units": arb_plan.target_units,
687
+ "package_rejection_report": arb_plan.rejection_report,
688
+ "basket_plan": plan,
689
+ "basket_target_units": arb_plan.target_units,
690
+ "beta_drift_report": self._stat_arb_beta_drift_report(idx, spec, arb_plan, rebalance_threshold),
691
+ "spread_report": self._stat_arb_spread_report(idx, spec, close_dict, arb_plan),
692
+ "leg_pnl_report": leg_pnl_report,
693
+ "package_pnl_report": package_report,
694
+ "rebalance_threshold": rebalance_threshold,
695
+ "fee_rate_oneway": fee_rates,
696
+ "contract_size": contract_sizes,
697
+ }
698
+ )
699
+ return result
700
+
701
+ def run_package_arbitrage(
702
+ self,
703
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
704
+ spec: ArbitrageSpec,
705
+ signal: pd.Series,
706
+ closes: Dict[str, pd.Series],
707
+ highs: Optional[Dict[str, pd.Series]] = None,
708
+ lows: Optional[Dict[str, pd.Series]] = None,
709
+ hedge_ratios: Optional[Dict[str, pd.Series]] = None,
710
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
711
+ contract_size: Optional[Union[float, Dict[str, float]]] = None,
712
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
713
+ ) -> BacktestResultV2:
714
+ unsupported = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec)
715
+ if isinstance(spec, unsupported):
716
+ raise NotImplementedError(
717
+ f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; "
718
+ "do not route it through generic package execution. "
719
+ "Use QuantBTEndpoint.arbitrage_support_matrix() to inspect supported routes."
720
+ )
721
+ supported = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec)
722
+ if not isinstance(spec, supported):
723
+ raise TypeError("run_package_arbitrage requires a Phase G package-style arbitrage spec")
724
+
725
+ idx = validate_datetime(datetime_index)
726
+ symbols = [leg.symbol for leg in spec.legs]
727
+ close_dict = align_series(closes, symbols, idx)
728
+ plan = build_arbitrage_order_plan(
729
+ datetime_index=idx,
730
+ spec=spec,
731
+ signal=signal,
732
+ closes=close_dict,
733
+ hedge_ratios=hedge_ratios,
734
+ )
735
+ contract_sizes = self._contract_size_for_spec(spec, contract_size)
736
+ fee_rates = self._fee_rate_for_spec(spec)
737
+ plan = self._apply_atomic_package_margin_policy(idx, plan, close_dict, contract_sizes, fee_rates, leverage)
738
+ package_funding = self._funding_for_spec(spec, funding_rate)
739
+ target_units = {symbol: plan.target_units[symbol] for symbol in symbols}
740
+
741
+ result = self.run_target_units(
742
+ datetime_index=idx,
743
+ target_units=target_units,
744
+ closes=close_dict,
745
+ highs=highs,
746
+ lows=lows,
747
+ funding_rate=package_funding,
748
+ contract_size=contract_sizes,
749
+ leverage=leverage,
750
+ fee_rate=fee_rates,
751
+ symbols=symbols,
752
+ )
753
+ funding_dict = prepare_funding(package_funding if self.config.use_funding else 0.0, symbols, idx)
754
+ leg_pnl_report = self._leg_pnl_report(
755
+ idx=idx,
756
+ symbols=symbols,
757
+ roles={leg.symbol: leg.role for leg in spec.legs},
758
+ result=result,
759
+ closes=close_dict,
760
+ funding=funding_dict,
761
+ contract_sizes=contract_sizes,
762
+ fee_rates=fee_rates,
763
+ )
764
+ package_report = self._package_pnl_report(idx, result, leg_pnl_report)
765
+ result.metadata.update(
766
+ {
767
+ "backend": "native_vectorized",
768
+ "engine": f"units_v2_{spec.arb_type.value}",
769
+ "arb_id": spec.arb_id,
770
+ "arb_type": spec.arb_type.value,
771
+ "arbitrage_plan": plan,
772
+ "package_target_units": plan.target_units,
773
+ "package_rejection_report": plan.rejection_report,
774
+ "spread_report": self._basis_spread_report(idx, spec, close_dict, plan.target_units),
775
+ "leg_pnl_report": leg_pnl_report,
776
+ "package_pnl_report": package_report,
777
+ "carry_report": self._carry_report(idx, spec, result, close_dict, funding_dict, contract_sizes),
778
+ "fee_rate_oneway": fee_rates,
779
+ "contract_size": contract_sizes,
780
+ }
781
+ )
782
+ return result
783
+
784
+ @staticmethod
785
+ def _per_symbol_array(value, symbols: List[str], default: float) -> np.ndarray:
786
+ if isinstance(value, dict):
787
+ return np.array([float(value.get(s, default)) for s in symbols], dtype=np.float64)
788
+ return np.full(len(symbols), float(value), dtype=np.float64)
789
+
790
+ @staticmethod
791
+ def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, float]:
792
+ if isinstance(value, dict):
793
+ return {s: float(value.get(s, default)) for s in symbols}
794
+ return {s: float(value) for s in symbols}
795
+
796
+ def _apply_atomic_package_margin_policy(
797
+ self,
798
+ idx: pd.DatetimeIndex,
799
+ plan: ArbitragePlan,
800
+ closes: Dict[str, pd.Series],
801
+ contract_sizes: Dict[str, float],
802
+ fee_rates: Dict[str, float],
803
+ leverage: Optional[Union[float, Dict[str, float]]],
804
+ ) -> ArbitragePlan:
805
+ spec = plan.spec
806
+ if spec.execution_policy.kind not in (PackageExecutionKind.ATOMIC_ALL_OR_NONE, PackageExecutionKind.BEST_EFFORT):
807
+ return plan
808
+
809
+ symbols = [leg.symbol for leg in spec.legs]
810
+ current_units = {symbol: 0.0 for symbol in symbols}
811
+ equity = float(self.config.account.initial_capital)
812
+ target_rows = []
813
+ orders = []
814
+ rejections = list(plan.rejections)
815
+ leverages = self._leverage_mapping(leverage, symbols)
816
+ slippage = self.config.execution.slippage_rate
817
+
818
+ for i, ts in enumerate(idx):
819
+ if i > 0:
820
+ prev_ts = idx[i - 1]
821
+ for symbol in symbols:
822
+ units = current_units[symbol]
823
+ if units != 0.0:
824
+ equity += units * (
825
+ float(closes[symbol].loc[ts]) - float(closes[symbol].loc[prev_ts])
826
+ ) * float(contract_sizes[symbol])
827
+
828
+ original_desired = {symbol: float(plan.target_units.loc[ts, symbol]) for symbol in symbols}
829
+ changed_symbols = [
830
+ symbol for symbol in symbols
831
+ if abs(original_desired[symbol] - current_units[symbol]) > 1e-12
832
+ ]
833
+ if changed_symbols:
834
+ if spec.execution_policy.kind is PackageExecutionKind.ATOMIC_ALL_OR_NONE:
835
+ allowed, details = self._atomic_package_has_margin(
836
+ ts=ts,
837
+ symbols=symbols,
838
+ current_units=current_units,
839
+ desired_units=original_desired,
840
+ closes=closes,
841
+ contract_sizes=contract_sizes,
842
+ fee_rates=fee_rates,
843
+ leverages=leverages,
844
+ equity=equity,
845
+ slippage=slippage,
846
+ )
847
+ if not allowed:
848
+ rejections.append(
849
+ PackageRejection(
850
+ timestamp=ts,
851
+ arb_id=spec.arb_id,
852
+ reason="insufficient_margin_atomic",
853
+ failed_legs=tuple(changed_symbols),
854
+ metadata={"details": details, "policy": spec.execution_policy.kind.value},
855
+ )
856
+ )
857
+ else:
858
+ self._append_package_orders(orders, ts, spec, symbols, current_units, original_desired)
859
+ equity -= float(details.get("cost", 0.0))
860
+ current_units = original_desired
861
+ else:
862
+ for symbol in symbols:
863
+ if abs(original_desired[symbol] - current_units[symbol]) <= 1e-12:
864
+ continue
865
+ candidate_units = dict(current_units)
866
+ candidate_units[symbol] = original_desired[symbol]
867
+ allowed, details = self._atomic_package_has_margin(
868
+ ts=ts,
869
+ symbols=symbols,
870
+ current_units=current_units,
871
+ desired_units=candidate_units,
872
+ closes=closes,
873
+ contract_sizes=contract_sizes,
874
+ fee_rates=fee_rates,
875
+ leverages=leverages,
876
+ equity=equity,
877
+ slippage=slippage,
878
+ )
879
+ if not allowed:
880
+ rejections.append(
881
+ PackageRejection(
882
+ timestamp=ts,
883
+ arb_id=spec.arb_id,
884
+ reason="insufficient_margin_best_effort",
885
+ failed_legs=(symbol,),
886
+ metadata={"details": details, "policy": spec.execution_policy.kind.value},
887
+ )
888
+ )
889
+ continue
890
+ self._append_package_orders(orders, ts, spec, [symbol], current_units, candidate_units)
891
+ equity -= float(details.get("cost", 0.0))
892
+ current_units = candidate_units
893
+
894
+ target_rows.append({symbol: current_units[symbol] for symbol in symbols})
895
+
896
+ return ArbitragePlan(
897
+ spec=spec,
898
+ orders=tuple(orders),
899
+ target_units=pd.DataFrame(target_rows, index=idx),
900
+ signals=plan.signals,
901
+ entry_ratios=plan.entry_ratios,
902
+ rejections=tuple(rejections),
903
+ metadata={**plan.metadata, "execution_margin_policy": "package_preflight"},
904
+ )
905
+
906
+ @staticmethod
907
+ def _append_package_orders(
908
+ orders: List[OrderIntent],
909
+ ts,
910
+ spec: ArbitrageSpec,
911
+ symbols: List[str],
912
+ current_units: Dict[str, float],
913
+ desired_units: Dict[str, float],
914
+ ) -> None:
915
+ for symbol in symbols:
916
+ delta = desired_units[symbol] - current_units[symbol]
917
+ if abs(delta) <= 1e-12:
918
+ continue
919
+ side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL
920
+ orders.append(
921
+ OrderIntent(
922
+ timestamp=ts,
923
+ symbol=symbol,
924
+ side=side,
925
+ order_type=spec.execution_policy.order_type,
926
+ qty=abs(delta),
927
+ tif=spec.execution_policy.tif,
928
+ tag=spec.arb_id,
929
+ metadata={
930
+ "arb_id": spec.arb_id,
931
+ "arb_type": spec.arb_type.value,
932
+ "package_policy": spec.execution_policy.kind.value,
933
+ "hedge_policy": spec.hedge_policy.kind.value,
934
+ "sizing_policy": spec.sizing_policy.kind.value,
935
+ "target_units": desired_units[symbol],
936
+ "previous_units": current_units[symbol],
937
+ },
938
+ )
939
+ )
940
+
941
+ def _atomic_package_has_margin(
942
+ self,
943
+ ts,
944
+ symbols: List[str],
945
+ current_units: Dict[str, float],
946
+ desired_units: Dict[str, float],
947
+ closes: Dict[str, pd.Series],
948
+ contract_sizes: Dict[str, float],
949
+ fee_rates: Dict[str, float],
950
+ leverages: Dict[str, float],
951
+ equity: float,
952
+ slippage: float,
953
+ ) -> tuple[bool, Dict[str, float]]:
954
+ cur_im = 0.0
955
+ margin_delta_sum = 0.0
956
+ cost_sum = 0.0
957
+ for symbol in symbols:
958
+ close_price = float(closes[symbol].loc[ts])
959
+ cs = float(contract_sizes[symbol])
960
+ lev = float(leverages[symbol])
961
+ current = float(current_units[symbol])
962
+ target = float(desired_units[symbol])
963
+ cur_im += abs(current) * close_price * cs / lev
964
+ delta = target - current
965
+ if abs(delta) <= 1e-12:
966
+ continue
967
+ exec_price = close_price * (1.0 + slippage if delta > 0.0 else 1.0 - slippage)
968
+ old_im = abs(current) * close_price * cs / lev
969
+ new_im = abs(target) * exec_price * cs / lev
970
+ margin_delta_sum += new_im - old_im
971
+ cost_sum += abs(delta) * exec_price * cs * float(fee_rates[symbol])
972
+ cost_sum += abs(delta) * abs(exec_price - close_price) * cs
973
+
974
+ available = max(0.0, float(equity) - cur_im)
975
+ required = cost_sum + max(0.0, margin_delta_sum)
976
+ return required <= available + 1e-12, {
977
+ "available": available,
978
+ "required": required,
979
+ "current_initial_margin": cur_im,
980
+ "margin_delta": margin_delta_sum,
981
+ "cost": cost_sum,
982
+ }
983
+
984
+ def _leverage_mapping(self, leverage, symbols: List[str]) -> Dict[str, float]:
985
+ default = float(self.config.account.leverage)
986
+ if isinstance(leverage, dict):
987
+ return {symbol: float(leverage.get(symbol, default)) for symbol in symbols}
988
+ if leverage is None:
989
+ return {symbol: default for symbol in symbols}
990
+ return {symbol: float(leverage) for symbol in symbols}
991
+
992
+ @staticmethod
993
+ def _fee_rate_metadata(fee_rates: np.ndarray, symbols: List[str]):
994
+ if len(fee_rates) == 0:
995
+ return 0.0
996
+ if np.allclose(fee_rates, fee_rates[0]):
997
+ return float(fee_rates[0])
998
+ return {symbol: float(fee_rates[i]) for i, symbol in enumerate(symbols)}
999
+
1000
+ def _fee_rate_for_spec(self, spec: ArbitrageSpec) -> Dict[str, float]:
1001
+ default_rates = self.config.fee_rate
1002
+ out: Dict[str, float] = {}
1003
+ for leg in spec.legs:
1004
+ if leg.fee_rate is not None:
1005
+ out[leg.symbol] = float(leg.fee_rate)
1006
+ elif isinstance(default_rates, dict):
1007
+ out[leg.symbol] = float(default_rates.get(leg.symbol, 0.0))
1008
+ else:
1009
+ out[leg.symbol] = float(default_rates)
1010
+ return out
1011
+
1012
+ @staticmethod
1013
+ def _contract_size_for_spec(
1014
+ spec: ArbitrageSpec,
1015
+ contract_size: Optional[Union[float, Dict[str, float]]],
1016
+ ) -> Dict[str, float]:
1017
+ out = {leg.symbol: float(leg.contract_size) for leg in spec.legs}
1018
+ if contract_size is None:
1019
+ return out
1020
+ if isinstance(contract_size, dict):
1021
+ out.update({symbol: float(value) for symbol, value in contract_size.items()})
1022
+ return out
1023
+ return {leg.symbol: float(contract_size) for leg in spec.legs}
1024
+
1025
+ @staticmethod
1026
+ def _funding_for_spec(spec: ArbitrageSpec, funding_rate: Union[float, pd.Series, Dict]):
1027
+ funding_symbols = {leg.symbol for leg in spec.legs if leg.funding_enabled}
1028
+ if isinstance(funding_rate, dict):
1029
+ return {
1030
+ leg.symbol: funding_rate.get(leg.symbol, 0.0) if leg.symbol in funding_symbols else 0.0
1031
+ for leg in spec.legs
1032
+ }
1033
+ return {leg.symbol: funding_rate if leg.symbol in funding_symbols else 0.0 for leg in spec.legs}
1034
+
1035
+ @staticmethod
1036
+ def _stat_arb_roles(spec: StatArbPairSpec) -> Dict[str, str]:
1037
+ symbols = [leg.symbol for leg in spec.legs]
1038
+ roles = {leg.symbol: str(leg.role or "leg") for leg in spec.legs}
1039
+ if len(symbols) >= 2 and len(set(roles.values())) == 1:
1040
+ roles[symbols[0]] = "leg"
1041
+ roles[symbols[1]] = "hedge"
1042
+ return roles
1043
+
1044
+ @staticmethod
1045
+ def _stat_arb_spread_report(
1046
+ idx: pd.DatetimeIndex,
1047
+ spec: StatArbPairSpec,
1048
+ closes: Dict[str, pd.Series],
1049
+ plan,
1050
+ ) -> pd.DataFrame:
1051
+ symbols = [leg.symbol for leg in spec.legs]
1052
+ leg_symbol = symbols[0]
1053
+ hedge_symbol = symbols[1] if len(symbols) > 1 else symbols[0]
1054
+ leg_close = closes[leg_symbol].astype(float)
1055
+ hedge_close = closes[hedge_symbol].astype(float)
1056
+ ref_ratio = plan.entry_ratios[leg_symbol].replace(0.0, np.nan).astype(float)
1057
+ hedge_ratio = (plan.entry_ratios[hedge_symbol].astype(float) / ref_ratio).fillna(0.0)
1058
+ spread = leg_close + hedge_ratio * hedge_close
1059
+ return pd.DataFrame(
1060
+ {
1061
+ "leg_symbol": leg_symbol,
1062
+ "hedge_symbol": hedge_symbol,
1063
+ "leg_close": leg_close,
1064
+ "hedge_close": hedge_close,
1065
+ "hedge_ratio_to_leg": hedge_ratio,
1066
+ "spread": spread,
1067
+ "abs_spread": spread.abs(),
1068
+ },
1069
+ index=idx,
1070
+ )
1071
+
1072
+ @staticmethod
1073
+ def _stat_arb_basket_from_spec(spec: StatArbPairSpec) -> BasketSpec:
1074
+ if spec.sizing_policy.kind is not SizingPolicyKind.TARGET_GROSS_NOTIONAL:
1075
+ raise NotImplementedError("Phase E StatArbPairSpec requires target_gross_notional sizing")
1076
+ return BasketSpec(
1077
+ basket_id=spec.arb_id,
1078
+ legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs),
1079
+ gross_notional=float(spec.sizing_policy.notional),
1080
+ freeze_hedge=bool(spec.hedge_policy.freeze_on_entry),
1081
+ hedged_margin_offset=float(spec.margin_model.hedged_margin_offset),
1082
+ metadata={
1083
+ "arb_type": spec.arb_type.value,
1084
+ "hedge_policy": spec.hedge_policy.kind.value,
1085
+ "sizing_policy": spec.sizing_policy.kind.value,
1086
+ },
1087
+ )
1088
+
1089
+ def _leg_pnl_report(
1090
+ self,
1091
+ idx: pd.DatetimeIndex,
1092
+ symbols: List[str],
1093
+ roles: Dict[str, str],
1094
+ result: BacktestResultV2,
1095
+ closes: Dict[str, pd.Series],
1096
+ funding: Dict[str, pd.Series],
1097
+ contract_sizes: Dict[str, float],
1098
+ fee_rates: Dict[str, float],
1099
+ ) -> pd.DataFrame:
1100
+ funding_mask = make_funding_mask(idx)
1101
+ cumulative = {symbol: 0.0 for symbol in symbols}
1102
+ rows = []
1103
+ slippage = self.config.execution.slippage_rate
1104
+ for i, ts in enumerate(idx):
1105
+ for symbol in symbols:
1106
+ cs = float(contract_sizes[symbol])
1107
+ close_price = float(closes[symbol].iloc[i])
1108
+ prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1])
1109
+ units = float(result.positions[f"Position_{symbol}"].iloc[i])
1110
+ delta = units - prev_units
1111
+ price_pnl = 0.0
1112
+ if i > 0:
1113
+ price_pnl = prev_units * (close_price - float(closes[symbol].iloc[i - 1])) * cs
1114
+ exec_price = close_price * (1.0 + slippage if delta > 0.0 else 1.0 - slippage)
1115
+ fee = abs(delta) * exec_price * cs * float(fee_rates[symbol]) if abs(delta) > 1e-12 else 0.0
1116
+ slippage_cost = abs(delta) * abs(exec_price - close_price) * cs if abs(delta) > 1e-12 else 0.0
1117
+ funding_cost = 0.0
1118
+ if self.config.use_funding and funding_mask[i]:
1119
+ funding_cost = prev_units * close_price * cs * float(funding[symbol].iloc[i])
1120
+ total_pnl = price_pnl - fee - slippage_cost - funding_cost
1121
+ cumulative[symbol] += total_pnl
1122
+ rows.append(
1123
+ {
1124
+ "timestamp": ts,
1125
+ "symbol": symbol,
1126
+ "role": roles.get(symbol, "leg"),
1127
+ "units": units,
1128
+ "close": close_price,
1129
+ "notional": abs(units) * close_price * cs,
1130
+ "price_pnl": price_pnl,
1131
+ "fill_pnl": -slippage_cost,
1132
+ "fee": fee,
1133
+ "funding_pnl": -funding_cost,
1134
+ "total_pnl": total_pnl,
1135
+ "cumulative_pnl": cumulative[symbol],
1136
+ }
1137
+ )
1138
+ return pd.DataFrame(rows)
1139
+
1140
+ @staticmethod
1141
+ def _package_pnl_report(idx: pd.DatetimeIndex, result: BacktestResultV2, leg_pnl_report: pd.DataFrame) -> pd.DataFrame:
1142
+ grouped = leg_pnl_report.groupby("timestamp", sort=False)
1143
+ package_pnl = grouped["total_pnl"].sum().reindex(idx, fill_value=0.0)
1144
+ price_pnl = grouped["price_pnl"].sum().reindex(idx, fill_value=0.0)
1145
+ fill_pnl = grouped["fill_pnl"].sum().reindex(idx, fill_value=0.0)
1146
+ fees = grouped["fee"].sum().reindex(idx, fill_value=0.0)
1147
+ funding_pnl = grouped["funding_pnl"].sum().reindex(idx, fill_value=0.0)
1148
+ role_pnl = leg_pnl_report.pivot_table(
1149
+ index="timestamp",
1150
+ columns="role",
1151
+ values="total_pnl",
1152
+ aggfunc="sum",
1153
+ fill_value=0.0,
1154
+ ).reindex(idx, fill_value=0.0)
1155
+ leg_pnl = role_pnl["leg"] if "leg" in role_pnl else pd.Series(0.0, index=idx)
1156
+ hedge_pnl = role_pnl["hedge"] if "hedge" in role_pnl else pd.Series(0.0, index=idx)
1157
+ report = pd.DataFrame(
1158
+ {
1159
+ "price_pnl": price_pnl,
1160
+ "fill_pnl": fill_pnl,
1161
+ "fees": fees,
1162
+ "funding_pnl": funding_pnl,
1163
+ "leg_pnl": leg_pnl,
1164
+ "hedge_pnl": hedge_pnl,
1165
+ "spread_pnl": leg_pnl + hedge_pnl,
1166
+ "package_pnl": package_pnl,
1167
+ "equity_delta": result.equity.diff().fillna(0.0),
1168
+ },
1169
+ index=idx,
1170
+ )
1171
+ report["pnl_residual"] = report["equity_delta"] - report["package_pnl"]
1172
+ return report
1173
+
1174
+ @staticmethod
1175
+ def _basis_spread_report(
1176
+ idx: pd.DatetimeIndex,
1177
+ spec: ArbitrageSpec,
1178
+ closes: Dict[str, pd.Series],
1179
+ target_units: pd.DataFrame,
1180
+ ) -> pd.DataFrame:
1181
+ symbols = [leg.symbol for leg in spec.legs]
1182
+ base_symbol = spec.spread_formula.base_symbol
1183
+ quote_symbol = spec.spread_formula.quote_symbol
1184
+ if base_symbol is None:
1185
+ base_symbol = next((leg.symbol for leg in spec.legs if leg.ratio < 0.0), symbols[0])
1186
+ if quote_symbol is None:
1187
+ quote_symbol = next((leg.symbol for leg in spec.legs if leg.ratio > 0.0), symbols[-1])
1188
+ base_close = closes[base_symbol].astype(float)
1189
+ quote_close = closes[quote_symbol].astype(float)
1190
+ spread = quote_close - base_close
1191
+ ratio_spread = quote_close / base_close.replace(0.0, np.nan) - 1.0
1192
+ expiry = next((leg.expiry for leg in spec.legs if leg.symbol == quote_symbol and leg.expiry is not None), None)
1193
+ if expiry is None:
1194
+ expiry = next((leg.expiry for leg in spec.legs if leg.expiry is not None), None)
1195
+ if expiry is None:
1196
+ annualized = pd.Series(np.nan, index=idx, dtype=float)
1197
+ else:
1198
+ days_to_expiry = pd.Series(
1199
+ [(expiry - ts).total_seconds() / 86_400.0 for ts in idx],
1200
+ index=idx,
1201
+ dtype=float,
1202
+ )
1203
+ annualized = ratio_spread * (365.0 / days_to_expiry.where(days_to_expiry > 0.0))
1204
+ report = pd.DataFrame(
1205
+ {
1206
+ "base_symbol": base_symbol,
1207
+ "quote_symbol": quote_symbol,
1208
+ "base_close": base_close,
1209
+ "quote_close": quote_close,
1210
+ "spread": spread,
1211
+ "ratio_spread": ratio_spread,
1212
+ "annualized_basis": annualized,
1213
+ },
1214
+ index=idx,
1215
+ )
1216
+ for symbol in symbols:
1217
+ report[f"target_units_{symbol}"] = target_units[symbol]
1218
+ return report
1219
+
1220
+ @staticmethod
1221
+ def _carry_report(
1222
+ idx: pd.DatetimeIndex,
1223
+ spec: ArbitrageSpec,
1224
+ result: BacktestResultV2,
1225
+ closes: Dict[str, pd.Series],
1226
+ funding: Dict[str, pd.Series],
1227
+ contract_sizes: Dict[str, float],
1228
+ ) -> pd.DataFrame:
1229
+ rows = []
1230
+ funding_mask = make_funding_mask(idx)
1231
+ for i, ts in enumerate(idx):
1232
+ for leg in spec.legs:
1233
+ symbol = leg.symbol
1234
+ prev_units = 0.0 if i == 0 else float(result.positions[f"Position_{symbol}"].iloc[i - 1])
1235
+ close_price = float(closes[symbol].iloc[i])
1236
+ notional = abs(prev_units) * close_price * float(contract_sizes[symbol])
1237
+ funding_cost = 0.0
1238
+ if funding_mask[i] and leg.funding_enabled:
1239
+ funding_cost = prev_units * close_price * float(contract_sizes[symbol]) * float(funding[symbol].iloc[i])
1240
+ rows.append(
1241
+ {
1242
+ "timestamp": ts,
1243
+ "symbol": symbol,
1244
+ "role": leg.role,
1245
+ "funding_enabled": bool(leg.funding_enabled),
1246
+ "borrow_rate": float(spec.carry_model.borrow_rate),
1247
+ "cash_yield": float(spec.carry_model.cash_yield),
1248
+ "notional": notional,
1249
+ "funding_cost": funding_cost,
1250
+ }
1251
+ )
1252
+ return pd.DataFrame(rows)
1253
+
1254
+ @staticmethod
1255
+ def _stat_arb_beta_drift_report(
1256
+ idx: pd.DatetimeIndex,
1257
+ spec: StatArbPairSpec,
1258
+ plan,
1259
+ rebalance_threshold: Optional[float],
1260
+ ) -> pd.DataFrame:
1261
+ symbols = [leg.symbol for leg in spec.legs]
1262
+ reference_symbol = symbols[0]
1263
+ rows = []
1264
+ for ts in idx:
1265
+ ref_units = float(plan.target_units.loc[ts, reference_symbol])
1266
+ ref_ratio = float(plan.entry_ratios.loc[ts, reference_symbol])
1267
+ active = abs(ref_units) > 1e-12 and abs(ref_ratio) > 1e-12
1268
+ for symbol in symbols:
1269
+ units = float(plan.target_units.loc[ts, symbol])
1270
+ current_ratio = float(plan.entry_ratios.loc[ts, symbol])
1271
+ if active:
1272
+ frozen_ratio_to_ref = units / ref_units
1273
+ current_ratio_to_ref = current_ratio / ref_ratio
1274
+ abs_drift = abs(current_ratio_to_ref - frozen_ratio_to_ref)
1275
+ rel_drift = abs_drift / max(abs(frozen_ratio_to_ref), 1e-12)
1276
+ else:
1277
+ frozen_ratio_to_ref = 0.0
1278
+ current_ratio_to_ref = 0.0
1279
+ abs_drift = 0.0
1280
+ rel_drift = 0.0
1281
+ rows.append(
1282
+ {
1283
+ "timestamp": ts,
1284
+ "symbol": symbol,
1285
+ "reference_symbol": reference_symbol,
1286
+ "target_units": units,
1287
+ "frozen_ratio_to_ref": frozen_ratio_to_ref,
1288
+ "current_ratio_to_ref": current_ratio_to_ref,
1289
+ "abs_beta_drift": abs_drift,
1290
+ "rel_beta_drift": rel_drift,
1291
+ "rebalance_threshold": rebalance_threshold,
1292
+ "breached": (
1293
+ rebalance_threshold is not None
1294
+ and rel_drift > rebalance_threshold
1295
+ and symbol != reference_symbol
1296
+ ),
1297
+ }
1298
+ )
1299
+ return pd.DataFrame(rows)