Quant500 Academy

2. Descargas incrementales y correcciones tardías

Nivel 5: Guardar, actualizar y escalar

El almacén del módulo anterior está montado y tiene veinticinco años dentro. Mañana habrá una sesión más, y pasado otra. Volver a descargar el histórico completo cada día es absurdo y, con proveedores de pago, además es caro. Aquí empieza la parte del oficio que separa un proyecto que aguanta años de uno que se rompe el mes que te vas de vacaciones.

Idempotente significa que puedes ejecutarlo dos veces

Un proceso es idempotente cuando ejecutarlo dos veces deja el sistema exactamente igual que ejecutarlo una. No es un lujo académico: es la única forma de que el cron que falla a medias, el reintento manual del lunes por la mañana y la ejecución de prueba que lanzaste sin darte cuenta no te dejen la tabla con filas repetidas.

El patrón ingenuo es mirar la última fecha guardada, pedir desde el día siguiente y añadir un fichero al final. Falla por dos sitios a la vez: si el proceso se corta después de escribir y antes de registrar el avance, repites; y si el proveedor devuelve el mismo día con solapamiento, duplicas. La versión que aguanta hace tres cosas distintas:

import os
import pandas as pd
from pathlib import Path

ALMACEN = Path("almacen/precios")
CLAVE = os.environ.get("PROVEEDOR_API_KEY", "tu_clave_aqui")

def ultima_fecha(simbolo):
    """Ultima fecha ya guardada del simbolo, o None si no hay nada."""
    if not ALMACEN.exists():
        return None
    ya = pd.read_parquet(ALMACEN, columns=["simbolo", "fecha"],
                         filters=[("simbolo", "==", simbolo)])
    return None if ya.empty else ya["fecha"].max()

def actualizar(simbolo, descargar, solape_dias=5):
    desde = ultima_fecha(simbolo)
    if desde is not None:
        # Solapamos a proposito: si el proveedor corrigio estos dias, entra la version buena
        desde = desde - pd.Timedelta(days=solape_dias)

    nuevo = descargar(simbolo, desde=desde, clave=CLAVE)
    if nuevo.empty:
        return 0

    nuevo["descargado_en"] = pd.Timestamp.now(tz="UTC")
    nuevo["anio"] = nuevo["fecha"].dt.year

    for anio, trozo in nuevo.groupby("anio"):
        viejo = leer_particion(simbolo, anio)          # DataFrame vacio si no existe
        junto = pd.concat([viejo, trozo], ignore_index=True)
        junto = junto.sort_values("descargado_en")
        junto = junto.drop_duplicates(["simbolo", "fecha"], keep="last")
        escribir_particion(simbolo, anio, junto)       # reescribe la particion completa
    return len(nuevo)

El día que el proveedor corrige el pasado

Los proveedores corrigen. Un cierre provisional que se consolida horas después, un volumen que se revisa cuando se liquidan las operaciones fuera de mercado, un ajuste por dividendo que llega con retraso, un dato fundamental que se reexpresa. No es un fallo del proveedor: es cómo funcionan los mercados y la contabilidad.

Ante una corrección tienes exactamente dos políticas honestas, y la peor decisión es no elegir:

Lo que nunca vale es sobrescribir a ciegas y seguir. Ese es el camino corto hacia el momento en que relanzas un experimento, sale distinto, y no tienes forma de saber si cambió tu código o cambió el dato bajo tus pies.

Dos marcas de tiempo: cuándo pasó y cuándo lo supiste

De aquí sale la idea que ordena todo el módulo. Cada fila lleva dos fechas distintas que no hay que mezclar nunca: la fecha del dato, que es el día de mercado al que se refiere, y la fecha de descarga, que es el instante en que ese valor entró en tu disco.

Con las dos puedes responder a la única pregunta que importa para no engañarte: qué sabía yo el 15 de marzo. Filtras por fecha de descarga menor o igual a ese instante y, dentro de lo que queda, te quedas con la última versión de cada pareja símbolo y fecha. Lo que obtienes es la foto del almacén tal y como estaba ese día, correcciones posteriores incluidas y descartadas.

def vista_a_fecha(historial, conocido_hasta):
    """Que sabiamos en 'conocido_hasta', ignorando correcciones posteriores."""
    visible = historial[historial["descargado_en"] <= conocido_hasta]
    visible = visible.sort_values("descargado_en")
    return visible.drop_duplicates(["simbolo", "fecha"], keep="last")

hoy = vista_a_fecha(historial, pd.Timestamp.now(tz="UTC"))
marzo = vista_a_fecha(historial, pd.Timestamp("2026-03-15", tz="UTC"))

# Cuantas filas cambiaron de valor entre las dos fotos
cambios = hoy.merge(marzo, on=["simbolo", "fecha"], suffixes=("_hoy", "_marzo"))
print((cambios["cierre_hoy"] != cambios["cierre_marzo"]).sum())

Caso: cuánto cuesta de verdad guardar las versiones

El miedo habitual a no borrar nada es que la tabla explote. Ponle números en vez de intuición. Sobre los 3,1 millones de filas del módulo anterior, supón que el proveedor corrige el 0,3% de las filas al año, que es un orden de magnitud razonable para precios diarios de valores líquidos y que deberías medir con tu proveedor antes de darlo por bueno.

Eso son unas 9.300 filas nuevas al año. En cinco años, 46.500 filas: un 1,5% más de almacén a cambio de poder reconstruir cualquier foto pasada. La misma cuenta con datos fundamentales, que se reexpresan mucho más, da un porcentaje mayor pero sigue muy lejos de duplicar nada.

La conclusión práctica es incómoda para quien va con prisa: no guardar las versiones casi nunca se decide por espacio, se decide por pereza, y el coste no aparece hasta el día en que un resultado cambia y no sabes por qué.

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.