Lesson 5.3.2 · 65 min

Triple-barrier method: labels that mimic a trade

A fixed-horizon label asks where the price will be in 30 minutes; a real trade closes earlier if it hits the stop or the target. The triple barrier labels each event with the first of three barriers the price touches. This lesson builds it on a year of 1-minute FNX bars, measures how the label distribution changes with the width, the horizon and the volatility scale, and checks that it recovers the known answer of the primary model.

Fig. — A real FNX event: the price nears the stop at minute 10 and touches the profit-taking barrier at minute 24. The label is set by the first barrier touched, not by the price at 30 minutes
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

Three barriers instead of one horizon

Lesson 5.3.1 labeled each observation with the return at a fixed horizon: up or down within hh bars. It has two flaws that matter in trading. The first is that a fixed threshold ignores volatility: a 20 bp move is noise at the open and a trend at midday. The second is that the return over hh bars is not the outcome of any real trade: whoever buys with a stop exits when it is hit, even if half an hour later the price has come back.

The triple-barrier method (López de Prado, 2018, chapter 3) labels each event with the first of three barriers the price touches from the entry:

  • Upper barrier, at +k⋅σth+k\cdot\sigma_t\sqrt{h} from the entry price: the profit-taking. Label +1+1.
  • Lower barrier, at −k⋅σth-k\cdot\sigma_t\sqrt{h}: the stop. Label −1-1.
  • Vertical barrier, hh bars later (or at the session close, if that comes first): the time limit. Label 00, or the sign of the return up to that moment if only two classes are wanted.

With σt\sigma_t the one-bar volatility forecast at the time of the event, tt, and the return measured in logarithms from the close of the event bar:

rt,t+j=ln⁡Pt+jPt,τ=min⁡{ j≤h:∣rt,t+j∣≥k σth }.r_{t,t+j} = \ln\frac{P_{t+j}}{P_t}, \qquad \tau = \min\{\,j \le h : |r_{t,t+j}| \ge k\,\sigma_t\sqrt{h}\,\}.

If τ\tau exists, the label is the sign of rt,t+τr_{t,t+\tau}; if not, the vertical decides it. Besides the label, the method returns the end of each one (t+τt+\tau or t+ht+h), which is the piece of data the following lessons need to measure the overlap between labels.

upperlowernone within hEvent at tσ forecast at tBarriers ±k·σ·√hFirst touch?+1−10 or sign
Mermaid source
flowchart TB
  E[Event at t] --> V[σ forecast at t]
  V --> B[Barriers ±k·σ·√h]
  B --> C{First touch?}
  C -- upper --> P[+1]
  C -- lower --> N[−1]
  C -- none within h --> Z[0 or sign]

FNX minute by minute

The data are a year of full sessions (247, from July 1, 2024 to June 30, 2025) of FNX, a fictitious future on an index, in 1-minute bars: 390 per session, from 9:30 to 16:00 New York time. They are synthetic and carry a planted effect (level 5 README): a latent regime of trend (a third of the time, with drift in one direction) or of reversion to the 30-minute mean. The lessons' primary model, a momentum triggered by a CUSUM filter, is right more often in trend than in reversion; hf5_verdad.parquet gives the regime of each minute. Here it is only used to measure, never as a feature.

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

barras = pd.read_parquet("hf5_barras_1min.parquet")
verdad = pd.read_parquet("hf5_verdad.parquet")
n_ses = len(barras) // 390
C = barras["cierre"].to_numpy().reshape(n_ses, 390)
H = barras["maximo"].to_numpy().reshape(n_ses, 390)
L = barras["minimo"].to_numpy().reshape(n_ses, 390)
tend = (verdad["regimen"] == "tendencia").to_numpy()
tend = tend.reshape(n_ses, 390)        # the truth: only for measuring
print(n_ses, "sessions of 390 minutes")
barras[["ts", "apertura", "maximo", "minimo", "cierre",
        "volumen"]].head(3)
OUT [01]FNX bars as a session × minute matrix
247 sessions of 390 minutes
                         ts  apertura   maximo   minimo   cierre  volumen
0 2024-07-01 13:30:00+00:00   5221.75  5224.75  5219.25  5219.25     9210
1 2024-07-01 13:31:00+00:00   5219.25  5220.75  5219.00  5219.00     7266
2 2024-07-01 13:32:00+00:00   5219.00  5219.00  5215.50  5217.00     5839

Since each session has exactly 390 minutes, the closes fit in a 247 × 390 matrix: one row per session. That way the return of the open relative to the previous day's close (the overnight gap, which is not tradable with 1-minute bars) is left out naturally.

The volatility that scales the barriers is an exponential moving average (EWMA) of the standard deviation of the 1-minute log returns, with a span of 100 minutes. It is computed on the chained return series and only with the data up to each minute (ewm does not look ahead):

IN [02]python
lr = np.diff(np.log(C), axis=1)    # 389 returns per session
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))
ancho = sig * np.sqrt(30)
print(f"1-minute σ, median: {1e4 * np.nanmedian(sig):.2f} bp")
print(f"±σ·√30, median: {1e4 * np.nanmedian(ancho):.1f} bp")
p5, p95 = 1e4 * np.nanpercentile(ancho, [5, 95])
print(f"5th and 95th percentiles: {p5:.1f} and {p95:.1f} bp")
OUT [02]1-minute EWMA volatility and barrier width
1-minute σ, median: 4.22 bp
±σ·√30, median: 23.1 bp
5th and 95th percentiles: 15.1 and 36.8 bp

The 1-minute standard deviation is around 4.2 bp and the ±σ30\pm\sigma\sqrt{30} barrier has a median of 23.1 bp, between 15.1 and 36.8 bp depending on the moment. The σh\sigma\sqrt{h} rule assumes that 1-minute returns are independent, and in FNX they almost are (first-order autocorrelation of −0.011 according to the README). With another structure (strong reversion at short horizons) you would have to scale with the volatility measured at hh minutes.

Events and labels

The triple barrier is applied to events, not to every minute. Here the events are those of the level's primary model: a symmetric CUSUM filter that fires when the price accumulates a 4σ4\sigma move in one direction since the last event, and the side of the event is that of the breakout (momentum). Lesson 5.3.5 builds and studies it; here its output is enough.

IN [03]python
def eventos_cusum(lr, sig, k=4.0):
    """Symmetric CUSUM filter per session (lesson 5.3.5)."""
    filas = []
    for d in range(lr.shape[0]):
        s_pos = s_neg = 0.0
        for m in range(lr.shape[1]):
            h = k * sig[d, m]
            if not np.isfinite(h):
                continue
            s_pos = max(0.0, s_pos + lr[d, m])
            s_neg = min(0.0, s_neg + lr[d, m])
            if s_pos > h or s_neg < -h:
                lado = 1 if s_pos > h else -1
                filas.append((d, m + 1, lado, sig[d, m]))
                s_pos = s_neg = 0.0
    return pd.DataFrame(
        filas, columns=["sesion", "minuto", "lado", "sigma"])


ev = eventos_cusum(lr, sig)
print(len(ev), "events,", round(len(ev) / n_ses, 1), "per session")
ev.head(3).round(6)
OUT [03]Primary model events (CUSUM filter)
6550 events, 26.5 per session
   sesion  minuto  lado     sigma
0       0      16    -1  0.000889
1       0      42     1  0.000670
2       0      73    -1  0.000497

There are 6,550 events, 26.5 per session, the same ones the generator's reference function gives. minuto is the index of the close that fires the event: the entry is made at that close, with σ\sigma computed up to that same minute.

IN [04]python
def triple_barrera(C, ev, k=1.0, h=30):
    """First touch of ±k·σ·√h or of the vertical at h minutes.

    fin: minute of the touch; ret: log return from the close of the
    event minute to the touch; toque: +1, -1 or 0 (vertical).
    """
    fin, ret, toque = [], [], []
    for d, m, s in ev[["sesion", "minuto", "sigma"]].itertuples(
            index=False):
        b = k * s * np.sqrt(h)
        ult = min(m + h, C.shape[1] - 1)     # vertical or close
        tramo = np.log(C[d, m + 1:ult + 1] / C[d, m])
        golpes = np.flatnonzero(np.abs(tramo) >= b)
        if len(tramo) == 0:                 # event at the close
            j, t = -1, 0
        elif len(golpes):
            j = golpes[0]
            t = int(np.sign(tramo[j]))
        else:
            j, t = len(tramo) - 1, 0
        fin.append(m + 1 + j)
        ret.append(float(tramo[j]) if len(tramo) else 0.0)
        toque.append(t)
    return ev.assign(fin=fin, ret=ret, toque=toque)


tb = triple_barrera(C, ev)
print(tb["toque"].value_counts().sort_index())
print("mean duration:", round((tb.fin - tb.minuto).mean(), 1),
      "minutes")
print("events in the last minute:", (tb.fin == tb.minuto).sum())
OUT [04]The triple barrier, event by event
toque
-1    1542
 0    3511
 1    1497
Name: count, dtype: int64
mean duration: 22.5 minutes
events in the last minute: 17

np.flatnonzero returns the positions where the path leaves the band; the first one is the touch. The 6,550 labels split into 1,497 upper touches, 1,542 lower ones and 3,511 verticals: with k=1k = 1 more than half of the events do not move one σ30\sigma\sqrt{30} in half an hour. The mean life of a label is 22.5 minutes. There are 17 events in the last minute of the session: their vertical is their own entry, with a return of 0. They cannot be traded; they are kept so that the figures match those of the README, and they do not move any of them in the first decimal place.

The figure in the header shows one of the events, the one on January 15, 2025 at 11:52:

IN [05]python
hora = pd.Timestamp("2025-01-15 16:52", tz="UTC")  # 11:52 in NY
d, m = divmod(int(barras.index[barras.ts == hora][0]), 390)
i = tb.index[(tb.sesion == d) & (tb.minuto == m)][0]
lado, s = int(tb.loc[i, "lado"]), tb.loc[i, "sigma"]
b = s * np.sqrt(30)
camino = 1e4 * np.log(C[d, m:m + 31] / C[d, m])
print(f"side {lado:+d}, barriers ±{1e4 * b:.1f} bp")
print("path minimum:", round(camino.min(), 1), "bp at minute",
      camino.argmin())
print("touch:", tb.loc[i, "toque"], "at minute",
      tb.loc[i, "fin"] - m, "with", round(1e4 * tb.loc[i, "ret"], 1),
      "bp")
print("30-minute return:", round(camino[30], 1), "bp")
OUT [05]One event up close
side +1, barriers ±19.8 bp
path minimum: -14.8 bp at minute 10
touch: 1 at minute 24 with 21.5 bp
30-minute return: 27.3 bp

The event is bullish. The price falls to −14.8 bp at minute 10, 5 bp from the stop, recovers and touches the upper barrier at minute 24 with +21.5 bp: label +1+1. At 30 minutes it was at +27.3 bp, but that figure does not enter the label: the trade would already have been closed.

With the vertical turned into the sign of the return at the exit, the labels end up almost balanced, and the primary model's hit rate (the side matches the sign of the return up to the touch) can be compared with the known answer:

IN [06]python
tb["etiqueta"] = np.where(tb.toque != 0, tb.toque, np.sign(tb.ret))
tb["acierto"] = (tb.lado * tb.ret > 0).astype(int)
tb["tendencia"] = tend[tb.sesion, tb.minuto]
print(tb["etiqueta"].value_counts(normalize=True).sort_index()
      .round(3))
res = tb.groupby("tendencia")["acierto"].agg(["size", "mean"])
res.loc["all"] = [len(tb), tb.acierto.mean()]
res.round(3)
OUT [06]Labels and primary model hit rate
etiqueta
-1.0    0.500
 0.0    0.012
 1.0    0.488
Name: proportion, dtype: float64
             size   mean
tendencia
False      4010.0  0.446
True       2540.0  0.604
all        6550.0  0.507

The 1.2% of label 0 are verticals with a return of exactly zero: the price moves in ticks of 0.25 points and sometimes comes back to the entry. The primary is right 50.7% of the time (standard error of about 0.6 points): on its own it does not win. But in the events that fall in trend it is right 60.4% of the time, and in reversion 44.6%: that is the known answer from the README. The triple barrier recovers it; what it cannot do is say which regime each event is in, because that is not observed. Learning to tell them apart with information available at the moment is the job of meta-labeling (lesson 5.3.3).

Width and horizon: who decides the label

The two parameters of the method are the width kk and the horizon hh. Sweeping both:

IN [07]python
filas = []
for h in (15, 30, 60):
    for k in (0.5, 1.0, 1.5, 2.0):
        x = triple_barrera(C, ev, k=k, h=h)
        reparto = x.toque.value_counts(normalize=True)
        filas.append({"h": h, "k": k,
                      "superior": reparto.get(1, 0.0),
                      "inferior": reparto.get(-1, 0.0),
                      "vertical": reparto.get(0, 0.0),
                      "acierto": (x.lado * x.ret > 0).mean(),
                      "minutos": (x.fin - x.minuto).mean()})
pd.DataFrame(filas).set_index(["h", "k"]).round(3)
OUT [07]Barrier width and vertical horizon
        superior  inferior  vertical  acierto  minutos
h  k
15 0.5     0.435     0.445     0.120    0.498    6.725
   1.0     0.223     0.227     0.551    0.499   11.833
   1.5     0.095     0.094     0.811    0.499   13.684
   2.0     0.036     0.037     0.926    0.498   14.317
30 0.5     0.441     0.447     0.111    0.500   11.892
   1.0     0.229     0.235     0.536    0.507   22.502
   1.5     0.100     0.101     0.799    0.504   26.527
   2.0     0.039     0.038     0.922    0.503   27.911
60 0.5     0.438     0.445     0.118    0.513   21.359
   1.0     0.229     0.229     0.542    0.521   42.447
   1.5     0.100     0.100     0.800    0.522   50.484
   2.0     0.040     0.042     0.917    0.522   53.261
Fig. — Barriers at ±k·σ·√30 on the 6,550 FNX events: the wider they are, the more labels the vertical barrier decides. With k = 1, 54%

Three readings:

  • The width distributes the labels. With k=0.5k = 0.5, almost nine out of ten labels are decided by a horizontal barrier in about 12 minutes; with k=2k = 2, 92% are decided by the vertical and the label resembles the fixed-horizon one. Neither is correct in the abstract: narrow barriers label short-term noise, and wide ones turn the method into what it wanted to avoid.
  • The horizon barely changes the distribution. With k=1k = 1, the vertical decides 55%, 54% and 54% with 15, 30 and 60 minutes. It is the consequence of scaling with h\sqrt{h}: the dispersion of a random walk grows with h\sqrt{h}, and the probability of touching a barrier that grows the same way is the same at any scale. What does change is the life of the label, from 12 to 42 minutes, and with it the overlap between labels (5.3.4).
  • The hit rate barely moves with kk and grows somewhat with hh (49.9%, 50.7% and 52.1% with k=1k = 1): trend episodes last 60 minutes on average, so a longer horizon leaves more time for the drift to show up. These are differences of one to two standard errors on the same events; they are not a reason to choose hh by looking at this table.

The path matters

The practical difference from the fixed-horizon label shows up when crossing the barrier touched with the sign of the 30-minute return:

IN [08]python
fijo = np.array([np.log(C[d, min(m + 30, 389)] / C[d, m])
                 for d, m in zip(tb.sesion, tb.minuto)])
pd.crosstab(tb.toque, np.sign(fijo),
            rownames=["toque"], colnames=["sign at 30 min"])
OUT [08]Path dependence
sign at 30 min  -1.0   0.0   1.0
toque
-1              1463     2    77
 0              1734    78  1699
 1                72     1  1424

In most events the two labels agree, but not in all of them: 77 events touched the lower barrier and were positive at 30 minutes, and 72 touched the upper one and ended negative. That is 149 labels (2.3%) that say the opposite depending on the method, and in all of them the fixed-horizon label describes a trade that would not have been made with a stop and a target. The effect grows with narrower barriers.

The path also depends on which prices you look at. The previous cell compares 1-minute closes; a real stop resting in the market triggers as soon as the price touches it, within the bar. With each minute's highs and lows:

IN [09]python
def primer_toque_hl(d, m, b, h=30):
    ult = min(m + h, 389)
    arriba = np.log(H[d, m + 1:ult + 1] / C[d, m]) >= b
    abajo = np.log(L[d, m + 1:ult + 1] / C[d, m]) <= -b
    ia = arriba.argmax() if arriba.any() else 10**6
    ib = abajo.argmax() if abajo.any() else 10**6
    if ia == ib:
        return 0 if ia == 10**6 else 9    # 9: both at once
    return 1 if ia < ib else -1


hl = np.array([primer_toque_hl(d, m, s * np.sqrt(30))
               for d, m, s in zip(tb.sesion, tb.minuto, tb.sigma)])
print("ambiguous (both in the same minute):", (hl == 9).sum())
print("labels that change:", (hl != tb.toque).sum())
pd.crosstab(tb.toque, hl, rownames=["closes"],
            colnames=["high/low"])
OUT [09]Barriers with each minute's highs and lows
ambiguous (both in the same minute): 0
labels that change: 332
high/low    -1     0     1
closes
-1        1531     0    11
 0         170  3194   147
 1           4     0  1493

332 labels change (5.1%): 170 verticals become stops and 147 become profit-takings, because an intra-minute low or high reaches the barrier even though the close does not. No minute touches both barriers at once: with barriers of about 20 bp and a volatility of 4 bp per minute it is impossible in practice, but with 15-minute bars or narrow barriers it happens, and then you do not know which was touched first. The usual ways out are to look at the bar of a shorter interval or to assume the worst (the stop first).

The rule is the one from lesson 4.2.1: the label has to mimic the execution that is going to be used. If the system will place stop orders in the market, highs and lows; if it decides at the close of each minute, closes.

Asymmetric barriers and the primary model's side

Up to here the barriers were symmetric and the label knew nothing about the side. When there is a primary model that decides to buy or sell, the barriers are defined relative to its side: the profit-taking at tp⋅σhtp\cdot\sigma\sqrt{h} in favor and the stop at sl⋅σhsl\cdot\sigma\sqrt{h} against, with the option of removing one of the two. The outcome of each event is the return of the side up to the exit:

IN [10]python
def con_lado(C, ev, tp, sl, h=30):
    """Profit-taking at tp·σ·√h and stop at sl·σ·√h (0 = none),
    in the direction of the side. Returns the side's return."""
    out = []
    for d, m, s, lado in ev[["sesion", "minuto", "sigma",
                             "lado"]].itertuples(index=False):
        u = s * np.sqrt(h)
        ult = min(m + h, C.shape[1] - 1)
        tramo = lado * np.log(C[d, m + 1:ult + 1] / C[d, m])
        if len(tramo) == 0:
            out.append(0.0)
            continue
        fuera = np.zeros(len(tramo), bool)
        if tp:
            fuera |= tramo >= tp * u
        if sl:
            fuera |= tramo <= -sl * u
        j = fuera.argmax() if fuera.any() else len(tramo) - 1
        out.append(tramo[j])
    return np.array(out)


filas = []
for tp, sl in ((1, 1), (2, 1), (1, 2), (0, 1), (0, 0)):
    r = 1e4 * con_lado(C, tb, tp, sl)
    t = tb.tendencia.to_numpy()
    filas.append({"tp": tp, "sl": sl, "media_pb": r.mean(),
                  "error_pb": r.std() / np.sqrt(len(r)),
                  "tendencia_pb": r[t].mean(),
                  "reversion_pb": r[~t].mean()})
pd.DataFrame(filas).round(2)
OUT [10]Asymmetric barriers with the primary's side
   tp  sl  media_pb  error_pb  tendencia_pb  reversion_pb
0   1   1      0.76      0.26          5.02         -1.94
1   2   1      1.11      0.28          6.61         -2.37
2   1   2      0.69      0.27          4.78         -1.90
3   0   1      1.19      0.29          6.86         -2.40
4   0   0      1.20      0.30          6.59         -2.21

All combinations give between 0.7 and 1.2 bp per trade before costs, with standard errors of 0.26 to 0.30 bp. The differences between rows are one or two standard errors, and five were tested on the same events: choosing “no profit-taking” because it gives 1.19 bp would be overfitting the exit rule (lesson 2.5.6). What is stable is the regime column: in trend, the primary earns +4.8 to +6.9 bp per trade with any exit; in reversion it loses about 2 bp. The exits change the outcome little; the regime changes everything.

With a side, the natural label becomes binary: did the primary's trade win? It is the meta-labeling label. One FNX tick is about 0.47 bp at the year's average price (5,287 points), so a mean of 0.76 bp per trade does not pay for a bid-ask spread and a commission: the primary is only tradable if something filters out the reversion events.

The volatility scale decides the distribution

The barrier is only comparable across events if σt\sigma_t is the volatility the price is going to have over the next hh minutes. Intraday that fails systematically: volatility follows a U, high at the open, low at midday and somewhat higher at the close. A 100-minute EWMA lags: in the first half hour the previous afternoon, which was calmer, still weighs in. The cell compares three scales by half hour of the event: the lesson's EWMA, a fixed width (the median) and the EWMA of the returns divided by the intraday profile of the previous 20 sessions, then multiplied by the profile of the next 30 minutes:

σt(h)=σ^z(t) ∑j=1hum+j2,zm=rmum,\sigma^{(h)}_t = \hat\sigma_z(t)\,\sqrt{\textstyle\sum_{j=1}^{h} u^2_{m+j}}, \qquad z_m = \frac{r_m}{u_m},

with um2u^2_m the mean variance of minute mm of the session over the previous 20 sessions, normalized to a mean of 1. Everything that goes in is known at the time of the event: the profile comes from past sessions.

IN [11]python
# 1) fixed width: the median σ for all events
s_fija = np.full(len(tb), np.nanmedian(tb.sigma))
# 2) intraday variance profile from the previous 20 sessions
u2 = (pd.DataFrame(lr**2).rolling(20).mean().shift(1)).to_numpy()
u2 = u2 / u2.mean(axis=1, keepdims=True)
z = lr / np.sqrt(u2)
sig_z = (pd.Series(z.ravel()).ewm(span=100).std()
         .to_numpy().reshape(n_ses, 389))
acum = np.nancumsum(np.c_[np.zeros(n_ses), u2], axis=1)


def vertical(anchos):
    """Fraction of events that end at the vertical barrier."""
    x = tb.assign(sigma=anchos / np.sqrt(30))
    return (triple_barrera(C, x).toque == 0).to_numpy()


d, m = tb.sesion.to_numpy(), tb.minuto.to_numpy()
fin30 = np.minimum(m + 30, 389)
ancho_estac = sig_z[d, m - 1] * np.sqrt(acum[d, fin30] - acum[d, m])
ok = d >= 20                      # the profile needs 20 sessions
tabla = pd.DataFrame({
    "media_hora": m[ok] // 30,
    "EWMA": vertical(tb.sigma.to_numpy() * np.sqrt(30))[ok],
    "fija": vertical(s_fija * np.sqrt(30))[ok],
    "EWMA_estacional": vertical(ancho_estac)[ok],
}).groupby("media_hora").mean()
print(tabla.std().round(3).to_dict())
tabla.round(2).T
OUT [11]The volatility scale and the time of day
{'EWMA': 0.096, 'fija': 0.117, 'EWMA_estacional': 0.032}
media_hora         0     1     2     3     4     5     6     7     8     9     10    11    12
EWMA             0.41  0.59  0.65  0.68  0.57  0.55  0.53  0.51  0.53  0.50  0.42  0.41  0.70
fija             0.25  0.40  0.49  0.59  0.57  0.60  0.60  0.59  0.60  0.59  0.51  0.49  0.73
EWMA_estacional  0.61  0.52  0.54  0.62  0.56  0.57  0.56  0.57  0.59  0.59  0.54  0.57  0.62
Fig. — Share of labels decided by the vertical barrier by time of the event. The 100-minute EWMA lags the intraday U; with the profile of the previous 20 sessions the split barely depends on the time of day

With a fixed width, in the first half hour the vertical decides only 25% of the labels (the price moves a lot and touches the barriers right away) and 60% at midday. The EWMA half corrects it at the open (41%), overshoots in mid-morning (68%), when it has already absorbed the volatility of the open and the market has calmed down, and falls short again from 14:30 onwards (41-42%), when volatility picks up toward the close and the average still remembers midday. With the intraday profile, the distribution stays between 52% and 62% all day and the dispersion across half hours drops from 0.096 to 0.032. The last half hour rises in all three scales for another reason: the vertical is the session close and arrives before 30 minutes.

It matters because a model trained with labels biased by time of day learns the time, not the signal. If at the open almost all labels are touches and at midday almost all are verticals, a feature like “minute of the session” will look predictive without saying anything about the market.

What to keep from each label

  • The start and the end. An event's label uses prices up to its fin: two labels whose lives overlap share information, and a validation that trains with one and evaluates with the other leaks the future. Lesson 5.3.4 measures that overlap and 5.4.2 purges it.
  • The scale used. σt\sigma_t has to be the one at the time of the event; a volatility computed with the whole day (or centered) looks ahead and makes the barriers “clairvoyant”.
  • The meaning of the vertical. Three classes or the sign of the return: with the sign, verticals close to 0 are almost random labels; with three classes, class 0 dominates if the barriers are wide.
  • The session. In a future with or without an overnight session, the vertical must not cross a close if the system does not hold positions from one day to the next.

Exercises

  1. Repeat the primary model's hit rate by regime with k=1k = 1 barriers and a vertical at 60 minutes. Does the difference between trend and reversion grow? Why would that make sense given the duration of the FNX episodes?
Solution
IN [12]python
x60 = triple_barrera(C, ev, k=1.0, h=60)
x60["acierto"] = (x60.lado * x60.ret > 0).astype(int)
x60["tendencia"] = tend[x60.sesion, x60.minuto]
x60.groupby("tendencia")["acierto"].mean().round(3)
OUT [12]Hit rate by regime with a vertical at 60 minutes
tendencia
False    0.458
True     0.621
Name: acierto, dtype: float64

With 60 minutes the hit rate rises in both regimes: 62.1% in trend and 45.8% in reversion, versus 60.4% and 44.6% with 30. The difference between them barely changes (16.3 points versus 15.8). Trend episodes last about 60 minutes on average: with a more distant vertical, the drift has more time to carry the price to the good barrier, but the probability that the episode ends before the exit also increases. The labels live twice as long (42 minutes) and overlap more, which is the cost that lesson 5.3.4 measures.

  1. Label only with the horizontal barriers (discard the events that end at the vertical) and compute the primary's hit rate by regime. What fraction of events is lost and what happens to the hit rate?
Solution
IN [13]python
toca = tb[tb.toque != 0]
print(f"events left: {len(toca)} ({len(toca) / len(tb):.1%})")
toca.groupby("tendencia")["acierto"].agg(["size", "mean"]).round(3)
OUT [13]Only the events with a horizontal touch
events left: 3039 (46.4%)
           size   mean
tendencia
False      1692  0.439
True       1347  0.653

3,039 events remain, 46.4%. Among them the contrast is sharper: a 65.3% hit rate in trend and 43.9% in reversion. But it is a selection made with the outcome: at the time of the event you do not know whether it will end at a horizontal barrier or at the vertical, so the system would trade all of them. Discarding the verticals is reasonable to train a model that learns clear moves; to measure what the system earns, it is not.

  1. A colleague computes σ\sigma with the standard deviation of all the returns of the event's session and gets “cleaner” labels. What mistake has been made and how would you detect it without looking at the code?
Solution

It uses information after the event: the volatility of the full session includes the minutes the label is going to measure. In a session that moves a lot after the event, its barriers will be wide precisely when the price is about to move, and vice versa; the labels end up “fitted” to the future. It is detected by comparing the label distribution with that of a scale known at the time: if the fraction of verticals is much more stable across sessions than with the EWMA, or if the barrier width correlates with the realized volatility of the next 30 minutes better than an honest estimator can, there is leakage. The underlying check is the look-ahead bias one from lesson 0.1.1: every figure a decision uses has to be computable with the data available at that instant.

Each answer is folded under its question

Self-assessment

  1. Why are the horizontal barriers set at multiples of σ·√h and not at a fixed percentage of the price?

    • ABecause that way all labels are +1 or −1
    • BBecause the FNX price is too high to use percentages
    • CBecause the probability of touching a fixed barrier depends on the volatility of the moment: with a fixed width, the vertical decides 25% of the labels in the first half hour and 60% at midday
    • DBecause σ·√h guarantees that the primary model is right
    Show answer

    Answer C. Because the probability of touching a fixed barrier depends on the volatility of the moment: with a fixed width, the vertical decides 25% of the labels in the first half hour and 60% at midday

    A 23 bp barrier is narrow at the open and wide at midday. Scaling it with the forecast volatility makes the label measure comparable moves at any time. The scale has to be the right one: the 100-minute EWMA lags behind the intraday U.

  2. With k = 1, the vertical barrier decides 55% of the labels with h = 15, 54% with h = 30 and 54% with h = 60. Why does it barely change?

    • ABecause the dispersion of a random walk grows with √h: if the barrier also grows with √h, the probability of touching it before h barely depends on h
    • BBy chance
    • CBecause the vertical always decides half of the labels
    • DBecause CUSUM events last 30 minutes
    Show answer

    Answer A. Because the dispersion of a random walk grows with √h: if the barrier also grows with √h, the probability of touching it before h barely depends on h

    For a driftless Brownian motion, the probability of exiting ±k·σ·√h before h depends only on k. FNX is not a pure random walk (it has regimes), but it is close: the distribution is the same with 15, 30 and 60 minutes.

  3. A bullish event hits the stop at minute 8, and after 30 minutes the price is 12 bp above the entry. What label does the triple barrier give it?

    • A+1, because the 30-minute return is positive
    • B0, because it touches two barriers
    • CNone: it is discarded
    • D−1: the trade would have been closed at the stop; what happens afterwards does not change it
    Show answer

    Answer D. −1: the trade would have been closed at the stop; what happens afterwards does not change it

    The label depends on the path. In FNX there are 77 events that touch the lower barrier and end positive at 30 minutes, and 72 the other way round. A fixed-horizon label would assign them the outcome of a trade that would not have been made.

  4. Checking the barriers with each minute's highs and lows changes 332 labels out of 6,550. What criterion decides which to use?

    • AThe one that gives more +1 labels
    • BThe one that matches how the exits would be executed: a stop resting in the market triggers within the bar (highs and lows); a rule that only looks at closes, with closes
    • CAlways closes, because highs and lows have errors
    • DIt does not matter: 332 labels are few
    Show answer

    Answer B. The one that matches how the exits would be executed: a stop resting in the market triggers within the bar (highs and lows); a rule that only looks at closes, with closes

    The label must mimic the trade that is going to be made. With highs and lows you have to decide what happens if a bar touches both barriers; in FNX with barriers of about 20 bp it never happens within one minute, but with longer bars or narrow barriers it does.

  5. With the primary model's side, the table gives 1.19 bp per trade with no profit-taking and a stop at 1·σ·√30, versus 0.76 bp with symmetric barriers (standard errors of 0.26-0.29 bp). What do you conclude?

    • ANothing solid about the combination: the differences are one or two standard errors and picking the best one in the table is overfitting; what is stable is the difference between trend (+5 to +7 bp) and reversion (−2 bp)
    • BThat you should trade without profit-taking
    • CThat the stop is useless
    • DThat the primary model is profitable
    Show answer

    Answer A. Nothing solid about the combination: the differences are one or two standard errors and picking the best one in the table is overfitting; what is stable is the difference between trend (+5 to +7 bp) and reversion (−2 bp)

    Five combinations tested on the same 6,550 events and picking the one with the highest mean is a search with selection bias (lessons 2.5.4 and 2.5.6). The signal lies in conditioning on the regime, which is what the meta-labeling of lesson 5.3.3 attempts.

Key points

  • The triple barrier labels each event with the first barrier it touches: profit-taking (+1), stop (−1) or time limit (0 or the sign of the return). It mimics a trade with a target, a stop and a time exit.
  • The horizontal barriers are scaled with the volatility forecast at the time of the event: ±k·σ·√h. With k = 1 and h = 30, in FNX the median is 23 bp and the vertical decides 54% of the labels.
  • The label depends on the path: 149 events end with a sign different from their 30-minute return, and 332 change if highs and lows are used instead of closes.
  • Over the 6,550 events, the primary model is right 50.7% of the time: 60.4% in trend and 44.6% in reversion. The label recovers the known answer; the problem is that the regime is not observed.
  • The scale decides the distribution by time of day: a fixed width or a lagging EWMA bias the labels at the open; the intraday profile of the previous sessions corrects it.
  • Each label keeps its start and its end: they are needed to measure the overlap (5.3.4) and to purge the validation (5.4.2).

Resources

Documentation

Data