Lección 2.1.1 · 70 min

Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos

Un backtest es una muestra y su rentabilidad media, una estimación con error. Esta lección mide ese error con tres herramientas (el contraste t, el bootstrap y los errores de Newey-West) sobre datos sintéticos donde se sabe qué estrategias tienen ventaja y cuáles no, y enseña por qué las rentabilidades solapadas fabrican un t de 10,5 donde el honesto es 1,8.

Fig. — p-valores del contraste t en 4.000 estrategias simuladas de 1.003 días: sin ventaja (izquierda) y con un Sharpe verdadero de 1 (derecha)
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

Un backtest es una muestra

Un backtest de cuatro años con una rentabilidad media del 15 % anual no dice que la estrategia gane un 15 % al año. Dice que, en esos cuatro años concretos, ganó eso. Si el mercado hubiera tirado otros dados, la media sería otra. La pregunta de esta lección es cuánto podría haber salido distinta y, en particular, si un resultado así es compatible con que la ventaja real sea cero.

La lección 1.6.3 ya usó el estadístico t y el bootstrap para los efectos calendario. Aquí se formaliza el método, se mide su potencia y se tratan los dos problemas que más t falsos producen en la práctica: las observaciones que se solapan y los activos que se mueven a la vez.

sínoHipótesis nulaEstadísticoDistrib. bajo H0p-valor¿p < α?Rechazar H0No rechazar
Fuente Mermaid
flowchart TB
  H[Hipótesis nula] --> E[Estadístico]
  E --> D[Distrib. bajo H0]
  D --> P[p-valor]
  P --> R{¿p < α?}
  R -- sí --> RE[Rechazar H0]
  R -- no --> NR[No rechazar]
  • Hipótesis nula (H0): la rentabilidad media esperada es cero. Es lo que se intenta refutar.
  • Estadístico: un número calculado con la muestra que sería pequeño si H0 fuera cierta. Para la media, el t de Student:
t=r‾s/nt = \frac{\overline{r}}{s / \sqrt{n}}

con r‾\overline{r} la media de las nn rentabilidades y ss su desviación típica.

  • Distribución bajo H0: cómo se repartiría el estadístico si H0 fuera cierta. Con observaciones independientes y nn grande, una normal estándar; con el bootstrap, la que sale de remuestrear los datos.
  • p-valor: la probabilidad, si H0 fuera cierta, de obtener un estadístico al menos tan extremo como el observado. No es la probabilidad de que H0 sea cierta.

Al decidir con un umbral α (el 5 % por costumbre) se pueden cometer dos errores:

H0 cierta (sin ventaja)H0 falsa (hay ventaja)
Se rechaza H0Error de tipo I (falso positivo), probabilidad αAcierto: la potencia
No se rechaza H0AciertoError de tipo II (falso negativo)

En trading, el error de tipo I es el caro: se pone dinero en una estrategia sin ventaja y se pagan costes por nada. El de tipo II es más silencioso: se descarta una estrategia buena porque la muestra era corta.

Una identidad útil conecta el t con el Sharpe. Si SRSR es el Sharpe anual y la muestra dura TT años,

t=r‾sn=SRdiarion=SRTt = \frac{\overline{r}}{s}\sqrt{n} = SR_{\text{diario}}\sqrt{n} = SR \sqrt{T}

Un Sharpe de 1 durante 4 años da un t de 2; un Sharpe de 0,5 necesita 16 años para llegar a 2.

Cuatro estrategias con respuesta conocida

ruido2_estrategias.parquet tiene los rendimientos diarios de 1.000 estrategias ficticias en las 1.003 sesiones de 2022 a 2025. Son datos sintéticos: 995 no tienen ninguna ventaja (Sharpe verdadero 0) y cinco tienen una plantada, documentada en ruido2_verdad.csv. Que una de ellas gane aquí no dice nada de ningún mercado real; sirve para saber si un método acierta. Se eligen cuatro:

  • e0518, Sharpe verdadero 1,5: la ventaja más clara del conjunto.
  • e0258, Sharpe verdadero 0,7: ventaja real pero modesta.
  • e0001, sin ventaja: una cualquiera.
  • la mejor de las 995 sin ventaja, por su Sharpe medido.
IN [01]python
import numpy as np
import pandas as pd
from scipy import stats

r = pd.read_parquet("ruido2_estrategias.parquet").pivot(
    index="fecha", columns="estrategia", values="rendimiento")
verdad = pd.read_csv("ruido2_verdad.csv").set_index("estrategia")
sr = r.mean() / r.std() * np.sqrt(252)
nulas = verdad.index[verdad["sharpe_real"] == 0]
mejor_nula = sr[nulas].idxmax()
elegidas = ["e0518", "e0258", "e0001", mejor_nula]

filas = {}
for e in elegidas:
    t, p = stats.ttest_1samp(r[e], 0)
    filas[e] = {"sharpe_real": verdad.loc[e, "sharpe_real"],
                "sharpe_medido": sr[e],
                "media_anual_%": 100 * 252 * r[e].mean(),
                "t": t, "p": p,
                "sr_x_raiz_anos": sr[e] * np.sqrt(len(r) / 252)}
print(len(r), "sesiones;", len(nulas), "estrategias sin ventaja")
pd.DataFrame(filas).T.round(3)
OUT [01]Cuatro estrategias y su contraste t
1003 sesiones; 995 estrategias sin ventaja
       sharpe_real  sharpe_medido  media_anual_%      t      p  sr_x_raiz_anos
e0518          1.5          1.579         33.165  3.150  0.002           3.150
e0258          0.7          0.622          8.492  1.240  0.215           1.240
e0001          0.0          0.003          0.063  0.007  0.995           0.007
e0598          0.0          1.503         26.607  2.999  0.003           2.999

La última columna comprueba la identidad: el t de scipy.stats.ttest_1samp coincide con el Sharpe medido por la raíz de los años (3,98 años). La tabla ya enseña los dos errores:

  • e0518 (Sharpe verdadero 1,5) sale con t = 3,15 y p = 0,002: el contraste acierta.
  • e0258 tiene ventaja real, pero su Sharpe medido es 0,62 y su p, 0,215: con cuatro años no se distingue del ruido. Es un error de tipo II, y no es mala suerte excepcional: con un Sharpe verdadero de 0,7 y cuatro años, el t esperado es 1,4.
  • La mejor de las nulas, e0598, tiene un Sharpe medido de 1,50 y p = 0,003: más «significativa» que una de las buenas. Es el máximo de 995 contrastes; el p-valor de un contraste solo vale para una hipótesis elegida antes de mirar. Esa selección la trata la lección 2.1.8; aquí basta con recordar que ningún método de esta lección la corrige.

El bootstrap: la distribución sin fórmulas

El contraste t supone que la media se reparte como una normal. Con rentabilidades de colas gruesas (lección 1.6.2) y un millar de observaciones, la aproximación suele ser buena, pero no siempre se tiene una fórmula: para el Sharpe, el drawdown o la mediana hay que trabajar más. El bootstrap evita la fórmula: remuestrea los datos con reemplazo miles de veces, recalcula el estadístico en cada remuestra y usa la dispersión de esos valores como su error.

Muestra n díasRemuestra B vecesEstadístico ×BPercentiles IC
Fuente Mermaid
flowchart LR
  M[Muestra n días] --> R[Remuestra B veces]
  R --> E[Estadístico ×B]
  E --> I[Percentiles IC]

El bootstrap i.i.d. sortea días sueltos, así que destruye cualquier dependencia entre días consecutivos. El bootstrap de bloques sortea tramos de días seguidos y conserva la dependencia dentro de cada tramo. La librería arch tiene los dos: IIDBootstrap y StationaryBootstrap (Politis y Romano, 1994), cuyos bloques tienen longitud aleatoria con media dada.

IN [02]python
from arch.bootstrap import IIDBootstrap, StationaryBootstrap

def media_anual(x):
    return 252 * x.mean()

def intervalos(x, bloque=20, reps=2000, semilla=7):
    x = np.asarray(x)
    m, se = media_anual(x), 252 * x.std(ddof=1) / np.sqrt(len(x))
    q = stats.t.ppf(0.975, len(x) - 1)
    out = {"t": (m - q * se, m + q * se)}
    for nombre, bs in [("iid", IIDBootstrap(x, seed=semilla)),
                       ("bloques", StationaryBootstrap(
                           bloque, x, seed=semilla))]:
        ic = bs.conf_int(media_anual, reps=reps, method="percentile")
        out[nombre] = tuple(ic.ravel())
    return out

ic = {(e, k): 100 * np.array(v) for e in elegidas
      for k, v in intervalos(r[e]).items()}
pd.DataFrame(ic, index=["inf_%", "sup_%"]).T.round(1)
OUT [02]Intervalo del 95 % de la media anual: t, i.i.d. y bloques
               inf_%  sup_%
e0518 t         12.5   53.8
      iid       13.5   55.0
      bloques    9.0   55.5
e0258 t         -4.9   21.9
      iid       -4.7   22.1
      bloques   -5.0   22.2
e0001 t        -18.8   19.0
      iid      -18.7   20.2
      bloques  -19.1   19.3
e0598 t          9.2   44.0
      iid        9.0   43.7
      bloques    6.8   46.0
Fig. — Intervalo del 95 % de la rentabilidad media anual de cuatro estrategias sintéticas, por la fórmula t, por bootstrap i.i.d. y por bootstrap de bloques

Los tres métodos dan casi el mismo intervalo en las cuatro estrategias; el de bloques sale algo más ancho por abajo en e0518 y e0598 (9,0 frente a 12,5 en la primera), por el azar del remuestreo y por el agrupamiento de volatilidad, que los bloques conservan. No es casualidad que coincidan: estos rendimientos diarios no tienen autocorrelación (solo agrupamiento de volatilidad), así que remuestrear días sueltos o bloques de 20 da lo mismo, y con 1.003 días la media ya se reparte casi como una normal. Es la situación habitual con rentabilidades diarias de una estrategia que no mantiene posiciones muchos días: el contraste t es suficiente.

La lección práctica está en la anchura. El intervalo de e0518 va de alrededor del 12 % al 55 % anual: la ventaja es real, pero cuatro años no dicen si es grande o pequeña. El de e0258, de −6 % a +22 %, incluye el cero aunque la ventaja exista.

El bootstrap también da un p-valor. Para contrastar H0, se centra la muestra en cero (se le resta su media, así la nula es cierta en las remuestras) y se cuenta qué fracción de las medias remuestreadas es tan extrema como la observada:

IN [03]python
x = r["e0258"].to_numpy()
bs = StationaryBootstrap(20, x - x.mean(), seed=7)
medias = np.array([media_anual(d[0][0])
                   for d in bs.bootstrap(4000)])
p_bs = np.mean(np.abs(medias) >= abs(media_anual(x)))
print(f"media anual observada: {100 * media_anual(x):.2f} %")
print(f"p bootstrap: {p_bs:.3f}  (p del t: "
      f"{stats.ttest_1samp(x, 0).pvalue:.3f})")
OUT [03]p-valor bootstrap de e0258 (muestra centrada)
media anual observada: 8.49 %
p bootstrap: 0.232  (p del t: 0.215)

Los dos p-valores son parecidos (0,232 frente a 0,215) y llevan a la misma conclusión. En rentabilidades diarias sin autocorrelación, el bootstrap confirma el contraste t; su valor aparece cuando la fórmula no existe o la dependencia sí, como en las secciones que vienen.

La potencia: cuánta historia hace falta

La figura de la cabecera responde a la pregunta inversa: si la ventaja existe, ¿con qué probabilidad la detecta un contraste? Se simulan 4.000 estrategias de 1.003 días con innovaciones t de Student (colas gruesas) y cuatro Sharpe verdaderos. Es una simulación con semilla, no los datos del curso: así se conoce la respuesta en cada una.

IN [04]python
rng = np.random.default_rng(211)
n, sims = 1003, 4000
filas = {}
for sr_real in [0, 0.5, 1.0, 1.5]:
    z = rng.standard_t(5, (sims, n)) * np.sqrt(3 / 5)  # varianza 1
    x = sr_real / np.sqrt(252) + z
    t = x.mean(1) / (x.std(1, ddof=1) / np.sqrt(n))
    p = 2 * stats.t.sf(np.abs(t), n - 1)
    filas[sr_real] = {
        "rechaza_5%": np.mean(p < 0.05),
        "teorica": stats.norm.cdf(sr_real * np.sqrt(n / 252) - 1.96),
        "p_mediano": np.median(p)}
pd.DataFrame(filas).T.round(3)
OUT [04]p-valores simulados bajo la nula y bajo la alternativa
     rechaza_5%  teorica  p_mediano
0.0       0.056    0.025      0.498
0.5       0.166    0.168      0.294
1.0       0.528    0.514      0.042
1.5       0.850    0.849      0.003

Con Sharpe 0 (la nula), un 5,6 % de las simulaciones rechaza al 5 %: el tamaño nominal, con el ruido de 4.000 réplicas. Los p-valores son uniformes: cada tramo de 0,05 recibe un 5 % de ellos, que es lo que se ve en el panel izquierdo de la figura. Con Sharpe 1, la potencia es del 52,8 %: la mitad de las veces una estrategia de Sharpe 1 probada durante cuatro años no pasa el umbral. Con Sharpe 0,5, solo un 16,6 %. La columna teorica (la normal con media SRTSR\sqrt{T}, sin contar el rechazo por el lado negativo) acierta casi exactamente.

Despejando TT de la condición «t esperado = 1,96 + 0,84» (el 0,84 da un 80 % de potencia) sale la regla de bolsillo:

IN [05]python
for s in [0.25, 0.5, 1.0, 1.5, 2.0]:
    anos = ((stats.norm.ppf(0.975) + stats.norm.ppf(0.8)) / s) ** 2
    print(f"Sharpe {s:4.2f}: {anos:6.1f} años")
OUT [05]Años necesarios para un 80 % de potencia
Sharpe 0.25:  125.6 años
Sharpe 0.50:   31.4 años
Sharpe 1.00:    7.8 años
Sharpe 1.50:    3.5 años
Sharpe 2.00:    2.0 años

Con un Sharpe verdadero de 0,5, una cifra ya buena para una estrategia real después de costes, harían falta 31 años de historia para detectarla con un 80 % de probabilidad. Por eso los backtests cortos con Sharpe alto deben leerse con desconfianza (el Sharpe medido de las afortunadas está inflado, lección 2.5.4) y por eso las estrategias de Sharpe bajo se validan con muchos activos a la vez más que con mucha historia de uno solo.

Rentabilidades solapadas: el t que se multiplica

Muchas señales predicen un horizonte largo: «la rentabilidad de las próximas 21 sesiones». Si se calcula ese objetivo cada día, dos observaciones consecutivas comparten 20 de sus 21 sesiones. La media sigue siendo correcta; el error típico de la fórmula clásica, no. Con solape hh, las autocovarianzas son positivas hasta el retardo h−1h-1 y la varianza real de la media es unas hh veces la que supone s2/ns^2/n: el t ingenuo sale inflado por cerca de h\sqrt{h}.

El estimador de Newey-West (1987) corrige la varianza sumando las autocovarianzas γ^j\hat\gamma_j hasta un retardo LL, con pesos decrecientes que garantizan que el resultado sea positivo:

Var⁡^(y‾)=1n[γ^0+2∑j=1L(1−jL+1)γ^j]\widehat{\operatorname{Var}}(\overline{y}) = \frac{1}{n}\left[\hat\gamma_0 + 2\sum_{j=1}^{L}\left(1-\frac{j}{L+1}\right)\hat\gamma_j\right]

En statsmodels es una regresión sobre una constante con cov_type="HAC". Primero, a mano y con la librería, sobre una estrategia sin ventaja:

IN [06]python
import statsmodels.api as sm

def nw_var_media(y, L):
    y = np.asarray(y) - np.mean(y)
    n = len(y)
    g = [y[j:] @ y[:n - j] / n for j in range(L + 1)]
    lrv = g[0] + 2 * sum((1 - j / (L + 1)) * g[j]
                         for j in range(1, L + 1))
    return lrv / n

h = 21
lr = np.log1p(r["e0001"]).cumsum()
y = (lr.shift(-h) - lr).dropna()       # 21 sesiones, cada día
ols = sm.OLS(y.to_numpy(), np.ones(len(y))).fit()
hac = sm.OLS(y.to_numpy(), np.ones(len(y))).fit(
    cov_type="HAC", cov_kwds={"maxlags": h})
print(f"autocorrelación en el retardo 1: {y.autocorr(1):.3f}")
print(f"t ingenuo: {ols.tvalues[0]:.2f}; "
      f"t Newey-West: {hac.tvalues[0]:.2f}")
print(f"error típico a mano: {np.sqrt(nw_var_media(y, h)):.6f}; "
      f"statsmodels: {hac.bse[0]:.6f}")
OUT [06]Newey-West a mano y con statsmodels
autocorrelación en el retardo 1: 0.959
t ingenuo: -0.97; t Newey-West: -0.25
error típico a mano: 0.007131; statsmodels: 0.007131

La autocorrelación en el retardo 1 es de 0,96, cerca del 20/21 teórico. El t ingenuo es unas cuatro veces el de Newey-West, y el error típico calculado a mano coincide con el de statsmodels (que usa por defecto los mismos pesos de Bartlett). En esta estrategia los dos t están lejos de 2, pero la pregunta importante es qué pasa en todas.

Las 995 estrategias sin ventaja permiten medir la tasa de falsos positivos de cada método sobre datos cuya verdad se conoce:

IN [07]python
lr = np.log1p(r[nulas]).cumsum()
Y = (lr.shift(-h) - lr).dropna()

def p_media(y, L=None):
    m = sm.OLS(y, np.ones(len(y)))
    f = m.fit() if L is None else m.fit(
        cov_type="HAC", cov_kwds={"maxlags": L})
    return f.pvalues[0]

tasas = {
    "diario, t": np.mean([stats.ttest_1samp(r[e], 0).pvalue < 0.05
                          for e in nulas]),
    "solapado, OLS": np.mean([p_media(Y[e].to_numpy()) < 0.05
                              for e in nulas])}
for L in [10, 21, 42]:
    tasas[f"solapado, NW L={L}"] = np.mean(
        [p_media(Y[e].to_numpy(), L) < 0.05 for e in nulas])
tasas["sin solape, t"] = np.mean(
    [stats.ttest_1samp(Y[e].iloc[::h], 0).pvalue < 0.05
     for e in nulas])
pd.Series(tasas, name="falsos_positivos").round(3)
OUT [07]Falsos positivos en 995 estrategias sin ventaja
diario, t            0.044
solapado, OLS        0.653
solapado, NW L=10    0.183
solapado, NW L=21    0.094
solapado, NW L=42    0.068
sin solape, t        0.039
Name: falsos_positivos, dtype: float64
Fig. — Falsos positivos al 5 % en 995 estrategias sin ventaja según el contraste: rentabilidades diarias, rentabilidades a 21 sesiones calculadas cada día (solapadas) y la misma serie sin solape

Con rentabilidades diarias, el contraste t da un 4,4 % de falsos positivos: lo nominal. Con las mismas estrategias y un objetivo solapado a 21 sesiones, la regresión con errores clásicos declara significativas al 65,3 %: dos de cada tres estrategias sin ventaja «predicen» su propia rentabilidad. Newey-West lo corrige casi del todo, y la elección de LL importa: con 10 retardos (menos que el solape) queda un 18,3 %; con 21, un 9,4 %; con 42, un 6,8 %. Muestrear una observación cada 21 sesiones (sin solape) devuelve el 3,9 %, a costa de quedarse con 47 observaciones.

El bootstrap también falla con el solape si se usa el i.i.d., y funciona con bloques más largos que el solape:

IN [08]python
y = Y["e0001"].to_numpy()
ee = {"fórmula clásica": y.std(ddof=1) / np.sqrt(len(y)),
      "Newey-West L=42": np.sqrt(nw_var_media(y, 42))}
for nombre, bs in [
        ("bootstrap iid", IIDBootstrap(y, seed=3)),
        ("bloques media 63", StationaryBootstrap(63, y, seed=3))]:
    m = [d[0][0].mean() for d in bs.bootstrap(2000)]
    ee[nombre] = np.std(m)
pd.Series(ee, name="error_tipico_media").round(5)
OUT [08]Bootstrap con solape: i.i.d. frente a bloques
fórmula clásica     0.00186
Newey-West L=42     0.00810
bootstrap iid       0.00191
bloques media 63    0.00792
Name: error_tipico_media, dtype: float64

El bootstrap i.i.d. reproduce el error de la fórmula clásica (0,0019) porque, al sortear días sueltos, rompe el solape; el de bloques, con bloques de 63 sesiones de media, da un error de la misma escala que Newey-West, unas cuatro veces mayor. La regla es la misma para los dos métodos: la ventana (retardos o bloque) tiene que cubrir la dependencia.

Regresión múltiple con errores robustos: carry y momentum en futuros

El caso más frecuente es una regresión predictiva: la rentabilidad futura de un activo sobre una o varias señales de hoy. futuros2_continuos.parquet y futuros2_carry.parquet tienen 24 mercados de futuros sintéticos de 2000 a 2025 con dos efectos plantados (README del nivel 2): el rendimiento esperado anual es 0,5 cexogeno+0,1 creversion+0,15 σ signo⁡(r252)0{,}5\,c_{\text{exogeno}} + 0{,}1\,c_{\text{reversion}} + 0{,}15\,\sigma\,\operatorname{signo}(r_{252}), con σ\sigma la volatilidad de largo plazo de cada mercado y el último término un momentum de series temporales. El carry observado, carry_anual, es la suma de las dos partes: una exógena, con prima, y otra que solo descuenta la vuelta del precio a su ancla, casi sin prima. La regresión natural es

yi,t=a+b1 ci,t+b2 σi,tsigno⁡(ri,t−252:t)+εi,ty_{i,t} = a + b_1\, c_{i,t} + b_2\, \sigma_{i,t}\operatorname{signo}(r_{i,t-252:t}) + \varepsilon_{i,t}

con yy la rentabilidad anualizada de las 21 sesiones siguientes y cc el carry observado. La respuesta conocida es b2≈0,15b_2 \approx 0{,}15; b1b_1 es una mezcla de 0,5 y 0,1 según qué parte del carry domine (el README mide 0,15 con datos de fin de mes). σ\sigma se estima con todos los rendimientos hasta cada fecha (ventana creciente): es la volatilidad de largo plazo sin mirar adelante. Las observaciones son diarias, así que hay solape de 21; además, los 24 mercados comparten días y factores. El estimador de Driscoll-Kraay (1998) trata las dos cosas: suma, para cada fecha, los productos regresor × residuo de todos los mercados y aplica Newey-West a esa serie. En statsmodels es cov_type="hac-groupsum" con el índice de fecha.

IN [09]python
cont = pd.read_parquet("futuros2_continuos.parquet")
carry = pd.read_parquet("futuros2_carry.parquet")
lp = np.log(cont.pivot(index="fecha", columns="mercado",
                       values="cierre_ajustado"))
c = carry.pivot(index="fecha", columns="mercado",
                values="carry_anual")
fwd = (lp.shift(-h) - lp) * 252 / h            # anualizada
# σ de largo plazo de cada mercado, sin mirar adelante
vol = lp.diff().expanding(252).std() * np.sqrt(252)
mom = np.sign(lp - lp.shift(252)) * vol
panel = pd.concat({"y": fwd, "carry": c, "mom": mom}, axis=1)
panel = panel.stack(future_stack=True).dropna()
X = sm.add_constant(panel[["carry", "mom"]])
fecha = panel.index.get_level_values("fecha").factorize()[0]

ols = sm.OLS(panel["y"], X).fit()
dk = sm.OLS(panel["y"], X).fit(
    cov_type="hac-groupsum", cov_kwds={"time": fecha, "maxlags": h})
print(len(panel), "filas,", panel.index.get_level_values(
    "mercado").nunique(), "mercados")
pd.DataFrame({"coef": ols.params, "t_OLS": ols.tvalues,
              "t_DK": dk.tvalues}).round(3)
OUT [09]Panel de 24 futuros: carry y momentum
150384 filas, 24 mercados
        coef   t_OLS   t_DK
const  0.013   5.844  0.808
carry  0.171   8.683  1.596
mom    0.107  10.511  1.834

Los coeficientes recuperan la respuesta con error: 0,107 para el momentum (verdad 0,15) y 0,171 para el carry, dentro de la mezcla esperada y cerca del 0,15 del README. Lo que cambia por completo es la confianza. Con errores clásicos, los t son 8,68 y 10,51: certeza aparente. Con Driscoll-Kraay, 1,60 y 1,83: ninguno pasa el umbral, aunque los dos efectos existen. Son 150.384 filas, pero la información es mucho menor: unas 300 rentabilidades mensuales independientes por mercado, y mercados que se mueven juntos en las crisis. Que el contraste honesto no rechace no dice que no haya efecto (aquí se sabe que lo hay): dice que 26 años de 24 mercados correlados no bastan para demostrarlo con un t de 2. Es la potencia de la sección anterior, ahora en un panel.

La comprobación sin fórmulas sofisticadas es quitar el solape: una fecha de cada 21 y errores agrupados por fecha (cov_type="cluster"), que corrigen la correlación entre mercados del mismo día.

IN [10]python
fechas = np.sort(panel.index.get_level_values("fecha").unique())
mensual = panel[panel.index.get_level_values("fecha").isin(
    fechas[::h])]
Xm = sm.add_constant(mensual[["carry", "mom"]])
grupos = mensual.index.get_level_values("fecha").factorize()[0]
om = sm.OLS(mensual["y"], Xm).fit()
cl = sm.OLS(mensual["y"], Xm).fit(
    cov_type="cluster", cov_kwds={"groups": grupos})
print(len(mensual), "filas en", len(fechas[::h]), "fechas")
pd.DataFrame({"coef": om.params, "t_OLS": om.tvalues,
              "t_cluster": cl.tvalues}).round(3)
OUT [10]Sin solape, con errores agrupados por fecha
7176 filas en 299 fechas
        coef  t_OLS  t_cluster
const  0.011  1.100      0.588
carry  0.209  2.288      1.536
mom    0.053  1.126      0.668

Sin solape, el t clásico del carry baja de 8,68 a 2,29 y el del momentum, de 10,51 a 1,13; agrupando por fecha, a 1,54 y 0,67. Los coeficientes se mueven más que con Driscoll-Kraay (0,209 y 0,053: quedarse con una fecha de cada 21 tira información y deja la estimación más ruidosa), pero la conclusión es la misma: la incertidumbre honesta es varias veces la ingenua.

¿Y si la señal no sirviera para nada? Un regresor placebo persistente, sin ninguna relación con los precios, es la prueba de fuego. Se simulan 20 placebos AR(1) con la misma persistencia que el carry del generador (0,997 diario), cada uno independiente por mercado, y se cuenta cuántas veces sale «significativo»:

IN [11]python
rng = np.random.default_rng(2111)
rech = {"OLS": 0, "Driscoll-Kraay": 0, "DK L=63": 0}
for _ in range(20):
    e = rng.standard_normal(lp.shape) * np.sqrt(1 - 0.997**2)
    pl = np.empty(lp.shape)
    pl[0] = rng.standard_normal(lp.shape[1])
    for i in range(1, len(pl)):
        pl[i] = 0.997 * pl[i - 1] + e[i]
    pl = pd.DataFrame(pl, index=lp.index, columns=lp.columns)
    d = pd.concat({"y": fwd, "carry": c, "mom": mom, "pl": pl},
                  axis=1).stack(future_stack=True).dropna()
    Xp = sm.add_constant(d[["carry", "mom", "pl"]])
    tt = d.index.get_level_values("fecha").factorize()[0]
    m = sm.OLS(d["y"], Xp)
    rech["OLS"] += m.fit().pvalues["pl"] < 0.05
    for k, L in [("Driscoll-Kraay", h), ("DK L=63", 63)]:
        f = m.fit(cov_type="hac-groupsum",
                  cov_kwds={"time": tt, "maxlags": L})
        rech[k] += f.pvalues["pl"] < 0.05
print({k: f"{v} de 20" for k, v in rech.items()})
OUT [11]20 placebos persistentes: OLS frente a Driscoll-Kraay
{'OLS': '14 de 20', 'Driscoll-Kraay': '1 de 20', 'DK L=63': '1 de 20'}

Con errores clásicos, el placebo es «significativo» en 14 de 20 simulaciones. Con Driscoll-Kraay, en 1 con 21 retardos y en 1 con 63. Veinte réplicas son pocas para medir una tasa con precisión, pero el orden de magnitud es claro: con señales persistentes y objetivos solapados, los errores clásicos no son una aproximación optimista, son un generador de descubrimientos.

Qué error típico usar

nosínosínosí¿Dependencia?¿Varios activos?¿Varios activos?t clásico o iidCluster por fechaNewey-West L=2hDriscoll-Kraay
Fuente Mermaid
flowchart TB
  Q1{¿Dependencia?} -- no --> Q2{¿Varios activos?}
  Q1 -- sí --> Q3{¿Varios activos?}
  Q2 -- no --> A[t clásico o iid]
  Q2 -- sí --> B[Cluster por fecha]
  Q3 -- no --> C[Newey-West L=2h]
  Q3 -- sí --> D[Driscoll-Kraay]
SituaciónError típicoEn statsmodelsComprobación
Rentabilidades diarias de una estrategiaClásico; bootstrap i.i.d. si no hay fórmulafit()Bootstrap de bloques: debe coincidir
Objetivo solapado de hh sesiones, un activoNewey-West con L≥hL \ge hcov_type="HAC"Submuestra sin solape
Varios activos, sin solapeAgrupado por fechacov_type="cluster"Fama-MacBeth (lección 2.3.5)
Varios activos y solapeDriscoll-Kraaycov_type="hac-groupsum"Sin solape + cluster por fecha
Cualquier estadístico sin fórmula (Sharpe, drawdown)Bootstrap de bloquesarch.bootstrapVarias longitudes de bloque

Tres advertencias que no resuelve ninguna fila de la tabla:

  • Selección. Si se probaron cien variantes y se informa de la mejor, todos estos p-valores son demasiado bajos (lecciones 2.1.8 y 2.5.4).
  • Costes. Un coeficiente significativo en rentabilidades brutas puede no sobrevivir a la horquilla y las comisiones (lección 2.4.7). La significación estadística no es rentabilidad.
  • Estabilidad. Un t de 3 sobre 20 años puede venir de una sola crisis. Mira el coeficiente por subperiodos antes de fiarte (lección 2.5.6).

Ejercicios

  1. Repite la tabla de falsos positivos de las 995 estrategias nulas con un objetivo solapado a 5 sesiones y a 63 sesiones, con errores clásicos y con Newey-West de L=hL = h. ¿Cómo crece el problema con el solape?
Solución
IN [12]python
for hh in [5, 63]:
    lr = np.log1p(r[nulas]).cumsum()
    Yh = (lr.shift(-hh) - lr).dropna()
    ols_fp = np.mean([p_media(Yh[e].to_numpy()) < 0.05
                      for e in nulas])
    nw_fp = np.mean([p_media(Yh[e].to_numpy(), hh) < 0.05
                     for e in nulas])
    print(f"h = {hh:2d}: OLS {ols_fp:.3f}; NW L=h {nw_fp:.3f}")
OUT [12]Falsos positivos según el solape
h =  5: OLS 0.364; NW L=h 0.076
h = 63: OLS 0.805; NW L=h 0.138

Con solape 5, los errores clásicos ya dan un 36,4 % de falsos positivos; con 63, un 80,5 %: el t ingenuo crece con h\sqrt{h} y cada vez más estrategias nulas superan el umbral. Newey-West baja la tasa en los dos casos (7,6 % y 13,8 %), pero con 63 retardos sobre unas 940 observaciones queda muy por encima de lo nominal: con un solape tan largo hay pocas observaciones independientes (unas 15) y cualquier estimador de la varianza es impreciso. Con objetivos muy largos, lo prudente es muestrear sin solape o usar más historia.

  1. Calcula el intervalo del 95 % del Sharpe anual de e0518 y de e0258 con un bootstrap estacionario de bloques de media 20. ¿Incluye el Sharpe verdadero? ¿Incluye el cero?
Solución
IN [13]python
def sharpe(x):
    return np.sqrt(252) * x.mean() / x.std()

for e in ["e0518", "e0258"]:
    bs = StationaryBootstrap(20, r[e].to_numpy(), seed=11)
    inf, sup = bs.conf_int(sharpe, reps=2000,
                           method="percentile").ravel()
    print(f"{e}: Sharpe medido {sharpe(r[e].to_numpy()):.2f}, "
          f"IC 95 % [{inf:.2f}, {sup:.2f}], "
          f"verdadero {verdad.loc[e, 'sharpe_real']}")
OUT [13]Intervalo bootstrap del Sharpe
e0518: Sharpe medido 1.58, IC 95 % [0.43, 2.77], verdadero 1.5
e0258: Sharpe medido 0.62, IC 95 % [-0.43, 1.69], verdadero 0.7

Los dos intervalos incluyen el Sharpe verdadero y tienen una anchura de unos dos puntos de Sharpe: es el error típico de ≈ 0,5 que da la regla 1/T1/\sqrt{T} con cuatro años. El de e0518 excluye el cero; el de e0258 lo incluye. Un Sharpe medido de 1,58 es compatible con un verdadero de 0,5 o de 2,7: cuatro años no distinguen una estrategia excelente de una mediocre.

  1. Un compañero presenta una regresión de la rentabilidad a 12 meses de 50 acciones sobre una señal de valor, con datos mensuales de 15 años (9.000 filas), y un t de 6 con errores clásicos. ¿Qué dos problemas tiene el t y cómo lo recalcularías?
Solución

Primero, solape: con rentabilidades a 12 meses observadas cada mes, cada observación comparte 11 meses con la siguiente, y el t está inflado por cerca de 12≈3,5\sqrt{12} \approx 3{,}5. Segundo, correlación entre acciones del mismo mes: las 50 rentabilidades de una fecha se mueven juntas, así que no son 50 datos independientes. Lo recalcularía con Driscoll-Kraay (cov_type="hac-groupsum" con la fecha y al menos 12 retardos) y lo comprobaría con una submuestra anual sin solape y errores agrupados por fecha. Un t de 6 ingenuo puede quedarse en 1 o 2. Y después preguntaría cuántas señales se probaron antes de esta (lección 2.1.8).

Respuesta plegada en cada pregunta

Autoevaluación

  1. Una estrategia tiene un Sharpe anual verdadero de 1,0 y la pruebas con cuatro años de datos diarios. ¿Qué probabilidad hay de que el contraste t al 5 % la declare significativa?

    • ACasi el 100 %: un Sharpe de 1 es muy alto
    • BAlrededor del 50 %: con cuatro años, el estadístico t esperado es 1 × √4 = 2, justo en el umbral
    • CEl 5 %
    • DEl 95 %
    Ver respuesta

    Respuesta B. Alrededor del 50 %: con cuatro años, el estadístico t esperado es 1 × √4 = 2, justo en el umbral

    El estadístico t de la media es aproximadamente el Sharpe anual por la raíz de los años. Con t esperado de 2 y umbral de 1,96, la mitad de las muestras quedan por debajo. En la simulación de la lección, el 52,8 % de 4.000 muestras rechaza la nula.

  2. Con rentabilidades a 21 sesiones calculadas cada día (solapadas), ¿por qué el t de la media sale inflado?

    • APorque las rentabilidades a 21 días son más grandes
    • BPorque dos observaciones consecutivas comparten 20 de sus 21 días: no son independientes, y la fórmula clásica cuenta 21 veces más información de la que hay
    • CPorque la media de las rentabilidades solapadas está sesgada
    • DPorque la varianza de las rentabilidades a 21 días es menor
    Ver respuesta

    Respuesta B. Porque dos observaciones consecutivas comparten 20 de sus 21 días: no son independientes, y la fórmula clásica cuenta 21 veces más información de la que hay

    La media no está sesgada; su error típico sí. La fórmula s/√n supone observaciones independientes; con solape h, la varianza real de la media es unas h veces la que da la fórmula. En las 995 estrategias sin ventaja, el contraste ingenuo da un 65,3 % de falsos positivos.

  3. Newey-West con 21 retardos sobre rentabilidades solapadas a 21 sesiones da un 9,4 % de falsos positivos en vez del 5 %. ¿Qué lo explica mejor?

    • AQue Newey-West está mal programado en statsmodels
    • BQue los pesos de Bartlett (1 − j/(L+1)) infraponderan las autocovarianzas cercanas al retardo L; con L igual al solape, el error típico se queda algo corto. Con L = 2h baja al 6,8 %
    • CQue hay que usar 0 retardos
    • DQue las estrategias sí tenían ventaja
    Ver respuesta

    Respuesta B. Que los pesos de Bartlett (1 − j/(L+1)) infraponderan las autocovarianzas cercanas al retardo L; con L igual al solape, el error típico se queda algo corto. Con L = 2h baja al 6,8 %

    Con solape h, las autocovarianzas son positivas hasta el retardo h − 1 y Bartlett las pondera cada vez menos. Es un sesgo conocido en muestras finitas; más retardos lo reducen a costa de más varianza del estimador. Muestrear sin solape es la comprobación sencilla.

  4. En el panel de 24 futuros, el coeficiente del momentum tiene t = 10,51 con OLS y t = 1,83 con Driscoll-Kraay. ¿Qué cambia entre uno y otro?

    • AEl coeficiente estimado
    • BSolo el error típico: Driscoll-Kraay suma los productos regresor × residuo de todos los mercados en cada fecha y aplica Newey-West a esa serie, así que tiene en cuenta el solape y que los 24 mercados se mueven a la vez
    • CEl número de observaciones
    • DLa variable dependiente
    Ver respuesta

    Respuesta B. Solo el error típico: Driscoll-Kraay suma los productos regresor × residuo de todos los mercados en cada fecha y aplica Newey-West a esa serie, así que tiene en cuenta el solape y que los 24 mercados se mueven a la vez

    El estimador es el mismo (0,107). OLS trata las 150.384 filas como independientes; en realidad hay unas 300 rentabilidades mensuales distintas por mercado y los mercados están correlados el mismo día. Con el error honesto, el momentum plantado no llega a t = 2.

  5. Entre 995 estrategias sin ventaja, la mejor tiene t = 3,0 en su contraste diario y su intervalo bootstrap de bloques no incluye el cero. ¿Qué conclusión es correcta?

    • ATiene ventaja: el bootstrap de bloques es robusto
    • BNada: el contraste y el bootstrap son válidos para una estrategia elegida de antemano, no para la mejor de cientos. La selección hay que corregirla aparte (lección 2.1.8)
    • CQue hay que usar un nivel del 1 %
    • DQue el bootstrap tiene un error
    Ver respuesta

    Respuesta B. Nada: el contraste y el bootstrap son válidos para una estrategia elegida de antemano, no para la mejor de cientos. La selección hay que corregirla aparte (lección 2.1.8)

    Ningún método de error típico corrige la selección. Con 995 nulas se esperan unas 50 con p < 0,05 y alguna con p < 0,005. La respuesta conocida del conjunto lo confirma: su Sharpe real es 0.

Ideas clave

  • El estadístico t de la media es aproximadamente Sharpe anual × √años: con un Sharpe verdadero de 0,5 harían falta 31 años para tener un 80 % de potencia.
  • Bajo la nula los p-valores son uniformes; bajo la alternativa se amontonan cerca de cero, pero con poca potencia muchas estrategias buenas quedan por encima de 0,05.
  • El bootstrap i.i.d. y el de bloques coinciden si no hay dependencia serial; con rentabilidades solapadas, solo el de bloques (con bloques más largos que el solape) da el error correcto.
  • Las rentabilidades solapadas multiplican el t por cerca de √h: 65,3 % de falsos positivos en 995 estrategias nulas. Newey-West con L ≥ h o muestrear sin solape lo corrige.
  • En un panel, la correlación entre activos del mismo día también infla el t: Driscoll-Kraay baja el t del momentum de 10,51 a 1,83 sin tocar el coeficiente, y un efecto que existe deja de ser significativo.
  • Ningún error típico corrige la selección entre muchas pruebas: eso es la lección 2.1.8.

Recursos