Lección 5.3.2 · 65 min

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.

Fig. — Un evento real de FNX: el precio roza el stop en el minuto 10 y toca la toma de beneficio en el 24. La etiqueta la decide la primera barrera tocada, no el precio a 30 minutos
Lecciones del nivel 5
  1. 5.1 · Fundamentos de ML aplicados a finanzas

    1. 5.1.1 Por qué el machine learning financiero es distinto
    2. 5.1.2 Aprendizaje supervisado, no supervisado y por refuerzo; pipelines de scikit-learn
    3. 5.1.3 Métricas económicas para modelos de trading: más allá del acierto
  2. 5.2 · Ingeniería de características

    1. 5.2.1 Features de precio, volumen y microestructura
    2. 5.2.2 Fundamentales point-in-time, macro y datos alternativos
    3. 5.2.3 Diferenciación fraccional para features
    4. 5.2.4 Normalización sin leakage y rank transversal
    5. 5.2.5 Importancia de features: MDI, MDA, SFI y SHAP
  3. 5.3 · Etiquetado y muestreo

    1. 5.3.1 Etiquetado de horizonte fijo y sus problemas
    2. 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
    3. 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
    4. 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
    5. 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
  4. 5.4 · Validación para series financieras

    1. 5.4.1 Por qué falla el k-fold aleatorio en datos financieros
    2. 5.4.2 Purged k-fold con embargo y CPCV para modelos de ML
    3. 5.4.3 Walk-forward con reentrenamiento: ventanas y frecuencia
    4. 5.4.4 Backtest de señales de ML con costes y Deflated Sharpe
  5. 5.5 · Modelos clásicos

    1. 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
    2. 5.5.2 Árboles de decisión, Random Forest y bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
    4. 5.5.4 Optuna y el riesgo de sobreajustar la validación
    5. 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
    6. 5.5.6 Learning to rank para la selección de acciones
  6. 5.6 · Deep learning con Keras 3

    1. 5.6.1 Keras 3: un modelo, varios motores de cálculo
    2. 5.6.2 Un MLP sobre un panel de acciones: regularizar y comparar
    3. 5.6.3 CNN 1D, LSTM y GRU: ventanas, escalado y fugas
    4. 5.6.4 Callbacks, una pérdida de Sharpe y el modelo en producción
  7. 5.7 · Deep learning con PyTorch

    1. 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
    2. 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
    3. 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
    4. 5.7.4 Redes de grafos para relaciones entre activos
    5. 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
  8. 5.8 · Modelos fundacionales de series temporales

    1. 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
    2. 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
    3. 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
    4. 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
    5. 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
  9. 5.9 · Aprendizaje por refuerzo

    1. 5.9.1 MDP, Q-learning, PPO y entornos Gymnasium
    2. 5.9.2 RL para ejecución, market making y cobertura dinámica
    3. 5.9.3 Por qué el RL sobreajusta en trading direccional
  10. 5.10 · Interpretabilidad, robustez y riesgo de modelo

    1. 5.10.1 SHAP, dependencia parcial y estabilidad de las importancias
    2. 5.10.2 Deriva de datos y de concepto: detectarla y qué hacer
    3. 5.10.3 Ensembles, combinación de señales y datos sintéticos

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 hh 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 hh 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⋅σth+k\cdot\sigma_t\sqrt{h} del precio de entrada: la toma de beneficio. Etiqueta +1+1.
  • Barrera inferior, a −k⋅σth-k\cdot\sigma_t\sqrt{h}: el stop. Etiqueta −1-1.
  • Barrera vertical, hh barras después (o al cierre de la sesión, si llega antes): el límite de tiempo. Etiqueta 00, o el signo del rendimiento hasta ese momento si se quieren solo dos clases.

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

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

Si τ\tau existe, la etiqueta es el signo de rt,t+τ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+τt+\tau o t+ht+h), que es el dato que las lecciones siguientes necesitan para medir el solapamiento entre etiquetas.

superiorinferiorninguno en hEvento en tσ prevista en tBarreras ±k·σ·√h¿Primer toque?+1−10 o signo
Fuente 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.

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

barras = pd.read_parquet("hf5_barras_1min.parquet")
verdad = pd.read_parquet("hf5_verdad.parquet")
n_ses = len(barras) // 390
C = barras["cierre"].to_numpy().reshape(n_ses, 390)
H = barras["maximo"].to_numpy().reshape(n_ses, 390)
L = barras["minimo"].to_numpy().reshape(n_ses, 390)
tend = (verdad["regimen"] == "tendencia").to_numpy()
tend = tend.reshape(n_ses, 390)        # la verdad: solo para medir
print(n_ses, "sesiones de 390 minutos")
barras[["ts", "apertura", "maximo", "minimo", "cierre",
        "volumen"]].head(3)
OUT [01]Las barras de FNX como matriz sesión × minuto
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):

IN [02]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")
OUT [02]Volatilidad EWMA de 1 minuto y anchura de la barrera
σ 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 ±σ30\pm\sigma\sqrt{30} queda en 23,1 pb de mediana, entre 15,1 y 36,8 pb según el momento. La regla σh\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 hh 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σ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.

IN [03]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)
OUT [03]Eventos del modelo primario (filtro CUSUM)
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.

IN [04]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())
OUT [04]La triple barrera, evento a evento
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=1k = 1 más de la mitad de los eventos no se mueve una σ30\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:

IN [05]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")
OUT [05]Un evento visto de cerca
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+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:

IN [06]python
tb["etiqueta"] = np.where(tb.toque != 0, tb.toque, np.sign(tb.ret))
tb["acierto"] = (tb.lado * tb.ret > 0).astype(int)
tb["tendencia"] = tend[tb.sesion, tb.minuto]
print(tb["etiqueta"].value_counts(normalize=True).sort_index()
      .round(3))
res = tb.groupby("tendencia")["acierto"].agg(["size", "mean"])
res.loc["todos"] = [len(tb), tb.acierto.mean()]
res.round(3)
OUT [06]Etiquetas y acierto del modelo primario
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 kk y el horizonte hh. Barriendo los dos:

IN [07]python
filas = []
for h in (15, 30, 60):
    for k in (0.5, 1.0, 1.5, 2.0):
        x = triple_barrera(C, ev, k=k, h=h)
        reparto = x.toque.value_counts(normalize=True)
        filas.append({"h": h, "k": k,
                      "superior": reparto.get(1, 0.0),
                      "inferior": reparto.get(-1, 0.0),
                      "vertical": reparto.get(0, 0.0),
                      "acierto": (x.lado * x.ret > 0).mean(),
                      "minutos": (x.fin - x.minuto).mean()})
pd.DataFrame(filas).set_index(["h", "k"]).round(3)
OUT [07]Anchura de las barreras y horizonte vertical
        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 %

Tres lecturas:

  • La anchura reparte las etiquetas. Con k=0,5k = 0{,}5, casi nueve de cada diez etiquetas las decide una barrera horizontal en unos 12 minutos; con k=2k = 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=1k = 1, la vertical decide el 55 %, el 54 % y el 54 % con 15, 30 y 60 minutos. Es la consecuencia de escalar con h\sqrt{h}: la dispersión de un paseo aleatorio crece con h\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 kk y crece algo con hh (49,9 %, 50,7 % y 52,1 % con k=1k = 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 hh mirando esta tabla.

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:

IN [08]python
fijo = np.array([np.log(C[d, min(m + 30, 389)] / C[d, m])
                 for d, m in zip(tb.sesion, tb.minuto)])
pd.crosstab(tb.toque, np.sign(fijo),
            rownames=["toque"], colnames=["signo a 30 min"])
OUT [08]Dependencia de la trayectoria
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:

IN [09]python
def primer_toque_hl(d, m, b, h=30):
    ult = min(m + h, 389)
    arriba = np.log(H[d, m + 1:ult + 1] / C[d, m]) >= b
    abajo = np.log(L[d, m + 1:ult + 1] / C[d, m]) <= -b
    ia = arriba.argmax() if arriba.any() else 10**6
    ib = abajo.argmax() if abajo.any() else 10**6
    if ia == ib:
        return 0 if ia == 10**6 else 9    # 9: 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."])
OUT [09]Barreras con máximos y mínimos de cada minuto
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⋅σhtp\cdot\sigma\sqrt{h} a favor y el stop a sl⋅σhsl\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:

IN [10]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)
OUT [10]Barreras asimétricas con el lado del primario
   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 σt\sigma_t es la volatilidad que va a tener el precio en los hh 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:

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

con um2u^2_m la varianza media del minuto mm 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.

IN [11]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
OUT [11]La escala de la volatilidad y la hora del día
{'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

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. σt\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=1k = 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
IN [12]python
x60 = triple_barrera(C, ev, k=1.0, h=60)
x60["acierto"] = (x60.lado * x60.ret > 0).astype(int)
x60["tendencia"] = tend[x60.sesion, x60.minuto]
x60.groupby("tendencia")["acierto"].mean().round(3)
OUT [12]Acierto por régimen con vertical a 60 minutos
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.

  1. 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
IN [13]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)
OUT [13]Solo los eventos con toque horizontal
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.

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

Respuesta plegada en cada pregunta

Autoevaluación

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

    • APorque así todas las etiquetas son +1 o −1
    • BPorque el precio de FNX es demasiado alto para usar porcentajes
    • CPorque 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
    • DPorque σ·√h garantiza que el modelo primario acierte
    Ver respuesta

    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?

    • APorque 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
    • BPor casualidad
    • CPorque la vertical siempre decide la mitad de las etiquetas
    • DPorque los eventos CUSUM duran 30 minutos
    Ver respuesta

    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
    • B0, porque toca dos barreras
    • CNinguna: se descarta
    • D−1: la operación se habría cerrado en el stop; lo que pase después no la cambia
    Ver respuesta

    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?

    • AEl que dé más etiquetas +1
    • BEl 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
    • CSiempre los cierres, porque los máximos y mínimos tienen errores
    • DDa igual: 332 etiquetas son pocas
    Ver respuesta

    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?

    • ANada 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)
    • BQue hay que operar sin toma de beneficio
    • CQue el stop no sirve
    • DQue el modelo primario es rentable
    Ver respuesta

    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

Documentación