How not to overfit a backtest
- Published
- Updated
- Topic
- Backtesting
- Reading time
- 8 min read
Why great backtests fail live, and a seven-step routine to catch overfitting: hold-out data, walk-forward tests, luck benchmarks and stress tests.
Every quant has seen it: a backtest with a smooth, rising equity curve that falls apart within weeks of going live. The strategy was not unlucky. It was overfit. It learned the noise in one stretch of history instead of a pattern that repeats.
Overfitting is the most expensive mistake in systematic trading, because it is invisible in the backtest. This guide explains where it comes from, how to measure it, and the testing routine that catches most of it before real money is at risk.
Why a good backtest proves so little
Overfitting is usually blamed on too many parameters. That is part of it, but the deeper cause is selection. Every time you try an idea, tweak a threshold or add a filter and keep the version that looks best, you are picking the luckiest result out of many tries. The more you try, the better the best one looks, whether or not any of them has an edge.
You can watch this happen in the backtest lab on our homepage. It generates rules that trade at random, so none of them has an edge, and lets you choose how many to test:
- Test 5 random rules and the best one shows a backtest Sharpe ratio of about 1.17. On the data it never saw, the same rule scores −1.14.
- Test 200 and the best backtest Sharpe climbs to 1.95, a number most traders would be delighted with. On unseen data it scores −1.75.
Nothing about the rules improved. Only the number of tries changed. This is the core problem: a backtest result means little until you know how many attempts it took to find it.
Step 1: Keep data the strategy has never seen
The single most effective defence is an untouched hold-out period: a block of recent data you do not look at while developing the strategy. Use it once, at the end, to check whether the result survives.
Two rules make a hold-out work:
- Decide the split before you start, for example the first 70% for development and the last 30% for the final test.
- Use it once. If you look at the hold-out result, change the strategy and test again, the hold-out has become development data. Every peek leaks information into your choices.
A strategy that loses most of its performance on the hold-out is telling you something important. Believe it.
Step 2: Walk forward instead of fitting once
A single split leaves you with one in-sample and one out-of-sample number. Walk-forward analysis repeats the process through time: optimise on a window, test on the period right after it, roll forward and repeat. You end up with a chain of genuinely out-of-sample results that covers several market regimes.
import pandas as pd
def walk_forward_windows(index: pd.DatetimeIndex, train_years: int = 3, test_months: int = 6):
"""Yield (train, test) date ranges that roll forward through the data."""
start = index[0]
while True:
train_end = start + pd.DateOffset(years=train_years)
test_end = train_end + pd.DateOffset(months=test_months)
if test_end > index[-1]:
break
yield (start, train_end), (train_end, test_end)
start += pd.DateOffset(months=test_months)
# For each window: choose parameters on the train range only,
# then record the strategy's returns on the test range with those parameters fixed.
# Stitch the test-range returns together to get the walk-forward equity curve.
Two details matter more than they look:
- Parameters chosen in a window must not change during its test period. Otherwise the test is in-sample again.
- Judge the stitched-together test periods, not the average of the in-sample fits. The stitched curve is the closest thing to what live trading would have looked like.
Step 3: Count your trials and correct for luck
If you tested 200 variants, the best one should be compared with what the best of 200 random variants would score. Bailey and López de Prado give an approximation for the highest Sharpe ratio you should expect from luck alone, given the number of independent trials and the length of the test:
import numpy as np
from scipy.stats import norm
def expected_max_sharpe(trials: int, days: int) -> float:
"""Best annualised Sharpe that luck alone is expected to produce."""
if trials <= 1:
return 0.0
gamma = 0.5772156649 # Euler–Mascheroni constant
sd = np.sqrt(252 / days) # rough spread of an annualised Sharpe estimate
return sd * ((1 - gamma) * norm.ppf(1 - 1 / trials)
+ gamma * norm.ppf(1 - 1 / (trials * np.e)))
print(round(expected_max_sharpe(200, 882), 2)) # 1.48
Worked example. Over 882 trading days (about three and a half years), the best of 200 edgeless strategies is expected to show a Sharpe ratio of roughly 1.48 by chance. That is the "luck alone" figure in our homepage lab. A backtest Sharpe of 1.95 after 200 tries is only modestly better than luck, and as the lab shows, it can still be pure noise.
This is the idea behind the deflated Sharpe ratio: judge a result against the luck benchmark for the number of trials, not against zero. In practice:
- Keep a log of every variant you test, including the ones you threw away. Your honest trial count is the size of that log.
- Treat strongly correlated variants as fewer independent trials. Ten nearly identical moving-average lengths are not ten independent ideas, so the effective count is lower. If anything, err on the side of counting more.
Step 4: Prefer plateaus to peaks
Plot performance across a grid of parameter values. You want to see a broad plateau: a region where nearby settings all do reasonably well. A sharp peak, where 20 days works brilliantly but 18 and 22 do not, is a fingerprint of noise.
results = {}
for fast in range(5, 55, 5):
for slow in range(50, 260, 10):
if fast < slow:
results[(fast, slow)] = backtest(fast, slow).sharpe # your own backtest function
grid = pd.Series(results).unstack()
# Look at the neighbourhood of the best cell, not just the best cell.
best = grid.stack().idxmax()
neighbours = grid.loc[best[0]-5:best[0]+5, best[1]-20:best[1]+20]
print(grid.max().max(), neighbours.mean().mean())
If the neighbourhood average is far below the peak, the peak is unlikely to hold. Many practitioners deliberately choose a parameter from the middle of a plateau rather than its highest point, and accept a slightly worse backtest for a more stable live result.
Step 5: Stress the result
A single equity curve is one path through history. Before trusting it, look at the range of outcomes it could easily have produced:
- Monte Carlo on trades. Resample the strategy's trades with replacement thousands of times and look at the spread of drawdowns and final returns. If the worst 5% of paths would be unacceptable to you, the strategy is too risky as sized, even if the historical path looked fine.
- Cost sensitivity. Rerun with costs and slippage doubled. Strategies that trade often and earn little per trade frequently go from profitable to losing with a small change in costs.
- Start-date sensitivity. Shift the start of the test by a few weeks or months. Results that depend on the exact start date are fragile.
rng = np.random.default_rng(7)
trades = np.asarray(trade_returns) # one return per closed trade
def max_drawdown(r):
equity = np.cumprod(1 + r)
return (equity / np.maximum.accumulate(equity) - 1).min()
sims = [max_drawdown(rng.choice(trades, size=len(trades), replace=True)) for _ in range(5000)]
print('95th percentile drawdown:', np.percentile(sims, 5))
Step 6: Clean data before clever models
Many "overfit" strategies are really data bugs that only exist in the backtest:
- Look-ahead bias. Using information before it was available: a day's close to trade at that day's open, revised economic data, or index membership as of today.
- Survivorship bias. Testing only on stocks that exist today ignores the ones that were delisted, and quietly removes many of the losers.
- Corporate actions. Splits, bonuses and dividends that are not adjusted create fake jumps that a strategy can "trade".
- Unrealistic fills. Assuming you buy at the low of a bar, or fill large orders at the touch, inflates returns, especially in less liquid contracts.
Fixing data issues often changes results more than any parameter change does.
Step 7: Confirm with paper trading
The last test is time. Run the finished system on live data with paper or very small positions for a few weeks, and compare its trades with what the backtest would have done over the same days. The two should match closely. When they do not, the gap usually points to a modelling problem: fills, costs, timing or data that the backtest got wrong.
A checklist before going live
- A hold-out period was set aside before development and used once.
- Walk-forward results are positive across several market regimes, not just one.
- The result clearly beats the luck benchmark for the honest number of trials.
- Performance sits on a plateau of parameter values, not a peak.
- The strategy survives doubled costs and different start dates.
- Monte Carlo drawdowns are within what you can tolerate.
- Data is free of look-ahead and survivorship bias, with corporate actions adjusted.
- Paper trades match the backtest over the same period.
A strategy that passes all eight can still lose money; markets change. But one that fails them is very likely to.
Summary
Overfitting comes from selection more than from complexity. The defences are simple to state and tedious to apply: keep unseen data unseen, walk forward, count every trial, prefer stable parameters, stress the result, clean the data and confirm with paper trading. Done together, they turn a backtest from a sales pitch into evidence.
This article is for education only and is not investment advice. Backtests are simulations on historical data and do not predict future results.