1. Calendarios, husos horarios y huecos: cuándo falta un dato y cuándo no existe
Nivel 4: Limpieza y validación
Un fichero de precios con menos filas de las que esperabas no está necesariamente roto. A veces le falta un dato que debería estar, y a veces contiene exactamente lo que tiene que contener porque ese día no hubo mercado. Las dos situaciones se ven igual en pantalla, una fila que no aparece, y se arreglan de formas opuestas. Confundirlas es el error más caro de toda la fase de limpieza.
Un dato que falta y un dato que no existe
Conviene separar cuatro casos que un valor ausente mezcla sin piedad:
- El dato falta. El mercado abrió, hubo negociación, y tu proveedor no te la ha entregado: su servicio se cayó, saltó un límite de peticiones, la descarga se cortó a mitad. El valor existe en el mundo y tú no lo tienes. Se puede volver a pedir.
- El dato no existe porque el mercado estaba cerrado. Fin de semana, festivo, sesión de medio día. No hay nada que recuperar y no hay nada que rellenar.
- El dato no existe porque el valor estaba suspendido. El mercado abrió, pero ese valor concreto no cotizó: subasta de volatilidad, pendiente de una noticia, suspendido por el regulador.
- El dato no existe porque la empresa aún no cotizaba, o porque ya dejó de hacerlo. Pedir el precio de una acción tres años antes de su salida a bolsa no es un hueco: es una pregunta sin sentido.
Los cuatro llegan a pandas como el mismo valor ausente. Si les aplicas la misma instrucción de relleno, acabas con un backtest que compra empresas que todavía no existían y con una estrategia que atraviesa una suspensión de cotización sin despeinarse, porque el precio se quedó amablemente congelado mientras el mundo real se hundía.
El calendario de mercado manda sobre tu fichero
La única forma de saber qué días debería haber datos es preguntárselo a un calendario de mercado, nunca deducirlo de los propios datos. Deducirlo de los datos es circular: si al proveedor le faltan tres sesiones, tú concluirás que esas tres sesiones no existieron.
import pandas as pd
import pandas_market_calendars as mcal
# El calendario manda: dice que dias DEBERIA haber sesion.
# XNYS es Nueva York; XMAD, Madrid; XETR, Fráncfort. No comparten festivos.
calendario = mcal.get_calendar("XNYS")
sesiones = calendario.schedule(start_date="2024-01-01", end_date="2024-12-31")
esperadas = pd.DatetimeIndex(sesiones.index).normalize()
precios = pd.read_csv("aapl.csv", parse_dates=["fecha"], index_col="fecha")
observadas = precios.index.normalize().unique()
# Huecos de verdad: dias de mercado sin fila. Esto es un fallo de la fuente.
huecos = esperadas.difference(observadas)
# Filas fantasma: dias con fila que el calendario dice que no existen.
# Casi siempre son husos horarios mal convertidos o festivos de otro mercado.
fantasmas = observadas.difference(esperadas)
print("sesiones esperadas:", len(esperadas))
print("huecos reales:", len(huecos), list(huecos[:5]))
print("filas fantasma:", len(fantasmas), list(fantasmas[:5]))Esa resta de índices en los dos sentidos vale más que cualquier inspección visual. Los huecos te dicen a quién reclamar; las filas fantasma te dicen que tu conversión de fechas está mal.
Husos horarios: por dónde se cuela el futuro
Una marca de tiempo sin zona horaria es un dato incompleto. No sabes si esa vela de las 09:30 es de horario de invierno o de verano, ni a qué instante universal corresponde. Y en cuanto mezclas dos mercados, esa ambigüedad deja de ser estética y se convierte en información del futuro.
El caso clásico: emparejas el cierre diario del IBEX 35 con el cierre diario del S&P 500 por la fecha del índice. Ambos llevan la misma fecha, así que parece simultáneo. No lo es: el cierre español ocurre a media tarde y el estadounidense varias horas después. Una señal que use la fila de hoy para operar mañana en Madrid está mirando un precio que, en el momento de decidir, todavía no se había formado. Y no es constante: entre los cambios de horario de verano de Europa y de Estados Unidos hay semanas al año en las que la diferencia cambia una hora.
# Localiza en la zona real del mercado y pasa TODO a hora universal.
intradia = pd.read_csv("spy_1m.csv", parse_dates=["ts"], index_col="ts")
intradia.index = intradia.index.tz_localize(
"America/New_York",
ambiguous="NaT", # hora repetida al retrasar el reloj en otoño
nonexistent="NaT", # hora que no llega a existir en primavera
)
intradia = intradia[intradia.index.notna()].tz_convert("UTC")
ibex = pd.read_csv("ibex_1m.csv", parse_dates=["ts"], index_col="ts")
ibex.index = ibex.index.tz_localize("Europe/Madrid").tz_convert("UTC")
# Para cada barra del IBEX, tomar SOLO informacion de Nueva York ya publicada.
cruce = pd.merge_asof(
ibex.sort_index(), intradia.sort_index(),
left_index=True, right_index=True,
direction="backward", suffixes=("_ibex", "_us"),
)Cuándo arrastrar el último valor conocido y cuándo es hacer trampa
Arrastrar el último precio conocido es legítimo cuando el activo seguía vivo, ese precio ya era público en la fecha a la que lo copias, y la fila queda marcada como arrastrada. Es trampa cuando tapa una suspensión, cuando propaga hacia atrás, o cuando nadie sabe después qué filas eran reales.
rejilla = pd.DatetimeIndex(
mcal.get_calendar("XMAD")
.schedule(start_date="2015-01-01", end_date="2024-12-31").index
).normalize()
panel = precios.reindex(rejilla) # ahora las ausencias son huecos de verdad
panel["cotizaba"] = panel["cierre"].notna()
# Arrastrar como MUCHO dos sesiones: mas tiempo sin precio no es un hueco
# tecnico, es que ese valor ha dejado de negociarse. Y eso hay que saberlo.
panel["cierre_relleno"] = panel["cierre"].ffill(limit=2)
panel["dato_arrastrado"] = (~panel["cotizaba"]) & panel["cierre_relleno"].notna()
# El volumen NO se arrastra jamas: sin sesion el volumen es cero, no el de ayer.
panel["volumen"] = panel["volumen"].fillna(0)Ejemplo trabajado: la estrategia que solo ganaba en las fechas raras
Supongamos un panel europeo y estadounidense con 2.600 sesiones. Al cruzarlo por fecha natural aparecen 22 días en los que un mercado abrió y el otro no. Si en esos días arrastras el cierre extranjero, la señal usa un precio de hace más de veinticuatro horas, pero fechado hoy. Un desglose por día suele revelar el patrón: si en esos 22 días la rentabilidad media por operación multiplica por tres la del resto de la muestra, no has descubierto un efecto de festivos, has descubierto tu propio relleno. Quita esos días y mira si la ventaja sobrevive. Casi nunca sobrevive.