Quant500 Academy

4. Rebuilding an adjusted series and proving it is right

Level 3: Adjustments and corporate actions

You know which events break a series and which factor corrects each one. What is missing is the part nobody teaches, and the part that separates a data pipeline from a pile of scripts: building the adjusted series from scratch and then proving it is right. Not assuming it. Proving it.

The cumulative factor

There is only one idea: each event contributes a factor below or above one that is applied to everything before it. With several events, the factors multiply, and the adjusted series is anchored to the last published price so that the right-hand end remains a genuinely tradable price.

import numpy as np
import pandas as pd

def factor_acumulado(indice, eventos):
    """Returns the factor to apply to each date. It is 1 in the last stretch."""
    factor = pd.Series(1.0, index=indice, dtype="float64")
    for fecha, f in zip(eventos["fecha"], eventos["factor"]):
        factor[indice < fecha] *= f
    return factor

def ajustar(precios, volumen, eventos):
    f = factor_acumulado(precios.index, eventos)
    # The price is multiplied by the factor; the volume, by its inverse.
    return precios * f, volumen / f

eventos = pd.DataFrame({
    "fecha": pd.to_datetime(["2024-06-10", "2024-09-19"]),
    "tipo": ["split", "dividendo"],
    "factor": [0.5, 0.9880952],
})

Note two decisions. First: the loop runs through the events in any order and the result is the same, because multiplication commutes; if your code depends on the order, there is a bug. Second: the volume is divided by the same factor, not multiplied. With a two-for-one split the factor is 0.5, the old prices are divided by two and the old volumes are multiplied by two.

Check 1: the adjusted return is the shareholder's return

This is the only one that really matters. You reconstruct, event by event and from the raw data, what the shareholder gained or lost each day, and you require it to match what the adjusted series returns.

def retorno_economico(precios, dividendos, splits):
    """Daily return reconstructed from the raw data."""
    d = dividendos.reindex(precios.index).fillna(0.0)
    r = splits.reindex(precios.index).fillna(1.0)   # split ratio for that day
    return (precios * r + d) / precios.shift(1) - 1

def auditar(ajustada, precios, dividendos, splits, tolerancia=2e-4):
    r_ajustada = ajustada.pct_change()
    r_real = retorno_economico(precios, dividendos, splits)
    dif = (r_ajustada - r_real).abs().dropna()
    return dif[dif > tolerancia].sort_values(ascending=False)

One nuance worth knowing: the usual dividend factor (one minus the dividend over the previous close) and the additive total return do not give exactly the same thing. They coincide exactly only if the price falls by precisely the amount distributed; in any other case they differ by a second-order term which, for a normal dividend, stays below one basis point. That is why the tolerance in the example is 2e-4 and not zero. What must never appear are differences of percentage points: that is no longer convention, it is a badly applied factor.

Check 2: no jump without an owner

def saltos_sin_explicacion(ajustada, eventos, umbral=0.25):
    r = ajustada.pct_change()
    fechas_evento = set(pd.to_datetime(eventos["fecha"]))
    grandes = r[r.abs() > umbral]
    return grandes[[d not in fechas_evento for d in grandes.index]]

Every large daily move has to have behind it either an event from your table or a genuine piece of news. The usual candidates on this list are a split your vendor never reported, a special dividend, an unadjusted spin-off and a symbol reassigned to another company. One extra piece of information helps discriminate: a change of scale does not come with anomalous volume, whereas a genuine collapse almost always multiplies the volume.

Check 3: consistency between price and volume

If the adjustment is done properly, the value traded (price times volume) has to be invariant: the factors cancel, because one multiplies and the other divides.

def coherencia_importe(px_aj, vol_aj, px_crudo, vol_crudo, tolerancia=1e-6):
    importe_aj = px_aj * vol_aj
    importe_crudo = px_crudo * vol_crudo
    error = ((importe_aj - importe_crudo) / importe_crudo).abs().max()
    assert error < tolerancia, "El volumen no se ajusto con el inverso del factor"
    return error

If the value traded in the stretch before a five-for-one split comes out at a fifth of the original, the diagnosis is immediate: you adjusted the price and left the volume untouched. It is a cheap check and a surprisingly effective one, because it catches the most frequent oversight in the whole process and does so without depending on any external source: the raw series itself is the witness.

Check 4: the cheap invariants

A real case: NVIDIA, June 10, 2024

NVIDIA carried out a ten-for-one stock split effective on June 10, 2024. The close on June 7 was above $1,200; the one on June 10 came in at around $122. On the raw series, that is a fall of about 90% in one session.

Run the audit on that day. Unadjusted, the daily return gives approximately minus 0.90 and the reconstructed economic return gives around plus 0.007: a difference of more than 90 percentage points, impossible to confuse with a matter of convention. With the 0.1 factor applied to the whole earlier history, both figures converge and the value-traded check comes out flat. That contrast (more than ninety points against less than one basis point) is exactly what an automated audit has to tell apart without anybody looking at the chart.

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.