Skip to content
GetProfitable
Search

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 is open.pct_change()
  • position[t + 1] = signal[t − 1], which is signal.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:

  1. Centered windows. rolling(n, center=True) uses future bars. Never in a signal.
  2. Whole-sample statistics. Normalising by the standard deviation of the entire series ((x − x.mean()) / x.std()) leaks the future into every bar. Use rolling or expanding versions.
  3. shift(-k), bfill(), interpolate(). Each pulls a future value backwards. ffill() is fine; bfill() is look-ahead.
  4. Same-bar high/low. "Buy if the low touches the 20-day low" and then earning close / low assumes you knew the bar's low while it was forming.
  5. 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.
  6. 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.
  7. Resampling labels. A weekly bar labelled Friday but computed with label="left" puts Friday's close on Monday's row.
  8. 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_lookahead on it. Then deliberately break it by computing RSI on close.shift(-1) and confirm the check fails. Finally, compute its next-open backtest with lag=2 and compare against lag=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() with signal.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.

A fast and a slow moving average crossingA jagged price line with two smoother average lines through it; the fast average dips below the slow one on the left and cuts back above it in the middle, where a circle marks the crossing.pricefast averageslow averagefast crosses belowfast crosses abovethe slow averageAverages of recent closes; the fast one reacts sooner than the slow one.
Fast and slow moving averages crossing. A moving average is the average of the last few closing prices, redrawn each period. An average over fewer periods turns sooner than one over many, so the two lines cross whenever the recent pace of the market changes.
The spread of outcomes behind an expectancyA histogram of forty trades: a tall block of small losses on the left, a low spread of larger wins on the right, and a line marking the average outcome.NUMBER OF TRADES051024 LOSSES, AVG −$20016 WINS, AVG +$600EXPECTANCY +$120−$400−$200$0+$200+$400+$600+$800PROFIT OR LOSS PER TRADEexpectancy = (40% × $600) − (60% × $200) = +$120 per trade
Expectancy: the average trade. Forty trades sorted by outcome: 24 small losses and 16 larger wins. Weighting each side by how often it happens gives the average result per trade, marked here by the dashed line at +$120.