Avi-L commited on
Commit
ab83ea6
·
verified ·
1 Parent(s): cf678fc

DSR/PBO/PSR/MinBTL statistics

Browse files
Files changed (1) hide show
  1. crypto_eval/stats.py +126 -0
crypto_eval/stats.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Backtest-overfitting statistics: PSR, DSR, PBO, MinBTL.
2
+
3
+ Grounded in the `purgedcv` package (MIT) and López de Prado (2018),
4
+ ch. 11-12. Accept a strategy only if DSR > 0.95 and PBO <= 0.5.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+ try:
12
+ from purgedcv import (
13
+ deflated_sharpe_ratio,
14
+ probabilistic_sharpe_ratio,
15
+ probability_of_backtest_overfitting,
16
+ min_track_record_length,
17
+ effective_n_trials,
18
+ )
19
+ except ImportError as e: # pragma: no cover
20
+ raise ImportError(
21
+ "purgedcv is required: pip install purgedcv==0.1.11"
22
+ ) from e
23
+
24
+ __all__ = ["eval_backtest", "eval_trial_set"]
25
+
26
+
27
+ def eval_backtest(
28
+ returns: np.ndarray,
29
+ n_trials: int,
30
+ trial_sr_variance: float,
31
+ benchmark_sr: float = 0.0,
32
+ periods_per_year: int = 24 * 365,
33
+ ) -> dict:
34
+ """Evaluate one OOS return series.
35
+
36
+ returns: per-bar strategy returns (already net of fees/funding).
37
+ n_trials: number of independent strategy configurations tried.
38
+ trial_sr_variance: variance of per-trial per-bar Sharpe ratios.
39
+ """
40
+ rets = np.asarray(returns, dtype=float)
41
+ rets = rets[~np.isnan(rets)]
42
+ if len(rets) < 30:
43
+ raise ValueError("need >= 30 return observations")
44
+ sr_bar = rets.mean() / (rets.std(ddof=1) + 1e-12)
45
+ sr_annualized = sr_bar * np.sqrt(periods_per_year)
46
+ sr_benchmark = benchmark_sr / np.sqrt(periods_per_year)
47
+
48
+ skew = float(pd_skew(rets))
49
+ kurt = float(pd_kurt(rets))
50
+
51
+ psr = probabilistic_sharpe_ratio(rets, sr_benchmark) # positional per API
52
+
53
+ # deflated_sharpe_ratio(returns, n_trials, var_sharpe, *, bars_per_year)
54
+ # var_sharpe is the variance of per-trial per-bar Sharpes; DSR only
55
+ # applies when more than one strategy configuration was tried.
56
+ if n_trials > 1:
57
+ dsr = deflated_sharpe_ratio(rets, n_trials, trial_sr_variance)
58
+ else:
59
+ dsr = psr
60
+
61
+ out = {
62
+ "n_obs": len(rets),
63
+ "sharpe_per_bar": sr_bar,
64
+ "sharpe_annualized": sr_annualized,
65
+ "skewness": skew,
66
+ "kurtosis": kurt,
67
+ "max_drawdown": max_drawdown(rets),
68
+ "deflated_sharpe": float(dsr) if dsr is not None else None,
69
+ }
70
+ try:
71
+ # min_track_record_length(observed_sharpe, target_sharpe, alpha,
72
+ # skew, kurtosis) - all positional scalars
73
+ out["min_track_record_length"] = float(
74
+ min_track_record_length(sr_annualized, benchmark_sr, 0.95, skew, kurt)
75
+ )
76
+ except Exception:
77
+ out["min_track_record_length"] = None
78
+ return out
79
+
80
+
81
+ def eval_trial_set(
82
+ trial_returns: list[np.ndarray],
83
+ ) -> dict:
84
+ """PBO over a set of trial return series (CSCV).
85
+
86
+ trial_returns: list of per-bar return series, one per strategy config.
87
+ probability_of_backtest_overfitting takes an (n_configs, n_obs) ndarray
88
+ and returns a PBOResult dataclass with .pbo / .logits / .slope.
89
+ n_splits is lowered to 8 (still 70 combos) because 16 splits with
90
+ purge/embargo is prohibitively slow on CPU.
91
+ """
92
+ series = [np.asarray(r, dtype=float) for r in trial_returns]
93
+ # CPCV test slices differ by a few bars from purge/embargo; CSCV needs
94
+ # a rectangular (n_configs, n_obs) matrix, so truncate to the shortest.
95
+ n_obs = min(len(r) for r in series)
96
+ series = [r[:n_obs] for r in series]
97
+ arr = np.vstack(series)
98
+ if not np.isfinite(arr).all():
99
+ raise ValueError("trial returns contain NaN/inf")
100
+ result = probability_of_backtest_overfitting(arr, n_splits=8)
101
+ pbo = float(getattr(result, "pbo", result))
102
+ per_trial_sr = np.array([
103
+ np.nanmean(r) / (np.nanstd(r, ddof=1) + 1e-12) for r in arr
104
+ ])
105
+ n_eff = effective_n_trials(per_trial_sr)
106
+ return {
107
+ "probability_of_backtest_overfitting": pbo,
108
+ "trial_sr_variance": float(np.var(per_trial_sr)),
109
+ "effective_n_trials": int(n_eff),
110
+ }
111
+
112
+
113
+ def max_drawdown(returns: np.ndarray) -> float:
114
+ eq = np.cumprod(1.0 + np.asarray(returns))
115
+ peak = np.maximum.accumulate(eq)
116
+ return float(np.min(eq / peak - 1.0))
117
+
118
+
119
+ def pd_skew(x: np.ndarray) -> float:
120
+ import pandas as pd
121
+ return pd.Series(x).skew()
122
+
123
+
124
+ def pd_kurt(x: np.ndarray) -> float:
125
+ import pandas as pd
126
+ return pd.Series(x).kurt() + 3.0 # pandas is excess kurtosis