deflated-sharpe
v0.1.0
Published
Deflated Sharpe Ratio (Bailey & Lopez de Prado) — corrects a backtest's Sharpe ratio for selection bias and non-normality. No dependencies.
Maintainers
Readme
deflated-sharpe
A dependency-free TypeScript implementation of the Deflated Sharpe Ratio (Bailey & López de Prado, 2014) — the correction that tells you whether your backtest found an edge or found the luckiest of your attempts.
import { deflatedSharpe } from "deflated-sharpe";
// 437 trade returns, expressed in R (or %, or anything scale-free).
// 60 is the number of variants you tried before settling on this one.
const result = deflatedSharpe(tradeReturns, 60);
result.sharpe; // 0.0350 — what you measured
result.threshold; // 0.1121 — what pure search would have produced
result.probabilisticSharpe; // 0.794 — looks fine, if you ignore the search
result.deflatedSharpe; // 0.035 — once you account for it
result.survives; // falseThe problem
If you test 100 strategies, roughly 5 will clear a 95% significance bar by chance alone. That is not a flaw in your process — it is what "95%" means.
So the Sharpe ratio you report is not the Sharpe ratio you found. It is the maximum of N draws, and the maximum of N draws is biased upward by construction. The more carefully you searched, the more inflated it is.
Two corrections are needed, and the DSR applies both:
1. Selection bias. expectedMaxSharpe(σ, N) gives the threshold SR₀ —
the highest Sharpe you would expect across N trials if none of them had any
edge. Beating zero is not the test. Beating SR₀ is.
2. Non-normality. Trade returns are skewed and fat-tailed. A trend-following
system has many small losses and a few enormous wins; a short-volatility system
has the reverse. The ordinary Sharpe ratio assumes normality and overstates
confidence when the skew runs against you. probabilisticSharpe takes
skewness and kurtosis as explicit inputs.
The result is a probability: how confident you can be that the true Sharpe exceeds the threshold. The conventional bar is 0.95.
Install
npm install deflated-sharpeNo dependencies. Node ≥ 18, or any bundler. Works in the browser.
Usage
The whole calculation
import { deflatedSharpe } from "deflated-sharpe";
const result = deflatedSharpe(returns, trials);
if (result === null) {
// Fewer than 3 observations, or zero variance.
// An infinite Sharpe ratio from a constant series is how a backtest
// reports certainty it does not have, so this returns null instead.
}Report a range, not a number
trials is the one input you cannot measure. It is a judgement call, and the
verdict depends on it. So show the reader how sensitive the answer is:
for (const n of [1, 10, 50, 100, 500]) {
console.log(n, deflatedSharpe(returns, n)?.deflatedSharpe.toFixed(3));
}
// 1 1.000
// 10 0.974
// 50 0.846
// 100 0.753
// 500 0.499A result that is 1.000 at N=1 and 0.499 at N=500 is not a finding. It is a statement about how hard you looked.
The pieces separately
import { moments, probabilisticSharpe, expectedMaxSharpe } from "deflated-sharpe";
const m = moments(returns); // { sharpe, n, skewness, kurtosis }
probabilisticSharpe(m!, 0); // P(true Sharpe > 0), non-normality only
expectedMaxSharpe(0.04, 250); // the threshold 250 trials alone producesCounting trials honestly
This is where the method is usually defeated, so it is worth being blunt.
Count every variant you looked at, including the ones you discarded in the first minute. A parameter you swept and rejected still consumed a draw from the same distribution. If you tried 9 trailing-stop widths across 2 data windows and 10 bar sizes across 3 exit rules, that is 108 — not "one strategy".
Count also:
- variants you tried in an earlier session and abandoned
- markets you added or removed from the universe
- date ranges you shifted
- anything a colleague tried on the same data and told you about
Under-counting is the default failure. When in doubt, take the higher number and see whether the result still stands.
A worked example, from a real project
This library was extracted from a systematic trading project where it was used on the project's own results. It is worth reporting what happened, because the interesting case is not the one it approved.
The best result the project had found was a configuration measured at +0.119R per trade. It survived three separate data splits. It looked like the strongest finding in months of work. It was then tested on a window that had never been touched, and came back at −0.081R.
Run after the fact, with an honest trial count of 60:
| | |
|---|---|
| measured Sharpe per trade | 0.0350 |
| threshold SR₀ at N=60 | 0.1121 |
| P(Sharpe > 0), non-normality only | 0.794 |
| deflated | 0.035 |
The measured Sharpe was not merely below the threshold — it was below a third of it. Pure search across 60 variants would have been expected to produce a better number than the one that had been celebrated, with no edge present at all.
Applied across the whole project, not one return-based finding survived. The two results that did survive were both measurements of structure rather than of edge: a cost-scaling exponent and a correlation figure. Both were arithmetic, and arithmetic does not get selected.
That is the honest use of this tool. It does not help you find an edge. It tells you when you have not found one — which is more often than anyone wants, and cheaper to learn before the money is committed than after.
Design notes
The Sharpe ratio is per observation, not annualised. Annualising requires assuming how many observations a year contains, and that assumption varies with timeframe and instrument count. The DSR only cares about the ratio of the Sharpe to its own standard error, and both scale identically — so leaving it per-observation removes a free parameter without changing any answer.
Unstable inputs return NaN or null, never a plausible-looking number.
moments returns null for a constant or too-short series.
probabilisticSharpe returns NaN when the variance term goes non-positive,
which happens with extreme tails on few observations. normalInv throws
outside (0, 1). A threshold computed from an undefined quantile is exactly
the kind of number that looks real and is not.
Accuracy. normalCdf uses Abramowitz & Stegun 7.1.26, accurate to ~1.5e-7
absolute; normalInv uses Acklam's algorithm with one Halley refinement. A
round trip is therefore accurate to ~1e-6, not machine epsilon — which the
tests assert honestly rather than to a tolerance the implementation cannot
meet. This is far tighter than the precision of the inputs: a Sharpe ratio
estimated from a few hundred trades is uncertain in the second decimal.
When you have measured the cross-trial dispersion, pass it.
deflatedSharpe(returns, trials, sigmaSharpe) takes the standard deviation of
the Sharpe estimates across your N attempts. Omitted, it falls back to the
standard error of a single estimate — which understates the true dispersion
and so makes the threshold too lenient. Worth remembering when the verdict
comes back "survives".
API
| export | purpose |
|---|---|
| deflatedSharpe(returns, trials, sigmaSharpe?) | the whole calculation |
| moments(returns) | Sharpe, n, skewness, kurtosis — or null |
| probabilisticSharpe(moments, threshold) | P(true Sharpe > threshold) |
| expectedMaxSharpe(sigmaSharpe, trials) | the selection-bias threshold SR₀ |
| normalCdf(x) / normalInv(p) | the normal distribution helpers |
Full types are exported: ReturnMoments, DeflatedSharpeResult.
Reference
Bailey, D. H. and López de Prado, M. (2014). The Deflated Sharpe Ratio: Correcting for Selection Bias, Backtest Overfitting, and Non-Normality. Journal of Portfolio Management, 40(5), 94–107. SSRN 2460551
Licence
MIT
