---
title: "1.7.4 Multi-timeframe without look-ahead"
description: "Combine daily, hourly and weekly signals with 5-minute bars without using the future: merge_asof on close time, shift, label and closed, and W-FRI."
url: "https://pythonparatrading.com/en/python-trading-course/level-1/multi-timeframe-analysis-without-look-ahead/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-1/multi-timeframe-analysis-without-look-ahead.png"
---

# 1.7.4 · Multi-timeframe analysis without look-ahead

> Mixing timeframes is the easiest way to let the future into a backtest without noticing: a 10:00 bar that “knows” the day’s close turns a mediocre rule into an excellent one. This lesson reproduces the three classic mistakes with NOVA, measures how much they inflate the result and sets the rule that avoids them: every data point is joined at the instant its bar ends.

- Course: [Level 1: Financial data, markets and analysis](https://pythonparatrading.com/en/python-trading-course/level-1/)
- Module: 1.7 · Programmed technical analysis
- Duration: 60 min
- Prerequisites: [0.5.3 · Selection, filtering, groupby, merge, pivot, resample and moving windows](https://pythonparatrading.com/en/python-trading-course/level-0/pandas-groupby-merge-resample-rolling/), [1.1.1 · Market participants and trading venues](https://pythonparatrading.com/en/python-trading-course/level-1/market-participants-and-trading-venues/), [1.7.1 · Technical indicators in Python](https://pythonparatrading.com/en/python-trading-course/level-1/technical-indicators-in-python/)

## By the end of this lesson

- Explain why joining a daily indicator to intraday bars by date uses the future, and measure how much it inflates the result.
- Align timeframes with `merge_asof` on the close time of each bar, and with `shift(1)` when sessions are contiguous.
- Choose `closed` and `label` in `resample` according to how the bars are stamped (start or end) and recognize the traps of each combination.
- Build weekly bars with `W-FRI` without losing weeks with a Friday holiday or using the week in progress.

## The problem: two clocks in the same table

A multi-timeframe system makes decisions on a fast timeframe (5-minute bars) with information from a slow one
(daily, weekly): “buy on 5 minutes only if the daily trend is up”. The temptation is to join the two tables by date.
The problem is that the daily row for June 4 carries the **close** of June 4, which is not known until 16:00, and
when joined by date it shows up in every bar of June 4, including the 9:30 one.

The header figure draws it with three NOVA sessions. The gray line is the 5-minute price. The correct step is the
last daily close **known** at each bar: yesterday’s until 16:00, today’s afterward. The wrong one is the day’s close
joined by date: at 9:30 on June 4 it already “knows” that NOVA will close at 55.14, 2.7% above the open.

The rule that avoids the problem is a single one:

```mermaid
flowchart LR
  L[Slow bar] --> F[Close time]
  F --> A[merge_asof backward]
  R[Fast bar] --> C[Close time]
  C --> A
  A --> D[Decision]
```

Every data point is joined at the **instant its bar ends**, and a decision made at instant $t$ only sees the data
that ended before $t$. The date, the label or the name of the bar do not count.

## 5-minute bars and daily bars

NOVA’s 5-minute bars (`nova_barras_5min.parquet`) cover the 502 NYSE sessions of 2024 and 2025. Their `ts` stamp is
the **start** of the bar, in UTC. Three more columns are needed to work with them: the New York time, the session the
bar belongs to and the time at which it **ends**:

```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), "bars,", b["sesion"].nunique(), "sessions")
b[["ts", "ny", "t_cierre", "cierre"]].head(3)
```
```output
38940 bars, 502 sessions
                         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
```

The daily bars are built by aggregating by session. The `fin` column stores the close time of the last bar of each
session: the instant at which the daily data point exists.

```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("Same as the daily CSV:", igual)
print(d["fin"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
      .value_counts())
```
```output
Same as the daily CSV: True
fin
16:00    496
13:00      6
Name: count, dtype: int64
```

The rebuilt daily bars match the daily CSV. And `fin` already shows why the date is not enough: 496 sessions end at
16:00 New York time, but six half sessions (July 3, the Friday after Thanksgiving, December 24) end at 13:00. In UTC,
moreover, 16:00 is 21:00 in winter and 20:00 in summer.

## Mistake 1: the daily indicator joined by date

The example rule is a trend filter: at 10:00 (close of the 9:55 bar), if the daily close is above its 20-session
SMA, buy and sell at the session close. The wrong version joins by date; the correct one uses `merge_asof` on the
availability time, without exact matches:

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

# WRONG: each bar sees the daily row of its own date
por_fecha = b.merge(diario, left_on="sesion", right_index=True)
# RIGHT: each bar sees the last daily row that ended before it
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({"by date (wrong)": filtro_diario(por_fecha),
              "merge_asof (right)": filtro_diario(por_hora)}).T.round(1)
```
```output
                    dias_activo_pct  anual_pct  aciertos_pct
by date (wrong)                60.2       40.5          59.9
merge_asof (right)             60.0        4.4          52.8
```

With the mistake, the filter is active on almost the same days (60.2% versus 60.0%), but it picks them much better:
40.5% a year and a 59.9% hit rate, versus 4.4% and 52.8% with the correct alignment. The difference is information
from the future: on the days NOVA closes strong, today’s close beats the average, the filter switches on and the
10:00 to 16:00 trade wins. None of that could be known at 10:00.

What each version sees in the first bar of a session:

```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="by date"),
           primera(por_hora).assign(union="merge_asof")]
          )[["union", "ny", "cierre", "c", "disponible"]]
```
```output
            union                        ny  cierre      c                disponible
27660     by date 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
```

The join by date gives the 9:30 bar a daily close of 55.14 that will not be available until 20:00 UTC that day.
`merge_asof` gives it the June 3 close, 53.00, available since the evening before.

The classic alternative is to **shift** the daily table one session back before joining by date: `d.shift(1)`. It
gives the same result… if sessions are contiguous and none is missing:

```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("Same values as merge_asof:", iguales)
```
```output
Same values as merge_asof: True
```

They match. But `shift(1)` shifts **rows**, not time: if a session is missing from the daily data (a day the
provider did not send, lesson 1.4.2), the next day’s bar silently receives the data from two sessions ago; and if the
slow timeframe is not a clean partition of the fast one (weeks with a holiday, 4-hour bars in a market with breaks),
there is no meaningful “previous row”. `merge_asof` on the availability time works in every case, and it puts the
assumption in writing.

## Mistake 2: the `resample` label

To go from 5-minute bars to hourly ones, `resample` needs two decisions: which edge of the interval is closed
(`closed`) and which edge it is labeled with (`label`). By default, at minute and hour frequencies, both are
`'left'`: the interval $[9{:}30, 10{:}30)$ contains the bars stamped 9:30 to 10:25 and is labeled 9:30. With
**start** stamps (NOVA’s), that is correct. `offset="30min"` aligns the intervals with the open:

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

With `closed='left'`, each session has six hours of 12 bars and a final half hour of 6 (15:30 to 16:00). With
`closed='right'`, the 9:30 bar ends up alone in the interval $(8{:}30, 9{:}30]$: an “hour” of one bar, with the
session open. All the others shift by five minutes and the last hour is left with 5 bars. For bars stamped at the
**end** (some providers do this: the 9:30 to 9:35 bar is stamped 9:35) the correct combination is exactly the
opposite, `closed='right'` and `label='right'`.

*Fig. — Which 5-minute bars (stamped by their start) go into each hourly bar depending on closed and label in resample, and where the label falls (triangles).* Four rows over the 9:30 to 11:30 New York window. Top, 24 five-minute bars. With closed and label on the left, two hours of 12 bars, 9:30 to 10:30 and 10:30 to 11:30, labelled 9:30 and 10:30. With closed on the left and label on the right, the same hours labelled 10:30 and 11:30, at their end. With closed and label on the right, the 9:30 bar sits alone in a bin, highlighted, and the following hours start five minutes late, 9:35 to 10:35.

```mermaid
flowchart TB
  Q{Start stamp?}
  Q -- yes --> L[closed left]
  Q -- no --> R[closed right]
  L --> D[Available: end]
  R --> D
```

`label` does not change which bars go into each interval, but it does change **where** the hourly bar is joined if it
is joined by its label. With `label='left'`, the hour labeled 10:30 contains data up to 11:30. Joined by its label to
the 5-minute bars (a `join` and an `ffill`, the usual shortcut), the 10:30 bar already knows the 11:30 close:

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

def regla_horaria(c_hora, nombre):
    """Buy if the hourly close seen is higher than the price; sell
    at the close of the current hour."""
    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()}

# WRONG: the hourly bar, at its start label, and ffill
mal = izq["cierre"].reindex(s.index).ffill()
# RIGHT: available when its last 5-minute bar ends
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, "label + ffill (wrong)"),
              regla_horaria(bien, "merge_asof (right)")]).round(2)
```
```output
                   regla  operaciones  aciertos_pct  media_pb
0  label + ffill (wrong)        17359        100.00     32.22
1     merge_asof (right)        18803         45.22     -0.33
```

With the shortcut, the rule wins 100% of its 17,359 trades, 32 basis points on average each: it buys when it “knows”
the hour will close higher. With the hour that is truly available (the last **finished** one), the same rule wins
45.2% of the time and loses 0.33 basis points on average, before costs. The rule has not changed; only when the data
is known.

## Mistake 3: weeks with `W-FRI`

Weekly bars are built from the daily ones with `resample("W-FRI")`: weeks that end on Friday, labeled with the
Friday and, by default at weekly frequencies, `closed='right'` and `label='right'`. Two traps show up right away: the
label’s Friday may not be a session, and the last week may not have finished.

```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), "weeks; labels that are not a session:", sin_sesion)
perdidas = semanal["c"].reindex(d.index).notna().sum()
print("Weeks that survive reindex onto sessions:", perdidas)
```
```output
105 weeks; labels that are not a session: [datetime.date(2024, 3, 29), datetime.date(2025, 4, 18), datetime.date(2025, 7, 4), datetime.date(2026, 1, 2)]
Weeks that survive reindex onto sessions: 101
```

Of the 105 weeks, four have their label on a day without a session: Good Friday in 2024 (March 29) and 2025
(April 18), July 4, 2025 (a Friday holiday) and January 2, 2026, the label of the last week, which in the data only
has three sessions (December 29 to 31). `reindex` onto the sessions silently drops them, and a later `ffill` drags
the previous week along: a one-week delay nobody sees.

The serious mistake is the opposite one: joining each day with **its** week, for example with `to_period("W-FRI")`.
Monday sees the close of the following Friday:

```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"]
    # exit: the close of the current week
    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({"by period (wrong)": regla_semanal(dias["c_sem_mal"]),
              "merge_asof (right)": regla_semanal(dias["c_sem_bien"])}
             ).T.round(1)
```
```output
                    operaciones  aciertos_pct  media_pb
by period (wrong)         209.0         100.0     229.3
merge_asof (right)        216.0          40.7      14.5
```

The rule “buy at the close if the weekly close is higher; sell at the close of the week” is right on all 209 trades
with the close of the week itself and earns 229 basis points on average per trade. With the last **finished** week
(`merge_asof` on the actual close time of its last session, which on Good Friday is Thursday at 16:00), it is right
on 40.7% of 216 trades, with an average of 14.5 basis points that, with overlapping trades and a stock that rises
over the period, cannot be told apart from noise. The weeks with a holiday sort themselves out: the `disponible`
column is Thursday’s, and `merge_asof` does not need a Friday to exist.

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

Thursday, March 28 still sees the previous week (close 50.50, available Friday the 22nd): with
`allow_exact_matches=False`, a week is not available at the same instant it ends. Monday, April 1 already sees the
Good Friday week, closed on Thursday at 51.34.

## Checking the alignment

As in lesson 1.7.1, the best defense is a check that fails on its own. With the availability column in view, the
test is one line: no data point can be available after the decision that uses it. And the truncation test applies
here too: the values aligned up to an instant cannot change if the data is cut at that instant.

```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"))
    # only the sessions that had already closed at the cutoff
    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("the join by date should fail")

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

The third test is a test of the test itself: it checks that the condition catches the join by date. A test that
cannot fail protects against nothing.

## Summary of the conventions

| Situation | Typical mistake | Correct approach |
|---|---|---|
| Daily indicator on intraday bars | `merge` by date | `merge_asof` on the session close time, or `shift(1)` if sessions are contiguous |
| Start-stamped bars to hours | `closed='right'` | `closed='left'` (default) and `offset` to align with the open |
| End-stamped bars to hours | `closed='left'` | `closed='right'`, `label='right'` |
| Slow bar joined by its label | `join` + `ffill` by label | Availability column = close of its last bar |
| Weeks | `to_period('W-FRI')` or `reindex` onto sessions | `merge_asof` on the close of the week’s last session |
| Last bar of the slow timeframe | Using it incomplete | Discarding it until it ends |
| Half sessions and daylight saving time | Fixed close times in UTC | Actual close time of each session |

*Fig. — Winning trades of three multi-timeframe rules on NOVA, 2024-2025, with the wrong alignment (orange) and the right one (grey).* Paired horizontal bars with the percentage of winning trades and a line at 50%. Daily filter: 59.9% joined by date against 52.8% with merge_asof. Hourly close joined by its label: 100%, highlighted, against 45.2%. Weekly close joined by period: 100% against 40.7%.

The figure brings the three mistakes together: with data from the future, the share of winning trades jumps from
around 50% to impossible values. A backtest that is almost always right is not a good strategy; it is almost always
a botched alignment.

## Exercises

1. Add the daily 14-session ATR (lesson 1.7.1) to the 5-minute bars, correctly aligned. Which ATR does the first bar
   of June 4, 2025 see, and from which session is it?

**Solution.**

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

The 9:30 bar on June 4 sees the ATR computed with the June 3 session, the last one that had ended. The same pattern
works for any daily column: add `disponible` and join with `merge_asof`.

2. Check that the `fin` column captures the half sessions: list the 2024 and 2025 sessions that end at 13:00 New
   York time and, for one of them, which daily data point the first bar of the next session sees.

**Solution.**

```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"], "sees the close", siguiente["c"],
      "available", 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 sees the close 68.25 available 2024-07-03 17:00:00+00:00
```

The six NYSE half sessions in the period are there. The July 5, 2024 session (after the July 4 holiday) sees the
July 3 close, available at 17:00 UTC (13:00 New York time). A rule that assumed “the daily data point is in at 20:00
or 21:00 UTC” would be wrong on those days; the `disponible` column would not.

3. An intraday rule uses the day’s **open** (the one from the 9:30 auction) from the 9:30 bar onward. Is that
   look-ahead?

**Solution.**

No, if it is used from the instant it exists. The official open is the price of the opening auction, which is set at
9:30:00 (lesson 1.1.2); any later decision can use it. What the 9:30 bar cannot use is its own close (9:35), nor its
high or its low. The way not to get it wrong is the same as in the whole lesson: give the open its own availability
column (9:30:00 of each session, 13:30 or 14:30 UTC depending on daylight saving time) and join it with `merge_asof`
like any other data point.

## Self-assessment

1. You join the daily 20-session SMA to the 5-minute bars with `merge` on the session date. What value does the 10:00 bar see?

   - A. The SMA computed with yesterday’s close
   - B. The SMA computed with today’s close, which is not known until 16:00
   - C. An empty value
   - D. The weekly SMA

   *Answer B: The SMA computed with today’s close, which is not known until 16:00.* The daily row for date t carries the close of t. Joined by date, it shows up in every bar of t, including the morning ones. In NOVA, a trend filter built this way goes from 4.4% to 40.5% a year.

2. Your provider’s 5-minute bars carry the timestamp of the **start** of the bar. Which `resample` gives hourly bars that start at 9:30 and contain the twelve 5-minute bars of that hour?

   - A. `resample('1h', offset='30min')`, with closed and label on the left (the defaults)
   - B. `resample('1h', closed='right')`
   - C. `resample('1h', label='right')` without `offset`
   - D. Any of them: they give the same result

   *Answer A: `resample('1h', offset='30min')`, with closed and label on the left (the defaults).* With start stamps, the 9:30 bar covers 9:30 to 9:35 and belongs to the interval [9:30, 10:30). That is `closed='left'`. With `closed='right'`, the 9:30 bar ends up alone in the previous interval and all the others shift by five minutes.

3. An hourly bar labeled 10:30 with `label='left'` contains data up to 11:30. When can its close be used?

   - A. At 10:30
   - B. At the session close
   - C. The next day
   - D. At 11:30, when the last bar it contains ends

   *Answer D: At 11:30, when the last bar it contains ends.* The label is only a name. The data point is available when its last bar ends. Joining it by its label to 5-minute bars gives the bars from 10:30 to 11:25 the 11:30 close: in NOVA, a “buy if the hourly close is higher” rule wins 100% of its trades.

4. You reindex the weekly series (`resample('W-FRI')`) onto the daily sessions of 2024-2025 and fill with `ffill`. What goes wrong?

   - A. Nothing
   - B. Volume is doubled
   - C. Weeks whose Friday is a holiday (Good Friday, July 4, 2025) have their label on a day without a session and disappear when reindexing, and the last week is incomplete
   - D. Weeks start on Sunday

   *Answer C: Weeks whose Friday is a holiday (Good Friday, July 4, 2025) have their label on a day without a session and disappear when reindexing, and the last week is incomplete.* `W-FRI` labels each week with its Friday, whether there is a session or not. When reindexing onto sessions, the three weeks with a Friday holiday and the last one (labeled January 2, 2026) are lost. `merge_asof` on the actual close time of the week does not have that problem.

5. What is the general rule for combining two timeframes without look-ahead?

   - A. Always use `ffill`
   - B. Round the timestamps to the hour
   - C. Join each data point of the slow timeframe at the instant its bar ends, so that only decisions made after that instant see it
   - D. Compute everything at the same frequency

   *Answer C: Join each data point of the slow timeframe at the instant its bar ends, so that only decisions made after that instant see it.* The date or the label of a bar does not say when it is known. The close instant does. `merge_asof(…, direction='backward', allow_exact_matches=False)` on that column is the rule, for any combination of frequencies.

## Key points

- A data point from a slow timeframe is known when its bar ends, not on its date or at its label. That availability time is the join key.
- `merge_asof` on the close time, backward and without exact matches, aligns any pair of frequencies, half sessions and gaps included; `shift(1)` and the date are enough only if sessions are contiguous and complete.
- In `resample`, `closed` and `label` depend on how the provider stamps its bars: with start stamps, `closed='left'`; the label, left or right, does not change the data, but it does change where the bar is joined.
- The three classic mistakes with NOVA: daily filter by date (40.5% a year versus 4.4%), hourly close by its label (100% of trades win) and weekly close by period (every trade wins).
- `W-FRI` labels each week with its Friday even when it is a holiday, and the last week may be incomplete: do not reindex onto sessions, join on the actual close time.

## Resources

### Research

- [Seasonality in Python. Searching for Intraday Patterns](https://pythonparatrading.com/en/seasonality-intraday-preprocessing/) · Research-0024 · 9 min

### Documentation

- [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: frequency aliases and anchored offsets (W-FRI)](https://pandas.pydata.org/docs/user_guide/timeseries.html#anchored-offsets) · pandas.pydata.org

### Data

- [Level 1 data: NOVA 5-minute bars, 2024-2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_barras_5min.parquet) · Download · nova_barras_5min.parquet

Previous lesson: [1.7.3 · Candlestick patterns and support and resistance](https://pythonparatrading.com/en/python-trading-course/level-1/candlestick-patterns-support-and-resistance/)
