Lección 5.3.3 · 70 min

Meta-labeling: un modelo para el lado y otro para el tamaño

El modelo primario de FNX acierta el 50,7 % de las veces: el 60,4 % en tendencia y el 44,6 % en reversión, pero el régimen no se observa. El meta-labeling entrena un segundo modelo, con información del momento del evento, que estima la probabilidad de que el primario acierte. Esta lección lo construye, mide cuánto del régimen recupera, compara umbrales con métricas económicas y convierte la probabilidad en un tamaño de apuesta.

Fig. — Meta-labeling en la segunda mitad de FNX: al subir el umbral de la probabilidad del modelo secundario se operan menos eventos con más precisión. La logística queda entre el primario solo y el oráculo que conoce el régimen
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

Dos decisiones, dos modelos

Un sistema de trading toma dos decisiones en cada señal: de qué lado ponerse y cuánto apostar (cero incluido). La mayoría de los modelos de ML las mezclan: un clasificador predice «sube» o «baja» y opera con tamaño fijo. El meta-labeling (López de Prado, 2018, sección 3.6) las separa:

  • El modelo primario decide el lado. Puede ser una regla técnica, un modelo econométrico, un analista fundamental o un modelo de ML. Se diseña para tener mucho recall: proponer casi todas las oportunidades, aunque muchas fallen.
  • El modelo secundario, o meta-modelo, recibe cada propuesta del primario y estima la probabilidad de que acierte. No decide el lado: decide si se opera y con qué tamaño. Su trabajo es subir la precisión.
Datos hasta tPrimario: ladoTriple barreraMeta-etiquetaFeatures en tSecundario: pOperar y tamaño
Fuente Mermaid
flowchart TB
  D[Datos hasta t] --> P[Primario: lado]
  P --> TB[Triple barrera]
  TB --> Y[Meta-etiqueta]
  D --> F[Features en t]
  F --> S[Secundario: p]
  Y --> S
  S --> Z[Operar y tamaño]

La separación tiene tres ventajas. El primario puede ser sencillo e interpretable, y el ML solo actúa como filtro, lo que reduce lo que puede sobreajustar. El tamaño sale de una probabilidad, no de una regla aparte. Y el método funciona con cualquier primario, también con uno discrecional: el secundario aprende en qué condiciones acierta ese analista.

El caso de esta lección tiene la respuesta conocida. En FNX (datos sintéticos del nivel 5), el primario, un momentum disparado por un filtro CUSUM (lección 5.3.5), acierta el 60,4 % de las veces en el régimen de tendencia y el 44,6 % en el de reversión. En conjunto, el 50,7 %: no gana. Un meta-modelo bueno debería acercarse a lo que haría quien conociera el régimen.

El modelo primario y la meta-etiqueta

Los datos, la volatilidad y las dos funciones vienen de las lecciones 5.3.2 y 5.3.5, en versión compacta:

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


def matriz(col):
    """Columna de las barras como matriz sesión x minuto."""
    return barras[col].to_numpy().reshape(n_ses, 390)


C, V = matriz("cierre"), matriz("volumen")
VC = matriz("volumen_comprador")
tend = (verdad["regimen"] == "tendencia").to_numpy()
tend = tend.reshape(n_ses, 390)        # la verdad: solo para medir
lr = np.diff(np.log(C), axis=1)
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))
print(C.shape, f"σ mediana {1e4 * np.nanmedian(sig):.2f} pb")
OUT [01]Datos de FNX y volatilidad EWMA
(247, 390) σ mediana 4.22 pb
IN [02]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:
                filas.append((d, m + 1, 1 if s_pos > h else -1,
                              sig[d, m]))
                s_pos = s_neg = 0.0
    return pd.DataFrame(
        filas, columns=["sesion", "minuto", "lado", "sigma"])


def triple_barrera(C, ev, k=1.0, h=30):
    """Barreras ±k·σ·√h y vertical a h minutos (lección 5.3.2)."""
    fin, ret = [], []
    for d, m, s in ev[["sesion", "minuto", "sigma"]].itertuples(
            index=False):
        ult = min(m + h, C.shape[1] - 1)
        tramo = np.log(C[d, m + 1:ult + 1] / C[d, m])
        golpes = np.flatnonzero(np.abs(tramo) >= k * s * np.sqrt(h))
        j = golpes[0] if len(golpes) else len(tramo) - 1
        fin.append(m + 1 + j)
        ret.append(float(tramo[j]) if len(tramo) else 0.0)
    return ev.assign(fin=fin, ret=ret)


ev = triple_barrera(C, eventos_cusum(lr, sig))
ev["y"] = (ev.lado * ev.ret > 0).astype(int)   # meta-etiqueta
ev["tendencia"] = tend[ev.sesion, ev.minuto]
print(len(ev), "eventos; acierto del primario:", round(ev.y.mean(), 3))
ev.groupby("tendencia")["y"].agg(["size", "mean"]).round(3)
OUT [02]Modelo primario y meta-etiquetas
6550 eventos; acierto del primario: 0.507
           size   mean
tendencia
False      4010  0.446
True       2540  0.604

La meta-etiqueta y es 1 si el rendimiento hasta el primer toque de la triple barrera (±σ30\pm\sigma\sqrt{30}, vertical a 30 minutos) tiene el signo del lado del primario. Son las cifras del README: 6.550 eventos y un acierto del 50,7 %, del 60,4 % en los 2.540 eventos en tendencia y del 44,6 % en los 4.010 en reversión. La columna tendencia viene de hf5_verdad.parquet y no se usará como feature: sirve para comprobar qué aprende el meta-modelo.

Features que se conocen en el evento

El meta-modelo solo puede usar lo que se sabe al cierre del minuto del evento. El README describe cuatro features de referencia, todas sobre la ventana de 30 minutos que termina en ese cierre:

  • imb_lado: el desequilibrio del flujo (compras agresoras menos ventas, sobre el volumen) con el signo del lado. Positivo cuando el flujo empuja a favor de la operación. En FNX, en tendencia, la fracción compradora tiene un sesgo persistente hacia la dirección del episodio; en los dos regímenes, además, sigue al precio de cada minuto.
  • imb_abs: su valor absoluto, la intensidad del flujo sin dirección.
  • vol_rel: el logaritmo del volumen de la ventana frente al de la misma franja horaria en las 20 sesiones anteriores. Compararlo con la misma franja quita la U intradía del volumen.
  • rv_rel: el logaritmo de la volatilidad realizada de la ventana frente a la que predecía la EWMA.
IN [03]python
d, m = ev.sesion.to_numpy(), ev.minuto.to_numpy()
cv = np.cumsum(V, axis=1)               # volumen acumulado
ci = np.cumsum(2 * VC - V, axis=1)      # compras menos ventas


def ultimos_30(acum, d, m):
    """Suma de los 30 minutos que acaban en el minuto m (incluido)."""
    antes = np.where(m >= 30, acum[d, np.maximum(m - 30, 0)], 0)
    return acum[d, m] - antes


vol30 = ultimos_30(cv, d, m)
imb30 = ultimos_30(ci, d, m) / vol30
# volumen de la misma franja en las 20 sesiones anteriores
hist = np.array([
    ultimos_30(cv, np.arange(max(0, s - 20), s),
               np.full(min(s, 20), mm)).mean() if s > 0 else np.nan
    for s, mm in zip(d, m)])
lc = np.log(C)
rv30 = np.array([np.sqrt((np.diff(lc[s, max(mm - 30, 0):mm + 1])**2)
                         .sum()) for s, mm in zip(d, m)])
X = pd.DataFrame({
    "imb_lado": ev.lado * imb30,          # a favor del lado: > 0
    "imb_abs": np.abs(imb30),
    "vol_rel": np.log(vol30 / hist),
    "rv_rel": np.log(rv30 / (ev.sigma * np.sqrt(np.minimum(m, 30)))),
})
X.describe().loc[["count", "mean", "std"]].round(3)
OUT [03]Features observables en el evento
       imb_lado   imb_abs   vol_rel    rv_rel
count  6550.000  6550.000  6528.000  6550.000
mean      0.033     0.055    -0.012     0.015
std       0.062     0.045     0.265     0.239

np.cumsum por sesión permite sumar cualquier ventana con una resta. El volumen histórico se calcula con las 20 sesiones anteriores, así que los eventos de la primera sesión no tienen vol_rel (son los 22 que faltan). El desequilibrio a favor del lado es positivo de media (0,033): es lógico, porque el CUSUM dispara después de un movimiento y el flujo sigue al precio. El meta-modelo tiene que distinguir cuándo ese flujo es persistente.

Validación: dos mitades por sesiones

La lección 5.4.2 explica por qué una validación cruzada con etiquetas solapadas necesita purga. Aquí basta una partición más simple: entrenar en las primeras 123 sesiones y evaluar en las 124 siguientes. Ninguna etiqueta cruza el cierre de una sesión, así que ninguna etiqueta de entrenamiento comparte minutos con una de prueba.

IN [04]python
ok = np.isfinite(X).all(axis=1).to_numpy()
mitad = n_ses // 2
ent = ok & (d < mitad)
pru = ok & (d >= mitad)
y, t = ev.y.to_numpy(), ev.tendencia.to_numpy()
print("entrenamiento:", ent.sum(), " prueba:", pru.sum())
print("acierto del primario en la prueba:", round(y[pru].mean(), 3))
print("eventos en tendencia en la prueba:", round(t[pru].mean(), 3))
print("etiquetas que cruzan sesiones:", (ev.fin > 389).sum())
OUT [04]Entrenar en la primera mitad, evaluar en la segunda
entrenamiento: 3241  prueba: 3287
acierto del primario en la prueba: 0.501
eventos en tendencia en la prueba: 0.402
etiquetas que cruzan sesiones: 0

En la segunda mitad el primario acierta el 50,1 % y el 40,2 % de los eventos cae en tendencia: es la población sobre la que se mide todo lo que sigue.

La baseline de una línea y el techo

Antes de entrenar nada conviene saber cuánto da la regla más simple que se le ocurriría a alguien que ha leído el README, y cuánto daría el que conoce el régimen:

IN [05]python
def resumen(sel, nombre):
    """Fracción operada, precisión y su error típico, media en pb."""
    yy = y[pru][sel]
    r = (ev.lado * ev.ret).to_numpy()[pru][sel]
    return {"filtro": nombre, "N": int(sel.sum()),
            "fraccion": sel.mean(), "precision": yy.mean(),
            "error": np.sqrt(yy.mean() * (1 - yy.mean()) / len(yy)),
            "media_pb": 1e4 * r.mean()}


filas = [resumen(np.ones(pru.sum(), bool), "todas"),
         resumen(X.imb_lado.to_numpy()[pru] > 0, "imb_lado > 0"),
         resumen(t[pru], "oráculo: tendencia")]
pd.DataFrame(filas).set_index("filtro").round(3)
OUT [05]Una baseline de una línea
                       N  fraccion  precision  error  media_pb
filtro
todas               3287     1.000      0.501  0.009     0.467
imb_lado > 0        2301     0.700      0.535  0.010     1.885
oráculo: tendencia  1323     0.402      0.597  0.013     4.925

La regla imb_lado > 0 opera el 70 % de los eventos con un 53,5 % de precisión (error típico de 1,0 puntos) y 1,9 pb de media por operación. El oráculo, que solo opera en tendencia, llega al 59,7 % y 4,9 pb: es el techo. Un meta-modelo que lo supere en la segunda mitad estaría midiendo ruido o usando información que no tiene.

El modelo secundario

Una regresión logística con las features tipificadas (lección 5.5.1), entrenada en la primera mitad:

IN [06]python
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

meta = make_pipeline(StandardScaler(), LogisticRegression())
meta.fit(X[ent], y[ent])
p = meta.predict_proba(X[pru])[:, 1]
coef = meta[-1].coef_[0].round(3).tolist()
print("coeficientes:", dict(zip(X.columns, coef)))
print("AUC frente al acierto:", round(roc_auc_score(y[pru], p), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[pru], p), 3))
filas.insert(2, resumen(p > 0.5, "logística p > 0,5"))
pd.DataFrame(filas).set_index("filtro").round(3)
OUT [06]El modelo secundario: regresión logística
coeficientes: {'imb_lado': 0.244, 'imb_abs': 0.028, 'vol_rel': 0.074, 'rv_rel': -0.011}
AUC frente al acierto: 0.575
AUC frente al régimen: 0.707
                       N  fraccion  precision  error  media_pb
filtro
todas               3287     1.000      0.501  0.009     0.467
imb_lado > 0        2301     0.700      0.535  0.010     1.885
logística p > 0,5   1729     0.526      0.545  0.012     2.502
oráculo: tendencia  1323     0.402      0.597  0.013     4.925

Los números coinciden con los de referencia del README. El coeficiente del imbalance a favor del lado (0,244) domina a los otros tres: el volumen relativo aporta algo (0,074) y la volatilidad realizada y la intensidad sin signo, casi nada. El AUC frente al acierto es 0,575, modesto, pero frente al régimen verdadero es 0,707: el modelo no ve el régimen y ha aprendido a reconocerlo bastante mejor de lo que predice cada operación, porque dentro de cada régimen el resultado sigue siendo ruidoso.

Operando solo cuando p>0,5p > 0{,}5, la precisión pasa al 54,5 % (error de 1,2 puntos) con la mitad de los eventos, y la media por operación de 0,5 a 2,5 pb. Queda entre el primario y el oráculo, como debe ser, y apenas por encima de la regla de una línea: la diferencia entre 53,5 % y 54,5 % no se distingue del ruido.

La prueba de que ha aprendido el régimen se ve por deciles de su probabilidad:

IN [07]python
dec = pd.DataFrame({"p": p, "tendencia": t[pru], "acierto": y[pru],
                    "decil": pd.qcut(p, 10, labels=False) + 1})
dec.groupby("decil").mean().round(3).T
OUT [07]Qué ha aprendido: deciles de la probabilidad
decil         1      2      3      4      5      6      7      8      9      10
p          0.407  0.443  0.462  0.479  0.496  0.513  0.533  0.556  0.589  0.650
tendencia  0.286  0.195  0.235  0.246  0.316  0.348  0.407  0.503  0.705  0.784
acierto    0.401  0.432  0.503  0.429  0.498  0.448  0.520  0.527  0.647  0.605
Fig. — El meta-modelo no ve el régimen, pero lo aprende: en el decil más alto de su probabilidad el 78 % de los eventos es de tendencia, frente al 40 % del total

En los deciles bajos, entre el 20 % y el 29 % de los eventos son de tendencia; en el más alto, el 78 %. La precisión sube con ellos: del 40 % al 60-65 %. Y la probabilidad está razonablemente calibrada: en el decil 10 el modelo dice 0,65 y acierta el 60,5 %; en el 1 dice 0,41 y acierta el 40,1 %. Si se va a usar como tamaño, esa calibración importa tanto como el AUC; con modelos de árboles conviene comprobarla y corregirla si hace falta.

Precisión, recall y el umbral

El umbral 0,5 no tiene nada de especial. Subirlo opera menos y con más precisión; bajarlo, al revés. La figura de la cabecera recorre todos los umbrales; la tabla, algunos:

IN [08]python
yp = y[pru]
r = (ev.lado * ev.ret).to_numpy()[pru]
filas_u = []
for umbral in (0.45, 0.48, 0.50, 0.52, 0.55, 0.58):
    s = p > umbral
    prec, rec = yp[s].mean(), yp[s].sum() / yp.sum()
    filas_u.append({"umbral": umbral, "operadas": s.mean(),
                    "precision": prec, "recall": rec,
                    "F1": 2 * prec * rec / (prec + rec),
                    "media_pb": 1e4 * r[s].mean(),
                    "error_pb": 1e4 * r[s].std() / np.sqrt(s.sum())})
pd.DataFrame(filas_u).set_index("umbral").round(3)
OUT [08]Precisión, recall y F1 según el umbral
        operadas  precision  recall     F1  media_pb  error_pb
umbral
0.45       0.814      0.521   0.847  0.645     1.311     0.415
0.48       0.647      0.532   0.687  0.600     2.006     0.468
0.50       0.526      0.545   0.572  0.558     2.502     0.525
0.52       0.411      0.570   0.468  0.514     3.917     0.594
0.55       0.275      0.602   0.330  0.427     5.128     0.734
0.58       0.172      0.631   0.217  0.323     6.423     0.969
  • Con umbral 0,45 se opera el 81 % con un 52,1 %; con 0,55, el 27,5 % con un 60,2 %, ya al nivel del oráculo pero con menos de un tercio de los eventos y un error típico mayor (0,73 pb en la media).
  • El F1 baja al subir el umbral: 0,645 con 0,45 y 0,427 con 0,55. Operar todo (recall 1, precisión 0,501) daría el F1 más alto de todos, 0,668. El F1 pesa igual una operación ganadora perdida que una perdedora hecha; en trading, con costes, no pesan igual. El umbral se elige con la métrica económica (lección 5.1.3): media neta por operación, número de operaciones y riesgo.
  • La media por operación sube de 1,3 a 6,4 pb, pero el número de operaciones cae a la quinta parte. El total neto, que es lo que se cobra, tiene un máximo intermedio.

¿Un modelo más flexible?

Si la logística aprende el régimen con cuatro features, un modelo no lineal podría aprender más:

IN [09]python
import lightgbm as lgb

gbm = lgb.LGBMClassifier(
    n_estimators=200, learning_rate=0.02, num_leaves=8,
    min_child_samples=50, subsample=0.8, subsample_freq=1,
    random_state=0, n_jobs=4, deterministic=True,
    force_row_wise=True, verbose=-1)
gbm.fit(X[ent], y[ent])
p_gbm = gbm.predict_proba(X[pru])[:, 1]
print("AUC frente al acierto:", round(roc_auc_score(y[pru], p_gbm), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[pru], p_gbm), 3))
pd.DataFrame([resumen(p_gbm > 0.5, "LightGBM p > 0,5")]).set_index(
    "filtro").round(3)
OUT [09]¿Un modelo más flexible? LightGBM
AUC frente al acierto: 0.567
AUC frente al régimen: 0.653
                     N  fraccion  precision  error  media_pb
filtro
LightGBM p > 0,5  1676      0.51      0.548  0.012     2.509

LightGBM (lección 5.5.3), con 200 árboles pequeños, queda peor que la logística en AUC (0,567 frente a 0,575) y en reconocer el régimen (0,653 frente a 0,707), y casi igual en precisión con p>0,5p > 0{,}5 (54,8 %). La relación que importa aquí es casi lineal en una feature; la flexibilidad solo añade varianza. Es el resultado habitual con poca señal: la baseline simple y el modelo lineal son el listón que un modelo complejo tiene que superar en datos que no ha visto, no en los de entrenamiento.

De la probabilidad al tamaño

La probabilidad del meta-modelo puede decidir el tamaño, no solo el sí o el no. La regla de López de Prado (2018, capítulo 10) trata pp como el resultado de un contraste de la hipótesis «la operación es una moneda al aire»:

z=p−12p(1−p),m=2 Φ(z)−1∈[0,1],z = \frac{p - \tfrac{1}{2}}{\sqrt{p(1-p)}}, \qquad m = 2\,\Phi(z) - 1 \in [0, 1],

con Φ\Phi la función de distribución normal y m=0m = 0 cuando p≤0,5p \le 0{,}5. El tamaño se redondea a múltiplos de un paso para no cambiar la posición por diferencias mínimas de probabilidad:

IN [10]python
from scipy.stats import norm


def tamano(p, paso=0.05):
    """2·Φ(z) - 1 con z = (p - 1/2) / √(p(1 - p)); 0 si p ≤ 1/2;
    redondeado a múltiplos de `paso`."""
    z = (p - 0.5) / np.sqrt(p * (1 - p))
    m = np.clip(2 * norm.cdf(z) - 1, 0, None)
    return np.round(m / paso) * paso


for q in (0.50, 0.55, 0.60, 0.65, 0.70):
    print(f"p = {q:.2f}  tamaño = {tamano(np.array([q]))[0]:.2f}")
tam = tamano(p)
print("tamaño medio:", round(tam.mean(), 3), " con tamaño > 0:",
      (tam > 0).sum())
OUT [10]Tamaño de la apuesta desde la probabilidad
p = 0.50  tamaño = 0.00
p = 0.55  tamaño = 0.10
p = 0.60  tamaño = 0.15
p = 0.65  tamaño = 0.25
p = 0.70  tamaño = 0.35
tamaño medio: 0.056  con tamaño > 0: 1441

La regla es prudente: con p=0,6p = 0{,}6 apuesta 0,15 del tamaño máximo y con 0,7 solo 0,35. Con las probabilidades de FNX, casi todas entre 0,40 y 0,65, el tamaño medio es 0,056 y 1.441 eventos reciben un tamaño mayor que cero. Para compararla con el filtro hay que llevarla a la misma exposición total: multiplicar los tamaños por 9,4. La lección 4.2.2 explica por qué el tamaño absoluto es una decisión de riesgo aparte.

El resultado económico en la segunda mitad

La P&G de cada operación es el tamaño por el rendimiento del lado hasta la salida, menos un coste. El coste supuesto es de 0,6 pb por operación completa y unidad de tamaño: un tick de FNX son unos 0,47 pb al precio medio del año (horquilla de un tick, pagada a medias al entrar y al salir) más una comisión. Es un supuesto, no un dato del mercado; la lección 2.4.7 explica cómo modelarlo.

IN [11]python
COSTE = 0.6e-4        # por operación completa y unidad de tamaño
ses_pru = d[pru]


def pyg(tam_op):
    """P&G neta por operación y Sharpe anualizado de la P&G diaria."""
    neto = tam_op * r - COSTE * tam_op
    diario = pd.Series(neto).groupby(ses_pru).sum()
    sr = diario.mean() / diario.std() * np.sqrt(252)
    return {"exposicion": tam_op.sum(),
            "bruto_pb_por_unidad": 1e4 * (tam_op * r).sum()
            / tam_op.sum(),
            "neto_total_pb": 1e4 * neto.sum(), "sharpe": sr,
            "error_sharpe": np.sqrt((1 + sr**2 / 504) / len(diario)
                                    * 252)}


escala = (p > 0.5).sum() / tam.sum()   # misma exposición total
res = pd.DataFrame({
    "todas": pyg(np.ones(len(p))),
    "logística p > 0,5": pyg((p > 0.5).astype(float)),
    "tamaño 2Φ(z) - 1": pyg(tam * escala),
    "oráculo": pyg(t[pru].astype(float)),
}).T
print(f"escala del tamaño: x{escala:.1f}")
res.round(2)
OUT [11]Resultado en la segunda mitad, con costes
escala del tamaño: x9.4
                   exposicion  bruto_pb_por_unidad  neto_total_pb  sharpe  error_sharpe
todas                  3287.0                 0.47        -436.04   -0.45          1.43
logística p > 0,5      1729.0                 2.50        3288.91    3.75          1.45
tamaño 2Φ(z) - 1       1729.0                 4.51        6768.89    5.12          1.46
oráculo                1323.0                 4.93        5722.38    8.01          1.51
Fig. — P&G neta acumulada en la segunda mitad (0,6 pb de coste por operación y unidad): el primario solo pierde; filtrado por el meta-modelo gana, y dimensionado con su probabilidad, a la misma exposición total, gana más. Efecto plantado en datos sintéticos
  • Operar todo pierde: 0,47 pb brutos por operación no pagan 0,6 de coste.
  • Filtrar con p>0,5p > 0{,}5 gana 2,5 pb brutos por operación y 3.289 pb netos en 124 sesiones, con un Sharpe anualizado de 3,75.
  • Dimensionar con 2Φ(z)−12\Phi(z) - 1, a la misma exposición total, gana 4,5 pb por unidad de tamaño, muy cerca del oráculo (4,9), y 6.769 pb netos: concentra el tamaño en los eventos más claros.
  • El oráculo opera menos (1.323 unidades) con el mejor resultado por unidad y un Sharpe de 8,0.

Dos advertencias. El error típico de cada Sharpe es de unos 1,45 con 124 sesiones: la diferencia entre el filtro y el tamaño (3,75 frente a 5,12) es de un error típico y no permite elegir entre ellos con esta muestra. Y el efecto de régimen está plantado con un tamaño grande para que el método se vea: en un mercado real la señal es más débil, cambia con el tiempo y la explotan otros. Lo que la lección demuestra es que el meta-labeling recupera una estructura que existe; no que exista en ningún futuro real.

Errores típicos

  • Features con información posterior. Cualquier ventana que incluya el minuto siguiente al evento, o un volumen «del día» completo, filtra el resultado. En el meta-modelo es especialmente fácil, porque las features se calculan después de conocer los eventos.
  • Entrenar el secundario con las mismas etiquetas con las que se ajustó el primario. Si el umbral del CUSUM o las barreras se eligieron mirando el acierto, el secundario aprende sobre una muestra seleccionada.
  • Usar la probabilidad como tamaño sin comprobar la calibración. Una probabilidad mal calibrada da tamaños sistemáticamente grandes o pequeños.
  • Medir con accuracy o F1. El objetivo es económico; el meta-modelo puede empeorar el F1 y mejorar el resultado.
  • Olvidar el solapamiento. Las meta-etiquetas de eventos cercanos comparten minutos; para validación cruzada dentro del entrenamiento hace falta purga y embargo (5.4.2), y para el bagging, pesos por unicidad (5.3.4).

Ejercicios

  1. Invierte las mitades: entrena el meta-modelo en la segunda y evalúalo en la primera. ¿Se mantienen el AUC y la precisión con p>0,5p > 0{,}5?
Solución
IN [12]python
meta2 = make_pipeline(StandardScaler(), LogisticRegression())
meta2.fit(X[pru], y[pru])
p2 = meta2.predict_proba(X[ent])[:, 1]
print("AUC frente al acierto:", round(roc_auc_score(y[ent], p2), 3))
print("AUC frente al régimen:", round(roc_auc_score(t[ent], p2), 3))
s2 = p2 > 0.5
print(f"operadas {s2.mean():.3f}  precisión {y[ent][s2].mean():.3f}"
      f"  (todas: {y[ent].mean():.3f})")
OUT [12]Las mitades al revés
AUC frente al acierto: 0.574
AUC frente al régimen: 0.674
operadas 0.535  precisión 0.564  (todas: 0.513)

Con las mitades al revés el resultado es casi el mismo: AUC de 0,574 frente al acierto y 0,674 frente al régimen, y una precisión del 56,4 % operando el 53,5 % de los eventos, frente al 51,3 % de todos los de la primera mitad. Que la mejora se mantenga en las dos direcciones es una comprobación barata de estabilidad; no sustituye a una validación con reentrenamiento (lección 5.4.3), pero descarta que el resultado dependa de una mitad afortunada.

  1. Añade como feature si el evento anterior de la misma sesión tuvo el mismo lado (+1, −1 o 0 si es el primero). En tendencia los eventos seguidos suelen repetir lado. ¿Mejora el AUC fuera de muestra?
Solución
IN [13]python
misma = (ev.sesion == ev.sesion.shift()).to_numpy()
prev = np.where(misma, ev.lado * ev.lado.shift().fillna(0), 0)
print("mismo lado que el anterior, en tendencia / reversión:",
      round((prev[t] == 1).mean(), 3), round((prev[~t] == 1).mean(), 3))
X5 = X.assign(prev_igual=prev)
meta5 = make_pipeline(StandardScaler(), LogisticRegression())
meta5.fit(X5[ent], y[ent])
p5 = meta5.predict_proba(X5[pru])[:, 1]
print("AUC con la feature:", round(roc_auc_score(y[pru], p5), 3),
      " sin ella:", round(roc_auc_score(y[pru], p), 3))
OUT [13]Una feature más: el lado del evento anterior
mismo lado que el anterior, en tendencia / reversión: 0.6 0.434
AUC con la feature: 0.574  sin ella: 0.575

En tendencia, el 60 % de los eventos repite el lado del anterior; en reversión, el 43,4 %. La feature tiene información, pero el AUC no mejora (0,574 frente a 0,575): lo que dice ya lo dice el imbalance a favor del lado, que también mide la persistencia del flujo. Añadir features que repiten la misma información no ayuda y suma varianza; la lección 5.2.5 enseña a medirlo con la importancia por grupos de features correlacionadas.

  1. Una gestora tiene un analista que recomienda comprar o vender índices sectoriales una vez por semana. ¿Cómo aplicarías el meta-labeling a sus recomendaciones y qué cuidarías especialmente?
Solución

El analista es el modelo primario: sus recomendaciones dan el lado. Cada recomendación se etiqueta con una triple barrera coherente con cómo se gestionaría la posición (por ejemplo, ±1,5 desviaciones semanales y vertical a cuatro semanas) y la meta-etiqueta es si ganó. El secundario usa solo información disponible en la fecha de cada recomendación: régimen de volatilidad, momentum del sector, dispersión, tiempo desde la última recomendación, incluso el historial reciente de aciertos del analista. Hay que cuidar tres cosas. Pocas observaciones: con 52 recomendaciones al año, un modelo lineal con pocas features y una validación con purga (las etiquetas de cuatro semanas se solapan). Que el analista cambie: su proceso no es estacionario, y el meta-modelo debe reentrenarse y vigilarse (5.10.2). Y que la fecha de cada recomendación sea la real, no la de su registro posterior.

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿Qué predice exactamente el modelo secundario en meta-labeling?

    • ASi el precio va a subir o a bajar
    • BEl régimen de mercado
    • CLa anchura óptima de las barreras
    • DLa probabilidad de que la operación propuesta por el modelo primario, con su lado, sea ganadora según la triple barrera
    Ver respuesta

    Respuesta D. La probabilidad de que la operación propuesta por el modelo primario, con su lado, sea ganadora según la triple barrera

    El lado lo pone el primario; la meta-etiqueta es 1 si su operación acierta y 0 si no. El secundario decide si operar y cuánto. Que su probabilidad acabe pareciéndose al régimen (AUC de 0,707 frente a él) es una consecuencia, no su objetivo.

  2. En la segunda mitad, la regla imb_lado > 0 opera el 70 % de los eventos con una precisión del 53,5 % y la logística (p > 0,5) el 52,6 % con un 54,5 %, con errores típicos de 1,0 y 1,2 puntos. ¿Qué se concluye?

    • AQue la logística es mucho mejor
    • BQue casi toda la mejora la da una feature y una regla de una línea; la logística añade poco y su ventaja sobre la regla está dentro del ruido
    • CQue la regla es mejor porque opera más
    • DQue el meta-labeling no funciona
    Ver respuesta

    Respuesta B. Que casi toda la mejora la da una feature y una regla de una línea; la logística añade poco y su ventaja sobre la regla está dentro del ruido

    La baseline simple es la vara: el coeficiente tipificado del imbalance a favor del lado (0,244) domina a los otros tres. Un LightGBM con las mismas features da un AUC de 0,567, peor que la logística (0,575).

  3. Con el umbral en 0,5, el F1 del meta-modelo es 0,558; el del primario solo (operar todo: recall 1, precisión 0,501) es 0,668. ¿Por qué no es un argumento contra el meta-modelo?

    • APorque el F1 pesa igual precisión y recall, y en trading perder operaciones dudosas cuesta poco: la P&G neta pasa de −436 a +3.289 pb en la segunda mitad
    • BPorque el F1 está mal calculado
    • CPorque el F1 solo vale para tres clases
    • DPorque el recall no importa nunca
    Ver respuesta

    Respuesta A. Porque el F1 pesa igual precisión y recall, y en trading perder operaciones dudosas cuesta poco: la P&G neta pasa de −436 a +3.289 pb en la segunda mitad

    Operar todo maximiza el recall con precisión de moneda al aire; con costes eso pierde. La métrica que decide es la económica (lección 5.1.3): resultado neto por operación y Sharpe con su error.

  4. Con p = 0,60, ¿qué tamaño da la regla 2Φ(z) − 1 con z = (p − 0,5)/√(p(1 − p))?

    • A0,60
    • B1, porque p > 0,5
    • CUnos 0,16 (0,15 tras redondear a múltiplos de 0,05): la regla es prudente con probabilidades cercanas a 0,5
    • D0,20
    Ver respuesta

    Respuesta C. Unos 0,16 (0,15 tras redondear a múltiplos de 0,05): la regla es prudente con probabilidades cercanas a 0,5

    z = 0,1/0,49 = 0,204 y 2Φ(0,204) − 1 = 0,162. En la segunda mitad el tamaño medio es 0,056: para compararlo con el filtro hay que escalarlo a la misma exposición total (×9,4).

  5. El meta-modelo da un Sharpe de 3,75 en la segunda mitad (error típico de 1,45). ¿Cómo hay que leerlo?

    • AComo la rentabilidad esperada de la estrategia en un futuro real
    • BComo un resultado sobreajustado que hay que descartar
    • CComo el máximo posible: el oráculo da menos
    • DComo la confirmación de que el método recupera un efecto plantado en datos sintéticos, con un error típico enorme por tener 124 sesiones, sin deslizamiento ni competencia
    Ver respuesta

    Respuesta D. Como la confirmación de que el método recupera un efecto plantado en datos sintéticos, con un error típico enorme por tener 124 sesiones, sin deslizamiento ni competencia

    El efecto de régimen está plantado con un tamaño conocido; el oráculo que lo conoce da un Sharpe de 8,0. En un mercado real los efectos son menores, cambian y otros los compiten. Y seis meses de datos dejan un intervalo de ±2,9 en el Sharpe.

Ideas clave

  • El meta-labeling separa dos decisiones: el primario elige el lado con mucho recall; el secundario estima la probabilidad de que acierte y decide si operar y cuánto.
  • La meta-etiqueta sale de la triple barrera con el lado del primario: 1 si la operación gana. Las features tienen que conocerse al cierre del minuto del evento.
  • En FNX el meta-modelo pasa del 50,1 % de precisión al 54,5 % operando la mitad de los eventos (el oráculo del régimen, 59,7 %), con un AUC de 0,575 frente al acierto y de 0,707 frente al régimen que no ve.
  • La baseline de una línea (imb_lado > 0) da el 53,5 %: casi toda la mejora es de una feature. Un LightGBM no la mejora.
  • El umbral se elige con métricas económicas: el F1 baja, pero la P&G neta pasa de −436 a +3.289 pb en la segunda mitad.
  • La probabilidad se convierte en tamaño con 2Φ(z) − 1: a igual exposición mejora el resultado por unidad (4,5 pb frente a 2,5), con un Sharpe cuyo error típico (1,45) impide separar las variantes.

Recursos