---
title: "5.3.2 Triple-barrier labeling method"
description: "Labels with profit-taking, stop-loss and time limit scaled by volatility, on 6,550 one-minute events of a fictitious future."
url: "https://pythonparatrading.com/en/python-trading-course/level-5/triple-barrier-labeling-method/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-5/triple-barrier-labeling-method.png"
---

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

- Course: [Level 5: Machine learning and deep learning (Keras/PyTorch)](https://pythonparatrading.com/en/python-trading-course/level-5/)
- Module: 5.3 · Labelling and sampling
- Duration: 65 min
- Prerequisites: [5.3.1 · Fixed-horizon labeling and its problems](https://pythonparatrading.com/en/python-trading-course/level-5/fixed-horizon-labeling/), [2.1.4 · Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library](https://pythonparatrading.com/en/python-trading-course/level-2/volatility-models-arch-garch-egarch-gjr/), [4.2.1 · Risk per trade, stops and risk of ruin](https://pythonparatrading.com/en/python-trading-course/level-4/risk-per-trade-stops-and-risk-of-ruin/)

## By the end of this lesson

- Build the triple barrier with horizontal barriers at ±k·σ·√h, scaled by an EWMA volatility known at the time of the event, and a vertical barrier at h minutes or at the session close.
- Measure the label distribution as a function of the width k and the horizon h, and explain why scaling with √h leaves it almost unchanged when the horizon changes.
- Show path dependence: how many labels change compared with the fixed-horizon sign and compared with using each bar's highs and lows.
- Label with the side of a primary model and asymmetric barriers without picking the combination that looks best on the data.
- Check that the volatility scale decides the distribution by time of day and correct the lag of an EWMA with the intraday profile.

## Three barriers instead of one horizon

Lesson 5.3.1 labeled each observation with the return at a fixed horizon: up or down within $h$ 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 $h$ 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\cdot\sigma_t\sqrt{h}$ from the entry price: the profit-taking. Label $+1$.
- **Lower barrier**, at $-k\cdot\sigma_t\sqrt{h}$: the stop. Label $-1$.
- **Vertical barrier**, $h$ bars later (or at the session close, if that comes first): the time limit. Label
  $0$, or the sign of the return up to that moment if only two classes are wanted.

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

$$
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 $r_{t,t+\tau}$; if not, the vertical decides it. Besides the label, the
method returns the **end** of each one ($t+\tau$ or $t+h$), which is the piece of data the following lessons need to
measure the overlap between labels.

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

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

```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")
```
```output
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 $\pm\sigma\sqrt{30}$ barrier has a median of 23.1 bp,
between 15.1 and 36.8 bp depending on the moment. The $\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 $h$ 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\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.

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

```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())
```
```output
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 = 1$ more than half of the events do
not move one $\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:

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

```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)
```
```output
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 $k$ and the horizon $h$. Sweeping both:

```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)
```
```output
        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%.* Four stacked bars, one per width k. k = 0.5: upper 44%, lower 45%, vertical 11%, mean duration 11.9 minutes. k = 1: 23%, 24% and 54% (highlighted figure), 22.5 minutes. k = 1.5: 10%, 10% and 80%, 26.5 minutes. k = 2: 4%, 4% and 92%, 27.9 minutes.

Three readings:

- **The width distributes the labels.** With $k = 0.5$, almost nine out of ten labels are decided by a horizontal
  barrier in about 12 minutes; with $k = 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 = 1$, the vertical decides 55%, 54% and 54% with 15, 30
  and 60 minutes. It is the consequence of scaling with $\sqrt{h}$: the dispersion of a random walk grows with
  $\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 $k$ and grows somewhat with $h$** (49.9%, 50.7% and 52.1% with $k = 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 $h$ by looking at this
  table.

<aside class="callout" data-kind="aviso">

Choosing $k$ and $h$ is a design decision, not a hyperparameter optimized on the outcome. The barriers must correspond
to the trade that is going to be made (the target, stop and maximum time that are accepted) and be fixed before
looking at the hit rate. If they are swept and the best one is chosen, the choice is part of the experiments that the
Deflated Sharpe counts (lesson 2.5.4).

</aside>

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

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

```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"])
```
```output
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\cdot\sigma\sqrt{h}$ in
favor and the stop at $sl\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:

```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)
```
```output
   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 $\sigma_t$ is the volatility the price is going to have over the next
$h$ 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:

$$
\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 $u^2_m$ the mean variance of minute $m$ 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.

```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
```
```output
{'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.* Three lines by half hour, from 9:30 to 15:30. With a fixed width, the vertical decides 25% of the labels in the first half hour and about 60% at midday. With the 100-minute EWMA, from 41% to 68% and back to 41% before the close. With the EWMA and the intraday profile (highlighted line), between 52% and 62% all day. In the last half hour all three rise because the vertical is the close.

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.** $\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 = 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.**

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

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

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

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

## Self-assessment

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

   - A. Because that way all labels are +1 or −1
   - B. Because the FNX price is too high to use percentages
   - 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
   - D. Because σ·√h guarantees that the primary model is right

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

   - 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
   - B. By chance
   - C. Because the vertical always decides half of the labels
   - D. Because CUSUM events last 30 minutes

   *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
   - B. 0, because it touches two barriers
   - C. None: it is discarded
   - D. −1: the trade would have been closed at the stop; what happens afterwards does not change it

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

   - A. The one that gives more +1 labels
   - 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
   - C. Always closes, because highs and lows have errors
   - D. It does not matter: 332 labels are few

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

   - 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)
   - B. That you should trade without profit-taking
   - C. That the stop is useless
   - D. That the primary model is profitable

   *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

### Research

- [Volatility Estimators for Trading](https://pythonparatrading.com/en/volatility-estimators-for-trading/) · Research-0002 · 4 min

### Documentation

- [pandas: Series.ewm](https://pandas.pydata.org/docs/reference/api/pandas.Series.ewm.html) · pandas.pydata.org
- [NumPy: numpy.flatnonzero](https://numpy.org/doc/stable/reference/generated/numpy.flatnonzero.html) · numpy.org

### Books

- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), chapter 3, “Labeling” (3.4, “Triple-Barrier Method”)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com

### Data

- [Level 5 data: FNX 1-minute bars (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Download · hf5_barras_1min.parquet
- [Level 5 data: true FNX regime minute by minute (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_verdad.parquet) · Download · hf5_verdad.parquet

Previous lesson: [5.3.1 · Fixed-horizon labeling and its problems](https://pythonparatrading.com/en/python-trading-course/level-5/fixed-horizon-labeling/)

Next lesson: [5.3.3 · Meta-labeling: one model for the side and another for the size](https://pythonparatrading.com/en/python-trading-course/level-5/meta-labeling-side-and-size/)
