DEV Community

Cover image for How to Detect Look-Ahead Bias in Your Own Backtest
Bot LAB
Bot LAB

Posted on Originally published at botlab.org.uk

How to Detect Look-Ahead Bias in Your Own Backtest

Look-ahead bias is the failure where your backtest uses information that did not exist yet at the moment it made a decision. You detect it mechanically, not by reading your code: compute your indicator twice — once over the full price history the way a vectorised backtest does, and once bar by bar on history truncated at that bar the way a live bot sees it — then compare the two values at the same bar. If they differ anywhere, something in your pipeline peeked at the future. That is the whole method, and it takes about ten lines.

This matters because look-ahead is the one backtest failure that never announces itself. Overfitting at least leaves a trail: good in-sample, bad out-of-sample. A leak looks like skill in every period, including your out-of-sample window, because the leak travels with the data. Reading the code does not reliably catch it either — the leak is usually not in a line that says shift(-1). It is in a .mean() over a whole column, a resample that joins a daily close onto hourly bars, a center=True you typed six months ago, or a gap-filling call that borrows the next bar's price.

The test

python full = signal(df) # vectorised, whole file for i in sampled_bars: inc = signal(df.iloc[:i+1]) # only data up to and including bar i assert full[i] == inc[-1] # any difference = look-ahead

Two details make it honest. First, truncate at the bar, not at the row index of some already-merged frame — if your feature table was built by joining timeframes, rebuild the join inside the truncated call. Second, sample bars from across the whole history, not just the end; some leaks only fire where the data has gaps, or where a higher-timeframe boundary falls.

What it looks like on real data

We ran this on 3,000 closed BTCUSDT 1-hour candles from Bybit (2026-06-11 03:00 UTC to 2026-10-14 02:00 UTC), auditing 120 randomly sampled bars with lookahead_analysis from our MIT-licensed library. Each row is a signal written the way people actually write signals.

| Signal construction | Bars checked | Mismatches | Leaks? | |---|---|---|---| | EMA(20) over EMA(50) cross | 120 | 0 | no | | z-score vs trailing 200-bar mean/std | 120 | 0 | no | | close.shift(-1) > close | 120 | 64 | yes | | Daily close joined onto 1h bars, not shifted | 120 | 57 | yes | | rolling(21, center=True).mean() | 120 | 32 | yes | | Min-max normalised over the whole file | 120 | 31 | yes | | z-score vs full-sample mean/std | 120 | 4 | yes |

Three things are worth reading off that table.

The obvious bug (shift(-1)) and the subtle one (a daily close joined onto hourly bars without shifting it forward) are equally fatal: 64 and 57 bad bars out of 120. The second is far more common in real code, because resampling and merging feels like data plumbing rather than a modelling decision.

Normalisation is a leak. Min-max scaling over the whole file mismatched on 31 of 120 bars, because the minimum and maximum of a column are facts about the future. Anyone who has scaled features before splitting train and test has done this.

A leak can be small and still be a leak. The full-sample z-score mismatched only 4 times in 120 bars — the mean and standard deviation of four months of hourly closes barely move when you add one more bar. It is still disqualifying: the value at bar i depends on bars after i, so the number your backtest traded on is a number your bot cannot compute.

The boolean trap

Here is the result that changed how we run this check. We took the same series with every seventh bar deleted, filled the holes with bfill() — backward fill, which copies the next available price into the gap — and computed an EMA(20) over it.

| What we audited | Bars checked | Mismatches | |---|---|---| | The EMA value itself | 120 | 15 | | The boolean close > EMA built from it | 120 | 0 |

Same bug, same bars. Audit the final true/false signal and the test passes. Audit the number underneath and the leak is plainly there. A boolean is a lossy summary: a contaminated value has to cross a threshold before the comparison flips, so most of the corruption is invisible at the signal level — until a day when it is not. Audit every intermediate value you compute, not only the entry flag.

Multi-timeframe and warm-up

If your strategy reads a 4h or daily candle while deciding on a 1h bar, the rule is simple: every higher-timeframe row you touch must have closed by the close of the bar you are evaluating. A partial candle is legal only if you build it yourself from lower-timeframe candles that have closed. Most exchange APIs return the currently-forming candle as the first element of the response, so this bug gets written by accident constantly. Our own fetch helper drops any candle whose close time is in the future, and the audit then checks the surviving rows against the decision timestamp.

A related failure produces the same symptom — backtest results your bot cannot reproduce — without any future data at all. Long indicators depend on how much history you loaded. Computing EMA(200) on the real BTC series above with only 100 bars of warm-up, versus 1,000 bars, moved the value by a median of 0.48% and up to 2.18%. That sounds harmless until you look at the trend boolean it feeds: close > EMA(200) flipped on 25% of the bars we evaluated (n = 20 evaluation bars, so treat the exact percentage as indicative, not precise). With 300 bars of warm-up the drift fell to 0.07% median and nothing flipped. Your backtest loads years of history; your live bot usually loads whatever its first API call returned.

What a leak is worth

To put a size on it, we ran one trading rule twice on the same 3,000 bars, with the same exits (stop at max(ATR14, 0.6%), target 1.5x the stop, 25-bar time stop) and the same cost assumption (0.055% taker fee per side, charged in R). The only difference is the entry condition: the second run compares the next close to the current one.

| Entry condition | Trades | Mean R | Win rate | Total R | |---|---|---|---|---| | EMA(20)/EMA(50) cross (clean) | 24 | +0.044R | 50.0% | +1.1R | | Next close > current close (leak) | 232 | +0.240R | 57.8% | +55.6R |

One bar of hindsight turns a rule with no detectable edge into one that looks like a business. The trade counts differ — the leaky condition triggers far more often — so this is an illustration of magnitude, not a controlled comparison. The direction is the point: leaks do not nudge results, they manufacture them.

What this method does not prove

A clean look-ahead audit is a licence to keep testing, nothing more.

  • It does not prove your edge is real. It proves only that your numbers were computable at the time. Overfitting, too few trades, concentration in two coins and ignored costs are separate failures with separate tests. For scale: across the 128 strategy books we track, as of 2026-10-14, zero pass our six readiness gates — and almost all of them are look-ahead clean.
  • It only covers the code you hand it. If the leak lives in how your trade ledger was assembled, or in a cached feature file built earlier over the whole history, auditing the signal function will not see it.
  • It is a sampled test. We checked 120 bars, not 3,000. Zero mismatches means "no leak at those bars", which is strong evidence, not proof. Increase the sample when a result looks surprisingly good.
  • Your data vendor can leak. Prices revised after the fact, timestamps labelled with a candle's close instead of its open, or funding rates stamped at the start of a settlement period rather than the end, will all pass a self-consistency check and still be wrong.
  • Nothing here says anything about fills. A signal that was computable at 14:00 can still be impossible to fill at the price your backtest assumed.

What this means for your own bot

  1. Write a signal(df) -> one value per bar function with no hidden state and no reads outside df. If you cannot, that is your first finding.
  2. Run the full-versus-truncated comparison on at least 100 bars spread over your whole history. Any mismatch stops everything else until it is fixed.
  3. Audit values, not booleans. Every indicator, every normalised feature, every merged higher-timeframe column — and the entry flag last.
  4. Grep for the usual suspects as a second pass, never as the first: shift(-, center=True, bfill, backfill, .interpolate(), resample(...).last(), and any .mean(), .std(), .min() or .max() taken over a full column.
  5. Drop unclosed candles at the source. Timestamp every input with the moment it became available, and compare that timestamp to the decision time rather than trusting row order.
  6. Fix warm-up before comparing backtest to live. Load the same amount of history live as your backtest effectively used, and measure the sensitivity rather than assuming it is small.
  7. Re-run the audit after every change to the feature pipeline. Leaks arrive with refactors, not with new ideas.

The Python implementation of all of the above — the full-versus-truncated audit, the warm-up sensitivity test, the multi-timeframe closed-candle check, plus the cost model and the statistical gates we use on our own books — is MIT-licensed at github.com/botlabtrade/botlab-oss. Install numpy and pandas, point it at your own data, and you get the same report we run on ours. If you would rather not install anything, there is a browser version of the checks at botlab.org.uk/#tool, which runs locally on a CSV of your trades.

This is educational material about testing methodology. Nothing here is financial advice, and no result above is a live trading record.

Paper trading results are simulated. Past results don't predict future results. Crypto trading can lose money, including all capital. Not financial advice.


Originally published on BOTLAB. Score your own bot's trade log free: Check your bot.

Top comments (1)

Collapse
 
ssapable profile image
ssapable •

The truncate-and-compare test is good, and it quietly assumes the bars themselves are right. That's where mine broke, with no leak anywhere in the code.

I replayed my partner's futures fills against public minute bars. Everything looked clean: no gaps, sane prices, the clock offset lined up. But only 233 of her 293 fills actually landed inside the high/low of the bar they were supposed to be in. When she exported her own bars from NinjaTrader, it was 293 of 293, and the main conclusion flipped. On the public bars her hand exits beat a fixed 3x/3x bracket. On her own bars the bracket won.

So the check I run now, before any look-ahead test: for every fill, is the fill price inside that bar's range? If a few percent of fills fall outside, the bars are the wrong contract or the wrong clock, and the indicator math on top doesn't matter yet.