Quant500 Academy

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:

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.

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.