Look-ahead & survivorship bias
Two silent backtest killers that manufacture profit from information you could never have had at the time — with concrete code showing how to introduce and catch each.
Overfitting is a sin you commit on purpose (even if you do not realise it). The next two biases are worse: they lie to you *automatically*, invisibly, inside code that looks completely correct. They both share one root cause — your backtest quietly uses information the real you could never have had at that moment.
Look-ahead bias: trading on the future
Look-ahead bias is using data from time *t+1* (or later) to make a decision at time *t*. In a backtest all of history sits in one DataFrame, past and future side by side, and it is trivially easy to reach one row too far. The result is a strategy that appears to predict the future — because it literally read it.
import pandas as pd
df = load_prices() class="c"># columns: open, high, low, close
class="c"># --- WRONG: decides using TODAYclass="s">'s close, then "buys" at TODAY's close ---
df[class="s">'signal'] = (df[class="s">'close'] > df[class="s">'close'].rolling(class="n">20).mean()).astype(int)
df[class="s">'ret'] = df[class="s">'close'].pct_change()
df[class="s">'pnl_bad'] = df[class="s">'signal'] * df[class="s">'ret'] class="c"># uses same-bar close it canclass="s">'t have known
class="c"># The bug: at the close of day t you compute the signal from day t's close,
class="c"># but class="s">'ret' is already the move INTO day t. You booked a move you could not
class="c"># have traded — you knew todayclass="s">'s close before "deciding".
class="c"># --- RIGHT: act on tomorrow's return, from a signal you had at todayclass="s">'s close ---
df['signalclass="s">'] = (df['closeclass="s">'] > df['closeclass="s">'].rolling(class="n">20).mean()).astype(int)
df['pnl_okclass="s">'] = df['signalclass="s">'].shift(class="n">1) * df['closeclass="s">'].pct_change()
class="c"># ^^^^^^^^ the signal is LAGGED: you decide on day t,
class="c"># you earn day t+class="n">1's return. No peeking.That single .shift(1) is the difference between a fantasy and a fair test. The rule is mechanical and non-negotiable: the signal that governs a bar's return must be computed only from data available before that bar. Any indicator, any threshold, any "if today closed above X" — all of it must be lagged by at least one bar relative to the return it earns.
Survivorship bias: testing only the winners
Survivorship bias is backtesting on a universe of instruments that only includes the ones that *survived to today*. Download "the S&P 500" and you get today's 500 companies — which by definition excludes every company that went bankrupt, got delisted, or was so bad it fell out of the index. You are testing your strategy on a list pre-filtered for success and then acting surprised that "buy and hold" looks unbeatable.
class="c"># --- WRONG: todayclass="s">'s index members, applied to the past ---
tickers = get_sp500_constituents() class="c"># <-- TODAY's class="n">500 names
prices = download(tickers, start=class="s">'class="n">2005-class="n">01-class="n">01')
class="c"># Every ticker in this list survived to class="n">2026. You have secretly guaranteed
class="c"># that none of your class="s">"picks" went to zero. Your backtest cannot lose the way
class="c"># a real class="n">2005 portfolio could have — Lehman, Enron, Wachovia arenclass="s">'t here.
class="c"># --- RIGHT: point-in-time membership ---
class="c"># For each rebalance date, use the constituents AS THEY WERE on that date,
class="c"># and keep delisted names in the price data (they stop, they don't vanish).
for date in rebalance_dates:
universe = index_members_asof(date) class="c"># who was actually in the index THEN
prices = pit_prices(universe, date) class="c"># includes names that later died
portfolio = strategy.select(prices) class="c"># now the losers can hurt youSurvivorship bias does not just flatter stock-picking strategies — it quietly inflates *any* test run on a curated universe: "top 20 coins by market cap", "major forex pairs", "liquid futures". Each of those lists was curated by survival. The fix is point-in-time data: for every historical decision, use the universe, prices, and fundamentals exactly as they existed *at that moment*, including the instruments that later failed.
How to catch both, every time
- Lag everything by default. Write your backtest so the signal for bar *t* is physically computed from bars up to *t-1*. Make peeking require effort, not the reverse.
- Never fit on the whole series. Any statistic used to transform data (mean, std, min/max scaling) must roll or expand — never use
.mean()over the full column. - Ask "when did I know this?" for every field. Fundamentals, index membership, even a "corrected" price all have a *release date* that is later than the date they describe.
- Sanity-check the impossible. A near-perfect equity curve, a Sharpe above ~3 on daily data, or a strategy that is never wrong at turning points is almost always a leak, not a genius.
- Re-derive one trade by hand. Pick a single winning trade and reconstruct, step by step, exactly what you would have seen on your screen at the decision moment. If you needed a number you did not yet have, you found your bug.