Quant500 Academy

3. A dataset that is auditable from end to end

Level 6: From data to decision

An auditable dataset is one in which any number in any cell can be traced back to its origin and to the exact list of operations that produced it. It sounds like bureaucracy right up until the day a result changes and you have no way of knowing whether it was the world, the vendor or you that changed.

Lineage: every number's biography

Data lineage is simply that: the complete biography of a value. Without the jargon: if a number in your table cannot tell you its life story, it is not a data point, it is a rumor in numeric format. And telling its life story means five concrete things:

There are three questions that record has to be able to answer without anybody remembering anything. Where did it come from? What was done to it? What was decided about it when it looked doubtful? If all three have written answers, the dataset is auditable; if any of them depends on your memory, it is not.

The manifest that travels with the table

Each version of the dataset comes with a metadata file generated by the same process that produces it. Never written by hand afterwards.

import hashlib, json, os, subprocess
import datetime as dt

CLAVE = os.environ.get("CLAVE_PROVEEDOR", "tu_clave_aqui")  # never in the manifest

def huella(ruta):
    h = hashlib.sha256()
    with open(ruta, "rb") as f:
        for bloque in iter(lambda: f.read(1 << 20), b""):
            h.update(bloque)
    return h.hexdigest()

manifiesto = {
    "conjunto": "panel_sp500_diario",
    "generado_utc": dt.datetime.utcnow().isoformat(timespec="seconds"),
    "commit": subprocess.check_output(["git", "rev-parse", "HEAD"]).decode().strip(),
    "fuentes": [
        {"campo": "cierre_ajustado", "origen": "proveedor A, endpoint /eod",
         "descargado": "2026-08-03", "fichero": "bruto/eod_2026-08-03.csv.gz",
         "sha256": huella("bruto/eod_2026-08-03.csv.gz")},
        {"campo": "bpa", "origen": "SEC EDGAR, companyfacts",
         "descargado": "2026-08-03", "fichero": "bruto/companyfacts_2026-08-03.zip",
         "sha256": huella("bruto/companyfacts_2026-08-03.zip")},
    ],
    "transformaciones": [
        "1. recortar al calendario oficial de sesiones del mercado",
        "2. aplicar factores de splits y dividendos sobre el precio bruto",
        "3. unir fundamentales por fecha_publicacion con merge_asof backward",
        "4. anular precios con volumen cero",
    ],
    "decisiones": {
        "precios_volumen_cero": "se anulan, no se interpolan; 412 filas",
        "empresas_sin_identificador": "se excluyen y se listan aparte; 7 simbolos",
    },
}
json.dump(manifiesto, open("panel_sp500_diario.manifiesto.json", "w"), indent=2)

The cryptographic hash is the piece almost everybody skips and the only one that tells apart two scenarios that look very much alike: that your code changed, or that the vendor rewrote its history without telling you. The row count is no use for that, because revising values does not change the count.

The transformations are an ordered list, not a heap

The order matters and you have to be able to recite it. Nulling out impossible prices before or after computing the adjustment factors gives different results; filtering on market capitalization before or after applying the join with the fundamentals changes the universe. The practical rule: the raw file is never touched, it is stored exactly as it arrived and everything else is rebuilt from it with a script that runs from beginning to end in one go. If your dataset only exists because one day you ran three cells of a notebook in a certain order, it is not reproducible.

Doubtful decisions get written down at the time

There will be cases no rule covers: a company that changes its financial year, a price that shoots up and might be real, a badly reflected merger. Every time you decide something, write in the same place which criterion you applied, on which rows, and which alternative you rejected and why. A year from now that note is worth more than the code, because the code says what you did and the note says why.

And there is a point of sequence that separates serious work from the rest: those decisions are taken and written down before looking at any result. The moment you know that nulling out 412 rows improves your return and keeping them worsens it, you have stopped deciding about the data and started choosing the answer you like. The decision log is, at bottom, a defense against yourself, and that is why its value depends entirely on when it gets written.

A report that generates itself

And finally, the dataset has to pass a fixed examination every time it is rebuilt.

def informe_auditoria(df, clave=("simbolo", "fecha"), col_precio="cierre_ajustado"):
    fallos, notas = [], []
    notas.append(f"filas {len(df)}  simbolos {df['simbolo'].nunique()}")
    notas.append(f"rango {df['fecha'].min().date()} a {df['fecha'].max().date()}")

    if df.duplicated(list(clave)).any():
        fallos.append("hay claves duplicadas")
    if df["fecha"].max() > pd.Timestamp.today():
        fallos.append("hay fechas en el futuro")
    if not df[col_precio].dropna().gt(0).all():
        fallos.append("hay precios no positivos")

    # Coverage: a symbol that disappears halfway through produces no nulls and no duplicates.
    por_ano = df.groupby(df["fecha"].dt.year)["simbolo"].nunique()
    notas.append("simbolos por ano: " + str(por_ano.to_dict()))

    saltos = df.groupby("simbolo")[col_precio].pct_change().abs()
    notas.append(f"saltos diarios por encima del 50 por ciento: {int((saltos > 0.5).sum())}")
    notas.append(f"nulos en {col_precio}: {df[col_precio].isna().mean():.3%}")

    return {"ok": not fallos, "fallos": fallos, "notas": notas}

Note the coverage check: the rows that are missing produce no nulls and no duplicates, so no value-based check sees them. That is the gap through which the survivorship bias of level 2 slips in. The report is stored alongside the manifest, with the same date, and compared with the previous run's: what matters is not only that it is fine today, but what has moved since yesterday.

A case: the reproduction that does not add up

Hou, Xue and Zhang repeated some four hundred and fifty published anomalies applying uniform criteria and found that a large majority, around two thirds, stopped being statistically significant. It was not bad faith or programming errors: the difference came from decisions nobody had written down, such as the minimum market capitalization, the size weighting, the treatment of extreme values and the rebalancing date. That is this module's entire argument. If the decisions in a dataset are not written down, not even you will be able to reproduce your own result a year from now, and you will not know whether the fault was the world's or yours.

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.