File size: 5,027 Bytes
3339913 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | """Probabilistic and Deflated Sharpe Ratios.
Bailey & López de Prado (2014), "The Deflated Sharpe Ratio: Correcting for
Selection Bias, Backtest Overfitting and Non-Normality".
The intuition: if you try 200 strategy variants, the best one will show a
handsome Sharpe *even when none of them has any edge*. The Deflated Sharpe
Ratio asks whether the winner beats what the luckiest of 200 coin-flippers
would have produced, and it charges extra for fat tails and negative skew --
exactly the return shapes that make naive Sharpe ratios flatter.
"""
from __future__ import annotations
import numpy as np
from scipy import stats
__all__ = [
"probabilistic_sharpe_ratio",
"expected_max_sharpe",
"deflated_sharpe_ratio",
"min_track_record_length",
]
_EULER = 0.5772156649015329
def _moments(returns: np.ndarray) -> tuple[float, float]:
"""Sample skew and *non-excess* kurtosis, as the PSR formula expects."""
arr = np.asarray(returns, dtype=float)
arr = arr[np.isfinite(arr)]
if arr.size < 4:
return 0.0, 3.0
return float(stats.skew(arr, bias=False)), float(stats.kurtosis(arr, bias=False) + 3.0)
def probabilistic_sharpe_ratio(
sharpe: float,
n_obs: int,
skew: float = 0.0,
kurtosis: float = 3.0,
benchmark: float = 0.0,
) -> float:
"""P(true Sharpe > ``benchmark``) given the observed Sharpe and its shape.
``sharpe`` and ``benchmark`` are per-observation (i.e. *not* annualised).
"""
if n_obs < 3:
return 0.5
denom = 1.0 - skew * sharpe + ((kurtosis - 1.0) / 4.0) * sharpe**2
if denom <= 0:
return 0.5
z = (sharpe - benchmark) * np.sqrt(n_obs - 1) / np.sqrt(denom)
return float(stats.norm.cdf(z))
def expected_max_sharpe(n_trials: int, variance_of_trials: float) -> float:
"""Expected maximum Sharpe across ``n_trials`` *skill-free* strategies.
This is the bar the winner has to clear to be interesting. It grows with
the number of things you tried — which is why "I found a strategy with
Sharpe 2" means nothing until you say how many you looked at.
"""
n = max(int(n_trials), 1)
if n == 1 or variance_of_trials <= 0:
return 0.0
sd = np.sqrt(variance_of_trials)
# Bailey & López de Prado's Gumbel-based approximation.
q1 = stats.norm.ppf(1.0 - 1.0 / n)
q2 = stats.norm.ppf(1.0 - 1.0 / (n * np.e))
return float(sd * ((1.0 - _EULER) * q1 + _EULER * q2))
def deflated_sharpe_ratio(
returns,
sharpe_annual: float,
periods_per_year: int,
n_trials: int,
trial_sharpes=None,
variance_of_trials: float | None = None,
) -> dict:
"""Deflate an annualised Sharpe for selection bias and non-normality.
Returns a dict with the PSR against a zero benchmark, the selection-bias
threshold, the deflated probability, and the inputs used, so the UI can
show its working rather than just a number.
"""
arr = np.asarray(returns, dtype=float)
arr = arr[np.isfinite(arr)]
n_obs = arr.size
sr_per_period = sharpe_annual / np.sqrt(periods_per_year)
skew, kurt = _moments(arr)
if variance_of_trials is None:
if trial_sharpes is not None and len(trial_sharpes) > 1:
trials = np.asarray(trial_sharpes, dtype=float) / np.sqrt(periods_per_year)
trials = trials[np.isfinite(trials)]
variance_of_trials = float(np.var(trials, ddof=1)) if trials.size > 1 else 0.0
else:
# With no trial cloud to measure, fall back to the asymptotic
# variance of a skill-free Sharpe estimate.
variance_of_trials = 1.0 / max(n_obs - 1, 1)
threshold = expected_max_sharpe(n_trials, variance_of_trials)
psr = probabilistic_sharpe_ratio(sr_per_period, n_obs, skew, kurt, 0.0)
dsr = probabilistic_sharpe_ratio(sr_per_period, n_obs, skew, kurt, threshold)
return {
"psr": float(psr),
"dsr": float(dsr),
"sr_per_period": float(sr_per_period),
"threshold_sr_per_period": float(threshold),
"threshold_sr_annual": float(threshold * np.sqrt(periods_per_year)),
"n_obs": int(n_obs),
"n_trials": int(n_trials),
"skew": float(skew),
"kurtosis": float(kurt),
"variance_of_trials": float(variance_of_trials),
}
def min_track_record_length(
sharpe: float,
n_obs: int,
skew: float = 0.0,
kurtosis: float = 3.0,
benchmark: float = 0.0,
confidence: float = 0.95,
) -> float:
"""Observations needed before the Sharpe is significant at ``confidence``.
Inputs are per-observation. Returns ``inf`` when the edge is too small to
ever clear the bar.
"""
if sharpe <= benchmark:
return float("inf")
z = stats.norm.ppf(confidence)
denom = (sharpe - benchmark) ** 2
if denom <= 0:
return float("inf")
numer = 1.0 - skew * sharpe + ((kurtosis - 1.0) / 4.0) * sharpe**2
if numer <= 0:
return float("inf")
return float(1.0 + numer * (z / (sharpe - benchmark)) ** 2)
|