Repository Public
1"""Probabilistic and Deflated Sharpe Ratio.23Bailey, D. H. & López de Prado, M. (2014), "The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest4Overfitting and Non-Normality", Journal of Portfolio Management 40(5).56Everything here is in PER-PERIOD units (the Sharpe ratio of one bar, not annualised):78 PSR(SR*) = Phi( (SR - SR*) * sqrt(T - 1) / sqrt(1 - g3*SR + (g4 - 1)/4 * SR^2) )9 SR0 = sqrt(V[SR_n]) * ( (1 - gamma) * Phi^-1(1 - 1/N) + gamma * Phi^-1(1 - 1/(N e)) )10 DSR = PSR(SR0)1112SR: observed Sharpe ratio of the selected strategy, T: number of returns, g3: skewness, g4: kurtosis (3 for a13normal distribution, NOT excess kurtosis), N: number of independent trials, V[SR_n]: variance of the trials' Sharpe14ratios, gamma: Euler-Mascheroni constant. SR0 is the Sharpe ratio one expects from the best of N skill-less trials.15DSR is the probability that the true Sharpe ratio is positive once that selection is accounted for; the paper's16reading is that a value below 0.95 does not reject "no skill".1718Caveats: trials are rarely independent (a parameter grid is highly correlated), so the nominal N overstates the19number of independent trials and makes the DSR conservative; the formula assumes the returns are stationary and20ignores serial correlation.21"""22from __future__ import annotations2324import math2526import numpy as np27from scipy import stats2829EULER_GAMMA = 0.5772156649015329303132def expected_max_sharpe(n_trials: int, var_trials: float) -> float:33 """SR0: expected maximum Sharpe ratio (per period) among `n_trials` trials with true Sharpe 0."""34 if n_trials < 1:35 raise ValueError("n_trials must be >= 1")36 if n_trials == 1 or not var_trials > 0:37 return 0.038 z1 = stats.norm.ppf(1.0 - 1.0 / n_trials)39 z2 = stats.norm.ppf(1.0 - 1.0 / (n_trials * math.e))40 return math.sqrt(var_trials) * ((1.0 - EULER_GAMMA) * z1 + EULER_GAMMA * z2)414243def probabilistic_sharpe(sr: float, sr_benchmark: float, n_obs: int, skew: float = 0.0, kurtosis: float = 3.0) -> float:44 """PSR: probability that the true per-period Sharpe exceeds `sr_benchmark`. NaN when undefined."""45 if n_obs < 2 or not math.isfinite(sr):46 return float("nan")47 radicand = 1.0 - skew * sr + (kurtosis - 1.0) / 4.0 * sr * sr48 if radicand <= 0:49 return float("nan")50 return float(stats.norm.cdf((sr - sr_benchmark) * math.sqrt(n_obs - 1) / math.sqrt(radicand)))515253def deflated_sharpe(sr: float, n_obs: int, skew: float, kurtosis: float, n_trials: int, var_trials: float) -> float:54 return probabilistic_sharpe(sr, expected_max_sharpe(n_trials, var_trials), n_obs, skew, kurtosis)555657def deflated_sharpe_from_returns(returns, trial_sharpes) -> dict:58 """DSR of a return series given the per-period Sharpe ratios of ALL the trials that were run.5960 `trial_sharpes` must include every configuration tried (the selected one too). Returns the inputs with the61 result so that the report shows exactly what went into the number.62 """63 r = np.asarray(returns, dtype="float64")64 trials = np.asarray([s for s in trial_sharpes if s is not None and math.isfinite(s)], dtype="float64")65 n_trials = max(int(len(trials)), 1)66 var_trials = float(trials.var(ddof=1)) if len(trials) > 1 else 0.067 sd = r.std(ddof=1) if len(r) > 1 else 0.068 if len(r) < 3 or not sd > 0:69 return {"deflated_sharpe": None, "sharpe_per_bar": None, "expected_max_sharpe_per_bar": None,70 "n_obs": int(len(r)), "skew": None, "kurtosis": None, "n_trials": n_trials,71 "var_trial_sharpes": var_trials}72 sr = float(r.mean() / sd)73 skew = float(stats.skew(r))74 kurt = float(stats.kurtosis(r, fisher=False))75 sr0 = expected_max_sharpe(n_trials, var_trials)76 dsr = probabilistic_sharpe(sr, sr0, len(r), skew, kurt)77 return {78 "deflated_sharpe": dsr if math.isfinite(dsr) else None,79 "probabilistic_sharpe_vs_zero": probabilistic_sharpe(sr, 0.0, len(r), skew, kurt),80 "sharpe_per_bar": sr,81 "expected_max_sharpe_per_bar": sr0,82 "n_obs": int(len(r)),83 "skew": skew,84 "kurtosis": kurt,85 "n_trials": n_trials,86 "var_trial_sharpes": var_trials,87 }888990# --- v7 research jobs ---------------------------------------------------------------------------------------------91def deflate_for_job(detail: dict, returns, own_sharpes, job: dict) -> dict:92 """Recompute a deflated Sharpe detail so that it counts the trials a research job consumed BEFORE this run.9394 `job` is spq.research.read_job_trials(): a count and the (untrusted) validation Sharpe ratios the job recorded.95 The number of trials is this run's own trials plus ALL the job's. The variance of the trial Sharpe ratios is96 the LARGER of (this run's own trials) and (own + the job's): numbers written by the job can make the97 correction harsher, never milder. Returns a new detail dict (the input is not modified)."""98 own = [float(s) for s in own_sharpes if s is not None and math.isfinite(s)]99 n_own = int(detail.get("n_trials") or max(len(own), 1)) # every own trial counts, also one without a finite Sharpe100 n_job = int(job.get("count", 0))101 n_total = n_own + n_job102 var_own = float(np.var(own, ddof=1)) if len(own) > 1 else 0.0103 both = own + [float(x) for x in job.get("sharpes", [])]104 var_both = float(np.var(both, ddof=1)) if len(both) > 1 else 0.0105 var_trials = max(var_own, var_both)106 out = dict(detail)107 out.update({"n_trials": n_total, "n_trials_this_run": n_own, "n_trials_job": n_job,108 "var_trial_sharpes": var_trials, "var_trial_sharpes_this_run": var_own,109 "job_ledger_unreadable_lines": int(job.get("unreadable", 0))})110 r = np.asarray(returns, dtype="float64")111 sd = r.std(ddof=1) if len(r) > 1 else 0.0112 if len(r) < 3 or not sd > 0:113 return out114 sr = float(r.mean() / sd)115 sr0 = expected_max_sharpe(n_total, var_trials)116 dsr = probabilistic_sharpe(sr, sr0, len(r), float(stats.skew(r)), float(stats.kurtosis(r, fisher=False)))117 out.update({"deflated_sharpe": dsr if math.isfinite(dsr) else None, "expected_max_sharpe_per_bar": sr0})118 return out119# --- end v7 -------------------------------------------------------------------------------------------------------120