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
quantbt/portfolio.py
ADDED
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quantbt.portfolio
|
|
3
|
+
-----------------
|
|
4
|
+
MultiSymbolPortfolio — independent multi-symbol backtest with portfolio-level
|
|
5
|
+
risk management, allocation modes, and attribution.
|
|
6
|
+
|
|
7
|
+
Fixes vs original
|
|
8
|
+
~~~~~~~~~~~~~~~~~
|
|
9
|
+
* Signal-notional portfolio sizing freezes units until signal changes, avoiding
|
|
10
|
+
price-drift micro-rebalancing.
|
|
11
|
+
* Market-neutral scaling: long and short sides are scaled simultaneously from
|
|
12
|
+
the ORIGINAL signed notional, not unit counts.
|
|
13
|
+
* Maintenance margin = notional × mm_rate (Binance formula, not im × mm_rate).
|
|
14
|
+
* Funding fires once per 8h window via make_funding_mask, not per-bar within hour.
|
|
15
|
+
* _run_portfolio_numba is wired in for crypto intrabar liquidation.
|
|
16
|
+
|
|
17
|
+
Modes
|
|
18
|
+
~~~~~
|
|
19
|
+
'longshort' raw positions, no adjustment
|
|
20
|
+
'market_neutral' gross long notional == gross short notional each bar
|
|
21
|
+
'directional' keep only the dominant side (by abs notional)
|
|
22
|
+
'equal_weight' equal fractional weight among active symbols
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Dict, List, Optional, Tuple, Union
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
from .core.preprocessor import (
|
|
33
|
+
validate_datetime,
|
|
34
|
+
make_funding_mask,
|
|
35
|
+
)
|
|
36
|
+
from .metrics.performance import full_report
|
|
37
|
+
from .core.types import BacktestResult
|
|
38
|
+
from .core.engine import _engine_portfolio
|
|
39
|
+
from .sizing.modes import compute_target_units
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MultiSymbolPortfolio:
|
|
43
|
+
"""
|
|
44
|
+
Multi-Symbol Backtest Engine.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
positions Dict[str, pd.Series] raw signal weights
|
|
49
|
+
closes Dict[str, pd.Series] close prices
|
|
50
|
+
datetime_index common DatetimeIndex (UTC)
|
|
51
|
+
mode 'longshort' | 'market_neutral' | 'directional' | 'equal_weight'
|
|
52
|
+
fee_rate canonical one-way fee per accepted trade side
|
|
53
|
+
fee optional legacy round-trip fee; halved internally
|
|
54
|
+
alloc_per_trade notional per full signal unit; float or per-symbol dict
|
|
55
|
+
contract_size float or per-symbol dict
|
|
56
|
+
hedge_type 'signal_notional' | 'notional' | 'unit'
|
|
57
|
+
initial_capital float
|
|
58
|
+
asset_type 'crypto' | 'stock'
|
|
59
|
+
use_funding override funding; None → follows asset_type
|
|
60
|
+
funding_rate float or per-symbol dict
|
|
61
|
+
leverage float or per-symbol dict
|
|
62
|
+
maintenance_ratio float Binance: notional × ratio
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
_ASSET_CFG = {
|
|
66
|
+
"crypto": {
|
|
67
|
+
"trading_days": 365,
|
|
68
|
+
"fee_rate": 0.0004,
|
|
69
|
+
"contract": 1.0,
|
|
70
|
+
"funding": True,
|
|
71
|
+
},
|
|
72
|
+
"stock": {
|
|
73
|
+
"trading_days": 252,
|
|
74
|
+
"fee_rate": 0.0001,
|
|
75
|
+
"contract": 100.0,
|
|
76
|
+
"funding": False,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
positions: Dict[str, pd.Series],
|
|
83
|
+
closes: Dict[str, pd.Series],
|
|
84
|
+
datetime_index: Union[pd.DatetimeIndex, pd.Series],
|
|
85
|
+
mode: str = "longshort",
|
|
86
|
+
fee_rate: Optional[float] = None,
|
|
87
|
+
alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
|
|
88
|
+
contract_size: Union[float, Dict[str, float]] = None,
|
|
89
|
+
hedge_type: str = "signal_notional",
|
|
90
|
+
initial_capital: float = 100_000.0,
|
|
91
|
+
asset_type: str = "crypto",
|
|
92
|
+
use_funding: Optional[bool] = None,
|
|
93
|
+
funding_rate: Union[float, Dict[str, float]] = None,
|
|
94
|
+
leverage: Union[float, Dict[str, float]] = 1.0,
|
|
95
|
+
maintenance_ratio: float = 0.005,
|
|
96
|
+
margin_buffer: float = 0.01,
|
|
97
|
+
use_binance_netting: bool = False,
|
|
98
|
+
# highs / lows for intrabar liquidation (optional)
|
|
99
|
+
highs: Optional[Dict[str, pd.Series]] = None,
|
|
100
|
+
lows: Optional[Dict[str, pd.Series]] = None,
|
|
101
|
+
fee: Optional[float] = None,
|
|
102
|
+
):
|
|
103
|
+
# ── config ────────────────────────────────────────────────────────
|
|
104
|
+
atype = asset_type.lower()
|
|
105
|
+
if atype not in self._ASSET_CFG:
|
|
106
|
+
raise ValueError("asset_type must be 'crypto' or 'stock'")
|
|
107
|
+
|
|
108
|
+
cfg = self._ASSET_CFG[atype]
|
|
109
|
+
self.asset_type = atype
|
|
110
|
+
self.trading_days = cfg["trading_days"]
|
|
111
|
+
if fee_rate is not None:
|
|
112
|
+
self.fee_rate = float(fee_rate)
|
|
113
|
+
elif fee is not None:
|
|
114
|
+
self.fee_rate = float(fee) / 2.0
|
|
115
|
+
else:
|
|
116
|
+
self.fee_rate = float(cfg["fee_rate"]) / 2.0
|
|
117
|
+
self.use_funding = use_funding if use_funding is not None else cfg["funding"]
|
|
118
|
+
self.maintenance_ratio = maintenance_ratio
|
|
119
|
+
self.initial_capital = initial_capital
|
|
120
|
+
self.mode = mode.lower()
|
|
121
|
+
self.hedge_type = hedge_type.lower()
|
|
122
|
+
self.use_binance_netting = use_binance_netting if atype == "crypto" else False
|
|
123
|
+
|
|
124
|
+
valid_modes = {"longshort", "market_neutral", "directional", "equal_weight"}
|
|
125
|
+
if self.mode not in valid_modes:
|
|
126
|
+
raise ValueError(f"mode must be one of {valid_modes}")
|
|
127
|
+
valid_hedge_types = {"signal_notional", "signal", "notional", "unit"}
|
|
128
|
+
if self.hedge_type not in valid_hedge_types:
|
|
129
|
+
raise ValueError(f"portfolio hedge_type must be one of {valid_hedge_types}")
|
|
130
|
+
|
|
131
|
+
# ── symbols ───────────────────────────────────────────────────────
|
|
132
|
+
self.symbols = list(positions.keys())
|
|
133
|
+
if set(self.symbols) != set(closes.keys()):
|
|
134
|
+
raise ValueError("positions and closes must have the same symbol keys")
|
|
135
|
+
|
|
136
|
+
# ── per-symbol config ─────────────────────────────────────────────
|
|
137
|
+
def _per_sym(v, default):
|
|
138
|
+
return v if isinstance(v, dict) else {s: (default if v is None else v) for s in self.symbols}
|
|
139
|
+
|
|
140
|
+
default_cs = cfg["contract"]
|
|
141
|
+
self.cs = _per_sym(contract_size, default_cs)
|
|
142
|
+
self.lev = _per_sym(leverage, 1.0)
|
|
143
|
+
self.alloc = _per_sym(alloc_per_trade, 100_000.0)
|
|
144
|
+
self.fund_rates = _per_sym(funding_rate, 0.0001) if self.use_funding else {s: 0.0 for s in self.symbols}
|
|
145
|
+
|
|
146
|
+
if initial_capital <= 0.0:
|
|
147
|
+
raise ValueError("initial_capital must be > 0")
|
|
148
|
+
if any(v <= 0.0 for v in self.lev.values()):
|
|
149
|
+
raise ValueError("leverage must be > 0")
|
|
150
|
+
if any(v <= 0.0 for v in self.cs.values()):
|
|
151
|
+
raise ValueError("contract_size must be > 0")
|
|
152
|
+
if any(v < 0.0 for v in self.alloc.values()):
|
|
153
|
+
raise ValueError("alloc_per_trade must be >= 0")
|
|
154
|
+
if maintenance_ratio < 0.0:
|
|
155
|
+
raise ValueError("maintenance_ratio must be >= 0")
|
|
156
|
+
|
|
157
|
+
# ── datetime index ────────────────────────────────────────────────
|
|
158
|
+
self._idx = validate_datetime(datetime_index)
|
|
159
|
+
|
|
160
|
+
# ── align data ────────────────────────────────────────────────────
|
|
161
|
+
def _align(d: Dict, fill=0.0):
|
|
162
|
+
out = {}
|
|
163
|
+
for s in self.symbols:
|
|
164
|
+
ser = d[s].copy()
|
|
165
|
+
if isinstance(ser.index, pd.DatetimeIndex):
|
|
166
|
+
ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC")
|
|
167
|
+
ser = ser[~ser.index.duplicated(keep="first")]
|
|
168
|
+
out[s] = ser.reindex(self._idx, method="ffill").fillna(fill)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
self._pos = _align(positions, 0.0)
|
|
172
|
+
self._close = _align(closes, np.nan)
|
|
173
|
+
|
|
174
|
+
fallback = {s: self._close[s] for s in self.symbols}
|
|
175
|
+
self._high = _align(highs, np.nan) if highs else fallback
|
|
176
|
+
self._low = _align(lows, np.nan) if lows else fallback
|
|
177
|
+
|
|
178
|
+
# ── scale positions → notional units ─────────────────────────────
|
|
179
|
+
self._scaled: Dict[str, pd.Series] = {}
|
|
180
|
+
for s in self.symbols:
|
|
181
|
+
self._scaled[s] = compute_target_units(
|
|
182
|
+
hedge_type=self.hedge_type,
|
|
183
|
+
signal=self._pos[s],
|
|
184
|
+
close=self._close[s],
|
|
185
|
+
alloc=self.alloc[s],
|
|
186
|
+
use_pyramiding=True,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# ── apply portfolio mode ──────────────────────────────────────────
|
|
190
|
+
self._apply_mode()
|
|
191
|
+
|
|
192
|
+
# ── run simulation ────────────────────────────────────────────────
|
|
193
|
+
self._result: Optional[BacktestResult] = None
|
|
194
|
+
self._pnl_per_sym: Dict[str, pd.Series] = {}
|
|
195
|
+
self._daily_fee: pd.Series = pd.Series(dtype=float)
|
|
196
|
+
self._daily_turnover: pd.Series = pd.Series(dtype=float)
|
|
197
|
+
self.run()
|
|
198
|
+
|
|
199
|
+
# ── portfolio mode application ────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
def _apply_mode(self):
|
|
202
|
+
"""
|
|
203
|
+
Adjust scaled positions according to the allocation mode.
|
|
204
|
+
All scaling is done from the original notional simultaneously.
|
|
205
|
+
"""
|
|
206
|
+
pos_df = pd.DataFrame({s: self._scaled[s] for s in self.symbols})
|
|
207
|
+
close_df = pd.DataFrame({s: self._close[s] for s in self.symbols})
|
|
208
|
+
cs = pd.Series({s: float(self.cs[s]) for s in self.symbols})
|
|
209
|
+
|
|
210
|
+
def _signed_notional(units: pd.DataFrame) -> pd.DataFrame:
|
|
211
|
+
return units.mul(close_df, axis=0).mul(cs, axis=1)
|
|
212
|
+
|
|
213
|
+
if self.mode == "market_neutral":
|
|
214
|
+
# Scale each side so gross_long_notional == gross_short_notional every bar.
|
|
215
|
+
# Capture long/short sums from the ORIGINAL signed notional in one pass.
|
|
216
|
+
notional_df = _signed_notional(pos_df)
|
|
217
|
+
long_mask = notional_df > 0
|
|
218
|
+
short_mask = notional_df < 0
|
|
219
|
+
long_sum = (notional_df * long_mask).sum(axis=1) # positive
|
|
220
|
+
short_sum = (notional_df * short_mask).abs().sum(axis=1) # positive
|
|
221
|
+
|
|
222
|
+
target = (long_sum + short_sum) / 2.0 # equal notional on each side
|
|
223
|
+
|
|
224
|
+
for s in self.symbols:
|
|
225
|
+
col = notional_df[s]
|
|
226
|
+
original_units = pos_df[s]
|
|
227
|
+
# scale independently per side; avoids the sequential mutation bug
|
|
228
|
+
long_scale = (target / long_sum.replace(0, np.nan)).fillna(1.0)
|
|
229
|
+
short_scale = (target / short_sum.replace(0, np.nan)).fillna(1.0)
|
|
230
|
+
pos_df[s] = np.where(col > 0, original_units * long_scale,
|
|
231
|
+
np.where(col < 0, original_units * short_scale, 0.0))
|
|
232
|
+
|
|
233
|
+
elif self.mode == "directional":
|
|
234
|
+
notional = _signed_notional(pos_df).abs()
|
|
235
|
+
dominant = notional.idxmax(axis=1)
|
|
236
|
+
for s in self.symbols:
|
|
237
|
+
pos_df[s] = pos_df[s].where(dominant == s, 0.0)
|
|
238
|
+
|
|
239
|
+
elif self.mode == "equal_weight":
|
|
240
|
+
notional_df = _signed_notional(pos_df)
|
|
241
|
+
active = (notional_df != 0).sum(axis=1)
|
|
242
|
+
gross = notional_df.abs().sum(axis=1)
|
|
243
|
+
target_abs = (gross / active.replace(0, np.nan)).fillna(0.0)
|
|
244
|
+
for s in self.symbols:
|
|
245
|
+
denom = (close_df[s] * float(self.cs[s])).replace(0.0, np.nan)
|
|
246
|
+
sign = np.sign(notional_df[s])
|
|
247
|
+
pos_df[s] = (sign * target_abs / denom).fillna(0.0)
|
|
248
|
+
|
|
249
|
+
for s in self.symbols:
|
|
250
|
+
self._scaled[s] = pos_df[s]
|
|
251
|
+
|
|
252
|
+
def run(self) -> BacktestResult:
|
|
253
|
+
"""Simulate and return BacktestResult."""
|
|
254
|
+
idx = self._idx
|
|
255
|
+
n = len(idx)
|
|
256
|
+
m = len(self.symbols)
|
|
257
|
+
is_fund = make_funding_mask(idx)
|
|
258
|
+
|
|
259
|
+
closes_m = np.zeros((n, m), dtype=np.float64)
|
|
260
|
+
highs_m = np.zeros((n, m), dtype=np.float64)
|
|
261
|
+
lows_m = np.zeros((n, m), dtype=np.float64)
|
|
262
|
+
target_m = np.zeros((n, m), dtype=np.float64)
|
|
263
|
+
funding_m = np.zeros((n, m), dtype=np.float64)
|
|
264
|
+
cs_arr = np.zeros(m, dtype=np.float64)
|
|
265
|
+
lev_arr = np.zeros(m, dtype=np.float64)
|
|
266
|
+
|
|
267
|
+
for j, s in enumerate(self.symbols):
|
|
268
|
+
closes_m[:, j] = self._close[s].fillna(0.0).values
|
|
269
|
+
highs_m[:, j] = self._high[s].fillna(self._close[s]).values
|
|
270
|
+
lows_m[:, j] = self._low[s].fillna(self._close[s]).values
|
|
271
|
+
target_m[:, j] = self._scaled[s].fillna(0.0).values
|
|
272
|
+
cs_arr[j] = float(self.cs[s])
|
|
273
|
+
lev_arr[j] = float(self.lev[s])
|
|
274
|
+
|
|
275
|
+
fr = self.fund_rates[s]
|
|
276
|
+
if isinstance(fr, pd.Series):
|
|
277
|
+
ser = fr.copy()
|
|
278
|
+
if isinstance(ser.index, pd.DatetimeIndex):
|
|
279
|
+
ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC")
|
|
280
|
+
funding_m[:, j] = ser.reindex(idx, method="ffill").fillna(0.0).values
|
|
281
|
+
elif isinstance(fr, np.ndarray):
|
|
282
|
+
if len(fr) != n:
|
|
283
|
+
raise ValueError(f"funding_rate array for {s} must have length {n}")
|
|
284
|
+
funding_m[:, j] = fr.astype(np.float64)
|
|
285
|
+
else:
|
|
286
|
+
funding_m[:, j] = float(fr)
|
|
287
|
+
|
|
288
|
+
(
|
|
289
|
+
eq_arr,
|
|
290
|
+
pos_arr,
|
|
291
|
+
sym_arr,
|
|
292
|
+
fee_arr,
|
|
293
|
+
_slippage_arr,
|
|
294
|
+
turn_arr,
|
|
295
|
+
liq_flag,
|
|
296
|
+
liq_idx,
|
|
297
|
+
) = _engine_portfolio(
|
|
298
|
+
n_bars = n,
|
|
299
|
+
n_syms = m,
|
|
300
|
+
highs = highs_m,
|
|
301
|
+
lows = lows_m,
|
|
302
|
+
closes = closes_m,
|
|
303
|
+
target_pos = target_m,
|
|
304
|
+
funding_rates = funding_m,
|
|
305
|
+
is_funding_bar = is_fund,
|
|
306
|
+
init_capital = self.initial_capital,
|
|
307
|
+
leverages = lev_arr,
|
|
308
|
+
maint_ratio = self.maintenance_ratio,
|
|
309
|
+
fee_rate = self.fee_rate,
|
|
310
|
+
slippage_rate = 0.0,
|
|
311
|
+
contract_sizes = cs_arr,
|
|
312
|
+
use_funding = bool(self.use_funding),
|
|
313
|
+
tradable = np.ones((n, m), dtype=np.bool_),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# ── assemble result ───────────────────────────────────────────────
|
|
317
|
+
equity_s = pd.Series(eq_arr, index=idx, name="equity")
|
|
318
|
+
returns = equity_s.pct_change().fillna(0)
|
|
319
|
+
|
|
320
|
+
pos_df = pd.DataFrame(
|
|
321
|
+
{f"Position_{s}": pos_arr[:, j] for j, s in enumerate(self.symbols)}, index=idx
|
|
322
|
+
)
|
|
323
|
+
close_df = pd.DataFrame(
|
|
324
|
+
{f"Close_{s}": closes_m[:, j] for j, s in enumerate(self.symbols)}, index=idx
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
self._daily_fee = pd.Series(fee_arr, index=idx).resample("1D").sum()
|
|
328
|
+
self._daily_turnover = pd.Series(turn_arr, index=idx).resample("1D").sum()
|
|
329
|
+
self._pnl_per_sym = {
|
|
330
|
+
s: pd.Series(sym_arr[:, j], index=idx) for j, s in enumerate(self.symbols)
|
|
331
|
+
}
|
|
332
|
+
target_units_report = pd.DataFrame(
|
|
333
|
+
{s: target_m[:, j] for j, s in enumerate(self.symbols)}, index=idx
|
|
334
|
+
)
|
|
335
|
+
accepted_units_report = pd.DataFrame(
|
|
336
|
+
{s: pos_arr[:, j] for j, s in enumerate(self.symbols)}, index=idx
|
|
337
|
+
)
|
|
338
|
+
close_report = pd.DataFrame(
|
|
339
|
+
{s: closes_m[:, j] for j, s in enumerate(self.symbols)}, index=idx
|
|
340
|
+
)
|
|
341
|
+
symbol_pnl_report = self._build_symbol_pnl_report(
|
|
342
|
+
accepted_units=accepted_units_report,
|
|
343
|
+
closes=close_report,
|
|
344
|
+
funding_rates=pd.DataFrame({s: funding_m[:, j] for j, s in enumerate(self.symbols)}, index=idx),
|
|
345
|
+
is_funding_bar=pd.Series(is_fund, index=idx),
|
|
346
|
+
)
|
|
347
|
+
exposure_report = self._build_exposure_report(
|
|
348
|
+
accepted_units=accepted_units_report,
|
|
349
|
+
target_units=target_units_report,
|
|
350
|
+
closes=close_report,
|
|
351
|
+
equity=equity_s,
|
|
352
|
+
)
|
|
353
|
+
rebalance_report = self._build_rebalance_report(
|
|
354
|
+
target_units=target_units_report,
|
|
355
|
+
accepted_units=accepted_units_report,
|
|
356
|
+
closes=close_report,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
self._result = BacktestResult(
|
|
360
|
+
equity = equity_s,
|
|
361
|
+
returns = returns,
|
|
362
|
+
positions = pos_df,
|
|
363
|
+
closes = close_df,
|
|
364
|
+
symbols = self.symbols,
|
|
365
|
+
initial_capital = self.initial_capital,
|
|
366
|
+
leverage = float(np.mean(list(self.lev.values()))),
|
|
367
|
+
liquidated = liq_flag,
|
|
368
|
+
liquidation_bar = int(liq_idx),
|
|
369
|
+
metadata = {
|
|
370
|
+
"mode": self.mode,
|
|
371
|
+
"asset_type": self.asset_type,
|
|
372
|
+
"hedge_type": self.hedge_type,
|
|
373
|
+
"engine": "numba_portfolio",
|
|
374
|
+
"initial_buying_power": self.initial_capital * float(np.mean(list(self.lev.values()))),
|
|
375
|
+
"funding_rate_unit": "per_event",
|
|
376
|
+
"target_units_report": target_units_report,
|
|
377
|
+
"accepted_units_report": accepted_units_report,
|
|
378
|
+
"target_notional_report": target_units_report.mul(close_report, axis=0).mul(pd.Series(self.cs), axis=1),
|
|
379
|
+
"accepted_notional_report": accepted_units_report.mul(close_report, axis=0).mul(pd.Series(self.cs), axis=1),
|
|
380
|
+
"exposure_report": exposure_report,
|
|
381
|
+
"symbol_pnl_report": symbol_pnl_report,
|
|
382
|
+
"rebalance_report": rebalance_report,
|
|
383
|
+
"fee_series": pd.Series(fee_arr, index=idx, name="fee"),
|
|
384
|
+
"turnover_series": pd.Series(turn_arr, index=idx, name="turnover"),
|
|
385
|
+
"fee_total": float(np.sum(fee_arr)),
|
|
386
|
+
"fee_rate_oneway": float(self.fee_rate),
|
|
387
|
+
"canonical_one_way_fee_rate": float(self.fee_rate),
|
|
388
|
+
"turnover_total": float(np.sum(turn_arr)),
|
|
389
|
+
},
|
|
390
|
+
)
|
|
391
|
+
return self._result
|
|
392
|
+
|
|
393
|
+
def _build_symbol_pnl_report(
|
|
394
|
+
self,
|
|
395
|
+
accepted_units: pd.DataFrame,
|
|
396
|
+
closes: pd.DataFrame,
|
|
397
|
+
funding_rates: pd.DataFrame,
|
|
398
|
+
is_funding_bar: pd.Series,
|
|
399
|
+
) -> pd.DataFrame:
|
|
400
|
+
frames = []
|
|
401
|
+
funding_mask = is_funding_bar.astype(bool) & bool(self.use_funding)
|
|
402
|
+
for s in self.symbols:
|
|
403
|
+
units = accepted_units[s].astype(float)
|
|
404
|
+
close = closes[s].astype(float)
|
|
405
|
+
prev_units = units.shift(1).fillna(0.0)
|
|
406
|
+
prev_close = close.shift(1).fillna(close)
|
|
407
|
+
delta = units.diff().fillna(units)
|
|
408
|
+
cs = float(self.cs[s])
|
|
409
|
+
mark_pnl = prev_units * (close - prev_close) * cs
|
|
410
|
+
funding_cost = prev_units * close * cs * funding_rates[s].astype(float)
|
|
411
|
+
funding_cost = funding_cost.where(funding_mask, 0.0)
|
|
412
|
+
fee = delta.abs() * close * cs * float(self.fee_rate)
|
|
413
|
+
total_pnl = mark_pnl - funding_cost - fee
|
|
414
|
+
frame = pd.DataFrame(
|
|
415
|
+
{
|
|
416
|
+
"timestamp": self._idx,
|
|
417
|
+
"symbol": s,
|
|
418
|
+
"position_units": units.to_numpy(dtype=float),
|
|
419
|
+
"close": close.to_numpy(dtype=float),
|
|
420
|
+
"mark_pnl": mark_pnl.to_numpy(dtype=float),
|
|
421
|
+
"funding_cost": funding_cost.to_numpy(dtype=float),
|
|
422
|
+
"funding_pnl": (-funding_cost).to_numpy(dtype=float),
|
|
423
|
+
"fee": fee.to_numpy(dtype=float),
|
|
424
|
+
"fee_pnl": (-fee).to_numpy(dtype=float),
|
|
425
|
+
"total_pnl": total_pnl.to_numpy(dtype=float),
|
|
426
|
+
}
|
|
427
|
+
)
|
|
428
|
+
frames.append(frame)
|
|
429
|
+
if not frames:
|
|
430
|
+
return pd.DataFrame(
|
|
431
|
+
columns=[
|
|
432
|
+
"timestamp",
|
|
433
|
+
"symbol",
|
|
434
|
+
"position_units",
|
|
435
|
+
"close",
|
|
436
|
+
"mark_pnl",
|
|
437
|
+
"funding_cost",
|
|
438
|
+
"funding_pnl",
|
|
439
|
+
"fee",
|
|
440
|
+
"fee_pnl",
|
|
441
|
+
"total_pnl",
|
|
442
|
+
]
|
|
443
|
+
)
|
|
444
|
+
return pd.concat(frames, ignore_index=True, copy=False)
|
|
445
|
+
|
|
446
|
+
def _build_exposure_report(
|
|
447
|
+
self,
|
|
448
|
+
accepted_units: pd.DataFrame,
|
|
449
|
+
target_units: pd.DataFrame,
|
|
450
|
+
closes: pd.DataFrame,
|
|
451
|
+
equity: pd.Series,
|
|
452
|
+
) -> pd.DataFrame:
|
|
453
|
+
cs = pd.Series({s: float(self.cs[s]) for s in self.symbols})
|
|
454
|
+
lev = pd.Series({s: float(self.lev[s]) for s in self.symbols})
|
|
455
|
+
accepted_notional = accepted_units.mul(closes, axis=0).mul(cs, axis=1)
|
|
456
|
+
target_notional = target_units.mul(closes, axis=0).mul(cs, axis=1)
|
|
457
|
+
abs_accepted = accepted_notional.abs()
|
|
458
|
+
initial_margin = abs_accepted.div(lev, axis=1).sum(axis=1)
|
|
459
|
+
maintenance_margin = abs_accepted.sum(axis=1) * float(self.maintenance_ratio)
|
|
460
|
+
out = pd.DataFrame(
|
|
461
|
+
{
|
|
462
|
+
"long_notional": accepted_notional.clip(lower=0.0).sum(axis=1),
|
|
463
|
+
"short_notional": accepted_notional.clip(upper=0.0).abs().sum(axis=1),
|
|
464
|
+
"gross_notional": abs_accepted.sum(axis=1),
|
|
465
|
+
"net_notional": accepted_notional.sum(axis=1),
|
|
466
|
+
"target_gross_notional": target_notional.abs().sum(axis=1),
|
|
467
|
+
"initial_margin": initial_margin,
|
|
468
|
+
"maintenance_margin": maintenance_margin,
|
|
469
|
+
"equity": equity,
|
|
470
|
+
"available_equity_after_im": equity - initial_margin,
|
|
471
|
+
"buying_power": equity * float(np.mean(list(self.lev.values()))),
|
|
472
|
+
},
|
|
473
|
+
index=self._idx,
|
|
474
|
+
)
|
|
475
|
+
out["gross_leverage"] = out["gross_notional"] / out["equity"].replace(0.0, np.nan)
|
|
476
|
+
out["net_exposure_pct"] = out["net_notional"] / out["equity"].replace(0.0, np.nan)
|
|
477
|
+
return out.fillna(0.0)
|
|
478
|
+
|
|
479
|
+
def _build_rebalance_report(
|
|
480
|
+
self,
|
|
481
|
+
target_units: pd.DataFrame,
|
|
482
|
+
accepted_units: pd.DataFrame,
|
|
483
|
+
closes: pd.DataFrame,
|
|
484
|
+
) -> pd.DataFrame:
|
|
485
|
+
diff = target_units - accepted_units
|
|
486
|
+
cs = pd.Series({s: float(self.cs[s]) for s in self.symbols})
|
|
487
|
+
mask = diff.abs() > 1e-10
|
|
488
|
+
if not mask.to_numpy().any():
|
|
489
|
+
return pd.DataFrame(
|
|
490
|
+
columns=[
|
|
491
|
+
"timestamp",
|
|
492
|
+
"symbol",
|
|
493
|
+
"target_units",
|
|
494
|
+
"accepted_units",
|
|
495
|
+
"unit_diff",
|
|
496
|
+
"notional_diff",
|
|
497
|
+
"reason",
|
|
498
|
+
]
|
|
499
|
+
)
|
|
500
|
+
notional_diff = diff.mul(closes, axis=0).mul(cs, axis=1)
|
|
501
|
+
stacked = diff.where(mask).stack(future_stack=True).dropna()
|
|
502
|
+
index = stacked.index
|
|
503
|
+
target_stacked = target_units.stack(future_stack=True)
|
|
504
|
+
accepted_stacked = accepted_units.stack(future_stack=True)
|
|
505
|
+
notional_stacked = notional_diff.stack(future_stack=True)
|
|
506
|
+
return pd.DataFrame(
|
|
507
|
+
{
|
|
508
|
+
"timestamp": index.get_level_values(0),
|
|
509
|
+
"symbol": index.get_level_values(1),
|
|
510
|
+
"target_units": target_stacked.reindex(index).to_numpy(dtype=float),
|
|
511
|
+
"accepted_units": accepted_stacked.reindex(index).to_numpy(dtype=float),
|
|
512
|
+
"unit_diff": stacked.to_numpy(dtype=float),
|
|
513
|
+
"notional_diff": notional_stacked.reindex(index).to_numpy(dtype=float),
|
|
514
|
+
"reason": "margin_or_portfolio_gate",
|
|
515
|
+
}
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def result(self) -> BacktestResult:
|
|
520
|
+
if self._result is None:
|
|
521
|
+
self.run()
|
|
522
|
+
return self._result
|
|
523
|
+
|
|
524
|
+
# ── analytics ─────────────────────────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
def print_metrics(self) -> None:
|
|
527
|
+
rpt = full_report(self.result, self.trading_days)
|
|
528
|
+
syms = ", ".join(self.symbols)
|
|
529
|
+
|
|
530
|
+
lines = [
|
|
531
|
+
("Symbols", syms),
|
|
532
|
+
("Asset Type", self.asset_type.upper()),
|
|
533
|
+
("Mode", self.mode),
|
|
534
|
+
("Initial Capital", f"${rpt['initial_capital']:>14,.0f}"),
|
|
535
|
+
("Final Equity", f"${rpt['final_equity']:>14,.2f}"),
|
|
536
|
+
("Total Return", f"{rpt['total_return_pct']:>+13.2f}%"),
|
|
537
|
+
("CAGR", f"{rpt['cagr_pct']:>+13.2f}%"),
|
|
538
|
+
("Sharpe Ratio", f"{rpt['sharpe']:>14.3f}"),
|
|
539
|
+
("Sortino Ratio", f"{rpt['sortino']:>14.3f}"),
|
|
540
|
+
("Calmar Ratio", f"{rpt['calmar']:>14.3f}"),
|
|
541
|
+
("Max Drawdown", f"{rpt['max_drawdown_pct']:>13.2f}%"),
|
|
542
|
+
("Profit Factor", f"{rpt['profit_factor']:>14.3f}"),
|
|
543
|
+
("Long Hit Rate", f"{rpt['long_hitrate_pct']:>13.2f}%"),
|
|
544
|
+
("Short Hit Rate", f"{rpt['short_hitrate_pct']:>13.2f}%"),
|
|
545
|
+
("Number of Trades", f"{rpt['num_trades']:>14,d}"),
|
|
546
|
+
("Liquidated", f"{'Yes' if rpt['liquidated'] else 'No':>14}"),
|
|
547
|
+
]
|
|
548
|
+
col_width = max(len(k) for k, _ in lines)
|
|
549
|
+
print()
|
|
550
|
+
for key, val in lines:
|
|
551
|
+
print(f" {key:<{col_width}} {val}")
|
|
552
|
+
print()
|
|
553
|
+
|
|
554
|
+
def analyze(self, theme: str = "dark", figsize: tuple = (14, 6)) -> None:
|
|
555
|
+
from .viz.plots import quick_plot
|
|
556
|
+
|
|
557
|
+
self.print_metrics()
|
|
558
|
+
quick_plot(self.result, theme=theme, figsize=figsize)
|
|
559
|
+
|
|
560
|
+
def tearsheet(
|
|
561
|
+
self,
|
|
562
|
+
theme: str = "dark",
|
|
563
|
+
figsize: tuple = (16, 20),
|
|
564
|
+
benchmark: Optional[pd.Series] = None,
|
|
565
|
+
) -> None:
|
|
566
|
+
from .viz.plots import tearsheet as _tearsheet
|
|
567
|
+
|
|
568
|
+
_tearsheet(
|
|
569
|
+
self.result,
|
|
570
|
+
theme = theme,
|
|
571
|
+
figsize = figsize,
|
|
572
|
+
trading_days = self.trading_days,
|
|
573
|
+
benchmark = benchmark,
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def hitrate_per_symbol(self) -> Dict[str, Tuple[float, float]]:
|
|
577
|
+
"""Returns {sym: (long_hr_pct, short_hr_pct)} for every symbol."""
|
|
578
|
+
out = {}
|
|
579
|
+
r = self.result
|
|
580
|
+
for s in self.symbols:
|
|
581
|
+
pos = r.positions[f"Position_{s}"]
|
|
582
|
+
cl = r.closes[f"Close_{s}"]
|
|
583
|
+
ret = cl.pct_change().fillna(0)
|
|
584
|
+
long_mask = pos > 0
|
|
585
|
+
short_mask = pos < 0
|
|
586
|
+
lw = ((ret > 0) & long_mask).sum()
|
|
587
|
+
lt = long_mask.sum()
|
|
588
|
+
sw = ((ret < 0) & short_mask).sum()
|
|
589
|
+
st = short_mask.sum()
|
|
590
|
+
out[s] = (
|
|
591
|
+
round(lw / lt * 100, 2) if lt > 0 else 0.0,
|
|
592
|
+
round(sw / st * 100, 2) if st > 0 else 0.0,
|
|
593
|
+
)
|
|
594
|
+
return out
|
|
595
|
+
|
|
596
|
+
def export_log(self, filename: str = "portfolio_log.csv") -> None:
|
|
597
|
+
r = self.result
|
|
598
|
+
log = pd.DataFrame({
|
|
599
|
+
"cumulative_return": (r.equity / self.initial_capital - 1) * 100,
|
|
600
|
+
"daily_return": r.returns * 100,
|
|
601
|
+
}, index=r.equity.index)
|
|
602
|
+
for s in self.symbols:
|
|
603
|
+
log[f"position_{s}"] = r.positions[f"Position_{s}"]
|
|
604
|
+
log[f"close_{s}"] = r.closes[f"Close_{s}"]
|
|
605
|
+
log.to_csv(filename)
|
|
606
|
+
print(f"Portfolio log exported → {filename}")
|
quantbt/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Reporting helpers for QuantBT result artifacts."""
|
|
2
|
+
|
|
3
|
+
from .arbitrage_audit import build_arbitrage_domain_audit, compare_native_arbitrage_results
|
|
4
|
+
from .nautilus_bundle import export_nautilus_report_bundle
|
|
5
|
+
from .nautilus_certification import (
|
|
6
|
+
NautilusToleranceProfile,
|
|
7
|
+
build_nautilus_certification_profile,
|
|
8
|
+
write_nautilus_certification_artifacts,
|
|
9
|
+
)
|
|
10
|
+
from .nautilus_diagnostics import build_nautilus_pct_equity_diagnostic
|
|
11
|
+
from .parity import (
|
|
12
|
+
build_native_nautilus_parity_report,
|
|
13
|
+
build_nautilus_depth_execution_report,
|
|
14
|
+
build_nautilus_depth_parity_summary,
|
|
15
|
+
summarize_native_nautilus_parity_report,
|
|
16
|
+
)
|
|
17
|
+
from .portfolio_audit import build_portfolio_domain_audit
|
|
18
|
+
from .portfolio_nautilus import (
|
|
19
|
+
build_portfolio_nautilus_position_report,
|
|
20
|
+
build_portfolio_nautilus_validation_report,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"build_arbitrage_domain_audit",
|
|
25
|
+
"build_native_nautilus_parity_report",
|
|
26
|
+
"build_nautilus_depth_execution_report",
|
|
27
|
+
"build_nautilus_depth_parity_summary",
|
|
28
|
+
"build_nautilus_certification_profile",
|
|
29
|
+
"build_nautilus_pct_equity_diagnostic",
|
|
30
|
+
"build_portfolio_domain_audit",
|
|
31
|
+
"build_portfolio_nautilus_position_report",
|
|
32
|
+
"build_portfolio_nautilus_validation_report",
|
|
33
|
+
"compare_native_arbitrage_results",
|
|
34
|
+
"export_nautilus_report_bundle",
|
|
35
|
+
"NautilusToleranceProfile",
|
|
36
|
+
"summarize_native_nautilus_parity_report",
|
|
37
|
+
"write_nautilus_certification_artifacts",
|
|
38
|
+
]
|