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.
- Módulo
- 2.1 · Estadística y econometría para trading
- Duración
- 70 min
Lecciones del nivel 2
2.1 · Estadística y econometría para trading
- 2.1.1 Contrastes de hipótesis, p-valores, bootstrap y regresión con errores robustos
- 2.1.2 Estacionariedad: ADF, KPSS, diferenciación y diferenciación fraccional
- 2.1.3 Modelos ARMA y ARIMA: autocorrelación simple (ACF) y parcial (PACF)
- 2.1.4 Modelos de volatilidad: ARCH, GARCH, EGARCH y GJR con la librería arch
- 2.1.5 Cointegración: Engle-Granger, Johansen y vida media de la reversión
- 2.1.6 Filtro de Kalman para hedge ratios dinámicos
- 2.1.7 Detección de regímenes: modelos de Markov ocultos y cambios estructurales
- 2.1.8 El problema de las pruebas múltiples: Bonferroni, FDR y el «factor zoo»
2.2 · Anatomía de un sistema de trading
- 2.2.1 Hipótesis de ventaja: riesgo, comportamiento, estructura e información
- 2.2.2 Universo, señal, filtro, entrada, salida, sizing y gestión del riesgo
- 2.2.3 Frecuencias: intradía, swing y posicional, y la capacidad de una estrategia
- 2.2.4 Diario de investigación y registro de experimentos contra el data snooping
2.3 · Familias de estrategias
- 2.3.1 Seguimiento de tendencia y momentum
- 2.3.2 Reversión a la media: RSI y Bollinger, reversión intradía y huecos
- 2.3.3 Pairs trading y arbitraje estadístico: distancia, cointegración y Kalman
- 2.3.4 Carry en divisas, futuros y bonos; estacionalidad y efectos calendario
- 2.3.5 Factores (value, calidad, baja volatilidad, tamaño) y estrategias de eventos
- 2.3.6 Introducción al market making: inventario, spread y selección adversa (Avellaneda-Stoikov)
- 2.3.7 Estrategias específicas de cripto: momentum, arbitraje de funding y basis
2.4 · Backtesting: motores y paradigmas
- 2.4.1 Backtest vectorizado desde cero: matrices, desfase, costes y límites
- 2.4.2 Backtest por eventos desde cero: bucle, cartera, órdenes y fills
- 2.4.3 vectorbt: barridos masivos de parámetros y simulación de carteras
- 2.4.4 backtesting.py y backtrader: dos motores didácticos y su estado
- 2.4.5 NautilusTrader: arquitectura, datos, estrategias y el mismo código en backtest y en real
- 2.4.6 LEAN/QuantConnect y Zipline Reloaded; bt para asignación de activos
- 2.4.7 Modelado de costes: comisiones, horquilla, impacto, financiación y préstamo de títulos
- 2.4.8 Modelado de fills: órdenes limitadas, cola, fills parciales, huecos y stops
2.5 · Sesgos y validación
- 2.5.1 Sesgos de backtest: anticipación, supervivencia, selección y narrativa
- 2.5.2 In-sample y out-of-sample: walk-forward anclado y móvil
- 2.5.3 Validación cruzada combinatoria purgada para estrategias
- 2.5.4 Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: remuestreo de trades, bootstrap de bloques y permutaciones de señales
- 2.5.6 Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
- 2.5.7 Datos sintéticos para pruebas de estrés: GBM, GARCH y bootstrapping
- 2.5.8 Checklist de «backtest honesto» antes de pasar a paper trading
2.6 · Métricas de rendimiento
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.
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:
con la media de las rentabilidades y 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 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 H0 | Error de tipo I (falso positivo), probabilidad α | Acierto: la potencia |
| No se rechaza H0 | Acierto | Error 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 es el Sharpe anual y la muestra dura años,
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.
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)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.999La ú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.e0258tiene 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.
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.
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) 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.0Los 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:
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})")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.
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) 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.003Con 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 , sin contar el rechazo por el lado negativo) acierta casi exactamente.
Despejando de la condición «t esperado = 1,96 + 0,84» (el 0,84 da un 80 % de potencia) sale la regla de bolsillo:
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")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ñosCon 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 , las autocovarianzas son positivas hasta el retardo y la varianza real de la media es unas veces la que supone : el t ingenuo sale inflado por cerca de .
El estimador de Newey-West (1987) corrige la varianza sumando las autocovarianzas hasta un retardo , con pesos decrecientes que garantizan que el resultado sea positivo:
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:
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}")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.007131La 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:
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)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: float64Con 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 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:
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)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: float64El 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
, con
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
con la rentabilidad anualizada de las 21 sesiones siguientes y el carry observado. La respuesta conocida es
; 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). 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.
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)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.834Los 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.
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)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.668Sin 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»:
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()}){'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
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ón | Error típico | En statsmodels | Comprobación |
|---|---|---|---|
| Rentabilidades diarias de una estrategia | Clásico; bootstrap i.i.d. si no hay fórmula | fit() | Bootstrap de bloques: debe coincidir |
| Objetivo solapado de sesiones, un activo | Newey-West con | cov_type="HAC" | Submuestra sin solape |
| Varios activos, sin solape | Agrupado por fecha | cov_type="cluster" | Fama-MacBeth (lección 2.3.5) |
| Varios activos y solape | Driscoll-Kraay | cov_type="hac-groupsum" | Sin solape + cluster por fecha |
| Cualquier estadístico sin fórmula (Sharpe, drawdown) | Bootstrap de bloques | arch.bootstrap | Varias 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
- 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 . ¿Cómo crece el problema con el solape?
Solución
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}")h = 5: OLS 0.364; NW L=h 0.076
h = 63: OLS 0.805; NW L=h 0.138Con solape 5, los errores clásicos ya dan un 36,4 % de falsos positivos; con 63, un 80,5 %: el t ingenuo crece con 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.
- Calcula el intervalo del 95 % del Sharpe anual de
e0518y dee0258con un bootstrap estacionario de bloques de media 20. ¿Incluye el Sharpe verdadero? ¿Incluye el cero?
Solución
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']}")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.7Los 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 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.
- 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 . 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
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.
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.
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.
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.
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
Documentación
Libros
- Newey, W. K. y West, K. D. (1987): A Simple, Positive Semi-Definite, Heteroskedasticity and Autocorrelation Consistent Covariance Matrix. Econometrica 55(3), 703-708
- Politis, D. N. y Romano, J. P. (1994): The Stationary Bootstrap. Journal of the American Statistical Association 89(428), 1303-1313
- Driscoll, J. C. y Kraay, A. C. (1998): Consistent Covariance Matrix Estimation with Spatially Dependent Panel Data. Review of Economics and Statistics 80(4), 549-560