---
title: "5.3.5 Muestreo por eventos: el filtro CUSUM"
description: "Elegir cuándo observar: filtro CUSUM simétrico con umbral escalado por la volatilidad, eventos según el umbral y comparación con barras de tiempo."
url: "https://pythonparatrading.com/curso-python-trading/nivel-5/muestreo-por-eventos-filtro-cusum/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-5/muestreo-por-eventos-filtro-cusum.png"
---

# 5.3.5 · 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.

- Curso: [Nivel 5: Machine learning y deep learning (Keras/PyTorch)](https://pythonparatrading.com/curso-python-trading/nivel-5/)
- Módulo: 5.3 · Etiquetado y muestreo
- Duración: 55 min
- Requisitos: [1.4.5 · Construcción de barras: tiempo, ticks, volumen, dólar y desequilibrio](https://pythonparatrading.com/curso-python-trading/nivel-1/barras-de-tiempo-ticks-volumen-y-dolar/), [4.2.5 · Control del drawdown y kill switch](https://pythonparatrading.com/curso-python-trading/nivel-4/control-del-drawdown-y-kill-switch/), [5.3.1 · Etiquetado de horizonte fijo y sus problemas](https://pythonparatrading.com/curso-python-trading/nivel-5/etiquetado-de-horizonte-fijo/)

## Al terminar esta lección

- Implementar el filtro CUSUM simétrico con umbral fijo o escalado por una volatilidad EWMA, sin información posterior.
- Medir cómo cambian el número de eventos, su reparto por hora y por régimen con el umbral, y por qué un umbral fijo depende de la volatilidad de cada sesión.
- Comparar el muestreo por eventos con el muestreo por tiempo a la misma frecuencia y con una regla de bandas, con la respuesta conocida de los datos.
- Aplicar el mismo filtro a una cinta de operaciones y relacionarlo con las barras de información de la lección 1.4.5.

## 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 $y_t$ el log-rendimiento de cada barra, el filtro lleva dos sumas, una de subidas y otra de bajadas, recortadas en
cero:

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

Cuando $S^+_t > h_t$ hay un evento alcista; cuando $S^-_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^+$ mide la subida acumulada
desde el último mínimo relativo, y el evento llega cuando esa subida supera $h_t$. La versión de López de Prado resta
a $y_t$ su media esperada, que en rendimientos de un minuto es cero a todos los efectos.

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

```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%}")
```
```output
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
$h_t = 4\,\hat\sigma_t$, con $\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.

```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")
```
```output
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):

```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)
```
```output
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):

```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)
```
```output
                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:

```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)
```
```output
   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.* Dos paneles. Izquierda, eventos por sesión según el umbral k: 68,6 con k = 2, 40,3 con 3, 26,5 con 4 (destacado), 18,8 con 5, 14,0 con 6 y 8,6 con 8. Derecha, tres líneas: el acierto del primario en tendencia sube del 56,5 % al 63,7 %, en reversión baja del 46,5 % al 40,4 % y la fracción de eventos en tendencia sube del 36,4 % al 44,1 %, por encima del 35 % de los minutos (línea discontinua).

- **Los eventos caen deprisa con el umbral**: 68,6 por sesión con $k = 2$, 26,5 con $k = 4$ y 8,6 con $k = 8$. Para un
  paseo aleatorio, el tiempo esperado hasta que una suma acumulada recorre $k\sigma$ crece con $k^2$; en FNX, de $k = 2$
  a $k = 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 = 2$ al 44,1 % con $k = 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 = 8$ hay muestras más «puras» pero un
tercio de las de $k = 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:

```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)
```
```output
             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 = 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:

```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)
```
```output
       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.* Tres líneas por media hora de 9:30 a 15:30. Una muestra cada 15 minutos: 1 en la primera media hora y 2 en las demás. CUSUM con umbral fijo: 4,1 eventos por sesión en la primera media hora, bajando a 1,8 a mediodía y subiendo a 2,3 en la última. CUSUM escalado (destacado): 3,0 en la primera, 1,6 a media mañana y subida gradual hasta 2,4 en la última.

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
$\pm 0{,}5\,\sigma\sqrt{30}$:

```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)
```
```output
             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 $h$ 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:

```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}")
```
```output
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:

```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
```
```output
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^+$ y $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.**

```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}")
```
```output
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 $k$ para todo el día.

2. 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.**

```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)}")
```
```output
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.

3. 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 $k$, 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.

## Autoevaluación

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

   - A. Sigue acumulando desde el valor que tenía
   - B. Pone a cero las dos sumas, la de subidas y la de bajadas: el siguiente evento exige otro movimiento completo desde ese punto
   - C. Cambia el umbral
   - D. Espera un número fijo de minutos

   *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?

   - A. Ninguna
   - B. El umbral fijo es mejor porque da más eventos
   - C. El umbral escalado mira al futuro
   - 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

   *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é?

   - 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
   - B. Porque el umbral no influye en el acierto
   - C. Porque hay pocos eventos
   - D. Por un error en la triple barrera

   *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?

   - A. El CUSUM duplica el acierto
   - B. El muestreo por tiempo no tiene sesgo de anticipación y el CUSUM sí
   - 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 %)
   - D. Ninguna

   *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?

   - 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
   - B. Son algoritmos sin relación
   - C. El de Page no tiene umbral
   - D. El de esta lección solo funciona con precios

   *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

### Research

- [Estacionalidad en Python. Buscando Patrones Intradia](https://pythonparatrading.com/estacionalidad-intradia-preprocesado/) · Research-0024 · 9 min

### Documentación

- [pandas: DataFrame.ewm](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.ewm.html) · pandas.pydata.org

### Libros

- [Marcos López de Prado: Advances in Financial Machine Learning (Wiley, 2018), capítulo 2 (2.5.2, «Event-Based Sampling»)](https://www.wiley.com/en-us/Advances+in+Financial+Machine+Learning-p-9781119482086) · wiley.com
- [E. S. Page, «Continuous inspection schemes», Biometrika 41 (1954), 100-115 (DOI)](https://doi.org/10.1093/biomet/41.1-2.100) · doi.org

### Datos

- [Datos del nivel 5: barras de 1 minuto de FNX (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/hf5_barras_1min.parquet) · Descarga · hf5_barras_1min.parquet
- [Datos del nivel 1: operaciones de NOVA del 2 de junio de 2025 (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_operaciones.parquet) · Descarga · nova_operaciones.parquet

Lección anterior: [5.3.4 · Unicidad de las etiquetas, pesos por solapamiento y bootstrap secuencial](https://pythonparatrading.com/curso-python-trading/nivel-5/unicidad-pesos-y-bootstrap-secuencial/)

Lección siguiente: [5.4.1 · Por qué falla el k-fold aleatorio en datos financieros](https://pythonparatrading.com/curso-python-trading/nivel-5/por-que-falla-el-k-fold-aleatorio/)
