quantbt-engine 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
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
+ ]