Look-ahead bias is when a backtest uses information that would not have been available at decision time, inflating results in ways that never survive live trading. This guide defines it, then walks the four traps that produce it most often in Python: negative .shift(), centered rolling windows, whole-series normalization, and same-bar execution. Every fix shown here is written to pass a look-ahead check. This is an educational engineering guide, not financial or trading advice, and nothing here implies any strategy is or will be profitable.
Look-ahead bias is when your backtest makes a decision at bar t using data that was not knowable until bar t+1 or later. The backtest "peeks" into the future, so its results describe a world you could never have traded in. It is the single most common reason a Pine or Python backtest looks strong on historical data and then falls apart on live bars.
The tricky part is that look-ahead rarely announces itself. There is no exception, no NaN, no failed assertion. The equity curve just looks better than it should, and you find out why only after you have risked capital. Because pandas makes it trivial to reference any row from any other row, a one-character mistake — a minus sign in a .shift(), a center=True you copied from a smoothing example — quietly leaks tomorrow into today.
In Python specifically, four patterns cause the overwhelming majority of leaks:
.shift() on a signal (pulling a future value backward)..rolling(center=True) used to build a signal (each window straddles the future).df['x'].max() (the max is only known at the end of history).The rest of this article shows the WRONG version and the corrected version of each, so you can grep your own code for them. If you want the check automated, the free [Python Backtest Validator](/python) flags all four statically. For the conceptual background, see [what look-ahead bias is](/learn/look-ahead-bias) and [why most backtests fail](/learn/why-most-backtests-fail).
.shift(n) moves data forward in time by n rows; .shift(-n) moves it backward — which means a negative shift on anything that feeds a trading decision pulls a future observation into the present. It is the most direct form of look-ahead there is.
The usual way it sneaks in is a "next return" column built for labeling, then accidentally reused as a feature or an entry condition.
# ❌ WRONG: signal references tomorrow's close df['next_close'] = df['close'].shift(-1) # future value df['signal'] = (df['next_close'] > df['close']).astype(int) # You are entering today because you already know tomorrow closed higher.
Every long here is taken on a bar where the outcome is already decided. The fix is to build signals only from data at or before the current bar, and to shift the signal forward so it acts on the next bar you could actually trade.
# ✅ CORRECT: signal uses only past/current data, acted on next bar df['sma_fast'] = df['close'].rolling(20).mean() df['sma_slow'] = df['close'].rolling(50).mean() df['raw_signal'] = (df['sma_fast'] > df['sma_slow']).astype(int) # Decide on bar t, hold the position starting bar t+1 df['position'] = df['raw_signal'].shift(1) df['position'] = df['position'].fillna(0)
The rule of thumb: a positive .shift(1) on your position is almost always correct (it enforces the one-bar delay between decision and execution). A negative shift anywhere near a signal is almost always a bug. Grep for .shift(- before you trust any result.
df['x'].rolling(window).mean() is trailing by default — each value uses the current bar and the window-1 bars before it. Add center=True and pandas re-centers the window so that half of it lies in the future. That is fine for offline smoothing of a chart you are only looking at. It is fatal for a signal.
# ❌ WRONG: centered window straddles future bars df['smooth'] = df['close'].rolling(20, center=True).mean() df['signal'] = (df['close'] > df['smooth']).astype(int) # 'smooth' at bar t is built from bars t-10 .. t+9 — ten future bars.
At bar t, a centered 20-period mean includes bars up to t+9. Comparing the current close against a line that already knows the next ten closes is pure hindsight. The fix is to keep the window trailing — never center a series you will make decisions from.
# ✅ CORRECT: trailing window only df['smooth'] = df['close'].rolling(20).mean() # center defaults to False df['raw_signal'] = (df['close'] > df['smooth']).astype(int) df['position'] = df['raw_signal'].shift(1).fillna(0)
The same warning applies to any centered or symmetric filter — Savitzky-Golay, a centered EMA, scipy.signal.filtfilt (which is explicitly zero-phase and therefore two-directional). If a smoother touches bars on both sides of the point it outputs, it cannot be used for signals. Keep those tools for visualization and use strictly trailing transforms for anything that drives an order.
Feature scaling is where look-ahead hides in plain sight, because the leaking line looks like ordinary preprocessing. The moment you divide by df['x'].max(), .min(), .mean(), or .std() computed over the entire DataFrame, every early bar is scaled using a statistic that was not known until the end of history.
# ❌ WRONG: whole-series statistics leak the future into every row df['norm'] = (df['close'] - df['close'].mean()) / df['close'].std() df['range_pct'] = df['volume'] / df['volume'].max() # The mean/std/max summarize ALL bars, including ones after t.
At bar 100, df['close'].mean() already incorporates bar 5,000. Your normalized feature at bar 100 encodes information from five years later. Two correct approaches: use a trailing/expanding window so each row only sees its own past, or — if you are doing a train/test split — fit the scaler on the training slice alone and apply it to the test slice.
# ✅ CORRECT (option A): trailing window, each row sees only its past roll = df['close'].rolling(252) df['norm'] = (df['close'] - roll.mean()) / roll.std() df['norm'] = df['norm'].fillna(0) # ✅ CORRECT (option B): fit on the train slice, apply to test split = int(len(df) * 0.7) train_mean = df['close'].iloc[:split].mean() train_std = df['close'].iloc[:split].std() df['norm'] = (df['close'] - train_mean) / train_std # stats from train only
Option B ties directly into [in-sample vs out-of-sample](/learn/in-sample-vs-out-of-sample) discipline: any statistic used to transform the test set must be estimated on the training set, never on the combined series. An expanding window (rolling with min_periods, or .expanding()) is the streaming equivalent and matches how the value would have been computed live.
The final trap is not in feature engineering at all — it is in the fill logic. Same-bar execution means you generate a signal from a bar's close and then assume you got filled at that same close. In reality, the moment you know the close, the bar is over and that price is gone.
# ❌ WRONG: decide on bar t's close, fill at bar t's close df['signal'] = (df['close'] > df['close'].rolling(20).mean()).astype(int) df['ret'] = df['close'].pct_change() df['strategy_ret'] = df['signal'] * df['ret'] # same-bar signal × same-bar return
Here the signal at bar t is multiplied by the return into bar t, which the close of t already contains. You are being paid for a move you could only have acted on after it happened. The standard fix is to lag the signal by one bar so the position earns the next bar's return, and to model a realistic fill and cost.
# ✅ CORRECT: signal on bar t, position held into bar t+1, with cost df['raw_signal'] = (df['close'] > df['close'].rolling(20).mean()).astype(int) df['position'] = df['raw_signal'].shift(1).fillna(0) # act next bar df['ret'] = df['close'].pct_change().fillna(0) df['gross'] = df['position'] * df['ret'] # subtract a per-turn cost whenever the position changes cost_per_turn = 0.0005 # 5 bps, illustrative only df['turns'] = df['position'].diff().abs().fillna(0) df['net'] = df['gross'] - df['turns'] * cost_per_turn
That single .shift(1) on the position is the difference between a curve you can trade and one you cannot. Combine all four fixes and validate with [walk-forward analysis](/learn/walk-forward-analysis) so leakage that survives a single split still gets caught out of sample. Run your file through the [Python Backtest Validator](/python) for the static checks, and the [overfitting tool](/backtest/overfitting) to gauge how much of the remaining edge is curve-fit. None of this makes a strategy profitable — it only makes the numbers honest.
Educational only — not financial advice. Trading involves substantial risk of loss.