SPB Git

Repository Public

dsr.py 6.2 KB 120 lines python

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
Files main
Menu

Theme