Skip to content

ENH: lib: Add deflated_sharpe_ratio() to judge optimize() results - #1388

Open
ipezygj wants to merge 3 commits into
kernc:masterfrom
ipezygj:add-deflated-sharpe-ratio
Open

ENH: lib: Add deflated_sharpe_ratio() to judge optimize() results#1388
ipezygj wants to merge 3 commits into
kernc:masterfrom
ipezygj:add-deflated-sharpe-ratio

Conversation

@ipezygj

@ipezygj ipezygj commented Aug 7, 2026

Copy link
Copy Markdown

What this adds

lib.deflated_sharpe_ratio(stats, trial_sharpe_ratios) — the probability (0–1) that the best run returned by Backtest.optimize() has a Sharpe ratio genuinely greater than zero, after correcting for the multiple testing the optimization itself performs.

Why

optimize() returns the maximum over all tried parameter combinations. The expected best Sharpe of N skill-less trials is already well above zero and grows with N, so the winning run's Sharpe is inflated by selection — the more combinations tried, the more the "best" result reflects luck rather than edge. The library currently reports the winner's Sharpe with no way to ask whether it clears that bar. This is the standard correction: Bailey & López de Prado (2014), The Deflated Sharpe Ratio (doi:10.3905/jpm.2014.40.5.094).

The trial count and the trial Sharpe dispersion are taken from the search itself (the heatmap), not from an assumed default — optimize() already produces exactly the data the estimator needs:

stats, heatmap = bt.optimize(fast=range(5, 30, 5), slow=range(10, 70, 10),
                             maximize='Sharpe Ratio', return_heatmap=True)
deflated_sharpe_ratio(stats, heatmap)

Concrete example with the test SmaCross on GOOG (28 combinations): the winner's Sharpe looks significant as a single test (PSR ≈ 0.98) but does not clear the hurdle its own 28-trial search sets by chance (DSR ≈ 0.18). That flip is the information the number adds.

Implementation notes

  • No new dependencies — normal CDF/PPF come from stdlib statistics.NormalDist; the rest is numpy/pandas already in use.
  • The periodic-returns resampling was extracted from compute_stats() into _stats.periodic_returns() and reused, so the winner's returns are computed by the exact same code path as the reported Sharpe — no duplicated freq logic.
  • Warns if the passed heatmap's name isn't 'Sharpe Ratio' (i.e. optimization maximized something else, so the values wouldn't be trial Sharpes).
  • Verified against an independent implementation of the same estimator (agreement to 3+ decimals on the example above and on the single-trial degenerate case, where DSR reduces to the probabilistic Sharpe ratio).
  • Unit test included; flake8 clean; full python -m backtesting.test suite passes.

The best run of Backtest.optimize() is the maximum over all tried
parameter combinations, so its Sharpe ratio is inflated by multiple
testing: the expected best Sharpe of N skill-less trials grows with N.
Add lib.deflated_sharpe_ratio(stats, trial_sharpe_ratios), computing
the probability the winning Sharpe ratio exceeds zero after correcting
for the number and dispersion of trials actually made
(Bailey & Lopez de Prado 2014, https://doi.org/10.3905/jpm.2014.40.5.094).

Uses only stdlib statistics.NormalDist — no new dependencies.
The periodic-returns resampling is extracted from compute_stats()
into _stats.periodic_returns() and reused, not duplicated.
An equity curve growing at a constant rate has no Sharpe ratio, but it does not
reach the deflation arithmetic as a nan. The standard deviation of its returns
is floating-point residue rather than an exact zero, so it divides out to a
Sharpe of ~1e13 -- finite, and therefore past the existing check. Deflating that
returned 1.0: certainty of a real edge, from the one input that carries no
information about one.

These returns are ratios of floats, so the residue is of the order of an ulp of
1.0 rather than of the returns' own magnitude. Measured at 0.44-0.61 eps across
constant rates from -1% to +5% and lengths 50-3000, against 4e7 eps for a real
series with sigma=1e-8, so one eps separates them with seven orders of magnitude
to spare. The test was run against the unfixed function and fails there, so it
tests the guard rather than accompanying it.
The first guard compared the standard deviation against eps x scale, which is the
residue of a single rounding rather than of the whole sum. Measured over constant
series spanning values 1e-7..1e3 and lengths 3..10000, the residue reaches
1.96 eps x scale, so the original threshold still let a flat series through at
other lengths: it was calibrated on one series and tested on that same series.

n eps x scale keeps a margin of at least 3.9x at every length measured, and a
real series with sigma=1e-12 sits more than ten orders of magnitude above it, so
nothing legitimate is caught. The test now sweeps values x lengths rather than
asserting one point, and was run against the unfixed function, where it fails.
@ipezygj

ipezygj commented Aug 11, 2026

Copy link
Copy Markdown
Author

Pushed a correctness fix to this branch, found by probing my own implementation rather than by review.

A constant return series has no Sharpe ratio, but it did not reach the deflation arithmetic as a nan: the standard deviation of a constant series is floating-point residue rather than an exact zero, so a flat 0.1% series divided out to a Sharpe of ~1e16. That is finite, so it passed every guard, and the deflated Sharpe came out 1.0 — certainty of a real edge, from the one input that carries no information about one. The statistic exists to catch results that are too good to be true, so answering with maximum confidence there is the worst possible failure direction.

The guard now compares the dispersion against the resolution of a float at the scale of the data, scaled by the number of terms summed (n * eps * scale), rather than against zero. Measured over constant series spanning values 1e-7..1e3 and lengths 3..10000, the residue reaches at most 1.96 eps × scale, so this keeps a margin of at least 3.9x at every length, while a real series with sigma=1e-12 sits more than ten orders of magnitude above the threshold — nothing legitimate is caught, and the test asserts that a quiet series still gets a number.

Two notes on the test, since they are the reason this took a second pass:

  • It sweeps values × lengths instead of asserting one series. My first attempt used a single flat series, passed, and still leaked at other lengths — the residue depends on both the value and the length, so a guard calibrated on one point tests only that point.
  • It was run against the unfixed function and fails there, so it tests the guard rather than merely accompanying it.

Happy to split this into a separate commit or reword anything if you would rather review it apart from the feature.

@kernc

kernc commented Aug 12, 2026

Copy link
Copy Markdown
Owner

The expected best Sharpe of N skill-less trials is already well above zero and grows with N, so the winning run's Sharpe is inflated by selection — the more combinations tried, the more the "best" result reflects luck rather than edge.

That's a reasonable idea.

Can you offer any comparison of Bailey-LdP scoring method against a simpler:

n_trials, sr_mean, sr_std, sr_max = len(heatmap), heatmap.mean(), heatmap.std(), heatmap.max()
penalty = (1 + lamda * np.log1p(n_trials))
score = norm.cdf((sr_max - sr_mean) / sr_std / penalty)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants