Quant500 Academy

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

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_exp

The 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

The rest of Quant500

What you are learning, applied to the index every day:

The model portfolio, weekly in your inbox

Every Saturday, its week in one minute: how it went, the best and the worst, and which insiders bought with their own money. Plus, the day the model is rebuilt, which stocks come in and which go out. A simulation, not advice. At most one email a week, no ads, no account; one-click unsubscribe. Made by one person: Pol Gracia.

A model portfolio with simulated results, not a personalized recommendation. Your email is used for this only: privacy.

Behind the Academy and the rest of Quant500 there is one person: Pol Gracia Serrahima.