Look-ahead bias in backtesting: a real example and how to catch it
Look-ahead bias means a backtest uses information that wouldn't have been available when the trade was made. It is the most common reason a backtest looks spectacular and then fails live. Here is a real example, reproducible with free data.
The example: a 10-day moving average on the S&P 500
Rule: hold the S&P 500 index while it closes above its 10-day simple moving average.
Data: daily closes from FRED, series SP500, 2,512 trading days from 17 Oct 2016 to 14 Oct 2026 (price index, no dividends).
sma = close.rolling(10).mean()
signal = close > sma # known only at today's close
ret = close.pct_change() # today's return: yesterday's close → today's close
buggy = (signal * ret) # uses today's close to "earn" today's return
fixed = (signal.shift(1) * ret) # decide at today's close, earn from tomorrow
| Buggy (no shift) | Fixed (shift by one day) | |
|---|---|---|
| Trades | 208 | 208 |
| Total return | +25,280.93% | +125.67% |
| Win rate | 99.52% | 43.27% |
| Max drawdown (trade by trade) | −0.05% | −19.79% |
| Buy-and-hold, same dates | +266.19% | +262.13% |
Our own calculation from FRED data, re-computed two independent ways. Buy-and-hold differs slightly because the two trade logs end one trading day apart. Code: GitHub.
Why does one missing .shift(1) do so much damage? On any day the index closes above its moving average, that day was more likely to be an up day. The buggy version already knows today's close when it decides to be "in" for today's return, so it collects mostly up days. That's information no trader has at the time of the decision.
How to spot it without reading the code
- Fills at an earlier price than the trade date. In the buggy trade log, 207 of 208 entries are filled at the previous day's close. Compare each fill with the price data for that date.
- Too good to be true. A 99.5% win rate, a near-zero drawdown or a t-stat above about 6 on a simple rule should be treated as a bug until proven otherwise.
- Same-bar fills. Filling at the same close the signal uses is only realistic with a market-on-close order placed before the close. Check that the signal can be known in time.
Other common sources of look-ahead
- Full-sample fitting: normalising, choosing thresholds or training a model on the whole dataset, then testing on the same period.
- Centred windows (e.g.
rolling(..., center=True)) or back-filling missing values from the future. - Fundamental and earnings data dated by the period end rather than the date it was published (10-Qs come out weeks after the quarter ends), or restated figures that weren't known at the time.
- Intraday high/low used to decide a trade filled at the open of the same bar.
- Survivorship (a close cousin): testing today's index members over past years, which quietly drops companies that failed.
A quick checklist
- Every signal uses data up to bar t; every fill happens at t+1 (or at t's close only if the signal is known before the close).
- Positions are shifted in vectorised code.
- Nothing is fitted on data after the decision date.
- Fundamentals use release dates.
- Fills exist in the price data on their dates and within the day's high–low range.
- Results survive realistic costs. In the fixed example, 50 bps per round trip turns +125.67% into −20.14%.
Check your own backtest for free
The free Backtest Sanity Checker skill runs these checks on your trade log (Python, no installs) and gives your AI agent a manual review checklist. It flagged the buggy version above as RED from the trade log and prices alone.
Educational content, not investment advice. Backtests have limitations and past or simulated results do not predict future returns. See our disclaimer.