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 errorIf 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
- The last value of the adjusted series matches the last published close, and the factor in that stretch is exactly 1.
- The factor contains no zeros, no negative values and no undefined values on any date in the index.
- Adjusting twice with the same event table gives the same result as adjusting once: the process has to be idempotent.
- The number of rows does not change. An adjustment that deletes or adds sessions is not an adjustment.
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.