Lesson 1.7.4 · 60 min

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.

Fig. — NOVA in 5-minute bars, 2 to 4 June 2025: the daily close each bar sees with the right alignment (the last known one) and joined by date (the same day's)
Level 1 lessons
  1. 1.1 · How markets work

    1. 1.1.1 Market participants and trading venues
    2. 1.1.2 Sessions, auctions, holidays and calendars
    3. 1.1.3 Order book: bid, ask, spread and depth
    4. 1.1.4 Order types
    5. 1.1.5 Costs, settlement, leverage and margin
  2. 1.2 · Asset classes

    1. 1.2.1 Stocks: splits, dividends and corporate actions
    2. 1.2.2 ETFs: replication, tracking error, leveraged ETFs and UCITS
    3. 1.2.3 Futures: specifications, margin, roll and contango
    4. 1.2.4 Forex: pairs, pips, lots, rollover and CFDs
    5. 1.2.5 Options: calls, puts, intrinsic value and time value
    6. 1.2.6 Crypto: spot, perpetuals, funding and custody
    7. 1.2.7 Fixed income and interest rates; CFDs and leveraged products
  3. 1.3 · Market data sources

    1. 1.3.1 Types of market data: EOD, bars, ticks, order book and reference
    2. 1.3.2 Free market data sources
    3. 1.3.3 Retail and prosumer market data APIs
    4. 1.3.4 Professional market data providers
    5. 1.3.5 Crypto and on-chain data
    6. 1.3.6 Brokers as market data sources
    7. 1.3.7 Market data licensing and terms of use
    8. 1.3.8 How to choose a market data provider
  4. 1.4 · Financial data engineering

    1. 1.4.1 Downloading over REST and websockets: pagination, rate limits and retries
    2. 1.4.2 Data cleaning: missing values, bad ticks, gaps and time zones
    3. 1.4.3 Adjusting for splits and dividends: adjusted and unadjusted prices
    4. 1.4.4 Universes without survivorship bias: delistings, mergers and symbol changes
    5. 1.4.5 Building bars: time, tick, volume, dollar and imbalance
    6. 1.4.6 Continuous futures series: roll rules, difference adjustment and ratio adjustment
    7. 1.4.7 Storage: CSV, Parquet, partitioning, DuckDB, TimescaleDB and ArcticDB
    8. 1.4.8 Reproducible pipeline: download, validation, incremental updates and quality tests
  5. 1.5 · Fundamental and macro data

    1. 1.5.1 Financial statements, ratios, XBRL and SEC EDGAR
    2. 1.5.2 Macro data, economic calendars, estimates and transcripts
  6. 1.6 · Financial exploratory analysis

    1. 1.6.1 Returns, volatility, rolling correlations and drawdown
    2. 1.6.2 Stylized facts: fat tails, clustering, autocorrelation and leverage
    3. 1.6.3 Seasonality: day of the week, month, turn of the month and intraday sessions
  7. 1.7 · Programmed technical analysis

    1. 1.7.1 Technical indicators in Python
    2. 1.7.2 Your own implementation versus TA-Lib: validating results
    3. 1.7.3 Candlestick patterns and support and resistance
    4. 1.7.4 Multi-timeframe analysis without look-ahead

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:

Slow barClose timemerge_asof backwardFast barClose timeDecision
Mermaid source
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 tt only sees the data that ended before tt. 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:

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), "bars,", b["sesion"].nunique(), "sessions")
b[["ts", "ny", "t_cierre", "cierre"]].head(3)
OUT [01]5-minute bars with their close time
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.

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("Same as the daily CSV:", igual)
print(d["fin"].dt.tz_convert("America/New_York").dt.strftime("%H:%M")
      .value_counts())
OUT [02]Daily bars from the 5-minute ones
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:

IN [03]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)
OUT [03]Daily filter: by date versus merge_asof
                    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:

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="by date"),
           primera(por_hora).assign(union="merge_asof")]
          )[["union", "ny", "cierre", "c", "disponible"]]
OUT [04]The 9:30 bar on June 4, 2025
            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:

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("Same values as merge_asof:", iguales)
OUT [05]shift(1) + date versus merge_asof
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)[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:

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]Hourly bars with left and right closed
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](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)
yesnoStart stamp?closed leftclosed rightAvailable: end
Mermaid source
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:

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):
    """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)
OUT [07]The hourly close joined by its label
                   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.

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), "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)
OUT [08]W-FRI weeks: labels without a session
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:

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"]
    # 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)
OUT [09]The weekly close joined by period
                    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.

IN [10]python
alineado.set_index("sesion").loc["2024-03-27":"2024-04-02",
                                 ["c", "c_sem", "disponible"]]
OUT [10]The Good Friday week of 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

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.

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"))
    # 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__)
OUT [11]Alignment tests
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

SituationTypical mistakeCorrect approach
Daily indicator on intraday barsmerge by datemerge_asof on the session close time, or shift(1) if sessions are contiguous
Start-stamped bars to hoursclosed='right'closed='left' (default) and offset to align with the open
End-stamped bars to hoursclosed='left'closed='right', label='right'
Slow bar joined by its labeljoin + ffill by labelAvailability column = close of its last bar
Weeksto_period('W-FRI') or reindex onto sessionsmerge_asof on the close of the week’s last session
Last bar of the slow timeframeUsing it incompleteDiscarding it until it ends
Half sessions and daylight saving timeFixed close times in UTCActual 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)

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
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]Daily ATR on 5-minute bars
                             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.

  1. 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
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"], "sees the close", siguiente["c"],
      "available", siguiente["disponible"])
OUT [13]Half sessions
['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.

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

Each answer is folded under its question

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?

    • AThe SMA computed with yesterday’s close
    • BThe SMA computed with today’s close, which is not known until 16:00
    • CAn empty value
    • DThe weekly SMA
    Show answer

    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?

    • Aresample('1h', offset='30min'), with closed and label on the left (the defaults)
    • Bresample('1h', closed='right')
    • Cresample('1h', label='right') without offset
    • DAny of them: they give the same result
    Show answer

    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?

    • AAt 10:30
    • BAt the session close
    • CThe next day
    • DAt 11:30, when the last bar it contains ends
    Show answer

    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?

    • ANothing
    • BVolume is doubled
    • CWeeks 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
    • DWeeks start on Sunday
    Show answer

    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?

    • AAlways use ffill
    • BRound the timestamps to the hour
    • CJoin each data point of the slow timeframe at the instant its bar ends, so that only decisions made after that instant see it
    • DCompute everything at the same frequency
    Show answer

    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

Data