Download algotrader/validation/deflated_sharpe.py from ParallelLLC/algorithmic_trading: direct link, hf CLI and curl.
- Browser
- Download file 5.03 kB
-
https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/deflated_sharpe.py
- Command line
-
hf download hf://ParallelLLC/algorithmic_trading/algotrader/validation/deflated_sharpe.py
-
curl -L -o deflated_sharpe.py https://huggingface.co/ParallelLLC/algorithmic_trading/resolve/main/algotrader/validation/deflated_sharpe.py
5.03 kB
| """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) | |