Lección 5.3.5 · 55 min

Muestreo por eventos: el filtro CUSUM simétrico

Un modelo que mira el mercado cada minuto pasa la mayor parte del tiempo observando ruido. El filtro CUSUM simétrico muestrea solo cuando el precio ha acumulado un movimiento suficiente desde la última observación. Esta lección lo construye sobre FNX, estudia el umbral fijo y el escalado por la volatilidad, mide cuántos eventos da cada umbral y en qué régimen caen, y lo compara con muestrear cada cierto tiempo y con una regla de bandas.

Fig. — Una sesión de FNX con los 32 eventos del filtro CUSUM simétrico (umbral 4σ): triángulos hacia arriba para las roturas al alza y hacia abajo para las bajistas. En la tendencia de la mañana los eventos repiten lado; en la reversión de la tarde alternan
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

Cuándo observar

Las lecciones 5.3.1 y 5.3.2 etiquetaban observaciones; falta decidir qué observaciones. La opción por defecto es una por barra: cada minuto, cada día. Tiene dos problemas. El primero es que la mayoría de esas observaciones son ruido: un minuto de mediodía en el que el precio no se mueve no enseña nada sobre la señal que se busca. El segundo es que observaciones consecutivas son casi iguales: features que cambian poco y etiquetas que se solapan, el problema que medía la lección 5.3.4.

La lección 1.4.5 atacaba un problema parecido al construir las barras: barras de ticks, de volumen o de dólar, que se cierran cuando hay actividad y no cuando pasa el reloj. El muestreo por eventos (López de Prado, 2018, sección 2.5.2) va un paso más allá: dada una serie, sea de barras de tiempo o de actividad, solo se observa cuando ha pasado algo relevante. El filtro más usado es el CUSUM simétrico, una variante de las sumas acumuladas de Page (1954) que la lección 4.2.5 usaba para detectar que una estrategia había dejado de funcionar.

El filtro CUSUM simétrico

Con yty_t el log-rendimiento de cada barra, el filtro lleva dos sumas, una de subidas y otra de bajadas, recortadas en cero:

St+=max⁡(0,  St−1++yt),St−=min⁡(0,  St−1−+yt).S^+_t = \max\left(0,\; S^+_{t-1} + y_t\right), \qquad S^-_t = \min\left(0,\; S^-_{t-1} + y_t\right).

Cuando St+>htS^+_t > h_t hay un evento alcista; cuando St−<−htS^-_t < -h_t, uno bajista. Tras cada evento las dos sumas vuelven a cero. El recorte en cero hace que una bajada no se compense con una subida anterior: S+S^+ mide la subida acumulada desde el último mínimo relativo, y el evento llega cuando esa subida supera hth_t. La versión de López de Prado resta a yty_t su media esperada, que en rendimientos de un minuto es cero a todos los efectos.

sin pasar de hS+ > h o S− < −hreinicio a 0AcumularEvento
Fuente Mermaid
stateDiagram-v2
  [*] --> Acumular
  Acumular --> Acumular: sin pasar de h
  Acumular --> Evento: S+ > h o S− < −h
  Evento --> Acumular: reinicio a 0

Los datos son los de FNX (datos sintéticos del nivel 5): un año de barras de 1 minuto de un futuro ficticio con un régimen latente de tendencia o de reversión, que hf5_verdad.parquet da minuto a minuto. Como en las lecciones anteriores, el régimen solo se usa para medir.

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)
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)        # 389 por sesión
sig = (pd.Series(lr.ravel()).ewm(span=100).std()
       .to_numpy().reshape(n_ses, 389))
print(f"minutos en tendencia: {tend.mean():.1%}")
OUT [01]FNX: rendimientos de 1 minuto y volatilidad EWMA
minutos en tendencia: 35.3%

El 35,3 % de los minutos del año está en tendencia. El umbral del modelo primario del nivel es ht=4 σ^th_t = 4\,\hat\sigma_t, con σ^t\hat\sigma_t la desviación EWMA (span de 100 minutos) de los rendimientos de 1 minuto calculada hasta el propio minuto: se conoce al cierre del minuto que dispara el evento, que es cuando se decide.

IN [02]python
def cusum(lr, umbral):
    """Filtro CUSUM simétrico, sesión a sesión.

    lr: log-rendimientos (sesiones x minutos); umbral: misma forma
    (o un número). Acumula subidas en s_pos y bajadas en s_neg desde
    el último evento; cuando una pasa del umbral, evento y las dos
    vuelven a 0. Devuelve sesión, minuto (índice del cierre que
    dispara), lado (+1 rotura al alza) y el umbral usado."""
    h = np.broadcast_to(umbral, lr.shape)
    filas = []
    for d in range(lr.shape[0]):
        s_pos = s_neg = 0.0
        for m in range(lr.shape[1]):
            if not np.isfinite(h[d, m]):
                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[d, m] or s_neg < -h[d, m]:
                lado = 1 if s_pos > h[d, m] else -1
                filas.append((d, m + 1, lado, h[d, m]))
                s_pos = s_neg = 0.0
    return pd.DataFrame(
        filas, columns=["sesion", "minuto", "lado", "umbral"])


ev = cusum(lr, 4.0 * sig)
print(len(ev), "eventos,", round(len(ev) / n_ses, 1), "por sesión")
print("minutos entre eventos (mediana):",
      ev.groupby("sesion").minuto.diff().median())
ev[ev.sesion == 133].head(6).assign(
    umbral_pb=lambda x: (1e4 * x.umbral).round(1)).drop(
    columns="umbral")
OUT [02]El filtro CUSUM simétrico
6550 eventos, 26.5 por sesión
minutos entre eventos (mediana): 13.0
      sesion  minuto  lado  umbral_pb
3525     133       4    -1       14.2
3526     133       6     1       16.0
3527     133      11     1       16.6
3528     133      38     1       18.1
3529     133      45     1       18.8
3530     133      49     1       19.6

Salen los 6.550 eventos del README, 26,5 por sesión, con una mediana de 13 minutos entre uno y el siguiente. La figura de la cabecera muestra la sesión del 15 de enero de 2025, la de los ejemplos de las lecciones 5.3.2 y 5.3.4. En la tendencia alcista de la mañana, casi todos los eventos son alcistas: el precio sube, el filtro acumula y dispara una y otra vez del mismo lado. En la reversión de la tarde, alcistas y bajistas alternan.

Los tres primeros eventos llegan en los primeros 11 minutos con umbrales de 14 a 17 pb. La EWMA arrastra la volatilidad de la tarde anterior, más baja que la de la apertura, así que el umbral es estrecho justo cuando el precio más se mueve. Es el mismo retraso que la lección 5.3.2 medía en las barreras.

Umbral fijo o escalado

La alternativa más simple es un umbral fijo en puntos básicos. Con el mismo nivel medio (4 veces la mediana de σ^\hat\sigma, 16,9 pb):

IN [03]python
h_fijo = 4.0 * np.nanmedian(sig)
ev_fijo = cusum(lr, h_fijo)
print(f"umbral fijo: {1e4 * h_fijo:.1f} pb; {len(ev_fijo)} eventos")
vol_ses = lr.std(axis=1)                  # volatilidad de cada sesión
tercil = pd.qcut(vol_ses, 3, labels=["baja", "media", "alta"])
por_sesion = pd.DataFrame({
    "tercil": tercil,
    "escalado": np.bincount(ev.sesion, minlength=n_ses),
    "fijo": np.bincount(ev_fijo.sesion, minlength=n_ses)})
por_sesion.groupby("tercil", observed=True).mean().round(1)
OUT [03]Umbral fijo frente a umbral escalado por la volatilidad
umbral fijo: 16.9 pb; 6897 eventos
        escalado  fijo
tercil
baja        26.9  19.4
media       26.6  27.6
alta        26.0  36.9

Con umbral fijo, las sesiones tranquilas (tercil bajo de volatilidad) dan 19,4 eventos y las agitadas 36,9: casi el doble. Un modelo entrenado con esos eventos aprende sobre todo de los días agitados, y un mismo movimiento de 17 pb significa cosas distintas según el día. Con el umbral escalado, cada tercil da entre 26,0 y 26,9 eventos: el filtro exige el mismo movimiento en unidades de volatilidad.

Para saber qué eventos son mejores hace falta etiquetarlos. Con la triple barrera de la lección 5.3.2 y el lado de la rotura (el modelo primario de momentum):

IN [04]python
def acierto(ev, h=30):
    """Triple barrera ±σ·√h (5.3.2); 1 si el lado acierta."""
    out = []
    for d, m, lado in ev[["sesion", "minuto", "lado"]].itertuples(
            index=False):
        b = sig[d, m - 1] * np.sqrt(h)
        ult = min(m + h, 389)
        tramo = np.log(C[d, m + 1:ult + 1] / C[d, m])
        golpes = np.flatnonzero(np.abs(tramo) >= b)
        j = golpes[0] if len(golpes) else len(tramo) - 1
        out.append(int(len(tramo) > 0 and lado * tramo[j] > 0))
    return np.array(out)


def resumen(ev):
    a = acierto(ev)
    t = tend[ev.sesion, ev.minuto]
    return {"por_sesion": len(ev) / n_ses, "en_tendencia": t.mean(),
            "acierto": a.mean(), "acierto_tend": a[t].mean(),
            "acierto_rev": a[~t].mean()}


pd.DataFrame({"escalado k = 4": resumen(ev),
              "fijo": resumen(ev_fijo)}).T.round(3)
OUT [04]Etiquetar los eventos con la triple barrera
                por_sesion  en_tendencia  acierto  acierto_tend  acierto_rev
escalado k = 4      26.518         0.388    0.507         0.604        0.446
fijo                27.923         0.388    0.504         0.588        0.450

Los dos filtros dan una fracción de eventos en tendencia del 38,8 % y un acierto parecido: 50,7 % el escalado y 50,4 % el fijo, una diferencia de medio error típico. El escalado acierta algo más en tendencia (60,4 % frente a 58,8 %). La razón principal para escalar no es el acierto medio sino la composición de la muestra.

Umbral y número de eventos

El umbral decide cuántos eventos hay. Barriéndolo:

IN [05]python
barrido = pd.DataFrame({k: resumen(cusum(lr, k * sig))
                        for k in (2, 3, 4, 5, 6, 8)}).T
barrido.index.name = "k"
barrido.round(3)
OUT [05]Umbral y número de eventos
   por_sesion  en_tendencia  acierto  acierto_tend  acierto_rev
k
2      68.575         0.364    0.502         0.565        0.465
3      40.308         0.373    0.503         0.575        0.460
4      26.518         0.388    0.507         0.604        0.446
5      18.785         0.396    0.506         0.612        0.436
6      13.976         0.413    0.507         0.615        0.431
8       8.636         0.441    0.507         0.637        0.404
Fig. — Subir el umbral del CUSUM reduce los eventos y concentra los que quedan en el régimen de tendencia: del 36 % al 44 % de los eventos, frente al 35 % de los minutos. El acierto total no cambia porque la reversión empeora a la vez
  • Los eventos caen deprisa con el umbral: 68,6 por sesión con k=2k = 2, 26,5 con k=4k = 4 y 8,6 con k=8k = 8. Para un paseo aleatorio, el tiempo esperado hasta que una suma acumulada recorre kσk\sigma crece con k2k^2; en FNX, de k=2k = 2 a k=4k = 4 los eventos se dividen por 2,6 y de 4 a 8 por 3,1, algo menos que 4 porque la sesión se corta al cierre y los episodios de tendencia aceleran el recorrido.
  • Los eventos se concentran en tendencia: del 36,4 % con k=2k = 2 al 44,1 % con k=8k = 8, frente al 35,3 % de los minutos. Un movimiento grande es más probable durante una tendencia, y el filtro lo encuentra.
  • El acierto total no se mueve (del 50,2 % al 50,7 %), porque a la vez que el acierto en tendencia sube del 56,5 % al 63,7 %, el de reversión baja del 46,5 % al 40,4 %: en un régimen de vuelta a la media, cuanto mayor es la desviación, más fuerte es la vuelta, y el lado de momentum se equivoca más.

El umbral es, por tanto, un compromiso entre cantidad y composición: con k=8k = 8 hay muestras más «puras» pero un tercio de las de k=4k = 4, y con ellas más varianza en todo lo que se estime. Igual que la anchura de las barreras (5.3.2), se fija por diseño y no mirando el acierto en la muestra de prueba.

Por eventos o por tiempo

¿Gana algo el CUSUM frente a observar cada cierto tiempo? Con el lado de momentum calculado sobre el intervalo anterior y la misma etiqueta:

IN [06]python
def por_tiempo(n):
    """Una muestra cada n minutos; lado = signo de los n anteriores."""
    filas = []
    for d in range(n_ses):
        for m in range(n, 390, n):
            r = np.log(C[d, m] / C[d, m - n])
            if r != 0:
                filas.append((d, m, int(np.sign(r))))
    return pd.DataFrame(filas, columns=["sesion", "minuto", "lado"])


tiempo = pd.DataFrame({f"cada {n} min": resumen(por_tiempo(n))
                       for n in (5, 15, 30)}).T
tiempo.round(3)
OUT [06]Muestreo por tiempo a la misma frecuencia
             por_sesion  en_tendencia  acierto  acierto_tend  acierto_rev
cada 5 min       75.332         0.353    0.503         0.556        0.474
cada 15 min      24.664         0.353    0.505         0.589        0.460
cada 30 min      11.879         0.354    0.511         0.592        0.466

Cada 15 minutos da 24,7 observaciones por sesión, casi las mismas que el CUSUM con k=4k = 4. Las diferencias:

  • El muestreo por tiempo reparte las observaciones como los minutos: el 35,3 % en tendencia, ni más ni menos. El CUSUM las inclina hacia la tendencia (38,8 %).
  • El acierto total es casi el mismo: 50,5 % cada 15 minutos y 50,7 % con el CUSUM. Con unos 6.000 eventos cada uno, el error típico de cada acierto ronda los 0,6 puntos: no hay diferencia medible. El CUSUM no hace bueno a un primario que no lo es; cambia qué momentos se observan.

Donde sí se ve la diferencia es en cuándo se observa:

IN [07]python
def por_media_hora(ev):
    return np.bincount(ev.minuto // 30, minlength=13) / n_ses


hora = pd.DataFrame({
    "CUSUM escalado": por_media_hora(ev),
    "CUSUM fijo": por_media_hora(ev_fijo),
    "cada 15 min": por_media_hora(por_tiempo(15))},
    index=[f"{9 + (30 + 30 * i) // 60}:{(30 + 30 * i) % 60:02d}"
           for i in range(13)])
hora.round(1)
OUT [07]Eventos por media hora
       CUSUM escalado  CUSUM fijo  cada 15 min
9:30              3.0         4.1          1.0
10:00             1.8         2.7          2.0
10:30             1.6         2.2          2.0
11:00             1.6         1.9          2.0
11:30             1.7         1.8          2.0
12:00             1.9         1.8          2.0
12:30             1.9         1.8          2.0
13:00             2.0         1.8          2.0
13:30             2.1         1.8          2.0
14:00             2.1         1.8          2.0
14:30             2.1         1.9          2.0
15:00             2.2         2.1          2.0
15:30             2.4         2.3          2.0
Fig. — Eventos por media hora y sesión. El muestreo por tiempo es plano; el CUSUM sigue a la actividad: más eventos en la apertura y al cierre. Con umbral fijo la U es más marcada; escalado por una EWMA, la apertura sigue pesando porque la EWMA va retrasada

El muestreo por tiempo es plano (la primera media hora tiene una sola observación porque el intervalo empieza a las 9:45). El CUSUM sigue a la actividad: con umbral fijo, 4,1 eventos por sesión en la primera media hora y 1,8 a mediodía, la misma U que dibujaban las barras de volumen de la lección 1.4.5. Escalado, la U se aplana pero no desaparece: 3,0 en la apertura por el retraso de la EWMA y una subida suave hacia el cierre. Si se quisiera un muestreo uniforme en unidades de volatilidad, el umbral tendría que usar el perfil intradía, como la barrera de la lección 5.3.2.

Bandas frente a CUSUM: por qué el reinicio importa

Una regla intuitiva para muestrear es la de bandas: un evento cada vez que el precio sale de una banda alrededor de su media móvil (al estilo de las bandas de Bollinger). Con una media exponencial de 30 minutos y bandas de ±0,5 σ30\pm 0{,}5\,\sigma\sqrt{30}:

IN [08]python
lc = np.log(C)
ema = pd.DataFrame(lc.T).ewm(span=30).mean().to_numpy().T
z = np.full_like(lc, np.nan)
z[:, 1:] = (lc[:, 1:] - ema[:, 1:]) / (sig * np.sqrt(30))
fuera_arr, fuera_ab = z > 0.5, z < -0.5
cruce = np.zeros_like(lc, dtype=int)
cruce[:, 1:] = (np.where(fuera_arr[:, 1:] & ~fuera_arr[:, :-1], 1, 0)
                - np.where(fuera_ab[:, 1:] & ~fuera_ab[:, :-1], 1, 0))
d_b, m_b = np.nonzero(cruce)
bandas = pd.DataFrame({"sesion": d_b, "minuto": m_b,
                       "lado": cruce[d_b, m_b]})


def racimos(ev):
    gap = ev.groupby("sesion").minuto.diff().dropna()
    return {"por_sesion": len(ev) / n_ses,
            "a_3_min_o_menos": (gap <= 3).mean(),
            "mediana_min": gap.median()}


pd.DataFrame({"bandas ±0,5": racimos(bandas),
              "CUSUM k = 4": racimos(ev)}).T.round(3)
OUT [08]Bandas frente a CUSUM: eventos en racimo
             por_sesion  a_3_min_o_menos  mediana_min
bandas ±0,5      22.514            0.149         11.0
CUSUM k = 4      26.518            0.031         13.0

Las bandas dan 22,5 eventos por sesión, casi como el CUSUM, pero el 14,9 % llega a 3 minutos o menos del anterior, frente al 3,1 % del CUSUM. Cuando el precio oscila alrededor del borde de la banda, la regla dispara cada vez que lo cruza: eventos en racimo, casi idénticos, que solapan etiquetas y repiten features. El CUSUM, al reiniciarse, exige un movimiento completo de hh desde el último evento, y eso los separa.

El lado del evento lleva información

Un efecto lateral del muestreo por eventos es que la secuencia de lados ya dice algo del régimen:

IN [09]python
misma = (ev.sesion == ev.sesion.shift()).to_numpy()
igual = (ev.lado == ev.lado.shift()).to_numpy()[misma]
t_ev = tend[ev.sesion, ev.minuto][misma]
print(f"mismo lado que el evento anterior: {igual.mean():.3f}")
print(f"  en tendencia: {igual[t_ev].mean():.3f}")
print(f"  en reversión: {igual[~t_ev].mean():.3f}")
OUT [09]El lado se repite en tendencia
mismo lado que el evento anterior: 0.518
  en tendencia: 0.624
  en reversión: 0.451

En conjunto, el 51,8 % de los eventos repite el lado del anterior de la misma sesión. En tendencia, el 62,4 %; en reversión, el 45,1 %. Es la firma visible en la figura de la cabecera y es información conocida en el momento de cada evento: una feature candidata para el meta-modelo (ejercicio 2 de la lección 5.3.3, donde resulta redundante con el imbalance del flujo).

El mismo filtro sobre la cinta de operaciones

Nada en el filtro exige barras de 1 minuto: funciona con cualquier serie de rendimientos. Sobre las operaciones regulares de NOVA del 2 de junio de 2025 (datos sintéticos del nivel 1, lección 1.1.1), operación a operación y con un umbral fijo de 10 pb:

IN [10]python
op = pd.read_parquet("nova_operaciones.parquet")
op = op[op["condicion"] == "regular"].reset_index(drop=True)
r_op = np.diff(np.log(op["precio"].to_numpy()))[None, :]
ev_op = cusum(r_op, 0.001)              # 10 pb, umbral fijo
hora_ny = op["ts"].iloc[ev_op.minuto].dt.tz_convert(
    "America/New_York")
print(len(op), "operaciones,", len(ev_op), "eventos")
franja = hora_ny.dt.floor("30min").dt.strftime("%H:%M")
franja.value_counts().sort_index().to_frame("eventos").T
OUT [10]CUSUM sobre la cinta de NOVA (operación a operación)
50938 operaciones, 165 eventos
ts       09:30  10:00  10:30  11:00  11:30  12:00  12:30  13:00  13:30  14:00  14:30  15:00  15:30
eventos     23     18     10     10      6     13      6      8     16      9      9     13     24

De 50.938 operaciones salen 165 eventos, con 23 en la primera media hora, 6 en la de las 11:30 y 24 en la última. Es la U de la actividad que las barras de volumen y de dólar dibujaban en la lección 1.4.5, conseguida por otro camino: allí la barra se cerraba al acumular volumen; aquí, al acumular movimiento. Con datos de operaciones hay un matiz: el rebote entre el bid y el ask suma y resta rendimientos sin que el precio se mueva de verdad. El CUSUM lo tolera mejor que una regla de cruces porque el rebote se cancela dentro de S+S^+ y S−S^-, pero con umbrales del orden de la horquilla dispararía por ruido de microestructura.

Errores típicos

  • Umbral calculado con el futuro. Una volatilidad de la sesión completa, o una EWMA centrada, hace que el filtro dispare «sabiendo» cuánto se va a mover el día.
  • Elegir el umbral por el acierto en la muestra de prueba. Es un parámetro más de la búsqueda (2.5.4).
  • Entrar al precio que dispara el evento. El evento se conoce al cierre del minuto; en real, la orden se ejecuta después (en FNX, la apertura del minuto siguiente coincide con ese cierre salvo el redondeo al tick, pero en un activo con horquilla o con barras más largas no).
  • Olvidar el reinicio diario. Si las sumas pasan de una sesión a la siguiente, el salto de la noche dispara eventos en la apertura que no se pueden operar al precio del cierre.

Ejercicios

  1. Compara el acierto del modelo primario en los eventos de la primera media hora con el del resto de la sesión. ¿Qué relación tiene con el retraso de la EWMA?
Solución
IN [11]python
a = acierto(ev)
apertura = (ev.minuto < 30).to_numpy()
t_a = tend[ev.sesion, ev.minuto]
for nombre, sel in (("apertura", apertura), ("resto", ~apertura)):
    print(f"{nombre:>8}: {sel.sum()} eventos, acierto"
          f" {a[sel].mean():.3f}, en tendencia {t_a[sel].mean():.3f}")
OUT [11]Eventos de la apertura frente al resto
apertura: 743 eventos, acierto 0.526, en tendencia 0.396
   resto: 5807 eventos, acierto 0.505, en tendencia 0.387

Los 743 eventos de la primera media hora aciertan el 52,6 % y los demás el 50,5 %, con una fracción en tendencia casi igual (39,6 % frente a 38,7 %). La diferencia es de poco más de un error típico (unos 1,8 puntos con 743 eventos): no se puede afirmar que la apertura sea mejor. Lo que sí es seguro es que hay muchos más eventos (743, el 11 % del total en el 8 % del tiempo) porque la EWMA va retrasada y el umbral es estrecho cuando la volatilidad es más alta. Si esos eventos no se quieren, la corrección es el perfil intradía (5.3.2), no subir kk para todo el día.

  1. Repite el CUSUM sobre la cinta de NOVA con umbrales de 5 y 20 pb. ¿Cuántos eventos salen y qué le pasa a la forma de U?
Solución
IN [12]python
for h in (0.0005, 0.002):
    e_h = cusum(r_op, h)
    hny = op["ts"].iloc[e_h.minuto].dt.tz_convert("America/New_York")
    franja = hny.dt.floor("30min").dt.strftime("%H:%M")
    n = franja.value_counts().sort_index()
    print(f"{1e4 * h:.0f} pb: {len(e_h)} eventos;"
          f" 9:30 {n.get('09:30', 0)}, 11:30 {n.get('11:30', 0)},"
          f" 15:30 {n.get('15:30', 0)}")
OUT [12]CUSUM sobre NOVA con otros umbrales
5 pb: 417 eventos; 9:30 59, 11:30 19, 15:30 57
20 pb: 70 eventos; 9:30 9, 11:30 2, 15:30 10

Con 5 pb salen 417 eventos (59 en la primera media hora, 19 en la de las 11:30 y 57 en la última); con 20 pb, solo 70 (9, 2 y 10). La forma de U se mantiene con los tres umbrales, porque la sigue la actividad del mercado y no el umbral. Lo que cambia es la resolución: con 20 pb hay franjas con dos eventos en toda la sesión, demasiado pocos para medir nada, y con 5 pb el umbral se acerca a la horquilla de NOVA (un céntimo en una acción de 53 USD son unos 2 pb) y el rebote entre bid y ask empieza a pesar.

  1. La lección 4.2.5 usaba un CUSUM para apagar una estrategia cuando sus rendimientos empeoraban. ¿Qué cambia entre ese uso y el de esta lección, y por qué en aquel se resta una media y en este no?
Solución

La recursión es la misma: sumas acumuladas de desviaciones, recortadas en cero, comparadas con un umbral. Cambia el objetivo. En 4.2.5 el CUSUM es un contraste secuencial de un cambio de media: a cada rendimiento tipificado se le compara con un valor de referencia kk, a medio camino entre la media del backtest y cero, de modo que la suma solo crece de forma sostenida cuando los rendimientos quedan por debajo de lo esperado; cuando pasa del umbral, se da la alarma y se para. El umbral se calibra con el compromiso entre falsas alarmas y retraso. Aquí el filtro no busca un cambio permanente sino movimientos acumulados de cierto tamaño en cualquier dirección, y tras cada evento se reinicia y sigue: es una regla de muestreo. Solo se resta la media esperada de los rendimientos, que en un minuto es prácticamente cero, porque lo que interesa es el movimiento, no la desviación respecto a un rendimiento objetivo. El umbral se elige por el número y el tipo de eventos que se quieren, no por una tasa de falsas alarmas.

Respuesta plegada en cada pregunta

Autoevaluación

  1. ¿Qué hace el filtro CUSUM simétrico después de disparar un evento?

    • ASigue acumulando desde el valor que tenía
    • BPone a cero las dos sumas, la de subidas y la de bajadas: el siguiente evento exige otro movimiento completo desde ese punto
    • CCambia el umbral
    • DEspera un número fijo de minutos
    Ver respuesta

    Respuesta B. Pone a cero las dos sumas, la de subidas y la de bajadas: el siguiente evento exige otro movimiento completo desde ese punto

    El reinicio es lo que evita los racimos: una regla de bandas dispara cada vez que el precio cruza la banda, y si el precio oscila alrededor de ella dispara muchas veces seguidas. En FNX, el 14,9 % de los eventos de bandas llega a 3 minutos o menos del anterior, frente al 3,1 % del CUSUM.

  2. Con un umbral fijo de 16,9 pb, las sesiones del tercil de volatilidad más bajo dan 19,4 eventos y las del más alto 36,9. Con el umbral de 4σ (EWMA), 26,9 y 26,0. ¿Qué consecuencia tiene para un modelo?

    • ANinguna
    • BEl umbral fijo es mejor porque da más eventos
    • CEl umbral escalado mira al futuro
    • DCon umbral fijo, los días agitados dominan la muestra de entrenamiento; con umbral escalado, cada sesión aporta un número parecido de eventos y el movimiento que se exige es comparable
    Ver respuesta

    Respuesta D. Con umbral fijo, los días agitados dominan la muestra de entrenamiento; con umbral escalado, cada sesión aporta un número parecido de eventos y el movimiento que se exige es comparable

    Un umbral fijo mezcla dos cosas: la señal que se busca y el nivel de volatilidad. Escalar con una volatilidad conocida en el momento hace el muestreo comparable entre días tranquilos y agitados.

  3. Al subir el umbral de k = 2 a k = 8, la fracción de eventos en tendencia sube del 36,4 % al 44,1 %, pero el acierto total del primario se queda en el 50,2-50,7 %. ¿Por qué?

    • APorque a la vez que el acierto en tendencia sube (del 56,5 % al 63,7 %), el de reversión baja (del 46,5 % al 40,4 %): un movimiento grande en reversión es más probable que se deshaga
    • BPorque el umbral no influye en el acierto
    • CPorque hay pocos eventos
    • DPor un error en la triple barrera
    Ver respuesta

    Respuesta A. Porque a la vez que el acierto en tendencia sube (del 56,5 % al 63,7 %), el de reversión baja (del 46,5 % al 40,4 %): un movimiento grande en reversión es más probable que se deshaga

    El CUSUM selecciona movimientos grandes. En tendencia, un movimiento grande suele continuar; en reversión a la media, cuanto mayor es la desviación, más fuerte es la vuelta. Los dos efectos se compensan en el agregado.

  4. Muestrear cada 15 minutos da 24,7 observaciones por sesión, casi como el CUSUM (26,5). ¿Qué diferencia se mide en FNX?

    • AEl CUSUM duplica el acierto
    • BEl muestreo por tiempo no tiene sesgo de anticipación y el CUSUM sí
    • CEl muestreo por tiempo reparte las observaciones como los minutos (35,3 % en tendencia); el CUSUM las inclina hacia la tendencia (38,8 %). El acierto total es casi el mismo (50,5 % frente a 50,7 %)
    • DNinguna
    Ver respuesta

    Respuesta C. El muestreo por tiempo reparte las observaciones como los minutos (35,3 % en tendencia); el CUSUM las inclina hacia la tendencia (38,8 %). El acierto total es casi el mismo (50,5 % frente a 50,7 %)

    El CUSUM no hace bueno al primario: cambia qué momentos se observan. Su ventaja es muestrear cuando el mercado se mueve (más en la apertura, menos a mediodía) y no repetir observaciones casi iguales.

  5. ¿En qué se diferencia el CUSUM de esta lección del de Page de la lección 4.2.5?

    • AEs la misma recursión de sumas acumuladas recortadas en cero, pero aquí se usa para muestrear (se reinicia tras cada evento y sigue) y allí para detectar un cambio de media y parar una estrategia
    • BSon algoritmos sin relación
    • CEl de Page no tiene umbral
    • DEl de esta lección solo funciona con precios
    Ver respuesta

    Respuesta A. Es la misma recursión de sumas acumuladas recortadas en cero, pero aquí se usa para muestrear (se reinicia tras cada evento y sigue) y allí para detectar un cambio de media y parar una estrategia

    Page (1954) propuso el CUSUM para control de calidad: alarma cuando la suma de desviaciones respecto a la media esperada pasa de un umbral. López de Prado lo usa sobre rendimientos con media esperada cero para decidir cuándo muestrear.

Ideas clave

  • Muestrear por eventos es decidir cuándo observar: solo cuando el precio ha acumulado un movimiento de tamaño h desde el último evento.
  • El filtro CUSUM simétrico acumula subidas y bajadas por separado, recortadas en cero, y reinicia las dos tras cada evento: no dispara en racimo cuando el precio oscila alrededor de un nivel (3,1 % de eventos a 3 minutos o menos, frente al 14,9 % de una regla de bandas).
  • Con umbral fijo, el número de eventos sigue a la volatilidad de cada día (19,4 frente a 36,9 por sesión); escalado con una EWMA, se mantiene en unos 26,5.
  • Subir el umbral reduce los eventos (de 68,6 a 8,6 por sesión) y los concentra en tendencia (del 36 % al 44 %), sin cambiar el acierto total: la reversión empeora a la vez.
  • Frente a muestrear cada 15 minutos, el CUSUM sigue la actividad (3,0 eventos en la primera media hora, 1,6 a media mañana) y se inclina hacia la tendencia; el acierto total es casi el mismo.
  • El filtro sirve con cualquier serie: sobre la cinta de NOVA dibuja la misma U intradía que las barras de actividad de la lección 1.4.5.

Recursos

Documentación