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:
- Pide con solapamiento a propósito. Se relee el último día ya guardado, e incluso los tres o cinco últimos. Es barato y captura las correcciones recientes.
- Deduplica por clave de negocio. La clave es la pareja símbolo y fecha, no el orden de llegada.
- Reescribe la partición entera en vez de añadir un fichero suelto. Así el estado final depende del contenido, no del número de veces que lo has lanzado.
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:
- Reescribir con registro. Sustituyes el valor viejo por el nuevo y anotas en un fichero aparte qué cambió, cuándo y cuánto. El almacén queda limpio y pequeño, y tienes rastro para explicar por qué un backtest de hace tres meses ya no sale igual. Lo que no puedes es reproducirlo.
- Guardar la corrección como versión nueva. No borras nada: añades una fila con la misma pareja símbolo y fecha y una marca de descarga posterior. El almacén crece, pero puedes reconstruir cualquier estado pasado.
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é.