2. Point-in-time data and look-ahead bias
Level 2: The biases that break a backtest
Look-ahead bias is the most humiliating of them all, because committing it takes no sophistication whatsoever and it produces the most spectacular results you will ever see. It comes down to one thing: on date T your model uses information that did not yet exist on date T. A single misaligned line of code is enough, and the symptom is not an error, it is a Sharpe of four.
Four routes by which the future sneaks in
- Fundamentals placed on the period date. The earnings of the quarter ending on March 31 are not available on March 31. The practical consequence in the backtest is that for weeks you trade on a figure the market did not yet know, and that figure is exactly what moves the price when it finally comes out.
- Deciding on the close and executing at that same close. The closing price is only known once the auction has finished, and by then you can no longer trade at it.
- Normalizing over the whole sample. Subtracting the mean and dividing by the standard deviation computed over the full history puts 2024 information into the 2010 data point.
- Filling gaps with later values. A backward fill copies the next known value into the gap, and the gap tends to sit right before the interesting event.
Aligning a fundamental with its publication date
The right tool is an as-of join, with the key set on the date the figure became public and never on the date of the period it refers to.
import pandas as pd
fund = fund.sort_values("fecha_publicacion")
precios = precios.sort_values("fecha")
panel = pd.merge_asof(
precios,
fund[["fecha_publicacion", "ticker", "bpa_ttm"]],
left_on="fecha",
right_on="fecha_publicacion", # NEVER fecha_periodo
by="ticker",
direction="backward", # only what has already been published
tolerance=pd.Timedelta("400D"), # discards stale data
)Three details matter. direction set to backward guarantees that each price row can only attach to publications at or before it. The by argument stops one issuer's data leaking into another's. And tolerance stops you dragging a three-year-old figure along indefinitely as though it were still current. If you switch right_on to the period date, the code carries on without complaint and the backtest improves: that improvement is the leak.
A single day of shift changes the entire result
This is the snippet that gets broken most often, and the one that most often goes unnoticed because it looks immaculate.
media = precio.rolling(20).mean()
senal = (precio < media).astype(int)
retornos = precio.pct_change()
# WRONG: today's signal earns today's return
pnl_malo = senal * retornos
# RIGHT: you decide at today's close and earn tomorrow's move
pnl_bueno = senal.shift(1) * retornos
print(pnl_malo.mean() / pnl_malo.std() * 252 ** 0.5)
print(pnl_bueno.mean() / pnl_bueno.std() * 252 ** 0.5)The unshifted version is not a bit better: it is usually a different strategy altogether. The reason is that the signal is built on the same day's price, so multiplying it by that day's return amounts to buying only on the days you already know will close below their average. It is mean reversion with the answer in front of you. In short-horizon strategies it is common to watch a Sharpe comfortably above two fall below half of that once you insert that single shift.
It is worth understanding why the effect is so brutal when the lag is so small. The correlation between a day's return and a signal built from that same return is enormous by pure arithmetic, whereas the correlation between tomorrow's return and any signal available today is tiny: in liquid equities, a few hundredths. You are comparing an accounting identity with a genuine prediction. That is why the shift does not qualify the result, it replaces it. And that is why, when somebody shows you a spectacular curve, the first question is not which indicator it uses but where the shift is.
Normalizing is a decision with a date on it
# WRONG: mean and standard deviation over the whole series, future included
z_malo = (factor - factor.mean()) / factor.std()
# RIGHT: only with what was known up to each date
media_exp = factor.expanding(min_periods=252).mean()
desv_exp = factor.expanding(min_periods=252).std()
z_bueno = (factor - media_exp) / desv_expThe fact that the target variable appears in neither of the two lines is what makes the first one look harmless. It is not: the z-score on any given date depends on every later value, and if the factor was in a different regime at the end of the sample, the global normalization distributes that information backwards. The same goes for winsorizing on global percentiles, for scaling by the volatility of the whole series and for any adjustment that reads the complete sample before splitting it.
A real case: why six months of margin
In serious empirical work this is solved with a deliberately generous margin. Fama and French, when building the value portfolio that half the profession uses as its benchmark, match the accounting data for the financial year ended in the previous calendar year with returns starting in July of the following year. In other words, they impose a minimum separation of six months between the accounting year end and the moment that figure is allowed to influence a decision, well beyond the statutory filing deadlines. They do not do it out of laziness: they do it because any smaller margin forces you to prove, company by company, that the information really was available, and because accounting databases rewrite figures when there are restatements.
That is the standard you should copy. If you cannot justify with a specific date that the figure existed, delay it until you can. A backtest with margin to spare that produces a modest result is useful information; a backtest cut to the bone that produces an extraordinary result is no information of any kind.
To go deeper into this lesson
- Point-in-time datain the glossary
- Look-ahead biasin the glossary