---
title: "5.3.2 Método de la triple barrera"
description: "Etiquetas con toma de beneficio, stop y límite de tiempo escalados por la volatilidad, sobre 6.550 eventos de 1 minuto de un futuro ficticio."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/metodo-de-la-triple-barrera/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/metodo-de-la-triple-barrera.png"
---

# 5.3.2 · Método de la triple barrera: etiquetas que imitan una operación

> Una etiqueta de horizonte fijo pregunta dónde está el precio dentro de 30 minutos; una operación real se cierra antes si toca el stop o el objetivo. La triple barrera etiqueta cada evento con la primera de tres barreras que toca el precio. Esta lección la construye sobre un año de barras de 1 minuto de FNX, mide cómo cambia el reparto de etiquetas con la anchura, el horizonte y la escala de la volatilidad, y comprueba que recupera la respuesta conocida del modelo primario.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.3 · Etiquetado y muestreo
- Duración: 65 min
- Requisitos: [5.3.1 · Etiquetado de horizonte fijo y sus problemas](https://pythonparatrading.com/curso-python-trading/nivel-5/etiquetado-de-horizonte-fijo/), [2.1.4 · Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch](https://pythonparatrading.com/curso-python-trading/nivel-2/volatilidad-arch-garch-egarch-gjr/), [4.2.1 · Riesgo por operación, stops y riesgo de ruina](https://pythonparatrading.com/curso-python-trading/nivel-4/riesgo-por-operacion-stops-y-riesgo-de-ruina/)

## Al terminar esta lección

- Construir la triple barrera con barreras horizontales a ±k·σ·√h, escaladas con una volatilidad EWMA conocida en el momento del evento, y una barrera vertical a h minutos o al cierre de la sesión.
- Medir el reparto de etiquetas según la anchura k y el horizonte h, y explicar por qué escalar con √h lo deja casi igual al cambiar el horizonte.
- Mostrar la dependencia de la trayectoria: cuántas etiquetas cambian frente al signo a horizonte fijo y frente a usar máximos y mínimos de cada barra.
- Etiquetar con el lado de un modelo primario y barreras asimétricas sin elegir la combinación que mejor sale en los datos.
- Comprobar que la escala de la volatilidad decide el reparto por hora del día y corregir el retraso de una EWMA con el perfil intradía.

## Tres barreras en lugar de un horizonte

La lección 5.3.1 etiquetaba cada observación con el rendimiento a un horizonte fijo: sube o baja dentro de $h$ barras.
Tiene dos defectos que en trading pesan. El primero, que un umbral fijo ignora la volatilidad: un movimiento de 20 pb es
ruido en la apertura y una tendencia a mediodía. El segundo, que el rendimiento a $h$ barras no es el resultado de
ninguna operación real: quien compra con un stop sale cuando lo toca, aunque media hora después el precio haya vuelto.

El **método de la triple barrera** (López de Prado, 2018, capítulo 3) etiqueta cada evento con la primera de tres
barreras que toca el precio desde la entrada:

- **Barrera superior**, a $+k\cdot\sigma_t\sqrt{h}$ del precio de entrada: la toma de beneficio. Etiqueta $+1$.
- **Barrera inferior**, a $-k\cdot\sigma_t\sqrt{h}$: el stop. Etiqueta $-1$.
- **Barrera vertical**, $h$ barras después (o al cierre de la sesión, si llega antes): el límite de tiempo. Etiqueta
  $0$, o el signo del rendimiento hasta ese momento si se quieren solo dos clases.

Con $\sigma_t$ la volatilidad de una barra prevista en el instante del evento, $t$, y el rendimiento medido en
logaritmos desde el cierre de la barra del evento:

$$
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}\,\}.
$$

Si $\tau$ existe, la etiqueta es el signo de $r_{t,t+\tau}$; si no, la decide la vertical. Además de la etiqueta, el
método devuelve el **fin** de cada una ($t+\tau$ o $t+h$), que es el dato que las lecciones siguientes necesitan para
medir el solapamiento entre etiquetas.

```mermaid
flowchart TB
  E[Evento en t] --> V[σ prevista en t]
  V --> B[Barreras ±k·σ·√h]
  B --> C{¿Primer toque?}
  C -- superior --> P[+1]
  C -- inferior --> N[−1]
  C -- ninguno en h --> Z[0 o signo]
```

## FNX minuto a minuto

Los datos son un año de sesiones completas (247, del 1 de julio de 2024 al 30 de junio de 2025) de **FNX**, un futuro
ficticio sobre un índice, en barras de 1 minuto: 390 por sesión, de 9:30 a 16:00 de Nueva York. Son **sintéticos** y
llevan un efecto plantado (README del nivel 5): un régimen latente de **tendencia** (un tercio del tiempo, con deriva
en una dirección) o de **reversión** a la media de 30 minutos. El modelo primario de las lecciones, un momentum
disparado por un filtro CUSUM, acierta más en tendencia que en reversión; `hf5_verdad.parquet` da el régimen de cada
minuto. Aquí solo se usa para medir, nunca como 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)        # la verdad: solo para medir
print(n_ses, "sesiones de 390 minutos")
barras[["ts", "apertura", "maximo", "minimo", "cierre",
        "volumen"]].head(3)
```
```output
247 sesiones de 390 minutos
                         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
```

Como cada sesión tiene exactamente 390 minutos, los cierres caben en una matriz de 247 × 390: una fila por sesión.
Así el rendimiento de la apertura respecto al cierre del día anterior (el salto de la noche, que no es negociable con
barras de 1 minuto) queda fuera de forma natural.

La volatilidad que escala las barreras es una media móvil exponencial (EWMA) de la desviación típica de los
log-rendimientos de 1 minuto, con un *span* de 100 minutos. Se calcula sobre la serie de rendimientos encadenada y
solo con los datos hasta cada minuto (`ewm` no mira hacia delante):

```python
lr = np.diff(np.log(C), axis=1)    # 389 rendimientos por sesión
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))
ancho = sig * np.sqrt(30)
print(f"σ de 1 minuto, mediana: {1e4 * np.nanmedian(sig):.2f} pb")
print(f"±σ·√30, mediana: {1e4 * np.nanmedian(ancho):.1f} pb")
p5, p95 = 1e4 * np.nanpercentile(ancho, [5, 95])
print(f"percentiles 5 y 95: {p5:.1f} y {p95:.1f} pb")
```
```output
σ de 1 minuto, mediana: 4.22 pb
±σ·√30, mediana: 23.1 pb
percentiles 5 y 95: 15.1 y 36.8 pb
```

La desviación típica de 1 minuto ronda los 4,2 pb y la barrera de $\pm\sigma\sqrt{30}$ queda en 23,1 pb de mediana,
entre 15,1 y 36,8 pb según el momento. La regla $\sigma\sqrt{h}$ supone que los rendimientos de 1 minuto son
independientes, y en FNX casi lo son (autocorrelación de orden 1 de −0,011 según el README). Con otra estructura
(reversión fuerte a horizontes cortos) habría que escalar con la volatilidad medida a $h$ minutos.

## Eventos y etiquetas

La triple barrera se aplica a **eventos**, no a cada minuto. Aquí los eventos son los del modelo primario del nivel: un
filtro CUSUM simétrico que dispara cuando el precio acumula un movimiento de $4\sigma$ en una dirección desde el último
evento, y el lado del evento es el de la rotura (momentum). La lección 5.3.5 lo construye y estudia; aquí basta su
salida.

```python
def eventos_cusum(lr, sig, k=4.0):
    """Filtro CUSUM simétrico por sesión (lección 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), "eventos,", round(len(ev) / n_ses, 1), "por sesión")
ev.head(3).round(6)
```
```output
6550 eventos, 26.5 por sesión
   sesion  minuto  lado     sigma
0       0      16    -1  0.000889
1       0      42     1  0.000670
2       0      73    -1  0.000497
```

Salen 6.550 eventos, 26,5 por sesión, los mismos que da la función de referencia del generador. `minuto` es el índice
del cierre que dispara el evento: la entrada se hace a ese cierre, con $\sigma$ calculada hasta ese mismo minuto.

```python
def triple_barrera(C, ev, k=1.0, h=30):
    """Primer toque de ±k·σ·√h o de la vertical a h minutos.

    fin: minuto del toque; ret: log-rendimiento desde el cierre del
    minuto del evento hasta el toque; toque: +1, -1 o 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 o cierre
        tramo = np.log(C[d, m + 1:ult + 1] / C[d, m])
        golpes = np.flatnonzero(np.abs(tramo) >= b)
        if len(tramo) == 0:                 # evento en el cierre
            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("duración media:", round((tb.fin - tb.minuto).mean(), 1),
      "minutos")
print("eventos en el último minuto:", (tb.fin == tb.minuto).sum())
```
```output
toque
-1    1542
 0    3511
 1    1497
Name: count, dtype: int64
duración media: 22.5 minutos
eventos en el último minuto: 17
```

`np.flatnonzero` devuelve las posiciones donde el camino sale de la banda; la primera es el toque. Las 6.550
etiquetas se reparten en 1.497 toques superiores, 1.542 inferiores y 3.511 verticales: con $k = 1$ más de la mitad
de los eventos no se mueve una $\sigma\sqrt{30}$ en media hora. La vida media de una etiqueta es de 22,5 minutos.
Hay 17 eventos en el último minuto de la sesión: su vertical es su propia entrada, con rendimiento 0. No se pueden
operar; se conservan para que las cifras coincidan con las del README, y no mueven ninguna en la primera decimal.

La figura de la cabecera muestra uno de los eventos, el del 15 de enero de 2025 a las 11:52:

```python
hora = pd.Timestamp("2025-01-15 16:52", tz="UTC")  # 11:52 en 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"lado {lado:+d}, barreras ±{1e4 * b:.1f} pb")
print("mínimo del camino:", round(camino.min(), 1), "pb en el minuto",
      camino.argmin())
print("toque:", tb.loc[i, "toque"], "en el minuto",
      tb.loc[i, "fin"] - m, "con", round(1e4 * tb.loc[i, "ret"], 1),
      "pb")
print("rendimiento a 30 minutos:", round(camino[30], 1), "pb")
```
```output
lado +1, barreras ±19.8 pb
mínimo del camino: -14.8 pb en el minuto 10
toque: 1 en el minuto 24 con 21.5 pb
rendimiento a 30 minutos: 27.3 pb
```

El evento es alcista. El precio cae hasta −14,8 pb en el minuto 10, a 5 pb del stop, se recupera y toca la barrera
superior en el minuto 24 con +21,5 pb: etiqueta $+1$. A los 30 minutos estaba en +27,3 pb, pero ese dato no entra en
la etiqueta: la operación ya se habría cerrado.

Con la vertical convertida en el signo del rendimiento a la salida, las etiquetas quedan casi equilibradas, y el
acierto del modelo primario (el lado coincide con el signo del rendimiento hasta el toque) se puede comparar con la
respuesta conocida:

```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["todos"] = [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
todos      6550.0  0.507
```

El 1,2 % de etiqueta 0 son verticales con rendimiento exactamente nulo: el precio se mueve en ticks de 0,25 puntos y a
veces vuelve al de entrada. El primario acierta el 50,7 % de las veces (error típico de unos 0,6 puntos): por sí solo
no gana. Pero en los eventos que caen en tendencia acierta el **60,4 %**, y en reversión el **44,6 %**: es la
respuesta conocida del README. La triple barrera la recupera; lo que no puede hacer es decir en qué régimen está cada
evento, porque eso no se observa. Aprender a distinguirlo con información del momento es el trabajo del meta-labeling
(lección 5.3.3).

## Anchura y horizonte: quién decide la etiqueta

Los dos parámetros del método son la anchura $k$ y el horizonte $h$. Barriendo los dos:

```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. — Barreras a ±k·σ·√30 sobre los 6.550 eventos de FNX: cuanto más anchas, más etiquetas las decide la barrera vertical. Con k = 1, el 54 %.* Cuatro barras apiladas, una por anchura k. k = 0,5: superior 44 %, inferior 45 %, vertical 11 %, duración media 11,9 minutos. k = 1: 23 %, 24 % y 54 % (cifra destacada), 22,5 minutos. k = 1,5: 10 %, 10 % y 80 %, 26,5 minutos. k = 2: 4 %, 4 % y 92 %, 27,9 minutos.

Tres lecturas:

- **La anchura reparte las etiquetas.** Con $k = 0{,}5$, casi nueve de cada diez etiquetas las decide una barrera
  horizontal en unos 12 minutos; con $k = 2$, el 92 % las decide la vertical y la etiqueta se parece a la de horizonte
  fijo. Ninguna es la correcta en abstracto: unas barreras estrechas etiquetan ruido de corto plazo, y unas anchas
  convierten el método en lo que quería evitar.
- **El horizonte casi no cambia el reparto.** Con $k = 1$, la vertical decide el 55 %, el 54 % y el 54 % con 15, 30 y 60
  minutos. Es la consecuencia de escalar con $\sqrt{h}$: la dispersión de un paseo aleatorio crece con $\sqrt{h}$ y la
  probabilidad de tocar una barrera que crece igual es la misma a cualquier escala. Lo que sí cambia es la vida de la
  etiqueta, de 12 a 42 minutos, y con ella el solapamiento entre etiquetas (5.3.4).
- **El acierto apenas se mueve con $k$ y crece algo con $h$** (49,9 %, 50,7 % y 52,1 % con $k = 1$): los episodios de
  tendencia duran 60 minutos de media, así que un horizonte más largo deja más tiempo para que la deriva aparezca. Son
  diferencias de uno a dos errores típicos sobre los mismos eventos; no son una razón para elegir $h$ mirando esta
  tabla.

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

Elegir $k$ y $h$ es una decisión de diseño, no un hiperparámetro que se optimiza sobre el resultado. Las barreras
deben corresponder a la operación que se va a hacer (objetivo, stop y tiempo máximo que se aceptan) y fijarse antes de
mirar el acierto. Si se barren y se elige la mejor, la elección forma parte de los experimentos que cuenta el Deflated
Sharpe (lección 2.5.4).

</aside>

## La trayectoria importa

La diferencia práctica con la etiqueta de horizonte fijo se ve cruzando la barrera tocada con el signo del rendimiento
a 30 minutos:

```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=["signo a 30 min"])
```
```output
signo a 30 min  -1.0   0.0   1.0
toque
-1              1463     2    77
 0              1734    78  1699
 1                72     1  1424
```

En la mayoría de los eventos las dos etiquetas coinciden, pero no en todos: 77 eventos tocaron la barrera inferior y a
los 30 minutos estaban en positivo, y 72 tocaron la superior y acabaron en negativo. Son 149 etiquetas (el 2,3 %) que
dicen lo contrario según el método, y en todas la de horizonte fijo describe una operación que no se habría hecho
con stop y objetivo. El efecto crece con barreras más estrechas.

La trayectoria también depende de con qué precios se mire. La celda anterior compara cierres de 1 minuto; un stop real
en el mercado salta en cuanto el precio lo toca, dentro de la barra. Con los máximos y mínimos de cada minuto:

```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: los dos a la vez
    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("ambiguos (las dos en el mismo minuto):", (hl == 9).sum())
print("etiquetas que cambian:", (hl != tb.toque).sum())
pd.crosstab(tb.toque, hl, rownames=["cierres"],
            colnames=["máx./mín."])
```
```output
ambiguos (las dos en el mismo minuto): 0
etiquetas que cambian: 332
máx./mín.    -1     0     1
cierres
-1         1531     0    11
 0          170  3194   147
 1            4     0  1493
```

Cambian 332 etiquetas (el 5,1 %): 170 verticales pasan a stop y 147 a toma de beneficio, porque un mínimo o un máximo
intraminuto llega a la barrera aunque el cierre no. Ningún minuto toca las dos barreras a la vez: con barreras de unos
20 pb y una volatilidad de 4 pb por minuto es imposible en la práctica, pero con barras de 15 minutos o barreras
estrechas pasa, y entonces no se sabe qué tocó antes. Las salidas habituales son mirar la barra de un intervalo menor
o asumir lo peor (el stop primero).

La regla es la de la lección 4.2.1: la etiqueta tiene que imitar la ejecución que se va a usar. Si el sistema pondrá
órdenes stop en el mercado, máximos y mínimos; si decide al cierre de cada minuto, cierres.

## Barreras asimétricas y el lado del modelo primario

Hasta aquí las barreras eran simétricas y la etiqueta no sabía nada del lado. Cuando hay un modelo primario que decide
comprar o vender, las barreras se definen respecto a su lado: la toma de beneficio a $tp\cdot\sigma\sqrt{h}$ a favor
y el stop a $sl\cdot\sigma\sqrt{h}$ en contra, con la posibilidad de quitar una de las dos. El resultado de cada
evento es el rendimiento del lado hasta la salida:

```python
def con_lado(C, ev, tp, sl, h=30):
    """Toma de beneficio a tp·σ·√h y stop a sl·σ·√h (0 = sin ella),
    en la dirección del lado. Devuelve el rendimiento del lado."""
    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
```

Todas las combinaciones dan entre 0,7 y 1,2 pb por operación antes de costes, con errores típicos de 0,26 a 0,30 pb.
Las diferencias entre filas son de uno o dos errores típicos, y se han probado cinco sobre los mismos eventos: elegir
«sin toma de beneficio» porque da 1,19 pb sería sobreajustar la regla de salida (lección 2.5.6). Lo que sí es estable
es la columna de régimen: en tendencia, el primario gana de +4,8 a +6,9 pb por operación con cualquier salida; en
reversión pierde unos 2 pb. Las salidas cambian poco el resultado; el régimen lo cambia todo.

Con un lado, la etiqueta natural pasa a ser **binaria**: ¿ganó la operación del primario? Es la etiqueta del
meta-labeling. Un tick de FNX son unos 0,47 pb al precio medio del año (5.287 puntos), así que 0,76 pb de media por operación no paga una horquilla
y una comisión: el primario solo es operable si algo filtra los eventos de reversión.

## La escala de la volatilidad decide el reparto

La barrera solo es comparable entre eventos si $\sigma_t$ es la volatilidad que va a tener el precio en los $h$
minutos siguientes. En un intradía eso falla de forma sistemática: la volatilidad sigue una U, alta en la apertura,
baja a mediodía y algo más alta al cierre. Una EWMA de 100 minutos va **retrasada**: en la primera media hora todavía
pesa la tarde anterior, más tranquila. La celda compara tres escalas por media hora del evento: la EWMA de la lección,
una anchura fija (la mediana) y la EWMA de los rendimientos divididos por el perfil intradía de las 20 sesiones
anteriores, multiplicada después por el perfil de los 30 minutos siguientes:

$$
\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},
$$

con $u^2_m$ la varianza media del minuto $m$ de la sesión en las 20 sesiones anteriores, normalizada a media 1. Todo
lo que entra se conoce en el momento del evento: el perfil sale de sesiones pasadas.

```python
# 1) anchura fija: la mediana de σ para todos los eventos
s_fija = np.full(len(tb), np.nanmedian(tb.sigma))
# 2) perfil intradía de la varianza con las 20 sesiones anteriores
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):
    """Fracción de eventos que acaban en la barrera vertical."""
    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                      # el perfil necesita 20 sesiones
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. — Fracción de etiquetas que decide la barrera vertical según la hora del evento. La EWMA de 100 minutos va retrasada respecto a la U intradía; con el perfil de las 20 sesiones anteriores el reparto apenas depende de la hora.* Tres líneas por media hora, de 9:30 a 15:30. Con anchura fija, la vertical decide el 25 % de las etiquetas en la primera media hora y en torno al 60 % a mediodía. Con la EWMA de 100 minutos, del 41 % al 68 % y vuelta al 41 % antes del cierre. Con la EWMA y el perfil intradía (línea destacada), entre el 52 % y el 62 % todo el día. En la última media hora las tres suben porque la vertical es el cierre.

Con anchura fija, en la primera media hora la vertical decide solo el 25 % de las etiquetas (el precio se mueve mucho
y toca las barreras enseguida) y a mediodía el 60 %. La EWMA lo corrige a medias en la apertura (41 %), se pasa a media mañana (68 %), cuando ya ha
incorporado la volatilidad de la apertura y el mercado se ha calmado, y vuelve a quedarse corta a partir de las 14:30
(41-42 %), cuando la volatilidad remonta hacia el cierre y la media todavía recuerda el mediodía. Con
el perfil intradía, el reparto queda entre el 52 % y el 62 % todo el día y la dispersión entre medias horas baja de
0,096 a 0,032. La última media hora sube en las tres escalas por otra razón: la vertical es el cierre de la sesión y
llega antes de 30 minutos.

Importa porque un modelo entrenado con etiquetas sesgadas por hora aprende la hora, no la señal. Si en la apertura casi
todas las etiquetas son toques y a mediodía casi todas son verticales, una feature como «minuto de la sesión» parecerá
predictiva sin decir nada del mercado.

## Lo que hay que guardar de cada etiqueta

- **El inicio y el fin.** La etiqueta de un evento usa precios hasta su `fin`: dos etiquetas cuyas vidas se solapan
  comparten información, y una validación que entrena con una y evalúa con la otra filtra el futuro. La lección 5.3.4
  mide ese solapamiento y la 5.4.2 lo purga.
- **La escala usada.** $\sigma_t$ tiene que ser la del momento del evento; una volatilidad calculada con todo el día
  (o centrada) mira hacia delante y hace las barreras «adivinas».
- **El sentido de la vertical.** Tres clases o el signo del rendimiento: con signo, las verticales cercanas a 0 son
  etiquetas casi aleatorias; con tres clases, la clase 0 domina si las barreras son anchas.
- **La sesión.** En un futuro con sesión nocturna o sin ella, la vertical no debe cruzar un cierre si el sistema no
  mantiene posiciones de un día para otro.

## Ejercicios

1. Repite el acierto del modelo primario por régimen con barreras de $k = 1$ y vertical a 60 minutos. ¿Crece la
   diferencia entre tendencia y reversión? ¿Por qué tendría sentido con la duración de los episodios de FNX?

**Solución.**

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

Con 60 minutos el acierto sube en los dos regímenes: 62,1 % en tendencia y 45,8 % en reversión, frente a 60,4 % y
44,6 % con 30. La diferencia entre ellos casi no cambia (16,3 puntos frente a 15,8). Los episodios de tendencia duran
unos 60 minutos de media: con una vertical más lejana, la deriva tiene más tiempo para llevar el precio a la barrera
buena, pero también aumenta la probabilidad de que el episodio termine antes de la salida. Las etiquetas viven el
doble (42 minutos) y se solapan más, que es el coste que mide la lección 5.3.4.

2. Etiqueta solo con las barreras horizontales (descarta los eventos que acaban en la vertical) y calcula el acierto
   del primario por régimen. ¿Qué fracción de eventos se pierde y qué le pasa al acierto?

**Solución.**

```python
toca = tb[tb.toque != 0]
print(f"eventos que quedan: {len(toca)} ({len(toca) / len(tb):.1%})")
toca.groupby("tendencia")["acierto"].agg(["size", "mean"]).round(3)
```
```output
eventos que quedan: 3039 (46.4%)
           size   mean
tendencia
False      1692  0.439
True       1347  0.653
```

Quedan 3.039 eventos, el 46,4 %. Entre ellos el contraste es más nítido: 65,3 % de acierto en tendencia y 43,9 % en
reversión. Pero es una selección hecha con el resultado: en el momento del evento no se sabe si acabará en una barrera
horizontal o en la vertical, así que el sistema operaría todos. Descartar las verticales es razonable para entrenar
un modelo que aprenda movimientos claros; para medir lo que gana el sistema, no.

3. Un compañero calcula $\sigma$ con la desviación típica de todos los rendimientos de la sesión del evento y obtiene
   etiquetas «más limpias». ¿Qué error ha cometido y cómo lo detectarías sin mirar el código?

**Solución.**

Usa información posterior al evento: la volatilidad de la sesión completa incluye los minutos que la etiqueta va a
medir. En una sesión que se mueve mucho después del evento, sus barreras serán anchas justo cuando el precio va a
moverse, y al revés; las etiquetas quedan «ajustadas» al futuro. Se detecta comparando el reparto de etiquetas con el
de una escala conocida en el momento: si la fracción de verticales es mucho más estable entre sesiones que con la
EWMA, o si la anchura de la barrera correlaciona con la volatilidad realizada de los 30 minutos siguientes mejor de lo
que puede un estimador honesto, hay fuga. La comprobación de fondo es la del sesgo de anticipación de la lección 0.1.1: cada cifra que
usa una decisión tiene que poder calcularse con los datos que había en ese instante.

## Autoevaluación

1. ¿Por qué las barreras horizontales se fijan en múltiplos de σ·√h y no en un porcentaje fijo del precio?

   - A. Porque así todas las etiquetas son +1 o −1
   - B. Porque el precio de FNX es demasiado alto para usar porcentajes
   - C. Porque la probabilidad de tocar una barrera fija depende de la volatilidad del momento: con anchura fija, la vertical decide el 25 % de las etiquetas en la primera media hora y el 60 % a mediodía
   - D. Porque σ·√h garantiza que el modelo primario acierte

   *Respuesta C: Porque la probabilidad de tocar una barrera fija depende de la volatilidad del momento: con anchura fija, la vertical decide el 25 % de las etiquetas en la primera media hora y el 60 % a mediodía.* Una barrera de 23 pb es estrecha en la apertura y ancha a mediodía. Escalarla con la volatilidad prevista hace que la etiqueta mida movimientos comparables en cualquier momento. La escala tiene que ser la correcta: la EWMA de 100 minutos va retrasada respecto a la U intradía.

2. Con k = 1, la barrera vertical decide el 55 % de las etiquetas con h = 15, el 54 % con h = 30 y el 54 % con h = 60. ¿Por qué casi no cambia?

   - A. Porque la dispersión de un paseo aleatorio crece con √h: si la barrera también crece con √h, la probabilidad de tocarla antes de h apenas depende de h
   - B. Por casualidad
   - C. Porque la vertical siempre decide la mitad de las etiquetas
   - D. Porque los eventos CUSUM duran 30 minutos

   *Respuesta A: Porque la dispersión de un paseo aleatorio crece con √h: si la barrera también crece con √h, la probabilidad de tocarla antes de h apenas depende de h.* Para un movimiento browniano sin deriva, la probabilidad de salir de ±k·σ·√h antes de h solo depende de k. FNX no es un paseo aleatorio puro (tiene regímenes), pero casi: el reparto es el mismo con 15, 30 y 60 minutos.

3. Un evento alcista toca el stop en el minuto 8 y a los 30 minutos el precio está 12 pb por encima de la entrada. ¿Qué etiqueta le da la triple barrera?

   - A. +1, porque el rendimiento a 30 minutos es positivo
   - B. 0, porque toca dos barreras
   - C. Ninguna: se descarta
   - D. −1: la operación se habría cerrado en el stop; lo que pase después no la cambia

   *Respuesta D: −1: la operación se habría cerrado en el stop; lo que pase después no la cambia.* La etiqueta depende del camino. En FNX hay 77 eventos que tocan la barrera inferior y terminan en positivo a 30 minutos, y 72 al revés. Una etiqueta de horizonte fijo les asignaría el resultado de una operación que no se habría hecho.

4. Comprobar las barreras con los máximos y mínimos de cada minuto cambia 332 etiquetas de 6.550. ¿Qué criterio decide cuál usar?

   - A. El que dé más etiquetas +1
   - B. El que coincida con cómo se ejecutarían las salidas: un stop en el mercado salta dentro de la barra (máximos y mínimos); una regla que solo mira cierres, con cierres
   - C. Siempre los cierres, porque los máximos y mínimos tienen errores
   - D. Da igual: 332 etiquetas son pocas

   *Respuesta B: El que coincida con cómo se ejecutarían las salidas: un stop en el mercado salta dentro de la barra (máximos y mínimos); una regla que solo mira cierres, con cierres.* La etiqueta debe imitar la operación que se va a hacer. Con máximos y mínimos hay que decidir qué pasa si una barra toca las dos barreras; en FNX con barreras de unos 20 pb no ocurre nunca en un minuto, pero con barras más largas o barreras estrechas sí.

5. Con el lado del modelo primario, la tabla da 1,19 pb por operación sin toma de beneficio y stop a 1·σ·√30, frente a 0,76 pb con barreras simétricas (errores típicos de 0,26-0,29 pb). ¿Qué se concluye?

   - A. Nada sólido sobre la combinación: las diferencias son de uno o dos errores típicos y elegir la mejor de la tabla es sobreajustar; lo estable es la diferencia entre tendencia (+5 a +7 pb) y reversión (−2 pb)
   - B. Que hay que operar sin toma de beneficio
   - C. Que el stop no sirve
   - D. Que el modelo primario es rentable

   *Respuesta A: Nada sólido sobre la combinación: las diferencias son de uno o dos errores típicos y elegir la mejor de la tabla es sobreajustar; lo estable es la diferencia entre tendencia (+5 a +7 pb) y reversión (−2 pb).* Cinco combinaciones probadas sobre los mismos 6.550 eventos y elegir la de mayor media es una búsqueda con sesgo de selección (lecciones 2.5.4 y 2.5.6). La señal está en condicionar al régimen, que es lo que intenta el meta-labeling de la lección 5.3.3.

## Ideas clave

- La triple barrera etiqueta cada evento con la primera barrera que toca: toma de beneficio (+1), stop (−1) o límite de tiempo (0 o el signo del rendimiento). Imita una operación con objetivo, stop y salida por tiempo.
- Las barreras horizontales se escalan con la volatilidad prevista en el momento del evento: ±k·σ·√h. Con k = 1 y h = 30, en FNX la mediana es de 23 pb y la vertical decide el 54 % de las etiquetas.
- La etiqueta depende del camino: 149 eventos acaban con signo distinto al de su rendimiento a 30 minutos y 332 cambian si se miran máximos y mínimos en vez de cierres.
- Sobre los 6.550 eventos, el modelo primario acierta el 50,7 %: el 60,4 % en tendencia y el 44,6 % en reversión. La etiqueta recupera la respuesta conocida; el problema es que el régimen no se observa.
- La escala decide el reparto por hora del día: una anchura fija o una EWMA retrasada sesgan las etiquetas de la apertura; el perfil intradía de las sesiones anteriores lo corrige.
- Cada etiqueta guarda su inicio y su fin: hacen falta para medir el solapamiento (5.3.4) y para purgar la validación (5.4.2).

## Recursos

### Research

- [Estimadores de Volatilidad](https://pythonparatrading.com/estimadores-de-volatilidad-para-trading/) · Research-0002 · 4 min

### Documentación

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

### Libros

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

### Datos

- [Datos del nivel 5: barras de 1 minuto de FNX (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Descarga · hf5_barras_1min.parquet
- [Datos del nivel 5: régimen verdadero de FNX minuto a minuto (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_verdad.parquet) · Descarga · hf5_verdad.parquet

Lección anterior: [5.3.1 · Etiquetado de horizonte fijo y sus problemas](https://pythonparatrading.com/curso-python-trading/nivel-5/etiquetado-de-horizonte-fijo/)

Lección siguiente: [5.3.3 · Meta-labeling: un modelo para el lado y otro para el tamaño](https://pythonparatrading.com/curso-python-trading/nivel-5/meta-labeling/)
