Quant500 Academy

3. Automated checks: the data validates itself

Level 4: Cleaning and validation

Everything above has one practical problem: it gets done once, on the day you set the project up, and after that nobody looks at it again. Six months later the vendor changes the format of a column, a new public holiday enters the calendar, or the overnight download brings half the universe, and nothing notices. The solution is not more personal discipline: it is turning the cleaning into code that runs before every use and that stops the process when something does not add up.

A contract, not an eyeball review

An automated check is an explicit contract about what the data has to look like for the rest of the system to have permission to use it. The minimum list, which covers the overwhelming majority of real incidents:

Writing it so that it fails loudly

class DatosInvalidos(Exception):
    pass


def validar(df, universo_esperado, cobertura_min=0.95):
    fallos = []

    faltan = {"apertura", "maximo", "minimo", "cierre", "volumen"} - set(df.columns)
    if faltan:
        fallos.append("faltan columnas: " + ", ".join(sorted(faltan)))

    if not df.index.is_monotonic_increasing:
        fallos.append("el indice de fechas no esta ordenado")
    if not df.index.is_unique:
        repes = df.index[df.index.duplicated()].unique()[:5]
        fallos.append("fechas duplicadas: " + str(list(repes)))

    if (df[["apertura", "maximo", "minimo", "cierre"]] <= 0).any().any():
        fallos.append("hay precios nulos o negativos")

    cobertura = df.groupby(level=0)["cierre"].count() / universo_esperado
    flojas = cobertura[cobertura < cobertura_min]
    if len(flojas):
        fallos.append("cobertura baja en " + str(len(flojas)) + " sesiones, "
                      "minima " + str(round(flojas.min(), 3)))

    if fallos:
        raise DatosInvalidos(" | ".join(fallos))
    return df

Three design decisions that are not cosmetic. First, every failure is accumulated and they are raised together: if you abort on the first one, you fix it, run again and discover the next, and so on six times over. Second, the message carries the specific data, which dates are duplicated and what the minimum coverage is, because an error that only says something is wrong forces you to repeat the whole investigation. And third, an exception is raised, not a warning emitted. A warning gets lost in the overnight job's output and everything carries on as if nothing had happened, delivering bad data that looks good.

The history should not change, and you have to check it

The most neglected check is also the one that catches the most silent incidents: comparing today's download with yesterday's over the stretch they share. That stretch is the past; barring a legitimate vendor revision, it has to be identical.

def comparar_con_anterior(nuevo, anterior, tolerancia=1e-9):
    comun = nuevo.index.intersection(anterior.index)
    a, b = nuevo.loc[comun, "cierre"], anterior.loc[comun, "cierre"]

    cambiadas = comun[(a - b).abs() > tolerancia * b.abs().clip(lower=1)]
    desaparecidas = anterior.index.difference(nuevo.index)

    if len(cambiadas) or len(desaparecidas):
        raise DatosInvalidos(
            "el historico ha cambiado: " + str(len(cambiadas)) + " cierres "
            "distintos y " + str(len(desaparecidas)) + " filas desaparecidas"
        )

This check firing does not always mean there is an error: vendors revise data for legitimate reasons, and companies restate accounts with the regulator. It means somebody has to decide, and that the decision has to be written down. What is unacceptable is for the past to change and nobody to notice, because then two identical backtests give different results and weeks get lost hunting for the fault in the strategy.

Where these checks hook in

They go at the exact point where the data crosses from one stage to the next: when the download finishes, before writing to the store, and again when loading it for a backtest. Duplicating the validation on load looks redundant and is not: it protects you from somebody having touched the files outside the process, which is a far more common incident than anybody admits.

The command that orchestrates the download must finish with a non-zero exit code when the validation fails, so that the task scheduler treats it as what it is, a failure, and raises the alarm. And there is a design decision worth taking early: when a check fires, the new data does not replace the old. It is written to one side, in quarantine, and the system carries on running on the last valid version until somebody resolves the incident. It is the difference between one bad night and a week trading blind.

A word of warning about the temptation to loosen thresholds: when a check fires often for legitimate reasons, the instinctive reaction is to raise the tolerance until it stops being a nuisance. That turns the check into decoration. The right move is to split it in two, a strict one that stops the process and an informational one that only counts, and to review the informational one from time to time.

A worked example: the coverage that falls slowly

A universe of 480 stocks with a 95% threshold requires at least 456 stocks with data every session. One day 447 arrive, and the check fails by nine stocks. Taken one at a time, the incident looks minor. Taken as a series, it is something else: if coverage has been falling from 99% to 93% over three weeks, it is not a one-off incident, it is a leak, typically identifiers that have stopped resolving after a name change. The check that runs every day catches the trend; the manual review, which happens when somebody has the time, never catches it.

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.