Quant500 Academy

1. Aligning data and signals without look-ahead

Level 6: From data to decision

The previous five levels have left you with loose pieces: prices from a vendor with stable identifiers, a history adjusted for splits and dividends, a cleaning routine that detects the impossible and a store that updates without duplicating rows. This module is where those pieces come together into a single table, and it is also where the most expensive mistake in the craft gets made, because it is the only one that raises no exception at all: the join comes out perfect, the backtest runs, and the returns look gorgeous because the table contains things that on that date nobody knew.

Three clocks that do not tell the same time

Any half-serious strategy mixes series of different frequencies. Prices are daily and arrive at the close of the session. Fundamentals are quarterly, but they carry two dates that do not coincide: the end of the period they describe and the day the company filed them. Macro data is monthly or quarterly, arrives weeks late and, on top of that, gets revised after publication, sometimes substantially.

That lag is not an administrative detail. In the United States, a large company has some forty calendar days after the quarter end to file its quarterly report and some sixty for the annual one; smaller companies have longer deadlines still. If your table sticks first-quarter earnings onto the March 31 row, you are handing the strategy between five and eight weeks of advantage over the rest of the market, precisely in the stretch where the reaction to the announcement is concentrated.

The backward join is the only honest one

The correct operation is not an exact-key join nor a forward fill over a daily calendar. It is an approximate backward join: for each price row, take the latest observation whose publication date is at or before that row's date.

import pandas as pd

precios = pd.read_parquet("precios_diarios.parquet")
# columns: fecha, simbolo, apertura, cierre_ajustado
fundamentales = pd.read_parquet("fundamentales_trim.parquet")
# columns: simbolo, fin_periodo, fecha_publicacion, bpa, patrimonio_neto

# The join's time key is NEVER fin_periodo: it is the day the figure became public.
precios = precios.sort_values("fecha")
fundamentales = fundamentales.sort_values("fecha_publicacion")

panel = pd.merge_asof(
    precios,
    fundamentales,
    left_on="fecha",
    right_on="fecha_publicacion",
    by="simbolo",                       # matches within each company
    direction="backward",               # only looks backwards
    allow_exact_matches=True,           # the filing day itself already counts
    tolerance=pd.Timedelta("400D"),     # an old quarter is not dragged along forever
)

Three details people overlook. One: merge_asof requires both tables to be sorted by their global time key, not within each group. Two: if a company files late, the panel will drag an old quarter along for weeks, and that is exactly right, because it was the only thing known. And three: the tolerance stops a company that has stopped reporting from keeping a three-year-old figure alive.

Resampling badly does not throw an error, it throws off returns

The shortcut that ruins the most backtests is this one, and at first glance it looks reasonable:

# WRONG: reindex on period end and propagate forwards.
malo = (fundamentales
        .set_index("fin_periodo")
        .groupby("simbolo")["bpa"]
        .resample("D")
        .ffill())
# The March 31 row already carries the first quarter's earnings,
# which were not known until the end of April. Forty days of future, zero warnings.

The same problem shows up when aligning macro data: if you take the GDP series with its reference date and extend it backwards with a resample, your strategy will know in January how the quarter ended. And there is a subtler variant: any transformation that looks at the whole series at once, such as normalizing by the mean and standard deviation of the entire history or filling gaps by interpolation, injects the future into every row without a single date being wrong.

Signal, decision and execution are three dates

Even if the join is impeccable, the second lag remains: between knowing something and collecting on it. These are three distinct moments and confusing them costs money:

panel = panel.sort_values(["simbolo", "fecha"])

# 1) Signal: computed with what is available up to the close of t.
panel["senal"] = panel.groupby("simbolo")["cierre_ajustado"].pct_change(60)

# 2) Decision: the signal from the close of t becomes an order that night,
#    so the position exists from the open of t+1.
panel["posicion"] = panel.groupby("simbolo")["senal"].shift(1)

# 3) Execution: you earn the stretch running from the open of t to the open of t+1.
panel["ret_apertura"] = panel.groupby("simbolo")["apertura"].pct_change()
panel["pnl"] = panel["posicion"] * panel.groupby("simbolo")["ret_apertura"].shift(-1)

If your operation cannot touch the open, one more shift is needed, and if on top of that you place orders by hand, count the real delay and not the ideal one. The practical check is brutally simple: when you are unsure which way a shift goes, try the other one. If the result improves, the one that improves is the wrong one. Alignment errors have an unmistakable signature, which is that they always fall in your favor.

A worked case: how many days of future you are slipping in

Do the arithmetic before arguing. Quarterly fundamentals, four filings a year, a median of some forty calendar days between period end and filing. If you join on period end, your table contains a figure that was not yet public for 4 × 40 = 160 days out of every 365: close to 44% of the history. And they are not just any 160 days, they are precisely the ones surrounding the earnings announcement, where a large stock routinely moves several percentage points in a single session.

When you fix this, what normally follows is not an improvement or a fine adjustment: it is that the annualized return collapses and the curve loses its suspicious smoothness. Marcos López de Prado has spent years pointing to look-ahead bias as one of the main reasons backtests do not resemble reality; it is worth treating that drop as the project's first piece of good news, because it is the first figure that describes the market rather than your spreadsheet.

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.