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