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.
- Module
- 5.3 · Labelling and sampling
- Duration
- 65 min
- Prerequisites
- 5.3.1 · Fixed-horizon labeling and its problems, 2.1.4 · Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library, 4.2.1 · Risk per trade, stops and risk of ruin
- Author
- Jesús Cuesta
- Updated
Level 5 lessons
5.1 · ML fundamentals applied to finance
5.2 · Feature engineering
5.3 · Labelling and sampling
5.4 · Validation for financial series
5.5 · Classical models
- 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
- 5.5.2 Decision trees, Random Forest and bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
- 5.5.4 Optuna and the risk of overfitting the validation
- 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
- 5.5.6 Learning to rank for stock selection
5.6 · Deep learning with Keras 3
5.7 · Deep learning with PyTorch
- 5.7.1 PyTorch: tensors, autograd and the training loop
- 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
- 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
- 5.7.4 Graph neural networks for asset relationships
- 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
5.8 · Time series foundation models
- 5.8.1 Time series foundation models: pretrain once, forecast without training
- 5.8.2 Foundation model families: architecture, data and license
- 5.8.3 In practice: context, quantiles, covariates and fine-tuning
- 5.8.4 Foundation models in finance: what the evidence says
- 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
5.9 · Reinforcement learning
5.10 · Interpretability, robustness and model risk
Three barriers instead of one horizon
Lesson 5.3.1 labeled each observation with the return at a fixed horizon: up or down within 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 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 from the entry price: the profit-taking. Label .
- Lower barrier, at : the stop. Label .
- Vertical barrier, bars later (or at the session close, if that comes first): the time limit. Label , or the sign of the return up to that moment if only two classes are wanted.
With the one-bar volatility forecast at the time of the event, , and the return measured in logarithms from the close of the event bar:
If exists, the label is the sign of ; if not, the vertical decides it. Besides the label, the method returns the end of each one ( or ), which is the piece of data the following lessons need to measure the overlap between labels.
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.
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)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 5839Since 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):
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")1-minute σ, median: 4.22 bp
±σ·√30, median: 23.1 bp
5th and 95th percentiles: 15.1 and 36.8 bpThe 1-minute standard deviation is around 4.2 bp and the barrier has a median of 23.1 bp, between 15.1 and 36.8 bp depending on the moment. The 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 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 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.
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)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.000497There 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 computed up to that same
minute.
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())toque
-1 1542
0 3511
1 1497
Name: count, dtype: int64
mean duration: 22.5 minutes
events in the last minute: 17np.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 more than half of the events do
not move one 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:
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")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 bpThe 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 . 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:
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)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.507The 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 and the horizon . Sweeping both:
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) 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.261Three readings:
- The width distributes the labels. With , almost nine out of ten labels are decided by a horizontal barrier in about 12 minutes; with , 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 , the vertical decides 55%, 54% and 54% with 15, 30 and 60 minutes. It is the consequence of scaling with : the dispersion of a random walk grows with , 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 and grows somewhat with (49.9%, 50.7% and 52.1% with ): 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 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:
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"])sign at 30 min -1.0 0.0 1.0
toque
-1 1463 2 77
0 1734 78 1699
1 72 1 1424In 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:
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"])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 1493332 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 in favor and the stop at 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:
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) 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.21All 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 is the volatility the price is going to have over the next 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:
with the mean variance of minute 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.
# 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{'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.62With 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. 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
- Repeat the primary model's hit rate by regime with 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
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)tendencia
False 0.458
True 0.621
Name: acierto, dtype: float64With 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.
- 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
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)events left: 3039 (46.4%)
size mean
tendencia
False 1692 0.439
True 1347 0.6533,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.
- A colleague computes 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
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.
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.
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.
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.
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).