quantbt-engine 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,45 @@
1
+ from .performance import (
2
+ full_report,
3
+ total_return,
4
+ cagr,
5
+ sharpe,
6
+ sortino,
7
+ calmar,
8
+ omega,
9
+ max_drawdown,
10
+ max_drawdown_pct,
11
+ avg_drawdown,
12
+ drawdown_duration,
13
+ hitrate,
14
+ number_of_trades,
15
+ profit_factor,
16
+ avg_win_loss,
17
+ expectancy,
18
+ rolling_sharpe,
19
+ rolling_drawdown,
20
+ )
21
+ from .options_analytics import option_attribution_report, option_report_bundle, option_run_manifest
22
+
23
+ __all__ = [
24
+ "full_report",
25
+ "total_return",
26
+ "cagr",
27
+ "sharpe",
28
+ "sortino",
29
+ "calmar",
30
+ "omega",
31
+ "max_drawdown",
32
+ "max_drawdown_pct",
33
+ "avg_drawdown",
34
+ "drawdown_duration",
35
+ "hitrate",
36
+ "number_of_trades",
37
+ "profit_factor",
38
+ "avg_win_loss",
39
+ "expectancy",
40
+ "rolling_sharpe",
41
+ "rolling_drawdown",
42
+ "option_attribution_report",
43
+ "option_report_bundle",
44
+ "option_run_manifest",
45
+ ]
@@ -0,0 +1,46 @@
1
+ """
2
+ Option-domain report helpers.
3
+
4
+ These functions summarize `OptionBacktestResult` artifacts without recomputing
5
+ ledger accounting or execution PnL.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Dict
11
+
12
+ import pandas as pd
13
+
14
+
15
+ def option_run_manifest(result) -> Dict:
16
+ """Return the option run manifest stored by `NativeOptionBackend`."""
17
+ return dict(getattr(result, "run_manifest", None) or result.metadata.get("run_manifest", {}))
18
+
19
+
20
+ def option_attribution_report(result) -> pd.DataFrame:
21
+ """Return the option attribution table, or an empty DataFrame."""
22
+ report = getattr(result, "attribution_report", None)
23
+ if report is None:
24
+ report = result.metadata.get("attribution_report")
25
+ return report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame()
26
+
27
+
28
+ def option_report_bundle(result) -> Dict[str, pd.DataFrame]:
29
+ """Return all standard option audit tables as a dictionary."""
30
+ names = (
31
+ "fills_report",
32
+ "packages_report",
33
+ "cash_report",
34
+ "marks_report",
35
+ "greeks_report",
36
+ "settlements_report",
37
+ "margin_report",
38
+ "attribution_report",
39
+ )
40
+ out = {}
41
+ for name in names:
42
+ report = getattr(result, name, None)
43
+ if report is None:
44
+ report = result.metadata.get(name)
45
+ out[name] = report.copy() if isinstance(report, pd.DataFrame) else pd.DataFrame()
46
+ return out
@@ -0,0 +1,541 @@
1
+ """
2
+ quantbt.metrics.performance
3
+ ----------------------------
4
+ Pure functions. All accept a BacktestResult (or bare pd.Series of returns)
5
+ and return scalars or DataFrames. No side-effects, no plotting.
6
+
7
+ All return-based statistics default to daily frequency with 365-day Sharpe
8
+ scaling (crypto); pass trading_days=252 for equities.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Dict, Sequence, Tuple
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from ..core.types import BacktestResult
19
+
20
+
21
+ # ── helpers ──────────────────────────────────────────────────────────────────
22
+
23
+ def _daily(result: BacktestResult) -> pd.Series:
24
+ return result.daily_returns
25
+
26
+
27
+ def _equity_daily(result: BacktestResult) -> pd.Series:
28
+ return result.daily_equity
29
+
30
+
31
+ def _finite_returns(series: pd.Series) -> pd.Series:
32
+ r = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
33
+ return r.astype(float)
34
+
35
+
36
+ def _returns_for_stats(result: BacktestResult) -> pd.Series:
37
+ """
38
+ Return sample used by distribution metrics.
39
+
40
+ Daily returns are preferred for stable multi-day reports. Very short
41
+ intraday/scoped runs can collapse to one daily equity point, producing an
42
+ empty daily return sample; in that case we fall back to bar returns so
43
+ Sharpe, Omega, PF, and avg win/loss do not become artificial 0/inf values.
44
+ """
45
+ daily = _finite_returns(_daily(result))
46
+ if len(daily) > 0:
47
+ return daily
48
+ bar = _finite_returns(result.returns)
49
+ if len(bar) > 0:
50
+ return bar
51
+ return _finite_returns(result.equity.pct_change().fillna(0.0))
52
+
53
+
54
+ def _annualization_periods(result: BacktestResult, trading_days: int) -> float:
55
+ daily = _finite_returns(_daily(result))
56
+ if len(daily) > 0:
57
+ return float(trading_days)
58
+ idx = result.equity.index
59
+ if len(idx) >= 2 and isinstance(idx, pd.DatetimeIndex):
60
+ deltas = idx.to_series().diff().dropna().dt.total_seconds()
61
+ deltas = deltas[deltas > 0.0]
62
+ if len(deltas) > 0:
63
+ median_seconds = float(deltas.median())
64
+ if median_seconds > 0.0:
65
+ return float(365.25 * 24 * 60 * 60 / median_seconds)
66
+ return float(trading_days)
67
+
68
+
69
+ def _elapsed_years(result: BacktestResult, trading_days: int) -> float:
70
+ eq = result.equity.dropna()
71
+ if len(eq) < 2:
72
+ return 0.0
73
+ idx = eq.index
74
+ if isinstance(idx, pd.DatetimeIndex):
75
+ elapsed_days = (idx[-1] - idx[0]).total_seconds() / 86_400.0
76
+ if elapsed_days > 0.0:
77
+ return elapsed_days / 365.25
78
+ daily = _equity_daily(result)
79
+ if len(daily) >= 2:
80
+ return len(daily) / float(trading_days)
81
+ return len(eq) / float(trading_days)
82
+
83
+
84
+ # ── return metrics ───────────────────────────────────────────────────────────
85
+
86
+ def total_return(result: BacktestResult) -> float:
87
+ """Total return as a decimal (0.25 = 25%)."""
88
+ eq = result.equity
89
+ return (eq.iloc[-1] - result.initial_capital) / result.initial_capital
90
+
91
+
92
+ def cagr(result: BacktestResult, trading_days: int = 365) -> float:
93
+ """Compound annual growth rate."""
94
+ eq = result.equity.dropna()
95
+ if len(eq) >= 2 and isinstance(eq.index, pd.DatetimeIndex):
96
+ elapsed_days = (eq.index[-1] - eq.index[0]).total_seconds() / 86_400.0
97
+ if 0.0 < elapsed_days < 1.0:
98
+ return total_return(result)
99
+ years = _elapsed_years(result, trading_days)
100
+ if years <= 0:
101
+ return 0.0
102
+ growth = eq.iloc[-1] / eq.iloc[0]
103
+ if growth <= 0.0:
104
+ return -1.0
105
+ annual_log = np.log(growth) / years
106
+ if annual_log > 50.0:
107
+ return float(np.expm1(50.0))
108
+ if annual_log < -50.0:
109
+ return float(np.expm1(-50.0))
110
+ return float(np.expm1(annual_log))
111
+
112
+
113
+ def sharpe(result: BacktestResult, trading_days: int = 365, risk_free: float = 0.0) -> float:
114
+ periods = _annualization_periods(result, trading_days)
115
+ r = _returns_for_stats(result) - risk_free / periods
116
+ sd = r.std(ddof=1)
117
+ return (r.mean() / sd) * np.sqrt(periods) if sd > 0 else 0.0
118
+
119
+
120
+ def sortino(result: BacktestResult, trading_days: int = 365, mar: float = 0.0) -> float:
121
+ periods = _annualization_periods(result, trading_days)
122
+ r = _returns_for_stats(result)
123
+ d = r[r < mar] - mar
124
+ dd = np.sqrt((d ** 2).mean()) if len(d) > 0 else 0.0
125
+ if dd == 0.0 and r.mean() > mar:
126
+ return np.inf
127
+ return (r.mean() / dd) * np.sqrt(periods) if dd > 0 else 0.0
128
+
129
+
130
+ def calmar(result: BacktestResult, trading_days: int = 365) -> float:
131
+ c = cagr(result, trading_days)
132
+ mdd = max_drawdown(result)
133
+ return c / mdd if mdd > 0 else 0.0
134
+
135
+
136
+ def omega(result: BacktestResult, threshold: float = 0.0) -> float:
137
+ """Omega ratio (Keating & Shadwick)."""
138
+ r = _returns_for_stats(result)
139
+ gain = (r[r > threshold] - threshold).sum()
140
+ loss = (threshold - r[r < threshold]).sum()
141
+ return gain / loss if loss > 0 else np.inf
142
+
143
+
144
+ # ── drawdown metrics ─────────────────────────────────────────────────────────
145
+
146
+ def max_drawdown(result: BacktestResult) -> float:
147
+ """Maximum drawdown as a positive fraction."""
148
+ return float(result.drawdown.max())
149
+
150
+
151
+ def max_drawdown_pct(result: BacktestResult) -> float:
152
+ return max_drawdown(result) * 100.0
153
+
154
+
155
+ def avg_drawdown(result: BacktestResult) -> float:
156
+ """Mean of all drawdown troughs (fraction)."""
157
+ dd = result.drawdown
158
+ return float(dd[dd > 0].mean()) if (dd > 0).any() else 0.0
159
+
160
+
161
+ def drawdown_duration(result: BacktestResult) -> Tuple[int, int]:
162
+ """
163
+ Returns (max_duration_bars, avg_duration_bars).
164
+ Duration counted in calendar days on daily equity.
165
+ """
166
+ eq = _equity_daily(result)
167
+ peak = eq.cummax()
168
+ in_dd = (peak != eq)
169
+
170
+ durations = []
171
+ run = 0
172
+ for v in in_dd:
173
+ if v:
174
+ run += 1
175
+ else:
176
+ if run > 0:
177
+ durations.append(run)
178
+ run = 0
179
+ if run > 0:
180
+ durations.append(run)
181
+
182
+ if not durations:
183
+ return 0, 0
184
+ return int(max(durations)), int(np.mean(durations))
185
+
186
+
187
+ # ── trade statistics ─────────────────────────────────────────────────────────
188
+
189
+ def hitrate(result: BacktestResult) -> Tuple[float, float]:
190
+ """
191
+ Returns (long_hitrate_pct, short_hitrate_pct).
192
+ A bar is a 'win' if the daily return > 0 while the position is active.
193
+ """
194
+ eq_ret = result.returns
195
+ long_hr = []
196
+ short_hr = []
197
+
198
+ for sym in result.symbols:
199
+ pos = result.positions[f"Position_{sym}"]
200
+ long_mask = pos > 0
201
+ short_mask = pos < 0
202
+
203
+ long_wins = ((eq_ret > 0) & long_mask).sum()
204
+ long_total = long_mask.sum()
205
+
206
+ short_wins = ((eq_ret > 0) & short_mask).sum()
207
+ short_total = short_mask.sum()
208
+
209
+ long_hr.append(long_wins / long_total * 100 if long_total > 0 else 0.0)
210
+ short_hr.append(short_wins / short_total * 100 if short_total > 0 else 0.0)
211
+
212
+ return float(np.mean(long_hr)), float(np.mean(short_hr))
213
+
214
+
215
+ def number_of_trades(result: BacktestResult) -> int:
216
+ """Count signal transitions (any symbol)."""
217
+ count = 0
218
+ for sym in result.symbols:
219
+ pos = result.positions[f"Position_{sym}"]
220
+ count += int((pos.diff() != 0).sum())
221
+ return count
222
+
223
+
224
+ def profit_factor(result: BacktestResult) -> float:
225
+ r = _returns_for_stats(result)
226
+ gains = r[r > 0].sum()
227
+ loss = abs(r[r < 0].sum())
228
+ return gains / loss if loss > 0 else np.inf
229
+
230
+
231
+ def avg_win_loss(result: BacktestResult) -> Tuple[float, float]:
232
+ """(avg_win_pct, avg_loss_pct) in percent."""
233
+ r = _returns_for_stats(result)
234
+ w = r[r > 0].mean() * 100 if (r > 0).any() else 0.0
235
+ l = r[r < 0].mean() * 100 if (r < 0).any() else 0.0
236
+ return float(w), float(l)
237
+
238
+
239
+ def expectancy(result: BacktestResult) -> float:
240
+ """
241
+ Expectancy = HR × avg_win + (1 − HR) × avg_loss
242
+ (uses combined long/short hitrate average)
243
+ """
244
+ lh, sh = hitrate(result)
245
+ hr = (lh + sh) / 200.0 # convert to decimal average
246
+ aw, al = avg_win_loss(result)
247
+ return hr * aw + (1 - hr) * al
248
+
249
+
250
+ # ── rolling metrics ──────────────────────────────────────────────────────────
251
+
252
+ def rolling_sharpe(
253
+ result: BacktestResult,
254
+ window: int = 30,
255
+ trading_days: int = 365,
256
+ ) -> pd.Series:
257
+ r = _daily(result)
258
+ mu = r.rolling(window).mean()
259
+ sd = r.rolling(window).std(ddof=1)
260
+ return (mu / sd) * np.sqrt(trading_days)
261
+
262
+
263
+ def rolling_drawdown(result: BacktestResult) -> pd.Series:
264
+ """Rolling drawdown fraction from trailing peak."""
265
+ eq = _equity_daily(result)
266
+ peak = eq.cummax()
267
+ return (peak - eq) / peak
268
+
269
+
270
+ # ── full report dict ─────────────────────────────────────────────────────────
271
+
272
+ def compute_performance_metrics(
273
+ *,
274
+ timestamps: Sequence,
275
+ equity: Sequence[float],
276
+ returns: Sequence[float],
277
+ positions,
278
+ symbols: Sequence[str],
279
+ initial_capital: float,
280
+ liquidated: bool = False,
281
+ trading_days: int = 365,
282
+ ) -> Dict:
283
+ """
284
+ Shared array-first metric contract.
285
+
286
+ This intentionally mirrors `full_report()` semantics so lightweight
287
+ prepared/native-event score paths and public `BacktestResultV2` reports use
288
+ one metric implementation.
289
+ """
290
+ idx = pd.DatetimeIndex(timestamps)
291
+ equity_arr = np.asarray(equity, dtype=np.float64)
292
+ returns_arr = np.asarray(returns, dtype=np.float64)
293
+ pos_arr = np.asarray(positions, dtype=np.float64)
294
+ if pos_arr.ndim == 1:
295
+ pos_arr = pos_arr.reshape(-1, 1)
296
+ if len(equity_arr) == 0:
297
+ raise ValueError("equity path cannot be empty")
298
+ if len(returns_arr) != len(equity_arr):
299
+ raise ValueError("returns must have the same length as equity")
300
+ if pos_arr.shape[0] != len(equity_arr):
301
+ raise ValueError("positions must have the same number of rows as equity")
302
+
303
+ stats_returns = _array_returns_for_stats(idx, equity_arr, returns_arr)
304
+ annual_periods = _array_annualization_periods(idx, stats_returns, trading_days)
305
+ elapsed_years = _array_elapsed_years(idx, equity_arr, trading_days)
306
+ drawdown = _array_drawdown(equity_arr)
307
+ max_dd = float(np.nanmax(drawdown)) if len(drawdown) else 0.0
308
+ avg_dd = float(np.nanmean(drawdown[drawdown > 0.0])) if np.any(drawdown > 0.0) else 0.0
309
+ max_dd_duration, avg_dd_duration = _array_drawdown_duration_days(idx, equity_arr)
310
+
311
+ final_equity = float(equity_arr[-1])
312
+ total_ret = (final_equity - float(initial_capital)) / float(initial_capital)
313
+ cagr_value = _array_cagr(equity_arr, total_ret, elapsed_years)
314
+ sharpe_value = _array_sharpe(stats_returns, annual_periods)
315
+ sortino_value = _array_sortino(stats_returns, annual_periods)
316
+ omega_value = _array_omega(stats_returns)
317
+ pf_value = _array_profit_factor(stats_returns)
318
+ long_hr, short_hr = _array_hitrate(returns_arr, pos_arr)
319
+ avg_win, avg_loss = _array_avg_win_loss(stats_returns)
320
+ hr = (long_hr + short_hr) / 200.0
321
+ expectancy_value = hr * avg_win + (1.0 - hr) * avg_loss
322
+
323
+ return {
324
+ "initial_capital": float(initial_capital),
325
+ "final_equity": final_equity,
326
+ "total_return_pct": float(total_ret * 100.0),
327
+ "cagr_pct": float(cagr_value * 100.0),
328
+ "sharpe": float(sharpe_value),
329
+ "sortino": float(sortino_value),
330
+ "calmar": float(cagr_value / max_dd) if max_dd > 0.0 else 0.0,
331
+ "omega": float(omega_value),
332
+ "max_drawdown_pct": float(max_dd * 100.0),
333
+ "avg_drawdown_pct": float(avg_dd * 100.0),
334
+ "max_dd_duration_days": int(max_dd_duration),
335
+ "avg_dd_duration_days": int(avg_dd_duration),
336
+ "profit_factor": float(pf_value),
337
+ "long_hitrate_pct": float(long_hr),
338
+ "short_hitrate_pct": float(short_hr),
339
+ "avg_win_pct": float(avg_win),
340
+ "avg_loss_pct": float(avg_loss),
341
+ "expectancy_pct": float(expectancy_value),
342
+ "num_trades": int(_array_number_of_trades(pos_arr)),
343
+ "liquidated": bool(liquidated),
344
+ }
345
+
346
+
347
+ def _array_finite_returns(values: np.ndarray) -> np.ndarray:
348
+ arr = np.asarray(values, dtype=np.float64)
349
+ return arr[np.isfinite(arr)]
350
+
351
+
352
+ def _array_daily_equity(idx: pd.DatetimeIndex, equity: np.ndarray) -> np.ndarray:
353
+ if len(equity) == 0:
354
+ return np.empty(0, dtype=np.float64)
355
+ if len(idx) != len(equity):
356
+ return np.asarray(equity, dtype=np.float64)
357
+ day_ns = 86_400_000_000_000
358
+ days = idx.view("int64") // day_ns
359
+ if len(days) == 0:
360
+ return np.empty(0, dtype=np.float64)
361
+ change = np.flatnonzero(days[1:] != days[:-1])
362
+ last_idx = np.concatenate((change, np.array([len(days) - 1], dtype=np.int64)))
363
+ return np.asarray(equity, dtype=np.float64)[last_idx]
364
+
365
+
366
+ def _array_returns_for_stats(idx: pd.DatetimeIndex, equity: np.ndarray, returns: np.ndarray) -> np.ndarray:
367
+ daily_equity = _array_daily_equity(idx, equity)
368
+ if len(daily_equity) >= 2:
369
+ base = daily_equity[:-1]
370
+ daily_returns = np.divide(
371
+ daily_equity[1:] - base,
372
+ base,
373
+ out=np.zeros(len(base), dtype=np.float64),
374
+ where=base != 0.0,
375
+ )
376
+ daily_returns = _array_finite_returns(daily_returns)
377
+ if len(daily_returns) > 0:
378
+ return daily_returns
379
+ bar = _array_finite_returns(returns)
380
+ if len(bar) > 0:
381
+ return bar
382
+ if len(equity) < 2:
383
+ return np.zeros(1, dtype=np.float64)
384
+ base = equity[:-1]
385
+ out = np.divide(equity[1:] - base, base, out=np.zeros(len(base), dtype=np.float64), where=base != 0.0)
386
+ return _array_finite_returns(out)
387
+
388
+
389
+ def _array_annualization_periods(idx: pd.DatetimeIndex, stats_returns: np.ndarray, trading_days: int) -> float:
390
+ daily_equity_returns = len(stats_returns) > 0
391
+ if daily_equity_returns and len(idx) >= 2:
392
+ day_ns = 86_400_000_000_000
393
+ if len(np.unique(idx.view("int64") // day_ns)) >= 2:
394
+ return float(trading_days)
395
+ if len(idx) >= 2:
396
+ ns = idx.view("int64")
397
+ deltas = np.diff(ns).astype(np.float64) / 1_000_000_000.0
398
+ deltas = deltas[deltas > 0.0]
399
+ if len(deltas) > 0:
400
+ median_seconds = float(np.median(deltas))
401
+ if median_seconds > 0.0:
402
+ return float(365.25 * 24 * 60 * 60 / median_seconds)
403
+ return float(trading_days)
404
+
405
+
406
+ def _array_elapsed_years(idx: pd.DatetimeIndex, equity: np.ndarray, trading_days: int) -> float:
407
+ if len(equity) < 2:
408
+ return 0.0
409
+ if len(idx) >= 2:
410
+ elapsed_days = (idx[-1] - idx[0]).total_seconds() / 86_400.0
411
+ if elapsed_days > 0.0:
412
+ return float(elapsed_days / 365.25)
413
+ daily_equity = _array_daily_equity(idx, equity)
414
+ if len(daily_equity) >= 2:
415
+ return float(len(daily_equity) / float(trading_days))
416
+ return float(len(equity) / float(trading_days))
417
+
418
+
419
+ def _array_cagr(equity: np.ndarray, total_ret: float, years: float) -> float:
420
+ if len(equity) >= 2 and years > 0.0:
421
+ elapsed_days = years * 365.25
422
+ if 0.0 < elapsed_days < 1.0:
423
+ return float(total_ret)
424
+ if years <= 0.0:
425
+ return 0.0
426
+ growth = float(equity[-1] / equity[0])
427
+ if growth <= 0.0:
428
+ return -1.0
429
+ annual_log = np.log(growth) / years
430
+ if annual_log > 50.0:
431
+ return float(np.expm1(50.0))
432
+ if annual_log < -50.0:
433
+ return float(np.expm1(-50.0))
434
+ return float(np.expm1(annual_log))
435
+
436
+
437
+ def _array_sharpe(r: np.ndarray, periods: float) -> float:
438
+ if len(r) < 2:
439
+ return 0.0
440
+ sd = float(np.std(r, ddof=1))
441
+ return float((np.mean(r) / sd) * np.sqrt(periods)) if sd > 0.0 else 0.0
442
+
443
+
444
+ def _array_sortino(r: np.ndarray, periods: float, mar: float = 0.0) -> float:
445
+ downside = r[r < mar] - mar
446
+ dd = float(np.sqrt(np.mean(downside ** 2))) if len(downside) > 0 else 0.0
447
+ mean = float(np.mean(r)) if len(r) > 0 else 0.0
448
+ if dd == 0.0 and mean > mar:
449
+ return np.inf
450
+ return float((mean / dd) * np.sqrt(periods)) if dd > 0.0 else 0.0
451
+
452
+
453
+ def _array_omega(r: np.ndarray, threshold: float = 0.0) -> float:
454
+ gain = float(np.sum(r[r > threshold] - threshold))
455
+ loss = float(np.sum(threshold - r[r < threshold]))
456
+ return gain / loss if loss > 0.0 else np.inf
457
+
458
+
459
+ def _array_drawdown(equity: np.ndarray) -> np.ndarray:
460
+ peak = np.maximum.accumulate(equity)
461
+ return np.divide(peak - equity, peak, out=np.zeros_like(equity, dtype=np.float64), where=peak != 0.0)
462
+
463
+
464
+ def _array_drawdown_duration_days(idx: pd.DatetimeIndex, equity: np.ndarray) -> Tuple[int, int]:
465
+ daily_equity = _array_daily_equity(idx, equity)
466
+ if len(daily_equity) == 0:
467
+ return 0, 0
468
+ peak = np.maximum.accumulate(daily_equity)
469
+ in_dd = peak != daily_equity
470
+ durations = []
471
+ run = 0
472
+ for value in in_dd:
473
+ if value:
474
+ run += 1
475
+ elif run > 0:
476
+ durations.append(run)
477
+ run = 0
478
+ if run > 0:
479
+ durations.append(run)
480
+ if not durations:
481
+ return 0, 0
482
+ return int(max(durations)), int(np.mean(durations))
483
+
484
+
485
+ def _array_hitrate(returns: np.ndarray, positions: np.ndarray) -> Tuple[float, float]:
486
+ long_hr = []
487
+ short_hr = []
488
+ for col in range(positions.shape[1]):
489
+ pos = positions[:, col]
490
+ long_mask = pos > 0.0
491
+ short_mask = pos < 0.0
492
+ long_total = int(np.sum(long_mask))
493
+ short_total = int(np.sum(short_mask))
494
+ long_wins = int(np.sum((returns > 0.0) & long_mask))
495
+ short_wins = int(np.sum((returns > 0.0) & short_mask))
496
+ long_hr.append(long_wins / long_total * 100.0 if long_total > 0 else 0.0)
497
+ short_hr.append(short_wins / short_total * 100.0 if short_total > 0 else 0.0)
498
+ return float(np.mean(long_hr)), float(np.mean(short_hr))
499
+
500
+
501
+ def _array_number_of_trades(positions: np.ndarray) -> int:
502
+ if positions.size == 0:
503
+ return 0
504
+ total = 0
505
+ for col in range(positions.shape[1]):
506
+ pos = positions[:, col]
507
+ total += 1
508
+ if len(pos) > 1:
509
+ total += int(np.sum(np.diff(pos) != 0.0))
510
+ return int(total)
511
+
512
+
513
+ def _array_profit_factor(r: np.ndarray) -> float:
514
+ gains = float(np.sum(r[r > 0.0]))
515
+ loss = float(abs(np.sum(r[r < 0.0])))
516
+ return gains / loss if loss > 0.0 else np.inf
517
+
518
+
519
+ def _array_avg_win_loss(r: np.ndarray) -> Tuple[float, float]:
520
+ wins = r[r > 0.0]
521
+ losses = r[r < 0.0]
522
+ win = float(np.mean(wins) * 100.0) if len(wins) > 0 else 0.0
523
+ loss = float(np.mean(losses) * 100.0) if len(losses) > 0 else 0.0
524
+ return win, loss
525
+
526
+ def full_report(result: BacktestResult, trading_days: int = 365) -> Dict:
527
+ """
528
+ Returns an ordered dict of all key metrics.
529
+ Suitable for programmatic use; viz/tearsheet renders it.
530
+ """
531
+ positions = result.positions[[f"Position_{sym}" for sym in result.symbols]].to_numpy(dtype=np.float64)
532
+ return compute_performance_metrics(
533
+ timestamps=result.equity.index,
534
+ equity=result.equity.to_numpy(dtype=np.float64),
535
+ returns=result.returns.to_numpy(dtype=np.float64),
536
+ positions=positions,
537
+ symbols=result.symbols,
538
+ initial_capital=float(result.initial_capital),
539
+ liquidated=bool(result.liquidated),
540
+ trading_days=trading_days,
541
+ )