quantbt-engine 1.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
)
|