The Analyst's Path

Look-ahead bias

Look-ahead bias is using, at time t, information that was not available at time t. It is the most damaging pitfall because its rewards are the most spectacular and its causes are the most mundane. A strategy that uses only past information is, by construction, invariant to any change you make to the future. A Sharpe above roughly 3 on daily data with a real cost model is a bug report until proven otherwise.

10 min readPart of Building a Backtesting Engine: Design & the Classic PitfallsOpen in course mode

Advertisement

Look-ahead bias is using, at time t, information that was not available at time t. It is the most damaging pitfall because its rewards are the most spectacular and its causes are the most mundane. Six mechanisms cover nearly all real cases:

What are the six causes of look-ahead bias?

1. The missing shift. The signal is computed from bar t's close and applied to bar t's return. This is the toy example, and at scale it is catastrophic. On a seeded three-year path (seed 20240301, 756 bars, daily volatility 1.35%), a fast rule (long when the close is above its 10-bar mean, short otherwise, flipping 142 times in three years) produces:

terminalCAGRSharpemax drawdown
Leaky (no shift)97.4516×361.11%7.774.5%
Honest (shift 1)0.7473×−9.26%−0.3360.0%

On $1,000,000: $97,451,579 against $747,299. The Sharpe gap is 8.10, and the tell is the Sharpe itself: 7.77 with a 4.5% maximum drawdown is not a good strategy, it is a physically implausible one. A Sharpe above roughly 3 on daily data with a real cost model is a bug report until proven otherwise. The most valuable instinct in this node is that suspicion, and it is why the honest number being bad is the reassuring outcome.

Advertisement

The same seeded path with a slow rule (a 10-bar against 50-bar moving-average crossover, flipping only 8 times in three years) gives 2.9768× leaky against 2.8462× honest: a gap of 2.14 percentage points a year. The size of the leak scales with how often you trade, because the leak is worth one bar of foresight per decision. That is not a comforting fact about slow strategies; it is a warning about fast ones, and it explains why intraday research is so much more dangerous than daily.

2. The full-sample normalisation. z = (x - x.mean()) / x.std() where mean and std are computed over the entire history, including the future. Every z-score in the sample now contains information from the end of the sample. The same applies to a min-max scaler fitted on all the data, a percentile rank taken across the full period, a PCA fitted on everything, a winsorisation threshold set from the whole sample, and a median used to fill missing values. This is the leak that survives careful people, because it looks like data cleaning rather than strategy logic. It is also the single most common way an ML pipeline leaks (see DA2.02), and it is why sklearn's Pipeline exists.

3. The centred window. rolling(5, center=True) averages two bars ahead. Likewise a Savitzky-Golay filter, most spectral smoothers, and any interpolation that fills a gap using both sides. Smoothing that looks backwards is a signal; smoothing that looks both ways is a time machine.

4. Restated data. The value you downloaded today for a quarter three years ago may not be the value that was published then. Earnings get restated, index membership gets revised, macro series get benchmarked and rebased, and adjusted-close series change retroactively on every corporate action. QD1.01 owns point-in-time data properly; the discipline here is to know whether your source is point-in-time or as-of-today, and to say which in your write-up.

5. The publication-lag leak. This one deserves its own numbers, because it is entirely invisible and entirely mechanical. Fundamental data has two dates: the period end (the quarter it describes) and the publication date (when anyone could act on it). Stamping a row with its period end gives you the numbers weeks before they existed. On a seeded four-year path (seed 606, 1,008 bars, 15 quarterly announcements, a 45-bar reporting lag, an announcement-day jump proportional to the surprise plus a 40-day drift):

terminalCAGRSharpe
Stamped at period end (leak)3.6549×38.31%1.65
Stamped at publication date2.2094×21.94%1.06

A 0.59 Sharpe gap and 16.37 percentage points a year, and the entire difference is 45 days of date arithmetic on 15 rows. No line of strategy code is wrong. The join was wrong.

6. The survivorship-selected universe. Choosing today's index constituents and running them back through history is look-ahead about membership, you used the future to pick the names. It is a big enough case to get its own section.

How do you detect look-ahead bias?

And now the detector. Reading code for these six is a losing game: they hide in joins, in library defaults, and in the one helper function nobody re-reads. So do not read the code, test the code, with a property that every honest strategy satisfies and no leaking strategy can:

A strategy that uses only past information is, by construction, invariant to any change you make to the future.

Change the future; if today's decision moves, today's decision was reading the future. That is no_lookahead, exercise QD1.03-e02, and it is the most important thing in this node. The implementation is a loop: for each bar t, take the baseline decision, then re-run the strategy with prices[t+1:] replaced by a probe while prices[:t+1] stays byte-identical, and flag t if the decision moves by more than 1e-12.

Four probes, and the reason for each is worth understanding rather than memorising:

probecatchesmisses alone
future × 0.5any level-based peek in one directionscale-invariant peeks
future × 2.0the other directionscale-invariant peeks
future reversedordering and direction-of-drift peekssymmetric statistics
future flattened to prices[t]anything using future returns or dispersiona constant-return peek

Scaling alone is not enough, and this is the subtlety that makes the exercise worth its XP: a strategy that reads the mean of future returns is completely invariant to multiplying every future price by a constant. Reversal flips the sign of the cumulative future return; flattening sets it to zero. Between the four probes, every leak in the six mechanisms above is caught. Run against seven strategies on a 20-bar ₹ fixture, the reference harness reports:

strategyverdict
3-bar moving average on history onlyclean
momentum over the trailing 5 barsclean
reads tomorrow's closeflagged, 19 of 19 testable bars
full-sample z-scoreflagged, 19 of 19
min-max scaled on all dataflagged, 19 of 19
centred 5-bar rolling meanflagged, 16 of 19 (correctly clean where the window fits entirely in the past)
rank within the whole sampleflagged, 15 of 19

Two properties make this a test rather than a heuristic. First, it is strategy-agnostic: it never reads your code, so it cannot be fooled by a leak hidden three call levels down in a helper you inherited. Second, it localises: it tells you which bars leaked, and a leak on every fifth bar points straight at the resampling step that only touches every fifth bar. The half-peek fixture in the hidden tests returns exactly [0, 5, 10, 15], which is a diagnosis, not just an alarm.

What are the limits of the look-ahead test?

And its limits, which you must be able to state, because a harness you trust beyond its warrant is worse than none:

  • The last bar is untestable. There is no future to perturb. The harness must skip it, and a learner implementation that "tests" bar n−1 is failed by a hidden test that asserts precisely this.
  • It cannot see leaks that are not a function of the input array. If your strategy reads a restated fundamental from a file, or a universe list built with hindsight, perturbing the price series proves nothing. Mechanism 4 and mechanism 6 need data-layer discipline, not this harness.
  • A leak that never changes a decision is not detected, and, usefully, is also not a leak that affects your P&L.
  • It costs one strategy call per probe per bar. On a 5,000-bar series that is 20,001 calls; run it on a slice during development and on the full series once before you publish.
{
  "skill": "Auditing backtest code for look-ahead — the leak that hides in a join, a library default, or a helper you inherited",
  "use": "AFTER you have implemented no_lookahead yourself and it passes the pack's hidden tests, use an assistant as an adversarial reader on code you did not write: paste a backtest function and ask it to enumerate every place information could travel backwards in time, ranked by how plausible the mistake is. Then do the thing that actually settles it — run YOUR harness on the strategy and compare its verdict with the model's list. The model generates hypotheses; your harness produces evidence.",
  "tools": ["Chat assistants — Claude, ChatGPT, Gemini", "IDE copilots — GitHub Copilot, Cursor", "Your own no_lookahead harness from QD1.03-e02, which is the only one of these three that can be wrong in a way you can detect"],
  "prompt": "Here is a backtest function. Do NOT rewrite it. (1) List every point at which information from a later timestamp could reach an earlier decision — check the shift alignment, every rolling window's centring, every normalisation or scaler and the sample it is fitted on, every join or reindex or resample, every fillna, and every use of a full-sample statistic. (2) For each, state the observable symptom in the results (Sharpe range, drawdown shape, hit rate) if it were present. (3) Name the ONE test that would settle each hypothesis. (4) Separately, list any place I have charged a one-way cost where a round trip occurs, or a round-trip cost where only one side trades.",
  "verify": "Never accept a verdict, only a hypothesis. Run no_lookahead on the strategy: an empty flag list beats any amount of confident prose, and a flag list beats any denial. Recompute at least one cited figure by hand. Watch specifically for three copilot failure modes that are endemic to this domain — a 'cleaner' rewrite that quietly drops the shift, a cost model that charges half the round trip, and a pandas or vendor API that does not exist (Primary-Source Guardrail: Trace, Recompute).",
  "diy": "Gated: you must have passed QD1.03-e01 and QD1.03-e02 by hand first. An audit you cannot check is a second opinion you cannot evaluate, and on this specific topic the model's opinion is confident, fluent and frequently wrong about alignment.",
  "market": "US"
}

Advertisement

Part of the course

This page is part of Building a Backtesting Engine: Design & the Classic Pitfalls (QD1.03), in Phase 12: Finance Plus, AI and the quant-code track. The module's flashcards and its quiz are in the app.