ENH: lib: Add deflated_sharpe_ratio() to judge optimize() results - #1388
ENH: lib: Add deflated_sharpe_ratio() to judge optimize() results#1388ipezygj wants to merge 3 commits into
Conversation
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.
|
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 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 ( Two notes on the test, since they are the reason this took a second pass:
Happy to split this into a separate commit or reword anything if you would rather review it apart from the feature. |
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) |
What this adds
lib.deflated_sharpe_ratio(stats, trial_sharpe_ratios)— the probability (0–1) that the best run returned byBacktest.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:Concrete example with the test
SmaCrossonGOOG(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
statistics.NormalDist; the rest is numpy/pandas already in use.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.'Sharpe Ratio'(i.e. optimization maximized something else, so the values wouldn't be trial Sharpes).flake8clean; fullpython -m backtesting.testsuite passes.