Look-Ahead Bias in Multi-Timeframe Backtests
A backtest that looks great is usually a backtest that knows something it should not. Single-timeframe strategies leak in one or two obvious places. The moment you add a second timeframe — confirm on 3-minute, enter on 5-minute — the number of places future information can slip in roughly triples, and none of them raise an error.
Here are the four that mattered in a moving-average breakout backtest I built, and how each one is closed.
1. The entry price on the signal bar
The signal is “close crossed above the 20-period average.” The bar that crosses is only known to have crossed after it closes. So the earliest realistic fill is that bar’s close:
crossed = s_close[i - 1] <= s_ma20[i - 1] and cl > s_ma20[i]
if crossed and flat:
entry = cl # close of the crossing bar
Filling at that bar’s open — a common shortcut, because the open is right there in the row — means entering before the information existed. On a trending instrument that single substitution can carry an entire strategy’s apparent edge.
There is a real-world gap in the other direction too: you cannot actually trade the close, only near it. That is slippage, and it belongs in the cost model. It is a separate problem from look-ahead, and mixing them up hides both.
2. Confirmation from a bar that has not closed yet
This is the multi-timeframe trap, and it is the one that is genuinely hard to see.
The rule is “3-minute must also be above both averages.” At the moment of a 5-minute bar’s close, which 3-minute bars have closed? Only those at or before that timestamp. If your lookup grabs the 3-minute bar containing the entry time, or worse joins on nearest neighbour, you have imported information from a bar that closes after your entry.
def fast_ready(ts: int) -> bool:
"""State of the most recent fast bar at or before ts."""
best = None
for t in f_ok:
if t <= ts and (best is None or t > best):
best = t
return bool(f_ok.get(best)) if best is not None else False
The t <= ts is the entire guard. Note it is <=, not <: a 3-minute bar closing exactly
at the 5-minute boundary has closed, and its information is legitimately available.
A useful sanity check: run the backtest with the fast timeframe deliberately shifted one bar into the future. If results barely change, your alignment is probably fine. If they improve dramatically, you have found where the leak was.
3. Intrabar ordering you cannot know
A bar has an open, high, low, and close. It does not have an order. When a bar’s low touches your stop and its high touches your target, you cannot know from the bar which came first.
Optimistic backtests resolve this in the profitable direction, usually without saying so. That single assumption can flip a losing system into a winning one, because it applies to precisely the volatile bars where the most money moves.
The honest resolution is to always assume the adverse fill:
If both the stop and the target are touched in the same bar, treat the stop as hit first.
You will understate performance somewhat. That is the correct direction to be wrong in. If a strategy only works under the optimistic assumption, what you have found is the assumption, not a strategy.
4. Timezones and session boundaries
Least glamorous, most common.
If you store US bars as exchange wall-clock time and render them in another zone, the offset changes twice a year with daylight saving. Get it wrong by an hour and your “regular session only” filter silently includes pre-market bars — which are thinner, gappier, and flatter your entry statistics.
Two things fixed this:
- Store one canonical form, convert only at the edges. Mixing exchange-local and viewer-local inside the engine guarantees an eventual off-by-one-hour.
- Filter sessions explicitly, then assert. Count bars per session day and check the number against the expected count. A regular US session at 5-minute bars has a fixed bar count; anything else means the filter is admitting something it should not.
And the closely-related one: index arithmetic on daily bars. Match rows by explicit date string, never by position. Reading a daily array one index off — treating yesterday’s bar as today’s — produces results that are fluent, internally consistent, and about the wrong day. I have misdiagnosed that as a code bug more than once when the code was fine and my indexing was not.
What a clean run looks like
None of this makes a strategy work. It makes the number mean something. In practice the checklist is short:
- Fill at the close of the bar that produced the signal, not its open.
- Every cross-timeframe lookup filtered by
t <= entry_time. - Stop resolved before target inside the same bar.
- One canonical timezone, explicit session filter, asserted bar counts.
- Shift-the-fast-timeframe test as a leak detector.
If the edge survives all five, it might be real. If it does not survive, you have saved yourself the version of that discovery that costs money.
This describes backtest methodology. It is not investment advice, and no strategy parameters, targets, or results are published here.