---
title: "1.7.4 Multi-timeframe sin look-ahead"
description: "Combinar señales diarias, horarias y semanales con barras de 5 minutos sin usar el futuro: merge_asof por hora de cierre, shift, label y closed, y W-FRI."
url: "https://pythonparatrading.com/curso-python-trading/nivel-1/multi-timeframe-sin-look-ahead/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-1/multi-timeframe-sin-look-ahead.png"
---

# 1.7.4 · 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.

- Curso: [Nivel 1: Datos financieros, mercados y análisis](https://pythonparatrading.com/curso-python-trading/nivel-1/)
- Módulo: 1.7 · Análisis técnico programado
- Duración: 60 min
- Requisitos: [0.5.3 · Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles](https://pythonparatrading.com/curso-python-trading/nivel-0/pandas-groupby-merge-resample-y-rolling/), [1.1.1 · Participantes y centros de negociación](https://pythonparatrading.com/curso-python-trading/nivel-1/participantes-y-centros-de-negociacion/), [1.7.1 · Indicadores técnicos en Python](https://pythonparatrading.com/curso-python-trading/nivel-1/indicadores-tecnicos-en-python/)

## Al terminar esta lección

- Explicar por qué unir por fecha un indicador diario a barras intradía usa el futuro, y medir cuánto infla el resultado.
- Alinear marcos temporales con `merge_asof` sobre la hora de cierre de cada barra, y con `shift(1)` cuando las sesiones son contiguas.
- Elegir `closed` y `label` en `resample` según cómo estén marcadas las barras (inicio o fin) y reconocer las trampas de cada combinación.
- Construir barras semanales con `W-FRI` sin perder semanas con festivo en viernes ni usar la semana en curso.

## 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:

```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 $t$ solo ve los
datos que terminaron antes de $t$. 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**:

```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)
```
```output
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.

```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())
```
```output
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:

```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)
```
```output
                   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:

```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"]]
```
```output
            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:

```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)
```
```output
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)$ 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:

```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)
```
```output
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]$: 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).* Cuatro filas sobre la franja de 9:30 a 11:30 de Nueva York. Arriba, 24 barras de 5 minutos. Con closed y label a la izquierda, dos horas de 12 barras, de 9:30 a 10:30 y de 10:30 a 11:30, etiquetadas 9:30 y 10:30. Con closed a la izquierda y label a la derecha, las mismas horas etiquetadas 10:30 y 11:30, al final. Con closed y label a la derecha, la barra de las 9:30 queda sola en un intervalo, destacado, y las horas siguientes empiezan cinco minutos tarde, de 9:35 a 10:35.

```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:

```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)
```
```output
                    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.

```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)
```
```output
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:

```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)
```
```output
                   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.

```python
alineado.set_index("sesion").loc["2024-03-27":"2024-04-02",
                                 ["c", "c_sem", "disponible"]]
```
```output
                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.

```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__)
```
```output
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ón | Error típico | Forma correcta |
|---|---|---|
| Indicador diario en barras intradía | `merge` por fecha | `merge_asof` por hora de cierre de la sesión, o `shift(1)` si las sesiones son contiguas |
| Barras con marca de inicio a horas | `closed='right'` | `closed='left'` (por defecto) y `offset` para alinear con la apertura |
| Barras con marca de fin a horas | `closed='left'` | `closed='right'`, `label='right'` |
| Barra lenta unida por su etiqueta | `join` + `ffill` por etiqueta | Columna de disponibilidad = cierre de su última barra |
| Semanas | `to_period('W-FRI')` o `reindex` sobre sesiones | `merge_asof` por el cierre de la última sesión de la semana |
| Última barra del marco lento | Usarla incompleta | Descartarla hasta que termine |
| Medias sesiones y horario de verano | Horas de cierre fijas en UTC | Hora 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).* Barras horizontales por parejas con el porcentaje de operaciones ganadoras y una línea en el 50 %. Filtro diario: 59,9 % unido por fecha frente a 52,8 % con merge_asof. Cierre horario unido por su etiqueta: 100 %, destacado, frente a 45,2 %. Cierre semanal unido por periodo: 100 % frente a 40,7 %.

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.**

```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})
```
```output
                             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`.

2. 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.**

```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"])
```
```output
['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.

3. 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.

## 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?

   - A. La SMA calculada con el cierre de ayer
   - B. La SMA calculada con el cierre de hoy, que no se conoce hasta las 16:00
   - C. Un valor vacío
   - D. La SMA de la semana

   *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?

   - A. `resample('1h', closed='right')`
   - B. `resample('1h', offset='30min')`, con closed y label a la izquierda (los valores por defecto)
   - C. `resample('1h', label='right')` sin `offset`
   - D. Cualquiera: dan lo mismo

   *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?

   - A. A las 10:30
   - B. A las 11:30, cuando termina la última barra que contiene
   - C. Al cierre de la sesión
   - D. Al día siguiente

   *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?

   - A. Nada
   - 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
   - C. El volumen se duplica
   - D. Las semanas empiezan en domingo

   *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?

   - A. Usar siempre `ffill`
   - 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
   - C. Redondear las marcas de tiempo a la hora
   - D. Calcular todo en la misma frecuencia

   *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

### Research

- [Estacionalidad en Python. Buscando Patrones Intradia](https://pythonparatrading.com/estacionalidad-intradia-preprocesado/) · Research-0024 · 9 min

### Documentación

- [pandas: merge_asof (direction, allow_exact_matches, by)](https://pandas.pydata.org/docs/reference/api/pandas.merge_asof.html) · pandas.pydata.org
- [pandas: DataFrame.resample (closed, label, offset, origin)](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.resample.html) · pandas.pydata.org
- [pandas: alias de frecuencias y anclajes (W-FRI)](https://pandas.pydata.org/docs/user_guide/timeseries.html#anchored-offsets) · pandas.pydata.org

### Datos

- [Datos del nivel 1: barras de 5 minutos de NOVA, 2024-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_barras_5min.parquet) · Descarga · nova_barras_5min.parquet

Lección anterior: [1.7.3 · Patrones de velas y soportes y resistencias](https://pythonparatrading.com/curso-python-trading/nivel-1/patrones-de-velas-y-soportes/)
