Quant500 Academy

4. Reproducibilidad: versionar los datos, no solo el código

Nivel 4: Limpieza y validación

Tienes el código en un repositorio, con su historial de cambios y su identificador de commit. Fijas las versiones de las librerías. Fijas la semilla del generador aleatorio. Ejecutas el backtest en enero y da un 11,2% anual. Lo vuelves a ejecutar en marzo, sin haber tocado una sola línea, y da un 8,7%. No hay ningún fallo en tu código. Lo que ha cambiado está fuera de él.

El código es determinista; la fuente no

Un script de descarga puede ser perfectamente reproducible y aun así devolver cosas distintas en dos momentos, porque lo que hay al otro lado cambia. Ocurre por motivos legítimos y frecuentes:

Versionar el código responde a la pregunta de qué instrucciones se ejecutaron. Versionar los datos responde a la otra mitad: sobre qué se ejecutaron. Sin las dos mitades no hay resultado, hay una anécdota.

Instantánea fechada y suma de verificación

La receta es sencilla y aburrida, que es como deben ser estas cosas: cada descarga se congela en un fichero inmutable con fecha en el nombre, se le calcula una suma de verificación, y esa suma se apunta.

import hashlib, json, pathlib

def guardar_instantanea(df, nombre, carpeta="datos/instantaneas"):
    dia = pd.Timestamp.utcnow().strftime("%Y%m%d")
    ruta = pathlib.Path(carpeta) / (nombre + "_" + dia + ".parquet")
    ruta.parent.mkdir(parents=True, exist_ok=True)

    # Orden estable: sin esto, la misma tabla puede dar sumas distintas.
    df = df.sort_index(axis=0).sort_index(axis=1)
    df.to_parquet(ruta, index=True)

    resumen = hashlib.sha256(ruta.read_bytes()).hexdigest()
    manifiesto = {
        "fichero": str(ruta),
        "sha256": resumen,
        "filas": int(len(df)),
        "desde": str(df.index.min()),
        "hasta": str(df.index.max()),
        "descargado_utc": pd.Timestamp.utcnow().isoformat(),
        "proveedor": "tu_proveedor",
        "clave_api": "tu_clave_aqui",   # nunca la clave real en un manifiesto
    }
    with open(str(ruta) + ".json", "w", encoding="utf-8") as f:
        json.dump(manifiesto, f, ensure_ascii=False, indent=2)
    return resumen

Dos avisos prácticos. El primero: la suma se calcula sobre los bytes del fichero, no sobre el contenido lógico. Cambiar el formato de las fechas al exportar, el orden de las columnas o la codificación altera la suma sin tocar ni un valor, y produce un falso positivo. Por eso se ordenan filas y columnas antes de escribir y se fija el formato de salida: un control que salta por cosas inocuas deja de tomarse en serio justo antes de saltar por algo grave. El segundo: las instantáneas son de solo lectura. Si algo hay que corregir se crea una nueva, nunca se reescribe la vieja, porque hay resultados archivados apuntando a ella.

Sobre el volumen, que es la objeción habitual: una instantánea diaria de un panel de acciones ocupa mucho menos de lo que la gente imagina en formato columnar comprimido, y en todo caso no hace falta guardar una copia entera cada día. Basta con congelar la versión que usa cada resultado publicado y los cierres de mes, y dejar que el resto caduque. Lo que nunca se borra es lo que sostiene un número que has enseñado a alguien.

Registrar qué datos produjeron qué resultado

def registrar_ejecucion(nombre_backtest, sha_datos, metricas, ruta="registro.jsonl"):
    fila = {
        "cuando_utc": pd.Timestamp.utcnow().isoformat(),
        "backtest": nombre_backtest,
        "commit": subprocess.check_output(
            ["git", "rev-parse", "HEAD"], text=True).strip(),
        "sucio": bool(subprocess.check_output(
            ["git", "status", "--porcelain"], text=True).strip()),
        "sha_datos": sha_datos,
        "ventana": ["2015-01-01", "2024-12-31"],   # fijada, nunca "hoy"
        "metricas": metricas,
    }
    with open(ruta, "a", encoding="utf-8") as f:
        f.write(json.dumps(fila, ensure_ascii=False) + "\n")

La marca de árbol de trabajo sucio es la línea más útil de todo el registro. Un identificador de commit no significa nada si en el momento de ejecutar había cambios sin confirmar: creerás tener el código exacto y tendrás otro. Y la ventana de fechas se escribe explícita: un backtest cuyo final es la fecha de hoy nunca se reproduce, porque cada ejecución mira una muestra distinta.

La regla de los seis meses

Un resultado que no puedes reproducir dentro de seis meses no es un resultado. No es una exigencia burocrática ni un capricho de ingeniero ordenado: es lo que decide si puedes atribuir un cambio de rendimiento a la estrategia o al terreno que se ha movido bajo tus pies. Con instantáneas y registro, cuando el número cambia sabes exactamente qué cambió: la suma de verificación coincide y entonces el problema está en el código o en el entorno, o no coincide y entonces la fuente reescribió el pasado. Sin eso solo tienes una sospecha, y las sospechas se resuelven con opiniones.

Hay un beneficio menos obvio, y es el que más se agradece con el tiempo. Las instantáneas fechadas te dan gratis algo que de otro modo cuesta mucho montar: la foto de cómo se veían los datos en cada momento, revisiones incluidas. Con ellas puedes preguntarte si tu estrategia habría funcionado con la información tal y como estaba disponible aquel día, no con la versión corregida años después. Es una pregunta incómoda y casi siempre rebaja el resultado, que es justo la señal de que estabas midiendo mal.

Ejemplo trabajado: reconstruir la diferencia

Ese salto del 11,2% al 8,7% deja de ser un misterio en cinco minutos si guardaste lo debido. Recuperas la instantánea de enero, la ejecutas con el commit registrado y vuelve a salir 11,2%: el código está limpio. Cruzas ambas instantáneas y aparecen, por ejemplo, 6.400 cierres modificados en el histórico, casi todos concentrados en un puñado de valores que reformularon cuentas. Ahí la pregunta pasa a ser interesante: si tu estrategia solo funciona con la versión antigua de los datos, quizá lo que descubriste fue un error del proveedor y no una ineficiencia del mercado.

El resto de Quant500

Lo que estás aprendiendo, aplicado al índice todos los días:

La cartera del modelo, cada semana en tu correo

Cada sábado, su semana en un minuto: cómo fue, lo mejor y lo peor, y qué directivos compraron con su dinero. Y el día que el modelo se rehace, qué entra y qué sale. Es una simulación, no un consejo. Como mucho un correo a la semana, sin publicidad y sin cuenta; te das de baja con un clic. Lo hace una persona: Pol Gracia.

Cartera modelo con resultados simulados, no una recomendación personalizada. Tu correo se usa solo para esto: privacidad.

Detrás de la Academia y del resto de Quant500 hay una sola persona: Pol Gracia Serrahima.