Lección 2.3.2 · 65 min

Reversión a la media: RSI y Bollinger, reversión intradía y huecos

Comprar lo que acaba de caer y vender lo que acaba de subir es la estrategia más intuitiva y la que peor sobrevive a los costes. Esta lección mide un efecto de cierre de hueco plantado en barras de 5 minutos, lo compara con el resto de la sesión, que no tiene ninguno, y prueba las reglas de RSI y Bollinger en 120 acciones donde no hay reversión que explotar.

Fig. — Cierre del hueco en KAPA: recorrido medio de la primera media hora frente al hueco de apertura, sesiones agrupadas en veinte grupos por tamaño del hueco (2022-2025)
Lecciones del nivel 2
  1. 2.1 · Estadística y econometría para trading

    1. 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
    2. 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
    3. 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
    4. 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
    5. 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
    6. 2.1.6 Filtro de Kalman para hedge ratios dinámicos
    7. 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
    8. 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
  2. 2.2 · Anatomía de un sistema de trading

    1. 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
    2. 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
    3. 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
    4. 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
  3. 2.3 · Familias de estrategias

    1. 2.3.1 Seguimiento de tendencia y momentum
    2. 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
    3. 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
    4. 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
    5. 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
    6. 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
    7. 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
  4. 2.4 · Backtesting: motores y paradigmas

    1. 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
    2. 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
    3. 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
    4. 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
    5. 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
    6. 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
    7. 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
    8. 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
  5. 2.5 · Sesgos y validación

    1. 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
    2. 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
    3. 2.5.3 Validación cruzada combinatoria purgada para estrategias
    4. 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
    6. 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
    7. 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
    8. 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
  6. 2.6 · Métricas de rendimiento

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown y Ulcer Index
    2. 2.6.2 Win rate, payoff, expectativa, alpha, beta, information ratio, turnover y capacidad
    3. 2.6.3 Informes con QuantStats o pyfolio-reloaded y tearsheets propios

Revertir a la media: de dónde podría salir la ventaja

Una estrategia de reversión a la media apuesta a que un movimiento reciente se deshará en parte: compra lo que acaba de caer y vende lo que acaba de subir. Es lo contrario del seguimiento de tendencia de la lección 2.3.1, y su lógica económica es distinta. Cuando un vendedor con prisa empuja el precio por debajo de su valor, alguien tiene que comprarle: el que lo hace presta liquidez y cobra por ello la vuelta del precio. Si el movimiento fue una reacción exagerada a una noticia, quien apuesta contra ella cobra la corrección.

Movimiento bruscoPrisa por venderNoticia nuevaRevierte: ganaContinúa: pierdeMenos costes
Fuente Mermaid
flowchart TB
  M[Movimiento brusco] --> L[Prisa por vender]
  M --> N[Noticia nueva]
  L --> R[Revierte: gana]
  N --> C[Continúa: pierde]
  R --> K[Menos costes]
  C --> K

La dificultad está en la última caja. Una ventaja de reversión suele ser pequeña por operación y se opera mucho, así que los costes son la mitad de la historia. En datos reales hay evidencia de reversión de corto plazo en acciones: Jegadeesh documentó en 1990 una autocorrelación negativa muy significativa de las rentabilidades mensuales de acciones individuales de EE. UU.1Nota 1 · N. Jegadeesh, «Evidence of Predictable Behavior of Security Returns», Journal of Finance 45(3), 1990, 881-898. DOI 10.1111/j.1540-6261.1990.tb05110.x., y Lehmann, ese mismo año, que las ganadoras de una semana tendían a perder la siguiente y las perdedoras a recuperarse, también tras corregir por horquillas y con costes plausibles2Nota 2 · B. N. Lehmann, «Fads, Martingales, and Market Efficiency», Quarterly Journal of Economics 105(1), 1990; versión de trabajo NBER w2533.. En los datos de esta lección, en cambio, se sabe exactamente dónde hay reversión y dónde no.

El hueco de apertura en KAPA

indice2_5min.parquet tiene 1.003 sesiones de la NYSE de 2022 a 2025 en barras de 5 minutos. La apertura de cada barra es el cierre de la anterior, salvo la primera de cada sesión, que lleva el hueco nocturno: lo que se movió el precio entre el cierre de ayer y la apertura de hoy.

IN [01]python
import numpy as np
import pandas as pd
import statsmodels.api as sm

b = pd.read_parquet("indice2_5min.parquet")
b["ny"] = b["ts"].dt.tz_convert("America/New_York")
b["dia"] = b["ny"].dt.date
b["k"] = b.groupby("dia").cumcount()        # barra 0 = 09:30
barras = b.groupby("dia").size()
print(len(barras), "sesiones;", barras.value_counts().to_dict())
print("primera barra:",
      b.loc[b["k"] == 0, "ny"].dt.strftime("%H:%M").unique().tolist())
OUT [01]Barras de 5 minutos en hora de Nueva York
1003 sesiones; {78: 994, 42: 9}
primera barra: ['09:30']

Hay 994 sesiones completas de 78 barras (de 09:30 a 16:00) y 9 medias sesiones de 42 (hasta las 13:00), y la primera barra de todas empieza a las 09:30 de Nueva York: la conversión desde UTC respeta el horario de verano (lección 1.1.2). Contar las barras por sesión es la comprobación más barata de que la zona horaria y el calendario están bien.

Para cada sesión se necesitan el hueco y el recorrido de la primera media hora, separado en la primera barra y las cinco siguientes, y como control, las barras de 10:00 a 10:30 y el resto del día:

ht=ln⁡AtCt−1,rt(0−6)=ln⁡Ct(6)At,rt(2−6)=ln⁡Ct(6)Ct(1)h_t = \ln\frac{A_t}{C_{t-1}}, \qquad r^{(0-6)}_t = \ln\frac{C^{(6)}_t}{A_t}, \qquad r^{(2-6)}_t = \ln\frac{C^{(6)}_t}{C^{(1)}_t}

donde AtA_t es la apertura de la sesión, Ct−1C_{t-1} el cierre anterior y Ct(j)C^{(j)}_t el cierre de la barra j.

IN [02]python
g = b.groupby("dia")
cierre_k = lambda k: b[b["k"] == k].set_index("dia")["cierre"]
d = pd.DataFrame({"apertura": g["apertura"].first(),
                  "cierre": g["cierre"].last(),
                  "c1": cierre_k(0), "c6": cierre_k(5),
                  "c12": cierre_k(11)})
d["hueco"] = np.log(d["apertura"] / d["cierre"].shift())
d["r0_6"] = np.log(d["c6"] / d["apertura"])
d["r2_6"] = np.log(d["c6"] / d["c1"])
d["r7_12"] = np.log(d["c12"] / d["c6"])
d["resto"] = np.log(d["cierre"] / d["c12"])
d = d.dropna()
print(len(d), "sesiones con hueco")
(1e4 * d[["hueco", "r0_6", "r7_12", "resto"]]).describe().loc[
    ["mean", "std", "min", "max"]].round(1)
OUT [02]Hueco y recorridos de cada sesión
1002 sesiones con hueco
       hueco   r0_6  r7_12  resto
mean     3.8   -0.3    0.8    3.8
std     60.8   47.9   26.4   79.8
min   -189.3 -245.5  -83.7 -322.4
max   1028.7  188.2  143.5  285.4

En puntos básicos, el hueco tiene una desviación típica de 61 (con un día extremo de +1.029, un hueco de más del 10 %), la primera media hora se mueve 48 y la media hora siguiente, 26: el perfil intradía en U del generador, con la volatilidad concentrada en la apertura. Las medias son casi cero en todas las ventanas, como corresponde a un mercado sin deriva visible a esta escala.

¿Se cierra el hueco? Una regresión con errores robustos

La pregunta es una regresión del recorrido de la primera media hora sobre el hueco, rt(0−6)=a+b ht+etr^{(0-6)}_t = a + b\,h_t + e_t. Si el hueco tiende a cerrarse, b<0b < 0. Los errores típicos se calculan robustos a heterocedasticidad (HC1), porque los días de hueco grande son también días de volatilidad alta (lección 2.1.1).

IN [03]python
def regresion(y, x="hueco"):
    m = sm.OLS(d[y], sm.add_constant(d[x])).fit(cov_type="HC1")
    return pd.Series({"b": m.params[x], "ee": m.bse[x],
                      "t": m.tvalues[x], "p": m.pvalues[x]})

tabla = pd.DataFrame({v: regresion(v)
                      for v in ["r0_6", "r2_6", "r7_12", "resto"]}).T
print(tabla.round(3).to_string())
b_est, ee = tabla.loc["r0_6", ["b", "ee"]]
print(f"distancia a la verdad (−0,10): {(b_est + 0.10) / ee:+.2f} ee")
print(f"intervalo al 95 %: [{b_est - 1.96 * ee:.3f}, "
      f"{b_est + 1.96 * ee:.3f}]")
OUT [03]Coeficiente del hueco en cada ventana
           b     ee      t      p
r0_6  -0.132  0.038 -3.518  0.000
r2_6  -0.108  0.023 -4.768  0.000
r7_12  0.027  0.016  1.668  0.095
resto -0.027  0.044 -0.613  0.540
distancia a la verdad (−0,10): -0.86 ee
intervalo al 95 %: [-0.206, -0.059]

La primera media hora entera da b=−0,132b = -0{,}132 con t = −3,5. La verdad del generador es −0,10: la estimación está a 0,86 errores típicos, perfectamente compatible. Pero el intervalo al 95 % va de −0,206 a −0,059: con cuatro años de sesiones, se sabe que el hueco tiende a cerrarse, no cuánto. Desde la segunda barra (la que se puede operar), el coeficiente es −0,108 con un error menor (0,023): lo plantado en esas cinco barras es −0,10⋅5/6≈−0,083-0{,}10 \cdot 5/6 \approx -0{,}083, también a menos de un error típico y medio.

Las dos ventanas de control no tienen efecto en el generador, y sus estimaciones lo reflejan solo a medias: el resto del día da −0,027 (t = −0,6), sin nada, pero la media hora de 10:00 a 10:30 da +0,027 con t = 1,67 y un p-valor de 0,095. No está plantado. Es el aspecto que tiene un control cualquiera: la mayoría de las veces sale cerca de cero y, de vez en cuando, cerca del umbral. Si alguien hubiera probado diez ventanas de la sesión y publicado la de 10:00 a 10:30 como «continuación del hueco», tendría un resultado tan convincente como falso.

La figura de la cabecera enseña la misma relación sin modelo: las sesiones agrupadas en veinte grupos por el tamaño del hueco, con el hueco medio de cada grupo y el recorrido medio de la primera media hora. La recta de pendiente −0,132 es la estimación; la de −0,10, la verdad del generador.

IN [04]python
grupo = pd.qcut(d["hueco"], 20, labels=False)
binned = (1e4 * d.groupby(grupo)[["hueco", "r0_6"]].mean()).round(1)
print(binned.iloc[[0, 1, 9, 10, 18, 19]].to_string())
OUT [04]Recorrido medio por grupos de hueco
       hueco  r0_6
hueco
0     -100.9   9.4
1      -65.6  -3.1
9       -1.2   6.1
10       4.9   0.0
18      73.6 -11.4
19     150.9 -11.8

El grupo de huecos más negativos (−101 puntos básicos de media) recupera 9,4 en la primera media hora, y el de huecos más positivos (+151) devuelve 11,8: un 9 % y un 8 % del hueco, cerca del 10 % plantado. En el centro, con huecos de uno o cinco puntos básicos, el recorrido medio es ruido de ±6 puntos. El efecto solo se ve donde el hueco es grande.

De un coeficiente a una regla

Una regla de cierre de hueco hace la apuesta contraria al hueco durante la primera media hora. Se decide con el primer precio de la sesión, pero se ejecuta en la barra siguiente, al cierre de la primera barra de 5 minutos, y se cierra al de la sexta:

09:30 hueco09:35 entra10:00 saleResto: nada
Fuente Mermaid
flowchart LR
  A[09:30 hueco] --> E[09:35 entra]
  E --> S[10:00 sale]
  S --> X[Resto: nada]

La ganancia esperada por operación, si el efecto es lineal, es 0,10⋅56⋅∣ht∣0{,}10 \cdot \tfrac{5}{6} \cdot |h_t|: proporcional al tamaño del hueco. Por eso tiene sentido operar solo los huecos grandes, que pagan más por operación aunque haya menos.

IN [05]python
def regla(umbral):
    s = d[d["hueco"].abs() > umbral]
    return -np.sign(s["hueco"]) * s["r2_6"]   # contra el hueco

filas = []
for umbral in (0.0, 0.002, 0.004, 0.006, 0.008):
    pnl = regla(umbral)
    t = pnl.mean() / pnl.std() * np.sqrt(len(pnl))
    filas.append({"umbral_pb": 1e4 * umbral, "operaciones": len(pnl),
                  "bruto_pb": 1e4 * pnl.mean(), "t": t,
                  "aciertos": (pnl > 0).mean()})
pd.DataFrame(filas).round(2)
OUT [05]Ganancia por operación según el tamaño mínimo del hueco
   umbral_pb  operaciones  bruto_pb     t  aciertos
0        0.0          999      4.00  3.18      0.55
1       20.0          623      4.39  2.66      0.56
2       40.0          342      7.48  3.19      0.60
3       60.0          183      6.96  1.93      0.57
4       80.0          112     11.50  2.30      0.60

Operando todos los huecos, 999 operaciones ganan 4,0 puntos básicos de media en bruto (t = 3,2), con un 55 % de aciertos. Con huecos de más de 40 puntos básicos quedan 342 operaciones que ganan 7,5, y con más de 80, 112 que ganan 11,5: la ganancia por operación crece con el umbral, como predice un efecto proporcional al hueco, pero el número de operaciones cae y el t no mejora (2,3 en el umbral más alto). El 0,55-0,60 de aciertos recuerda que una ventaja estadística real se parece mucho a lanzar una moneda.

Cada operación paga dos lados: la entrada y la salida. Con un coste cc por lado, el neto por operación es el bruto menos 2c2c, y el coste de equilibrio por lado es la mitad del bruto:

IN [06]python
costes_pb = np.arange(0, 6.5, 0.5)
sens = pd.DataFrame({
    f">{1e4 * u:.0f} pb": [1e4 * regla(u).mean() - 2 * c
                           for c in costes_pb]
    for u in (0.0, 0.004, 0.008)}, index=costes_pb)
sens.index.name = "coste_lado_pb"
print(sens.iloc[::2].round(2).to_string())
for u in (0.0, 0.004, 0.008):
    print(f"equilibrio con huecos > {1e4 * u:.0f} pb: "
          f"{1e4 * regla(u).mean() / 2:.2f} pb por lado")
OUT [06]Sensibilidad a los costes
               >0 pb  >40 pb  >80 pb
coste_lado_pb
0.0              4.0    7.48    11.5
1.0              2.0    5.48     9.5
2.0              0.0    3.48     7.5
3.0             -2.0    1.48     5.5
4.0             -4.0   -0.52     3.5
5.0             -6.0   -2.52     1.5
6.0             -8.0   -4.52    -0.5
equilibrio con huecos > 0 pb: 2.00 pb por lado
equilibrio con huecos > 40 pb: 3.74 pb por lado
equilibrio con huecos > 80 pb: 5.75 pb por lado
Fig. — Regla de cierre de hueco en KAPA: ganancia neta por operación según el coste por lado, para tres tamaños mínimos de hueco

Con todos los huecos, el equilibrio está en 2 puntos básicos por lado: a partir de ahí la regla pierde. Filtrando huecos de más de 40 puntos básicos aguanta 3,7, y con más de 80, 5,8. La figura enseña las tres rectas: cada punto básico de coste por lado resta dos al neto de cada operación.

El resto de la sesión: un control con un susto

El generador no tiene ningún efecto fuera de la primera media hora. Además de la ventana de 10:00 a 10:30 que ya está en la tabla, el control más general es la autocorrelación de los rendimientos intradía: si una barra de 5 minutos, o una media hora, predijera la siguiente, habría reversión (o continuación) intradía que explotar.

IN [07]python
b["r"] = np.log(b["cierre"] / b["apertura"])
tarde = b[b["k"] >= 6].copy()
tarde["previa"] = tarde.groupby("dia")["r"].shift(1)
media_h = (tarde.assign(h=tarde["k"] // 6)
           .groupby(["dia", "h"])["r"].sum().rename("r").to_frame())
media_h["previa"] = media_h.groupby(level="dia")["r"].shift(1)

for nombre, df in (("5 minutos", tarde), ("30 minutos", media_h)):
    df = df.dropna(subset=["r", "previa"])
    m = sm.OLS(df["r"], sm.add_constant(df["previa"])
               ).fit(cov_type="HC1")
    print(f"{nombre:>10}: b = {m.params['previa']:+.4f}  "
          f"t = {m.tvalues['previa']:+.2f}  n = {len(df)}")
OUT [07]Reversión intradía después de las 10:00
 5 minutos: b = +0.0005  t = +0.10  n = 70889
30 minutos: b = -0.0083  t = -0.66  n = 10979

Ni una barra de 5 minutos predice la siguiente (b = +0,0005, t = 0,1, con 70.889 pares) ni una media hora predice la siguiente (b = −0,008, t = −0,7). Fuera de la apertura no hay reversión intradía en estos datos, y una regla que la buscara solo pagaría costes. Es también un control del propio análisis: si el código de alineación de barras estuviera mal (una barra mezclada con la siguiente), aparecería una autocorrelación espuria aquí.

RSI y bandas de Bollinger en acciones

Las dos reglas de reversión más populares en análisis técnico se aplican a precios diarios (lección 1.7.1):

  • RSI (relative strength index) de Wilder: compara las subidas medias con las bajadas medias de las últimas n sesiones en una escala de 0 a 100. Por debajo de 30 se dice «sobreventa»; por encima de 70, «sobrecompra». La variante de 2 sesiones, con umbrales de 10 y 90, es la versión de muy corto plazo.
  • Bandas de Bollinger: una media de 20 sesiones más y menos dos desviaciones típicas. Un cierre bajo la banda inferior se lee como un exceso a la baja.

Una regla de reversión es una máquina de dos estados:

RSI menor de 30RSI mayor de 50FUERADENTRO
Fuente Mermaid
stateDiagram-v2
  [*] --> FUERA
  FUERA --> DENTRO: RSI menor de 30
  DENTRO --> FUERA: RSI mayor de 50

TA-Lib calcula los dos indicadores con las mismas fórmulas que la mayoría de las plataformas (lección 1.7.2). Se aplican sobre los precios ajustados de las 120 acciones:

IN [08]python
import talib

aj = pd.read_parquet("universo2_ajustados.parquet")
P = aj.pivot(index="fecha", columns="ticker",
             values="cierre_ajustado")
R = P.pct_change(fill_method=None)

def por_accion(f):
    return P.apply(lambda s: pd.Series(
        f(s.to_numpy(dtype=float)), index=s.index))

rsi14 = por_accion(lambda x: talib.RSI(x, timeperiod=14))
rsi2 = por_accion(lambda x: talib.RSI(x, timeperiod=2))
banda_alta = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[0])
media20 = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[1])
banda_baja = por_accion(lambda x: talib.BBANDS(x, 20, 2, 2)[2])
ejemplo = pd.DataFrame({"cierre": P["QXAJ"], "rsi14": rsi14["QXAJ"],
                        "rsi2": rsi2["QXAJ"],
                        "banda_baja": banda_baja["QXAJ"]})
ejemplo.loc["2020-03-16":"2020-03-20"].round(2)
OUT [08]RSI y bandas con TA-Lib
            cierre  rsi14   rsi2  banda_baja
fecha
2020-03-16   11.06  26.68  13.75       10.48
2020-03-17   10.62  24.53   6.84       10.13
2020-03-18    9.49  20.06   1.91        9.56
2020-03-19    9.25  19.25   1.46        9.05
2020-03-20    9.22  19.15   1.38        8.63

En la semana del 16 de marzo de 2020, en plena caída del guion, la acción QXAJ tiene un RSI de 14 sesiones por debajo de 30 todos los días y un RSI de 2 sesiones que baja hasta 1,38, y cierra bajo su banda inferior el 18 de marzo. Es exactamente la situación que una regla de reversión compraría, y en la que una regla de tendencia vendería.

La evaluación tiene que separar la regla del mercado. Una cartera larga en acciones gana en estos datos aunque la regla no sirva, porque el mercado sube en promedio. Por eso se mide la rentabilidad en exceso sobre el universo equiponderado del mismo día: si la regla no sabe nada, el exceso es cero. Cada día, la cartera reparte el capital por igual entre las acciones en estado DENTRO, se opera al cierre siguiente y paga media horquilla por cada cambio. El t usa errores HAC (Newey-West) de 5 retardos, porque las posiciones duran varios días y los resultados diarios no son independientes.

IN [09]python
car = pd.read_parquet("universo2_caracteristicas.parquet")
media_horq = (car.pivot(index="fecha", columns="ticker",
                        values="horquilla_pb")
              .reindex(P.index, method="ffill") / 2 / 1e4)

def estado(entra, sale):
    s = pd.DataFrame(np.where(entra, 1.0, np.where(sale, 0.0, np.nan)),
                     index=P.index, columns=P.columns)
    return s.ffill().fillna(0.0)

def evalua(senal, excluir=()):
    cols = [c for c in P.columns if c not in excluir]
    pos = senal[cols].shift(1)                 # al cierre siguiente
    w = pos.div(pos.sum(axis=1).replace(0, np.nan), axis=0).fillna(0)
    ex = R[cols].sub(R[cols].mean(axis=1), axis=0)   # exceso
    bruto = (w.shift(1) * ex).sum(axis=1)
    giro = (w - w.shift()).abs()
    coste = (giro * media_horq[cols]).sum(axis=1)
    activo = w.shift(1).sum(axis=1) > 0
    m = sm.OLS(bruto[activo], np.ones(activo.sum())).fit(
        cov_type="HAC", cov_kwds={"maxlags": 5})
    return pd.Series({
        "bruto_anual": 252 * bruto[activo].mean(),
        "t": m.tvalues.iloc[0],
        "coste_anual": 252 * coste[activo].mean(),
        "neto_anual": 252 * (bruto - coste)[activo].mean(),
        "giro_diario": giro.sum(axis=1)[activo].mean()})

reglas = {
    "rsi14 <30 a >50": estado(rsi14 < 30, rsi14 > 50),
    "rsi2 <10 a >50": estado(rsi2 < 10, rsi2 > 50),
    "bollinger baja a media": estado(P < banda_baja, P > media20),
    "rsi2 >90 a <50 (largo)": estado(rsi2 > 90, rsi2 < 50),
}
res = pd.DataFrame({k: evalua(v) for k, v in reglas.items()}).T
res.round(3)
OUT [09]Backtest de las reglas de reversión
                        bruto_anual      t  coste_anual  neto_anual  giro_diario
rsi14 <30 a >50              -0.054 -3.267        0.008      -0.062        0.111
rsi2 <10 a >50                0.005  0.314        0.043      -0.038        0.616
bollinger baja a media       -0.022 -1.118        0.021      -0.044        0.268
rsi2 >90 a <50 (largo)        0.035  1.449        0.064      -0.029        0.784

Ninguna regla gana en neto. La de RSI de 14 pierde un 5,4 % anual frente al universo en bruto, con t = −3,3: no es que no gane, es que acierta al revés de forma significativa. RSI(2) gana un 0,5 % en bruto (t = 0,3, nada) y paga un 4,3 % de costes, porque cambia cada día el 62 % de la cartera. Bollinger pierde un 2,2 % en bruto. Y la regla contraria, comprar la sobrecompra con RSI(2) por encima de 90, gana un 3,5 % en bruto (t = 1,4) que sus costes del 6,4 % convierten en una pérdida. La regla de reversión pierde y la de continuación gana un poco: el signo de lo que hay en los datos es el contrario del que se buscaba.

Continuación, no reversión

¿Por qué pierde una regla que compra sobreventa? Porque en estos datos no hay reversión de corto plazo y sí algo de continuación. El universo tiene tres fuentes de continuación plantadas: la prima de momentum (lección 2.3.1), la deriva tras los resultados (lección 2.3.5) y, sobre todo, cuatro quiebras guiadas: durante las 320 sesiones anteriores a cada quiebra el precio se lleva hacia un último precio de entre 0,30 y 1,50 USD. Una acción así pasa meses en sobreventa, y la regla la compra una y otra vez.

IN [10]python
quiebras = ["QXQT", "QXKV", "QXZT", "QXYX"]
sin_q = pd.DataFrame({k: evalua(v, excluir=quiebras)
                      for k, v in reglas.items()}).T
sin_q[["bruto_anual", "t", "neto_anual"]].round(3)
OUT [10]Las mismas reglas sin las cuatro quiebras
                        bruto_anual      t  neto_anual
rsi14 <30 a >50              -0.025 -1.289      -0.034
rsi2 <10 a >50                0.021  1.224      -0.026
bollinger baja a media        0.005  0.235      -0.016
rsi2 >90 a <50 (largo)        0.016  0.732      -0.043

Sin las cuatro quiebras, el RSI de 14 pasa de −5,4 % a −2,5 % (t = −1,3), RSI(2) de +0,5 % a +2,1 % (t = 1,2) y Bollinger de −2,2 % a +0,5 %. Ninguna es significativa y todas pierden en neto. Más de la mitad del resultado negativo del RSI de 14 era una sola cosa: comprar acciones en su camino hacia la quiebra. Ojo con el remedio, sin embargo: quitar las quiebras a posteriori es un sesgo de supervivencia (lección 1.4.4). Sirve para entender el resultado, no para mejorarlo: en tiempo real no se sabe qué acción va a quebrar.

La reversión clásica de la literatura es transversal: comprar las perdedoras de la última semana o del último mes frente a las ganadoras. Es el mismo ejercicio que el momentum de la lección 2.3.1, con el signo cambiado y un horizonte más corto:

IN [11]python
def perdedoras(n):
    rk = (P / P.shift(n) - 1).rank(axis=1, pct=True)
    return (rk <= 0.1).astype(float)

rev = pd.DataFrame({f"perdedoras {n}d": evalua(perdedoras(n),
                                                excluir=quiebras)
                    for n in (5, 21)}).T
rev.round(3)
OUT [11]Perdedoras de 5 y 21 sesiones
                bruto_anual      t  coste_anual  neto_anual  giro_diario
perdedoras 5d        -0.007 -0.262        0.071      -0.078        0.823
perdedoras 21d        0.001  0.021        0.037      -0.036        0.409
Fig. — Reglas de reversión en 120 acciones sintéticas sin reversión plantada (sin las cuatro quiebras): exceso anual bruto y neto de media horquilla, 2006-2025

Las perdedoras de una semana rinden −0,7 % anual frente al universo (t = −0,3) y las de un mes, +0,1 % (t = 0,0): nada en las dos, como corresponde a unos datos sin reversión plantada. Con costes, −7,8 % y −3,6 %. La figura reúne las seis reglas sin quiebras: barras brutas casi nulas y barras netas negativas, de −1,6 % a −7,8 %.

Los costes deciden

La columna giro_diario es la fracción de la cartera que cambia cada día. Multiplicada por el coste de una unidad de giro y por 252 sesiones da el coste anual, y dividida al revés da la horquilla de equilibrio: la media horquilla máxima con la que la regla no perdería dinero si su rentabilidad bruta fuera la medida.

IN [12]python
todas = pd.concat([sin_q, rev])
todas["equilibrio_pb"] = (1e4 * todas["bruto_anual"]
                          / (252 * todas["giro_diario"]))
todas["horquilla_media_pb"] = 1e4 * float(np.nanmean(media_horq))
todas[["bruto_anual", "giro_diario", "equilibrio_pb",
       "horquilla_media_pb"]].round(2)
OUT [12]Media horquilla de equilibrio de cada regla
                        bruto_anual  giro_diario  equilibrio_pb  horquilla_media_pb
rsi14 <30 a >50               -0.03         0.13          -7.60                3.15
rsi2 <10 a >50                 0.02         0.69           1.23                3.15
bollinger baja a media         0.01         0.28           0.76                3.15
rsi2 >90 a <50 (largo)         0.02         0.79           0.83                3.15
perdedoras 5d                 -0.01         0.82          -0.34                3.15
perdedoras 21d                 0.00         0.41           0.06                3.15

La media horquilla del universo es de 3,15 puntos básicos de media. Para no perder, RSI(2) necesitaría una media horquilla de 1,2 puntos, Bollinger de 0,8 y la regla de RSI(2) larga de 0,8: entre un cuarto y dos quintos de lo que cuesta de verdad. Las de RSI(14) y perdedoras de 5 días no tienen equilibrio porque pierden en bruto. Es la forma general del problema: una ventaja diaria de unos pocos puntos básicos, operada con una rotación del 30-80 % diario, necesita costes de una fracción de punto básico, que solo tienen los creadores de mercado y quien opera con órdenes limitadas y cobra la horquilla en vez de pagarla (lección 2.3.6).

Lo que esto cambia en tus backtests

  • Mide el efecto antes que la regla. Una regresión con errores robustos dice si hay algo y cuánto; una regla con umbrales elegidos a la vista del resultado añade grados de libertad.
  • Ejecuta en la barra siguiente. En reglas de minutos, entrar al precio que define la señal es la forma más común de inflar el resultado.
  • Calcula el coste de equilibrio. Con una ventaja de pocos puntos básicos por operación, la pregunta no es si gana en bruto, sino qué coste por lado aguanta.
  • Compara con el universo, no con cero. Una regla larga en acciones gana si el mercado sube; lo que mide su valor es el exceso.
  • Mira qué compra la regla. Una regla de sobreventa compra acciones en caída libre; unas pocas quiebras pueden decidir el resultado.

Ejercicios

  1. Repite la regla de cierre de hueco operando solo los huecos a la baja (comprar) y solo los huecos al alza (vender), con huecos de más de 40 puntos básicos. ¿Es simétrico el efecto?
Solución
IN [13]python
grandes = d[d["hueco"].abs() > 0.004]
for lado, s in (("a la baja", grandes[grandes["hueco"] < 0]),
                ("al alza", grandes[grandes["hueco"] > 0])):
    pnl = -np.sign(s["hueco"]) * s["r2_6"]
    t = pnl.mean() / pnl.std() * np.sqrt(len(pnl))
    print(f"{lado:>9}: {len(pnl)} operaciones, "
          f"{1e4 * pnl.mean():+.2f} pb, t = {t:+.2f}")
OUT [13]Huecos al alza y a la baja
a la baja: 169 operaciones, +8.52 pb, t = +2.60
  al alza: 173 operaciones, +6.45 pb, t = +1.93

Los dos lados ganan: 8,5 puntos básicos en los huecos a la baja (t = 2,6) y 6,5 en los huecos al alza (t = 1,9). El generador planta un efecto simétrico (−0,10 × hueco), así que la diferencia de 2 puntos entre los dos lados es ruido: con 170 operaciones por lado, el error típico de cada media es de unos 3 puntos. Elegir solo el lado «que funciona mejor» sería ajustar la regla al ruido.

  1. Cambia la salida de la regla de hueco: en vez de las 10:00, sal al cierre de la tercera barra (09:45) o al de la duodécima (10:30). ¿Qué esperas según el efecto plantado y qué sale?
Solución
IN [14]python
c3 = b[b["k"] == 2].set_index("dia")["cierre"]
s = d[d["hueco"].abs() > 0.004]
salidas = {"09:45": np.log(c3.reindex(s.index) / s["c1"]),
           "10:00": s["r2_6"],
           "10:30": np.log(s["c12"] / s["c1"])}
for k, r in salidas.items():
    pnl = -np.sign(s["hueco"]) * r
    print(f"salida {k}: {1e4 * pnl.mean():+.2f} pb por operación")
OUT [14]Otras salidas
salida 09:45: +2.54 pb por operación
salida 10:00: +7.48 pb por operación
salida 10:30: +7.56 pb por operación

Saliendo a las 09:45 se recogen solo dos de las cinco barras con efecto, y la ganancia cae a 2,5 puntos básicos por operación. Saliendo a las 10:30, después de la media hora sin efecto, queda en 7,6, prácticamente igual que a las 10:00 (7,5): las barras de 10:00 a 10:30 no añaden nada esperado, solo varianza y, en una regla real, más tiempo de exposición. La salida que mejor recoge un efecto es la que coincide con su duración, y aquí se conoce porque está plantada; con datos reales habría que estimarla fuera de la muestra (lección 2.5.2).

  1. Una web publica que «el RSI(2) por debajo de 10 tiene un 70 % de aciertos a 5 días en el S&P 500». Enumera qué comprobarías antes de operarlo.
Solución

Primero, contra qué se miden los aciertos: en un índice que sube a largo plazo, cualquier compra a 5 días acierta más de la mitad de las veces, así que hay que compararlo con el porcentaje de aciertos de una compra cualquiera de 5 días. Segundo, el tamaño de los aciertos frente al de los fallos: un 70 % de aciertos pequeños con fallos grandes puede perder dinero. Tercero, la ejecución: si la señal es al cierre, la compra es al cierre siguiente (o a la apertura), no al mismo cierre. Cuarto, los costes con la rotación real de la regla: en esta lección, una regla de RSI(2) cambia más de la mitad de la cartera cada día y su coste anual es del orden de su rentabilidad bruta o mayor. Quinto, cuántas variantes se probaron (umbrales, horizontes, filtros) hasta llegar a la publicada (lección 2.1.8). Y por último, si el periodo incluye algún tramo sin reversión: en datos reales la reversión de corto plazo existe en la literatura, pero varía mucho entre épocas y mercados.

Footnotes

  1. N. Jegadeesh, «Evidence of Predictable Behavior of Security Returns», Journal of Finance 45(3), 1990, 881-898. DOI 10.1111/j.1540-6261.1990.tb05110.x. ↩

  2. B. N. Lehmann, «Fads, Martingales, and Market Efficiency», Quarterly Journal of Economics 105(1), 1990; versión de trabajo NBER w2533. ↩

Respuesta plegada en cada pregunta

Autoevaluación

  1. En KAPA, la regresión del rendimiento de la primera media hora sobre el hueco da −0,132 con un error típico de 0,038. El efecto plantado es −0,10. ¿Qué concluyes?

    • AQue el generador está mal: debería salir −0,10 exacto
    • BQue la estimación es compatible con la verdad (está a menos de un error típico) y que con 1.002 sesiones el error de un coeficiente así es de casi el 40 % de su valor
    • CQue el efecto real es −0,132
    • DQue el hueco no tiene efecto
    Ver respuesta

    Respuesta B. Que la estimación es compatible con la verdad (está a menos de un error típico) y que con 1.002 sesiones el error de un coeficiente así es de casi el 40 % de su valor

    Una estimación es la verdad más ruido. La distancia de −0,132 a −0,10 es 0,032, menos de un error típico. Lo que sí dice el error es que, con cuatro años de datos, el coeficiente podría estar entre −0,06 y −0,21: el tamaño del efecto se conoce mal aunque su signo esté claro.

  2. La regla vende el hueco al alza al cierre de la primera barra (09:35) y cierra a las 10:00. ¿Por qué no entrar a la apertura de las 09:30?

    • APorque a las 09:30 la bolsa está cerrada
    • BPorque el hueco solo se conoce con el primer precio de la sesión: entrar a ese mismo precio supone que la orden se ejecuta en el instante en que se observa la señal, y en la práctica se ejecuta después
    • CPorque la primera barra no tiene volumen
    • DPor las comisiones
    Ver respuesta

    Respuesta B. Porque el hueco solo se conoce con el primer precio de la sesión: entrar a ese mismo precio supone que la orden se ejecuta en el instante en que se observa la señal, y en la práctica se ejecuta después

    Es la regla de todo el nivel: la decisión con datos de una barra se ejecuta en la siguiente. Se pierde la parte del efecto de la primera barra (un sexto de lo plantado), pero el resultado es alcanzable. Entrar al precio que define la señal es un sesgo de anticipación pequeño y muy habitual.

  3. El rendimiento de 10:00 a 10:30 sobre el hueco da un coeficiente de +0,027 con t = 1,67. ¿Qué es?

    • AUna continuación del hueco que hay que operar
    • BRuido: el generador no tiene ningún efecto después de las 10:00; un t de 1,67 aparece por azar con bastante frecuencia, y más si se prueban muchas ventanas
    • CLa prueba de que el cierre del hueco se revierte
    • DUn error de zona horaria
    Ver respuesta

    Respuesta B. Ruido: el generador no tiene ningún efecto después de las 10:00; un t de 1,67 aparece por azar con bastante frecuencia, y más si se prueban muchas ventanas

    Es el control y no tiene efecto por construcción. Un t de 1,67 tiene un p-valor bilateral de ≈ 0,10: si se prueban diez ventanas de la sesión, lo esperable es encontrar una así. Por eso los controles se miran con el mismo rigor que la señal (lección 2.1.8).

  4. En el universo de acciones, comprar cuando el RSI de 14 sesiones baja de 30 rinde −5,4 % anual frente al universo (t = −3,3). ¿Qué está pasando?

    • AQue el RSI está mal calculado
    • BQue en estos datos no hay reversión y sí continuación: las acciones que caen siguen cayendo un poco (momentum, deriva tras resultados y, sobre todo, cuatro quiebras guiadas a la baja durante más de un año)
    • CQue hay que vender en vez de comprar siempre
    • DQue el RSI funciona al revés en todos los mercados
    Ver respuesta

    Respuesta B. Que en estos datos no hay reversión y sí continuación: las acciones que caen siguen cayendo un poco (momentum, deriva tras resultados y, sobre todo, cuatro quiebras guiadas a la baja durante más de un año)

    Sin las cuatro quiebras, el resultado baja a −2,5 % y deja de ser significativo (t = −1,3). La regla compraba sistemáticamente acciones que se dirigían a cero. En datos reales hay evidencia de reversión semanal y mensual (Lehmann, Jegadeesh), pero aquí no está plantada: es la lección de no suponer el efecto que se busca.

  5. La regla RSI(2) cambia cada día un 62 % de la cartera y la media horquilla del universo es de unos 3 puntos básicos. ¿Cuánto le cuesta al año, aproximadamente?

    • AMenos del 0,1 %
    • BAlrededor del 4-5 %: 0,62 × 3 pb × 252 sesiones
    • CAlrededor del 50 %
    • DNada, porque es una cartera larga
    Ver respuesta

    Respuesta B. Alrededor del 4-5 %: 0,62 × 3 pb × 252 sesiones

    Rotación diaria por coste por unidad por sesiones: 0,62 × 0,0003 × 252 ≈ 4,7 %. Es más de ocho veces su rentabilidad bruta (+0,5 %). En reglas de pocos días, el coste no es un detalle del backtest: es el resultado.

Ideas clave

  • La reversión a la media de corto plazo es la familia más intuitiva y la más sensible a los costes: gana poco por operación y opera mucho.
  • El cierre de hueco plantado en KAPA se recupera con una regresión con errores robustos (−0,132 frente a −0,10, t = −3,5), con un error típico de 0,038 que deja el tamaño del efecto mal medido.
  • La regla que entra a las 09:35 y sale a las 10:00 gana unos 4 puntos básicos por operación en bruto; con 2 puntos básicos por lado ya no gana nada si se operan todos los huecos.
  • El resto de la sesión no tiene efecto, pero da un t de 1,67 en una ventana: los controles también producen casi falsos positivos.
  • RSI y Bollinger en acciones sin reversión plantada no ganan: lo que miden es la continuación heredada de las quiebras y del momentum, y los costes de su rotación diaria se llevan entre el 0,8 % y el 7,1 % al año.

Recursos