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.
- Módulo
- 5.3 · Etiquetado y muestreo
- Duración
- 65 min
Lecciones del nivel 5
5.1 · Fundamentos de ML aplicados a finanzas
5.2 · Ingeniería de características
5.3 · Etiquetado y muestreo
- 5.3.1 Etiquetado de horizonte fijo y sus problemas
- 5.3.2 Método de la triple barrera: etiquetas que imitan una operación
- 5.3.3 Meta-labeling: un modelo para el lado y otro para el tamaño
- 5.3.4 Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial
- 5.3.5 Muestreo por eventos: el filtro CUSUM simétrico
5.4 · Validación para series financieras
5.5 · Modelos clásicos
- 5.5.1 Regresiones regularizadas: Ridge, Lasso, Elastic Net y logística
- 5.5.2 Árboles de decisión, Random Forest y bagging
- 5.5.3 Gradient boosting: XGBoost, LightGBM y CatBoost
- 5.5.4 Optuna y el riesgo de sobreajustar la validación
- 5.5.5 Aprendizaje no supervisado: PCA, clustering y autoencoders
- 5.5.6 Learning to rank para la selección de acciones
5.6 · Deep learning con Keras 3
5.7 · Deep learning con PyTorch
- 5.7.1 PyTorch: tensores, autograd y el bucle de entrenamiento
- 5.7.2 Arquitecturas de series: TCN, N-BEATS, PatchTST e iTransformer
- 5.7.3 Modelos probabilísticos: pérdida cuantílica, DeepAR e intervalos
- 5.7.4 Redes de grafos para relaciones entre activos
- 5.7.5 Optimización de carteras end-to-end: capas diferenciables y pérdida de Sharpe
5.8 · Modelos fundacionales de series temporales
- 5.8.1 Modelos fundacionales de series: preentrenar una vez, prever sin entrenar
- 5.8.2 Las familias de modelos fundacionales: arquitectura, datos y licencia
- 5.8.3 Uso práctico: contexto, cuantiles, covariables y ajuste fino
- 5.8.4 Modelos fundacionales en finanzas: qué dice la evidencia
- 5.8.5 Laboratorio: modelos fundacionales frente a GARCH, HAR y LightGBM
5.9 · Aprendizaje por refuerzo
5.10 · Interpretabilidad, robustez y riesgo de modelo
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 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 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 del precio de entrada: la toma de beneficio. Etiqueta .
- Barrera inferior, a : el stop. Etiqueta .
- Barrera vertical, barras después (o al cierre de la sesión, si llega antes): el límite de tiempo. Etiqueta , o el signo del rendimiento hasta ese momento si se quieren solo dos clases.
Con la volatilidad de una barra prevista en el instante del evento, , y el rendimiento medido en logaritmos desde el cierre de la barra del evento:
Si existe, la etiqueta es el signo de ; si no, la decide la vertical. Además de la etiqueta, el método devuelve el fin de cada una ( o ), que es el dato que las lecciones siguientes necesitan para medir el solapamiento entre etiquetas.
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.
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)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 5839Como 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):
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")σ de 1 minuto, mediana: 4.22 pb
±σ·√30, mediana: 23.1 pb
percentiles 5 y 95: 15.1 y 36.8 pbLa desviación típica de 1 minuto ronda los 4,2 pb y la barrera de queda en 23,1 pb de mediana, entre 15,1 y 36,8 pb según el momento. La regla 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 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 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.
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)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.000497Salen 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 calculada hasta ese mismo minuto.
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())toque
-1 1542
0 3511
1 1497
Name: count, dtype: int64
duración media: 22.5 minutos
eventos en el último minuto: 17np.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 más de la mitad
de los eventos no se mueve una 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:
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")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 pbEl 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 . 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:
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)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.507El 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 y el horizonte . Barriendo los dos:
filas = []
for h in (15, 30, 60):
for k in (0.5, 1.0, 1.5, 2.0):
x = triple_barrera(C, ev, k=k, h=h)
reparto = x.toque.value_counts(normalize=True)
filas.append({"h": h, "k": k,
"superior": reparto.get(1, 0.0),
"inferior": reparto.get(-1, 0.0),
"vertical": reparto.get(0, 0.0),
"acierto": (x.lado * x.ret > 0).mean(),
"minutos": (x.fin - x.minuto).mean()})
pd.DataFrame(filas).set_index(["h", "k"]).round(3) superior inferior vertical acierto minutos
h k
15 0.5 0.435 0.445 0.120 0.498 6.725
1.0 0.223 0.227 0.551 0.499 11.833
1.5 0.095 0.094 0.811 0.499 13.684
2.0 0.036 0.037 0.926 0.498 14.317
30 0.5 0.441 0.447 0.111 0.500 11.892
1.0 0.229 0.235 0.536 0.507 22.502
1.5 0.100 0.101 0.799 0.504 26.527
2.0 0.039 0.038 0.922 0.503 27.911
60 0.5 0.438 0.445 0.118 0.513 21.359
1.0 0.229 0.229 0.542 0.521 42.447
1.5 0.100 0.100 0.800 0.522 50.484
2.0 0.040 0.042 0.917 0.522 53.261Tres lecturas:
- La anchura reparte las etiquetas. Con , casi nueve de cada diez etiquetas las decide una barrera horizontal en unos 12 minutos; con , 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 , la vertical decide el 55 %, el 54 % y el 54 % con 15, 30 y 60 minutos. Es la consecuencia de escalar con : la dispersión de un paseo aleatorio crece con 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 y crece algo con (49,9 %, 50,7 % y 52,1 % con ): 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 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:
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"])signo a 30 min -1.0 0.0 1.0
toque
-1 1463 2 77
0 1734 78 1699
1 72 1 1424En 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:
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."])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 1493Cambian 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 a favor y el stop a en contra, con la posibilidad de quitar una de las dos. El resultado de cada evento es el rendimiento del lado hasta la salida:
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) 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.21Todas 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 es la volatilidad que va a tener el precio en los 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:
con la varianza media del minuto 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.
# 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{'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.62Con 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. 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
- Repite el acierto del modelo primario por régimen con barreras de 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
x60 = triple_barrera(C, ev, k=1.0, h=60)
x60["acierto"] = (x60.lado * x60.ret > 0).astype(int)
x60["tendencia"] = tend[x60.sesion, x60.minuto]
x60.groupby("tendencia")["acierto"].mean().round(3)tendencia
False 0.458
True 0.621
Name: acierto, dtype: float64Con 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.
- 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
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)eventos que quedan: 3039 (46.4%)
size mean
tendencia
False 1692 0.439
True 1347 0.653Quedan 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.
- Un compañero calcula 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
¿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.
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.
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.
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í.
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).