Lección 1.7.4 · 60 min

Multi-timeframe sin anticipación

Mezclar marcos temporales es la forma más fácil de meter el futuro en un backtest sin darse cuenta: una barra de las 10:00 que «conoce» el cierre del día convierte una regla mediocre en una excelente. Esta lección reproduce los tres errores clásicos con NOVA, mide cuánto inflan el resultado y fija la regla que los evita: cada dato se une por el instante en que termina su barra.

Fig. — NOVA en barras de 5 minutos, 2 a 4 de junio de 2025: el cierre diario que ve cada barra con la alineación correcta (el último conocido) y unido por fecha (el del propio día)
Lecciones del nivel 1
  1. 1.1 · Cómo funcionan los mercados

    1. 1.1.1 Participantes y centros de negociación
    2. 1.1.2 Sesiones, subastas, festivos y calendarios
    3. 1.1.3 Libro de órdenes: bid, ask, spread y profundidad
    4. 1.1.4 Tipos de órdenes
    5. 1.1.5 Costes, liquidación, apalancamiento y margen
  2. 1.2 · Clases de activos

    1. 1.2.1 Acciones: splits, dividendos y eventos corporativos
    2. 1.2.2 ETFs: réplica, tracking error, apalancados y UCITS
    3. 1.2.3 Futuros: especificaciones, margen, roll y contango
    4. 1.2.4 Forex: pares, pips, lotes, rollover y CFD
    5. 1.2.5 Opciones: call, put, valor intrínseco y valor temporal
    6. 1.2.6 Cripto: spot, perpetuos, funding y custodia
    7. 1.2.7 Renta fija y tipos de interés; CFDs y productos apalancados
  3. 1.3 · Fuentes de datos de mercado

    1. 1.3.1 Tipos de datos de mercado: EOD, barras, ticks, libro y referencia
    2. 1.3.2 Fuentes de datos gratuitas
    3. 1.3.3 APIs de datos retail y prosumer
    4. 1.3.4 Proveedores de datos profesionales
    5. 1.3.5 Datos de cripto y on-chain
    6. 1.3.6 Brókeres como fuente de datos
    7. 1.3.7 Licencias y términos de uso de datos
    8. 1.3.8 Cómo elegir proveedor de datos
  4. 1.4 · Ingeniería de datos financieros

    1. 1.4.1 Descarga por REST y websockets: paginación, límites y reintentos
    2. 1.4.2 Limpieza de datos: faltantes, ticks erróneos, huecos y zonas horarias
    3. 1.4.3 Ajustes por splits y dividendos: precios ajustados y sin ajustar
    4. 1.4.4 Universos sin sesgo de supervivencia: deslistados, fusiones y cambios de símbolo
    5. 1.4.5 Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio
    6. 1.4.6 Series continuas de futuros: reglas de roll, ajuste por diferencia y ajuste por ratio
    7. 1.4.7 Almacenamiento: CSV, Parquet, particionado, DuckDB, TimescaleDB y ArcticDB
    8. 1.4.8 Pipeline reproducible: descarga, validación, actualización incremental y tests de calidad
  5. 1.5 · Datos fundamentales y macro

    1. 1.5.1 Estados financieros, ratios, XBRL y SEC EDGAR
    2. 1.5.2 Datos macro, calendarios económicos, estimaciones y transcripciones
  6. 1.6 · Análisis exploratorio financiero

    1. 1.6.1 Rentabilidades, volatilidad, correlaciones móviles y drawdown
    2. 1.6.2 Hechos estilizados: colas gruesas, agrupamiento, autocorrelación y apalancamiento
    3. 1.6.3 Estacionalidad: día de la semana, mes, cambio de mes y sesiones intradía
  7. 1.7 · Análisis técnico programado

    1. 1.7.1 Indicadores técnicos en Python
    2. 1.7.2 Implementación propia frente a TA-Lib: validar resultados
    3. 1.7.3 Patrones de velas y soportes y resistencias
    4. 1.7.4 Multi-timeframe sin anticipación

El problema: dos relojes en la misma tabla

Un sistema multi-timeframe toma decisiones en un marco rápido (barras de 5 minutos) con información de uno lento (el diario, el semanal): «compra en 5 minutos solo si la tendencia diaria es alcista». La tentación es unir las dos tablas por la fecha. El problema es que la fila diaria del 4 de junio lleva el cierre del 4 de junio, que no se conoce hasta las 16:00, y al unir por fecha aparece en todas las barras del 4 de junio, también en la de las 9:30.

La figura de la cabecera lo dibuja con tres sesiones de NOVA. La línea gris es el precio de 5 minutos. El escalón correcto es el último cierre diario conocido en cada barra: el de ayer hasta las 16:00, el de hoy después. El erróneo es el cierre del día unido por fecha: a las 9:30 del 4 de junio ya «sabe» que NOVA cerrará a 55,14, un 2,7 % por encima de la apertura.

La regla que evita el problema es una sola:

Barra lentaHora de cierremerge_asof atrásBarra rápidaHora de cierreDecisión
Fuente Mermaid
flowchart LR
  L[Barra lenta] --> F[Hora de cierre]
  F --> A[merge_asof atrás]
  R[Barra rápida] --> C[Hora de cierre]
  C --> A
  A --> D[Decisión]

Cada dato se une por el instante en que termina su barra, y una decisión tomada en el instante tt solo ve los datos que terminaron antes de tt. La fecha, la etiqueta o el nombre de la barra no cuentan.

Barras de 5 minutos y barras diarias

Las barras de 5 minutos de NOVA (nova_barras_5min.parquet) cubren las 502 sesiones de la NYSE de 2024 y 2025. Su marca ts es el inicio de la barra, en UTC. Para trabajar hacen falta tres columnas más: la hora de Nueva York, la sesión a la que pertenece la barra y la hora a la que termina:

IN [01]python
import numpy as np
import pandas as pd

b = pd.read_parquet("nova_barras_5min.parquet")
b["ny"] = b["ts"].dt.tz_convert("America/New_York")
b["sesion"] = b["ny"].dt.normalize().dt.tz_localize(None)
b["t_cierre"] = b["ts"] + pd.Timedelta("5min")
print(len(b), "barras,", b["sesion"].nunique(), "sesiones")
b[["ts", "ny", "t_cierre", "cierre"]].head(3)
OUT [01]Barras de 5 minutos con su hora de cierre
38940 barras, 502 sesiones
                         ts                        ny                  t_cierre  cierre
0 2024-01-02 14:30:00+00:00 2024-01-02 09:30:00-05:00 2024-01-02 14:35:00+00:00   43.46
1 2024-01-02 14:35:00+00:00 2024-01-02 09:35:00-05:00 2024-01-02 14:40:00+00:00   43.28
2 2024-01-02 14:40:00+00:00 2024-01-02 09:40:00-05:00 2024-01-02 14:45:00+00:00   43.24

Las barras diarias se construyen agregando por sesión. La columna fin guarda la hora de cierre de la última barra de cada sesión: el instante en que el dato diario existe.

IN [02]python
d = b.groupby("sesion").agg(o=("apertura", "first"),
                            h=("maximo", "max"), l=("minimo", "min"),
                            c=("cierre", "last"), v=("volumen", "sum"),
                            fin=("t_cierre", "last"))
csv = pd.read_csv("nova_diario_sin_ajustar.csv", parse_dates=["fecha"],
                  index_col="fecha").loc["2024":"2025"]
igual = (np.allclose(d["c"], csv["cierre"])
         and (d["v"] == csv["volumen"]).all())
print("Igual que el CSV diario:", igual)
print(d["fin"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
      .value_counts())
OUT [02]Barras diarias desde las de 5 minutos
Igual que el CSV diario: True
fin
16:00    496
13:00      6
Name: count, dtype: int64

Las barras diarias reconstruidas coinciden con el CSV diario. Y fin ya enseña por qué la fecha no basta: 496 sesiones terminan a las 16:00 de Nueva York, pero seis medias sesiones (3 de julio, viernes después de Acción de Gracias, 24 de diciembre) terminan a las 13:00. En UTC, además, las 16:00 son las 21:00 en invierno y las 20:00 en verano.

Error 1: el indicador diario unido por fecha

La regla de ejemplo es un filtro de tendencia: a las 10:00 (cierre de la barra de las 9:55), si el cierre diario está por encima de su SMA de 20 sesiones, comprar y vender al cierre de la sesión. La versión errónea une por fecha; la correcta, con merge_asof por hora de disponibilidad, sin coincidencias exactas:

IN [03]python
d["sma20"] = d["c"].rolling(20).mean()
diario = d[["c", "sma20"]].assign(disponible=d["fin"])

# MAL: cada barra ve la fila diaria de su propia fecha
por_fecha = b.merge(diario, left_on="sesion", right_index=True)
# BIEN: cada barra ve la última fila diaria que terminó antes
por_hora = pd.merge_asof(b.sort_values("t_cierre"),
                         diario.sort_values("disponible"),
                         left_on="t_cierre", right_on="disponible",
                         allow_exact_matches=False)

def filtro_diario(barras):
    a_las_10 = barras[barras["ny"].dt.strftime("%H:%M") == "09:55"]
    cierre_dia = a_las_10["sesion"].map(d["c"])
    r = cierre_dia / a_las_10["cierre"] - 1
    activo = a_las_10["c"] > a_las_10["sma20"]
    return {"dias_activo_pct": 100 * activo.mean(),
            "anual_pct": 100 * r.where(activo, 0.0).mean() * 252,
            "aciertos_pct": 100 * (r[activo] > 0).mean()}

pd.DataFrame({"por fecha (mal)": filtro_diario(por_fecha),
              "merge_asof (bien)": filtro_diario(por_hora)}).T.round(1)
OUT [03]Filtro diario: por fecha frente a merge_asof
                   dias_activo_pct  anual_pct  aciertos_pct
por fecha (mal)               60.2       40.5          59.9
merge_asof (bien)             60.0        4.4          52.8

Con el error, el filtro está activo casi los mismos días (60,2 % frente a 60,0 %), pero los elige mucho mejor: un 40,5 % anual y un 59,9 % de aciertos, frente a un 4,4 % y un 52,8 % con la alineación correcta. La diferencia es información del futuro: en los días en que NOVA cierra fuerte, el cierre de hoy supera la media, el filtro se activa y la operación de 10:00 a 16:00 gana. Nada de eso se podía saber a las 10:00.

Lo que ve cada versión en la primera barra de una sesión:

IN [04]python
primera = lambda x: x[x["ny"] == pd.Timestamp("2025-06-04 09:30",
                                              tz="America/New_York")]
pd.concat([primera(por_fecha).assign(union="por fecha"),
           primera(por_hora).assign(union="merge_asof")]
          )[["union", "ny", "cierre", "c", "disponible"]]
OUT [04]La barra de las 9:30 del 4 de junio de 2025
            union                        ny  cierre      c                disponible
27660   por fecha 2025-06-04 09:30:00-04:00   54.24  55.14 2025-06-04 20:00:00+00:00
27660  merge_asof 2025-06-04 09:30:00-04:00   54.24  53.00 2025-06-03 20:00:00+00:00

La unión por fecha le da a la barra de las 9:30 un cierre diario de 55,14 que no estará disponible hasta las 20:00 UTC de ese día. merge_asof le da el del 3 de junio, 53,00, disponible desde la víspera.

La alternativa clásica es desplazar la tabla diaria una sesión antes de unir por fecha: d.shift(1). Da lo mismo… si las sesiones son contiguas y no falta ninguna:

IN [05]python
desplazado = b.merge(diario[["c", "sma20"]].shift(1),
                     left_on="sesion", right_index=True)
iguales = np.allclose(desplazado.sort_values("t_cierre")["c"],
                      por_hora["c"], equal_nan=True)
print("Mismos valores que merge_asof:", iguales)
OUT [05]shift(1) + fecha frente a merge_asof
Mismos valores que merge_asof: True

Coinciden. Pero shift(1) desplaza filas, no tiempo: si falta una sesión en los datos diarios (un día que el proveedor no envió, lección 1.4.2), la barra del día siguiente recibe el dato de hace dos sesiones sin avisar; y si el marco lento no es una partición limpia del rápido (semanas con festivo, barras de 4 horas en un mercado con pausas), no hay «fila anterior» que tenga sentido. merge_asof sobre la hora de disponibilidad funciona en todos los casos, y deja escrita la suposición.

Error 2: la etiqueta de resample

Para pasar de barras de 5 minutos a horarias, resample necesita dos decisiones: qué borde del intervalo es cerrado (closed) y con qué borde se etiqueta (label). Por defecto, en frecuencias de minutos y horas, los dos son 'left': el intervalo [9:30,10:30)[9{:}30, 10{:}30) contiene las barras con marca de 9:30 a 10:25 y se etiqueta 9:30. Con marcas de inicio (las de NOVA), es lo correcto. offset="30min" alinea los intervalos con la apertura:

IN [06]python
s = b.set_index("ts")
agg = {"apertura": "first", "maximo": "max", "minimo": "min",
       "cierre": "last", "volumen": "sum"}
izq = s.resample("1h", offset="30min").agg(agg)
der = s.resample("1h", offset="30min", closed="right").agg(agg)
izq, der = izq.dropna(subset=["cierre"]), der.dropna(subset=["cierre"])

def barras_por_hora(**kw):
    n = s["cierre"].resample("1h", offset="30min", **kw).count()
    return n[n > 0].value_counts().to_dict()

print("closed='left': ", barras_por_hora())
print("closed='right':", barras_por_hora(closed="right"))
der.head(3)
OUT [06]Barras horarias con closed izquierdo y derecho
closed='left':  {12: 2994, 6: 502}
closed='right': {12: 2994, 1: 502, 5: 502}
                           apertura  maximo  minimo  cierre  volumen
ts
2024-01-02 13:30:00+00:00     43.36   43.53   43.36   43.46   298380
2024-01-02 14:30:00+00:00     43.46   43.58   43.19   43.50  1215459
2024-01-02 15:30:00+00:00     43.50   43.55   43.36   43.45   749578

Con closed='left', cada sesión tiene seis horas de 12 barras y una media hora final de 6 (15:30 a 16:00). Con closed='right', la barra de las 9:30 queda sola en el intervalo (8:30,9:30](8{:}30, 9{:}30]: una «hora» de una barra, con la apertura de la sesión. Todas las demás se desplazan cinco minutos y la última hora queda en 5 barras. Para barras marcadas al final (algunos proveedores lo hacen: la barra de 9:30 a 9:35 se marca 9:35) la combinación correcta es justo la contraria, closed='right' y label='right'.

Fig. — Qué barras de 5 minutos (marcadas por su inicio) entran en cada barra horaria según closed y label en resample, y dónde queda la etiqueta (triángulos)
síno¿Marca de inicio?closed leftclosed rightDisponible: fin
Fuente Mermaid
flowchart TB
  Q{¿Marca de inicio?}
  Q -- sí --> L[closed left]
  Q -- no --> R[closed right]
  L --> D[Disponible: fin]
  R --> D

label no cambia qué barras entran en cada intervalo, pero sí dónde se une la barra horaria si se une por su etiqueta. Con label='left', la hora etiquetada 10:30 contiene datos hasta las 11:30. Unida por su etiqueta a las barras de 5 minutos (un join y un ffill, el atajo habitual), la barra de las 10:30 ya conoce el cierre de las 11:30:

IN [07]python
por_hora_s = s["cierre"].resample("1h", offset="30min")
hora_actual = por_hora_s.transform("last")

def regla_horaria(c_hora, nombre):
    """Compra si el cierre horario visto es mayor que el precio; vende
    al cierre de la hora en curso."""
    compra = c_hora > s["cierre"]
    r = (hora_actual / s["cierre"] - 1)[compra]
    return {"regla": nombre, "operaciones": int(compra.sum()),
            "aciertos_pct": 100 * (r > 0).mean(),
            "media_pb": 1e4 * r.mean()}

# MAL: la barra horaria, en su etiqueta de inicio, y ffill
mal = izq["cierre"].reindex(s.index).ffill()
# BIEN: disponible al terminar su última barra de 5 minutos
horas = izq[["cierre"]].assign(
    disponible=s["t_cierre"].resample("1h", offset="30min").last())
bien = pd.merge_asof(b.sort_values("t_cierre"),
                     horas.sort_values("disponible"),
                     left_on="t_cierre", right_on="disponible",
                     allow_exact_matches=False, suffixes=("", "_hora"))
bien = bien.set_index("ts")["cierre_hora"]
pd.DataFrame([regla_horaria(mal, "etiqueta + ffill (mal)"),
              regla_horaria(bien, "merge_asof (bien)")]).round(2)
OUT [07]El cierre horario unido por su etiqueta
                    regla  operaciones  aciertos_pct  media_pb
0  etiqueta + ffill (mal)        17359        100.00     32.22
1       merge_asof (bien)        18803         45.22     -0.33

Con el atajo, la regla gana en el 100 % de sus 17.359 operaciones, 32 puntos básicos de media cada una: compra cuando «sabe» que la hora cerrará más arriba. Con la hora disponible de verdad (la última terminada), la misma regla gana en el 45,2 % y pierde 0,33 puntos básicos de media, antes de costes. La regla no ha cambiado; solo cuándo se conoce el dato.

Error 3: semanas con W-FRI

Las barras semanales desde las diarias se construyen con resample("W-FRI"): semanas que terminan en viernes, etiquetadas con el viernes y, por defecto en frecuencias semanales, closed='right' y label='right'. Dos trampas aparecen enseguida: el viernes de la etiqueta puede no ser una sesión, y la última semana puede no haber terminado.

IN [08]python
semanal = d.resample("W-FRI").agg({"o": "first", "h": "max",
                                   "l": "min", "c": "last"})
sin_sesion = [t.date() for t in semanal.index if t not in d.index]
print(len(semanal), "semanas; etiquetas que no son sesión:", sin_sesion)
perdidas = semanal["c"].reindex(d.index).notna().sum()
print("Semanas que sobreviven a reindex sobre sesiones:", perdidas)
OUT [08]Semanas W-FRI: etiquetas sin sesión
105 semanas; etiquetas que no son sesión: [datetime.date(2024, 3, 29), datetime.date(2025, 4, 18), datetime.date(2025, 7, 4), datetime.date(2026, 1, 2)]
Semanas que sobreviven a reindex sobre sesiones: 101

De las 105 semanas, cuatro tienen la etiqueta en un día sin sesión: el Viernes Santo de 2024 (29 de marzo) y de 2025 (18 de abril), el 4 de julio de 2025 (viernes festivo) y el 2 de enero de 2026, la etiqueta de la última semana, que en los datos solo tiene tres sesiones (29 a 31 de diciembre). reindex sobre las sesiones las borra en silencio, y un ffill posterior arrastra la semana anterior: un retraso de una semana que nadie ve.

El error grave es el contrario: unir cada día con su semana, por ejemplo con to_period("W-FRI"). El lunes ve el cierre del viernes siguiente:

IN [09]python
dias = d.assign(semana=d.index.to_period("W-FRI"))
cierre_sem = semanal["c"].copy()
cierre_sem.index = cierre_sem.index.to_period("W-FRI")
dias["c_sem_mal"] = dias["semana"].map(cierre_sem)

fin_semana = d["fin"].resample("W-FRI").last()
semanas = semanal[["c"]].assign(disponible=fin_semana)
alineado = pd.merge_asof(d.reset_index().sort_values("fin"),
                         semanas.rename(columns={"c": "c_sem"})
                         .sort_values("disponible"),
                         left_on="fin", right_on="disponible",
                         allow_exact_matches=False)
dias["c_sem_bien"] = alineado.set_index("sesion")["c_sem"]

def regla_semanal(c_sem):
    compra = c_sem > dias["c"]
    # salida: el cierre de la semana en curso
    r = (dias["c_sem_mal"] / dias["c"] - 1)[compra]
    return {"operaciones": int(compra.sum()),
            "aciertos_pct": 100 * (r > 0).mean(),
            "media_pb": 1e4 * r.mean()}

pd.DataFrame({"por periodo (mal)": regla_semanal(dias["c_sem_mal"]),
              "merge_asof (bien)": regla_semanal(dias["c_sem_bien"])}
             ).T.round(1)
OUT [09]El cierre semanal unido por periodo
                   operaciones  aciertos_pct  media_pb
por periodo (mal)        209.0         100.0     229.3
merge_asof (bien)        216.0          40.7      14.5

La regla «compra al cierre si el cierre semanal es mayor; vende al cierre de la semana» acierta en las 209 operaciones con el cierre de la propia semana y gana 229 puntos básicos de media por operación. Con la última semana terminada (merge_asof por la hora real de cierre de su última sesión, que en Viernes Santo es el jueves a las 16:00), acierta en el 40,7 % de 216 operaciones, con una media de 14,5 puntos básicos que, con operaciones solapadas y una acción que sube en el periodo, no distingue nada del ruido. El detalle de las semanas con festivo lo resuelve solo: la columna disponible es la del jueves, y merge_asof no necesita que exista un viernes.

IN [10]python
alineado.set_index("sesion").loc["2024-03-27":"2024-04-02",
                                 ["c", "c_sem", "disponible"]]
OUT [10]Semana del Viernes Santo de 2024
                c  c_sem                disponible
sesion
2024-03-27  51.44  50.50 2024-03-22 20:00:00+00:00
2024-03-28  51.34  50.50 2024-03-22 20:00:00+00:00
2024-04-01  51.77  51.34 2024-03-28 20:00:00+00:00
2024-04-02  51.56  51.34 2024-03-28 20:00:00+00:00

El jueves 28 de marzo todavía ve la semana anterior (cierre 50,50, disponible el viernes 22): con allow_exact_matches=False, una semana no está disponible en el mismo instante en que termina. El lunes 1 de abril ya ve la semana del Viernes Santo, cerrada el jueves a 51,34.

Comprobar la alineación

Como en la lección 1.7.1, la mejor defensa es una comprobación que falle sola. Con la columna de disponibilidad a la vista, la prueba es una línea: ningún dato puede estar disponible después de la decisión que lo usa. Y la prueba de truncado vale también aquí: los valores alineados hasta un instante no pueden cambiar si se cortan los datos en ese instante.

IN [11]python
def test_disponible_antes_de_decidir():
    assert (por_hora["disponible"].dropna()
            < por_hora.loc[por_hora["disponible"].notna(), "t_cierre"]
            ).all()

def test_truncado():
    corte = pd.Timestamp("2025-06-04 15:00", tz="UTC")
    parcial = b[b["t_cierre"] <= corte]
    d_parcial = parcial.groupby("sesion").agg(
        c=("cierre", "last"), fin=("t_cierre", "last"))
    # solo las sesiones que ya habían cerrado en el corte
    d_parcial = d_parcial[d_parcial["fin"] <= corte]
    d_parcial = d_parcial.rename(columns={"fin": "disponible"})
    unido = pd.merge_asof(parcial.sort_values("t_cierre"), d_parcial,
                          left_on="t_cierre", right_on="disponible",
                          allow_exact_matches=False)
    completo = por_hora[por_hora["t_cierre"] <= corte]
    np.testing.assert_allclose(unido["c"], completo["c"],
                               equal_nan=True)

def test_union_por_fecha_falla():
    try:
        assert (por_fecha["disponible"] < por_fecha["t_cierre"]).all()
    except AssertionError:
        return
    raise AssertionError("la unión por fecha debería fallar")

for t in [test_disponible_antes_de_decidir, test_truncado,
          test_union_por_fecha_falla]:
    t()
    print("PASSED", t.__name__)
OUT [11]Tests de alineación
PASSED test_disponible_antes_de_decidir
PASSED test_truncado
PASSED test_union_por_fecha_falla

El tercer test es un test del propio test: comprueba que la condición detecta la unión por fecha. Un test que no puede fallar no protege de nada.

Resumen de las convenciones

SituaciónError típicoForma correcta
Indicador diario en barras intradíamerge por fechamerge_asof por hora de cierre de la sesión, o shift(1) si las sesiones son contiguas
Barras con marca de inicio a horasclosed='right'closed='left' (por defecto) y offset para alinear con la apertura
Barras con marca de fin a horasclosed='left'closed='right', label='right'
Barra lenta unida por su etiquetajoin + ffill por etiquetaColumna de disponibilidad = cierre de su última barra
Semanasto_period('W-FRI') o reindex sobre sesionesmerge_asof por el cierre de la última sesión de la semana
Última barra del marco lentoUsarla incompletaDescartarla hasta que termine
Medias sesiones y horario de veranoHoras de cierre fijas en UTCHora de cierre real de cada sesión
Fig. — Operaciones ganadoras de tres reglas multi-timeframe sobre NOVA, 2024-2025, con la alineación errónea (naranja) y la correcta (gris)

La figura junta los tres errores: con el dato del futuro, la proporción de operaciones ganadoras salta del entorno del 50 % a valores imposibles. Un backtest que acierta casi siempre no es una buena estrategia; casi siempre es una alineación mal hecha.

Ejercicios

  1. Añade a las barras de 5 minutos el ATR diario de 14 sesiones (lección 1.7.1) correctamente alineado. ¿Qué ATR ve la primera barra del 4 de junio de 2025 y de qué sesión es?
Solución
IN [12]python
c_ant = d["c"].shift(1)
tr = pd.concat([d["h"] - d["l"], (d["h"] - c_ant).abs(),
                (d["l"] - c_ant).abs()], axis=1).max(axis=1)
d["atr14"] = tr.ewm(alpha=1 / 14, adjust=False, min_periods=14).mean()
atr_diario = d[["atr14"]].assign(disponible=d["fin"],
                                 sesion_atr=d.index)
con_atr = pd.merge_asof(b.sort_values("t_cierre"),
                        atr_diario.sort_values("disponible"),
                        left_on="t_cierre", right_on="disponible",
                        allow_exact_matches=False)
primera(con_atr)[["ny", "atr14", "sesion_atr"]].round({"atr14": 3})
OUT [12]ATR diario en barras de 5 minutos
                             ny  atr14 sesion_atr
27660 2025-06-04 09:30:00-04:00  2.209 2025-06-03

La barra de las 9:30 del 4 de junio ve el ATR calculado con la sesión del 3 de junio, la última que había terminado. El mismo patrón sirve para cualquier columna diaria: se añade disponible y se une con merge_asof.

  1. Comprueba que la columna fin recoge las medias sesiones: lista las sesiones de 2024 y 2025 que terminan a las 13:00 de Nueva York y, para una de ellas, qué dato diario ve la primera barra de la sesión siguiente.
Solución
IN [13]python
hora_fin = (d["fin"].dt.tz_convert("America/New_York")
            .dt.strftime("%H:%M"))
medias = d.index[hora_fin == "13:00"]
print([f.date().isoformat() for f in medias])
siguiente = por_hora[por_hora["sesion"] == "2024-07-05"].iloc[0]
print(siguiente["ny"], "ve el cierre", siguiente["c"],
      "disponible", siguiente["disponible"])
OUT [13]Medias sesiones
['2024-07-03', '2024-11-29', '2024-12-24', '2025-07-03', '2025-11-28', '2025-12-24']
2024-07-05 09:30:00-04:00 ve el cierre 68.25 disponible 2024-07-03 17:00:00+00:00

Las seis medias sesiones de la NYSE en el periodo están ahí. La sesión del 5 de julio de 2024 (tras el festivo del 4) ve el cierre del 3 de julio, disponible a las 17:00 UTC (13:00 de Nueva York). Una regla que supusiera «el dato diario está a las 20:00 o 21:00 UTC» se equivocaría en esos días; la columna disponible no.

  1. Una regla intradía usa la apertura del día (la de la subasta de las 9:30) desde la barra de las 9:30. ¿Es anticipación?
Solución

No, si se usa a partir del instante en que existe. La apertura oficial es el precio de la subasta de apertura, que se fija a las 9:30:00 (lección 1.1.2); cualquier decisión posterior puede usarla. Lo que no puede usar la barra de las 9:30 es su propio cierre (9:35), ni su máximo o su mínimo. La forma de no equivocarse es la misma de toda la lección: darle a la apertura su columna de disponibilidad (las 9:30:00 de cada sesión, 13:30 o 14:30 UTC según el horario de verano) y unirla con merge_asof como cualquier otro dato.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Unes la SMA de 20 sesiones diaria a las barras de 5 minutos con merge por la fecha de la sesión. ¿Qué valor ve la barra de las 10:00?

    • ALa SMA calculada con el cierre de ayer
    • BLa SMA calculada con el cierre de hoy, que no se conoce hasta las 16:00
    • CUn valor vacío
    • DLa SMA de la semana
    Ver respuesta

    Respuesta B. La SMA calculada con el cierre de hoy, que no se conoce hasta las 16:00

    La fila diaria de la fecha t lleva el cierre de t. Unida por fecha, aparece en todas las barras de t, también en las de la mañana. En NOVA, un filtro de tendencia así pasa de un 4,4 % a un 40,5 % anual.

  2. Las barras de 5 minutos de tu proveedor llevan la marca de tiempo del **inicio** de la barra. ¿Qué resample da barras horarias que empiezan a las 9:30 y contienen las doce de 5 minutos de esa hora?

    • Aresample('1h', closed='right')
    • Bresample('1h', offset='30min'), con closed y label a la izquierda (los valores por defecto)
    • Cresample('1h', label='right') sin offset
    • DCualquiera: dan lo mismo
    Ver respuesta

    Respuesta B. resample('1h', offset='30min'), con closed y label a la izquierda (los valores por defecto)

    Con marcas de inicio, la barra de las 9:30 cubre de 9:30 a 9:35 y pertenece al intervalo [9:30, 10:30). Es closed='left'. Con closed='right', la barra de las 9:30 queda sola en el intervalo anterior y las demás se desplazan cinco minutos.

  3. Una barra horaria etiquetada a las 10:30 con label='left' contiene datos hasta las 11:30. ¿Cuándo puede usarse su cierre?

    • AA las 10:30
    • BA las 11:30, cuando termina la última barra que contiene
    • CAl cierre de la sesión
    • DAl día siguiente
    Ver respuesta

    Respuesta B. A las 11:30, cuando termina la última barra que contiene

    La etiqueta solo es un nombre. El dato está disponible cuando termina su última barra. Unirla por su etiqueta a barras de 5 minutos da a las de 10:30 a 11:25 el cierre de las 11:30: en NOVA, una regla de «compra si el cierre de la hora es mayor» gana en el 100 % de las operaciones.

  4. Reindexas la serie semanal (resample('W-FRI')) sobre las sesiones diarias de 2024-2025 y rellenas con ffill. ¿Qué falla?

    • ANada
    • BLas semanas cuyo viernes es festivo (Viernes Santo, 4 de julio de 2025) tienen la etiqueta en un día sin sesión y desaparecen al reindexar, y la última semana está incompleta
    • CEl volumen se duplica
    • DLas semanas empiezan en domingo
    Ver respuesta

    Respuesta B. Las semanas cuyo viernes es festivo (Viernes Santo, 4 de julio de 2025) tienen la etiqueta en un día sin sesión y desaparecen al reindexar, y la última semana está incompleta

    W-FRI etiqueta cada semana con su viernes, haya sesión o no. Al reindexar sobre sesiones, las tres semanas con viernes festivo y la última (etiquetada el 2 de enero de 2026) se pierden. merge_asof por la hora real de cierre de la semana no tiene ese problema.

  5. ¿Cuál es la regla general para combinar dos marcos temporales sin anticipación?

    • AUsar siempre ffill
    • BUnir cada dato del marco lento por el instante en que termina su barra, y que solo lo vean las decisiones tomadas después de ese instante
    • CRedondear las marcas de tiempo a la hora
    • DCalcular todo en la misma frecuencia
    Ver respuesta

    Respuesta B. Unir cada dato del marco lento por el instante en que termina su barra, y que solo lo vean las decisiones tomadas después de ese instante

    La fecha o la etiqueta de una barra no dicen cuándo se conoce. El instante de cierre sí. merge_asof(…, direction='backward', allow_exact_matches=False) sobre esa columna es la regla, en cualquier combinación de frecuencias.

Ideas clave

  • Un dato de un marco temporal lento se conoce cuando termina su barra, no en su fecha ni en su etiqueta. Esa hora de disponibilidad es la clave de la unión.
  • merge_asof sobre la hora de cierre, hacia atrás y sin coincidencias exactas, alinea cualquier par de frecuencias, también con medias sesiones y huecos; shift(1) y la fecha bastan solo si las sesiones son contiguas y completas.
  • En resample, closed y label dependen de cómo marca el proveedor sus barras: con marcas de inicio, closed='left'; la etiqueta, a la izquierda o a la derecha, no cambia los datos, pero sí dónde se une la barra.
  • Los tres errores clásicos con NOVA: filtro diario por fecha (40,5 % anual frente a 4,4 %), cierre horario por su etiqueta (ganan el 100 % de las operaciones) y cierre semanal por periodo (todas ganan).
  • W-FRI etiqueta cada semana con su viernes aunque sea festivo, y la última semana puede estar incompleta: no se reindexa sobre sesiones, se une por la hora real de cierre.

Recursos