Next-bar execution and avoiding look-ahead
Lesson 14 · about 13 min
Look-ahead bias is using information at bar t that you would not have had until after bar t closed. It is the most common reason a backtest is wonderful and the live version is not, and it never announces itself. This lesson makes execution timing explicit, lists the ways look-ahead sneaks in, and gives you a test that catches most of them mechanically.
Where exactly do you trade?
A daily bar contains four prices, and you can act at two of them: the close, if your broker offers a market-on-close order and your signal can be computed before it, or the next open. Everything in between is intraday and needs intraday data.
Close-to-close (Module 4): signal from close[t − 1], position held from close[t − 1] to close[t], return close[t] / close[t − 1] − 1. Requires that you can compute the signal and get filled at the closing print, which is optimistic for most retail traders on most instruments.
Next-open execution: signal from close[t − 1], fill at open[t], held until the next fill at open[t + 1]. The return you actually earn is open[t + 1] / open[t] − 1. Indexing that return at the bar where it is realised, t + 1:
asset_ret[t + 1] = open[t + 1] / open[t] − 1, which isopen.pct_change()position[t + 1] = signal[t − 1], which issignal.shift(2)
import numpy as np
import pandas as pd
from src.data import synthetic_ohlcv
from src.indicators import sma
def ma_crossover_signal(close, fast=20, slow=50):
f, s = sma(close, fast), sma(close, slow)
signal = (f > s).astype(float)
signal[s.isna()] = np.nan
return signal
def backtest(price: pd.Series, signal: pd.Series, lag: int, cost_bps: float = 5.0) -> pd.DataFrame:
"""Generic vectorized backtest: position[t] = signal[t - lag]; returns from `price` bar to bar."""
if lag < 1:
raise ValueError("lag must be at least 1; lag 0 is look-ahead")
asset_ret = price.pct_change()
position = signal.shift(lag).fillna(0.0)
turnover = position.diff().abs().fillna(0.0)
net = (position * asset_ret).fillna(0.0) - turnover * cost_bps / 10_000.0
return pd.DataFrame({"position": position, "net": net, "equity": (1 + net).cumprod()})
bars = synthetic_ohlcv(1500, seed=42)
sig = ma_crossover_signal(bars["close"])
cc = backtest(bars["close"], sig, lag=1) # close-to-close, fill at close
oo = backtest(bars["open"], sig, lag=2) # next-open execution, open-to-open returns
print(f"close-to-close: {cc['equity'].iloc[-1]:.3f}")
print(f"next open: {oo['equity'].iloc[-1]:.3f}")
The general rule: pick the price series at which you actually trade, compute returns from that series bar to bar, and lag the signal by the number of bars between when it is computed and when the return starts accruing. Close signal, open fill: the return from open[t] to open[t + 1] is indexed at t + 1, two bars after the signal at t − 1. Hence lag=2.
The function refuses lag=0. There is no correct use of it.
How look-ahead sneaks in
The obvious version is a missing shift. The subtle versions:
- Centered windows.
rolling(n, center=True)uses future bars. Never in a signal. - Whole-sample statistics. Normalising by the standard deviation of the entire series (
(x − x.mean()) / x.std()) leaks the future into every bar. Userollingorexpandingversions. shift(-k),bfill(),interpolate(). Each pulls a future value backwards.ffill()is fine;bfill()is look-ahead.- Same-bar high/low. "Buy if the low touches the 20-day low" and then earning
close / lowassumes you knew the bar's low while it was forming. - Adjusted prices. Dividend-adjusted closes are recomputed every time a dividend is paid, so the "close" of five years ago, as downloaded today, contains information about all dividends since. For signals based on price levels (breakouts, round numbers) this matters.
- Fundamental and macro data stamped at the period, not at the release. GDP for Q1 is published in late April; a frame that puts it at 31 March lets you trade on it a month early.
- Resampling labels. A weekly bar labelled Friday but computed with
label="left"puts Friday's close on Monday's row. - Survivorship (Module 2). Not strictly look-ahead, but the same family: knowing today which companies survived.
Key idea: At bar t you may use anything computed from bars up to and including t − 1 (or t, if you fill at the close and can compute in time). Nothing from t + 1 onwards, in any disguise: no negative shifts, no centered windows, no whole-sample statistics, no back-filling.
A mechanical test for look-ahead
If a signal at bar t depends only on the past, then changing the data after t must not change the signal at t. That is testable.
def check_no_lookahead(signal_fn, bars: pd.DataFrame, cut: int, seed: int = 0) -> bool:
"""Perturb bars after `cut`; the signal up to `cut` must be unchanged."""
original = signal_fn(bars).iloc[: cut + 1]
rng = np.random.default_rng(seed)
scrambled = bars.copy()
future = scrambled.index[cut + 1:]
scrambled.loc[future, "close"] *= 1 + rng.normal(0, 0.05, len(future))
perturbed = signal_fn(scrambled).iloc[: cut + 1]
return original.equals(perturbed)
good = lambda b: ma_crossover_signal(b["close"])
bad_centered = lambda b: (b["close"].rolling(20, center=True).mean() > sma(b["close"], 50)).astype(float)
bad_zscore = lambda b: ((b["close"] - b["close"].mean()) / b["close"].std() > 0).astype(float)
for name, fn in [("crossover", good), ("centered", bad_centered), ("whole-sample z", bad_zscore)]:
print(f"{name:>15}: {'clean' if check_no_lookahead(fn, bars, cut=800) else 'LOOK-AHEAD'}")
The test shows clean for the crossover and LOOK-AHEAD for the other two. It cannot catch every leak (a leak through adjusted prices lives in the data, not the function), but any signal function you write should pass it, and running it in tests/ costs nothing.
Execution lag as a sensitivity test
The lag parameter has a second use. Run the strategy at lag=1, 2, 3 and watch the result. A rule whose edge disappears when you act one bar late has an edge that depends on precision you probably do not have. A rule that degrades gently is one that will tolerate a slow broker, a missed run, or a holiday.
for lag in [1, 2, 3, 5]:
r = backtest(bars["open"], sig, lag=lag)
print(f"lag {lag}: final equity {r['equity'].iloc[-1]:.3f}")
Try it: Write an RSI mean-reversion signal that enters when RSI < 30 and exits when RSI > 70 (Module 4), and run
check_no_lookaheadon it. Then deliberately break it by computing RSI onclose.shift(-1)and confirm the check fails. Finally, compute its next-open backtest withlag=2and compare againstlag=1.
Recap
- Decide the price you trade at; compute returns from that series; lag the signal by the bars between computation and the start of the return.
- Close signal with next-open fill is
open.pct_change()withsignal.shift(2). - Look-ahead hides in centered windows, whole-sample statistics, negative shifts, backfills, same-bar extremes, adjusted prices and mis-stamped releases.
- Test signals mechanically: perturb the future, and the past must not change.
- Vary the lag; a strategy that dies one bar late was never tradable.
See it drawn
Original diagrams for the ideas on this page. Illustrative, not real market data.