Lección 2.5.6 · 60 min
Sensibilidad de parámetros: mesetas frente a picos, robustez entre activos y periodos
Toda estrategia tiene parámetros, y casi cualquier rejilla de parámetros tiene un máximo. Lo que distingue un efecto de un accidente es lo que rodea a ese máximo: una meseta ancha que se repite en otros mercados y en otras épocas, o un pico aislado que se desvanece en cuanto se mira fuera de la muestra.
- Módulo
- 2.5 · Sesgos y validación
- Duración
- 60 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 óptimo no es un resultado
Cualquier estrategia tiene parámetros: la longitud de una media, un umbral, una ventana de volatilidad, un plazo de revisión. Probar varias combinaciones y quedarse con la mejor es inevitable y no es, en sí, un error. El error es leer la rejilla como un ranking y creer que el número de la primera fila es lo que la estrategia dará en el futuro. La lección 2.5.4 corrige ese número por el número de pruebas; esta mira la forma de la rejilla, que dice algo que el número solo no dice.
La intuición es mecánica. Si hay un efecto real que la estrategia captura, cambiar un poco un parámetro cambia poco lo que captura: una media de 125 sesiones y una de 150 se parecen mucho. El resultado forma una meseta. Si no hay efecto, el máximo de la rejilla es el ganador de una lotería: una combinación que acertó por casualidad los días buenos de esta muestra, y sus vecinas, que acertaron otros días o ninguno, no se le parecen. El resultado es un pico.
Fuente Mermaid
flowchart TB G[Rejilla] --> S[Superficie de Sharpe] S --> M[Métricas de suavidad] M --> C[Centro de meseta] C --> F[Fuera de muestra] F --> A[Otros activos] A --> P[Otros periodos] P --> D[Candidata]
La rejilla en los futuros: una meseta
La estrategia es un cruce de medias sobre los 24 futuros: en cada mercado, largo si la media de las últimas sesiones está por encima de la de las últimas y corto si está por debajo, con la posición escalada por la inversa de la volatilidad y ejecutada en la sesión siguiente (lección 2.3.1). Ocho medias rápidas por ocho lentas dan 64 combinaciones:
import numpy as np
import pandas as pd
def sharpe(x):
x = np.asarray(x, dtype=float)
x = x[~np.isnan(x)]
return np.mean(x) / np.std(x, ddof=1) * np.sqrt(252)
fut = pd.read_parquet("futuros2_continuos.parquet")
px = fut.pivot(index="fecha", columns="mercado",
values="cierre_ajustado")
r = px.pct_change()
vol = r.ewm(span=60, min_periods=60).std() * np.sqrt(252)
escala = 0.30 / vol / px.shape[1]
RAPIDAS = [5, 10, 20, 30, 40, 50, 60, 80]
LENTAS = [100, 125, 150, 175, 200, 250, 300, 350]
medias = {n: px.rolling(n).mean() for n in RAPIDAS + LENTAS}
def pnl_cruce(f, s):
"""Por mercado: largo si la media rápida está sobre la lenta."""
signo = np.sign(medias[f] - medias[s])
return ((signo * escala).shift(1) * r).loc["2001-01-02":]
por_mercado = {(f, s): pnl_cruce(f, s) for f in RAPIDAS for s in LENTAS}
carteras = pd.DataFrame({k: v.sum(axis=1)
for k, v in por_mercado.items()})
def rejilla(carteras, desde=None, hasta=None):
sr = carteras.loc[desde:hasta].apply(sharpe)
return sr.unstack().rename_axis(index="rápida", columns="lenta")
g_fut = rejilla(carteras)
g_fut.round(2)lenta 100 125 150 175 200 250 300 350
rápida
5 0.71 0.68 0.73 0.81 0.82 0.90 1.04 1.09
10 0.60 0.72 0.83 0.82 0.83 0.92 1.07 1.08
20 0.67 0.78 0.80 0.87 0.78 0.97 1.06 1.12
30 0.74 0.89 0.86 0.89 0.91 0.97 1.10 1.12
40 0.83 0.87 0.88 0.93 0.91 0.97 1.08 1.03
50 0.86 0.77 0.83 0.90 0.88 0.93 1.04 1.03
60 0.87 0.75 0.89 0.78 0.83 0.86 0.97 0.98
80 0.70 0.72 0.69 0.75 0.83 0.76 1.00 0.97Las 64 combinaciones ganan, con Sharpe entre 0,60 y 1,12. Hay estructura: la superficie sube hacia las medias lentas largas (las columnas de 300 y 350 sesiones van de 0,97 a 1,12) y baja algo con la rápida de 80 y con las lentas cortas. Pero ninguna combinación está sola: es la meseta del panel izquierdo de la figura de la cabecera, donde todos los cuadros son azules y del tamaño máximo. El efecto plantado es el signo de 12 meses (252 sesiones); ninguna de estas reglas lo calcula exactamente, todas lo capturan en parte porque las tendencias largas mueven las medias de cualquier longitud, y las que más se le acercan son las de media lenta larga: una media de sesiones va retrasada , así que la de 300 mira, en promedio, unos 150 días atrás.
La misma búsqueda en ruido: picos
Para ver un pico hace falta un caso sin efecto. La serie de control del README, ruptura después de su ruptura, es
ruido normal i.i.d. de media cero. Sobre ella se prueba una familia de reglas de reversión de corto plazo, que es donde
suelen acabar las búsquedas de un investigador impaciente: ir contra el signo de la rentabilidad de sesiones y
revisar la posición cada sesiones. Nueve por seis, 54 combinaciones, todas siempre en el mercado:
reg = pd.read_parquet("regimenes2.parquet")
ru = (reg[reg["serie"] == "ruptura"].set_index("fecha")["precio"]
.loc["2013-05-22":]) # tras la ruptura: i.i.d., media 0
r_ru = ru.pct_change()
DIAS = [1, 2, 3, 4, 5, 7, 10, 15, 20]
CADA = [1, 2, 3, 5, 7, 10]
def pnl_reversion(n, m):
"""Contra el signo de la rentabilidad de n sesiones; la
posición se revisa cada m sesiones."""
signo = -np.sign(ru.pct_change(n))
signo = signo.where(np.arange(len(signo)) % m == 0).ffill()
return (signo.shift(1) * r_ru).loc["2014-01-02":]
carteras_ru = pd.DataFrame({(n, m): pnl_reversion(n, m)
for n in DIAS for m in CADA})
g_ru = rejilla(carteras_ru).rename_axis(index="días",
columns="cada")
g_ru.round(2)cada 1 2 3 5 7 10
días
1 -0.02 0.00 -0.02 0.14 -0.46 -0.05
2 -0.29 -0.26 -0.42 0.01 -0.07 -0.02
3 -0.24 -0.38 -0.26 -0.13 -0.27 0.28
4 -0.11 -0.10 0.12 0.11 -0.46 0.57
5 0.02 0.07 0.05 -0.23 0.13 0.38
7 -0.01 -0.03 -0.21 -0.28 0.00 0.16
10 -0.22 -0.36 -0.13 -0.02 -0.01 0.24
15 -0.01 -0.00 0.27 0.34 0.08 0.19
20 0.25 0.09 -0.21 -0.05 -0.11 0.23La mejor combinación, ir contra la rentabilidad de 4 sesiones revisando cada 10, da un Sharpe de 0,57 en doce años: presentable en un informe. Está en el borde de la rejilla, y sus cinco vecinas valen 0,28, −0,27, −0,46, 0,13 y 0,38: de media, 0,01. Es el panel derecho de la figura de la cabecera: cuadros azules y naranjas de todos los tamaños mezclados sin orden. No hay nada que capturar, y cada combinación ha capturado un azar distinto.
Medir la suavidad
Mirar la superficie es lo primero, pero conviene resumirla en cifras que se puedan comparar entre estrategias y guardar en el diario de experimentos (lección 2.2.4). Cuatro sencillas:
- Vecinos/pico: media de las celdas que rodean al máximo (8, o menos en un borde) dividida por el máximo. Cerca de 1, meseta; cerca de 0 o negativa, pico aislado.
- Fracción positiva: qué parte de la rejilla gana. Sin efecto, en torno a la mitad.
- Rugosidad: salto medio entre celdas contiguas dividido por la desviación típica de la rejilla. En una superficie de ruido blanco (celdas independientes con la misma dispersión), el salto medio entre dos celdas es , así que la rugosidad vale . Una meseta da bastante menos.
- Correlación entre combinaciones: cuánto se parecen las series de rendimientos de la rejilla entre sí.
def vecinos(a, i, j):
filas, cols = a.shape
return [a[x, y] for x in range(i - 1, i + 2)
for y in range(j - 1, j + 2)
if (x, y) != (i, j) and 0 <= x < filas and 0 <= y < cols]
def suavidad(g):
a = g.to_numpy()
i, j = np.unravel_index(np.argmax(a), a.shape)
v = np.mean(vecinos(a, i, j))
saltos = np.r_[np.abs(np.diff(a, axis=0)).ravel(),
np.abs(np.diff(a, axis=1)).ravel()]
m = {"pico": a[i, j], "media_vecinos": v,
"vecinos/pico": v / a[i, j], "positivas": (a > 0).mean(),
"rugosidad": saltos.mean() / a.std()}
return {"en": f"{g.index[i]}/{g.columns[j]}",
**{k: float(round(x, 2)) for k, x in m.items()}}
pd.DataFrame({"futuros": suavidad(g_fut), "ruido": suavidad(g_ru)}) futuros ruido
en 20/350 4/10
pico 1.12 0.57
media_vecinos 1.09 0.01
vecinos/pico 0.97 0.02
positivas 1.0 0.41
rugosidad 0.47 0.94Las cifras dicen lo mismo que la figura. En los futuros, las vecinas del pico (cinco, porque está en el borde) conservan el 97 % de su Sharpe, todas las celdas son positivas y la rugosidad es 0,47. En el ruido, las vecinas conservan un 2 %, solo el 41 % de las celdas gana y la rugosidad, 0,94, está cerca del 1,13 del ruido blanco.
La cuarta métrica explica parte de la diferencia, y es una advertencia:
def parecido(carteras):
c = carteras.dropna().corr().to_numpy()
n = len(c)
autovalores = np.linalg.eigvalsh(c)
return {"combinaciones": n,
"correlación_media": (c.sum() - n) / (n * (n - 1)),
"primer_componente": autovalores[-1] / n}
pd.DataFrame({"futuros": parecido(carteras),
"ruido": parecido(carteras_ru)}).round(2) futuros ruido
combinaciones 64.00 54.00
correlación_media 0.77 0.32
primer_componente 0.78 0.35Las 64 variantes del cruce de medias tienen una correlación media de 0,77, y un solo componente principal explica el 78 % de su varianza: son casi la misma estrategia con pequeñas diferencias. Las de reversión se parecen mucho menos (0,32). Una parte de la suavidad de los futuros viene de ahí, y no del efecto: con parámetros tan correlados, una superficie puede salir suave también sobre ruido, sobre todo con medias largas (el ejercicio 2 lo comprueba). La suavidad es necesaria para fiarse de unos parámetros, pero no suficiente. Esta correlación es, además, lo que hay que tener en cuenta al contar el número de pruebas para el Deflated Sharpe (lección 2.5.4): 64 variantes casi idénticas no son 64 intentos independientes.
Elegir el centro de la meseta
La regla práctica que se deduce: no elijas el máximo de la rejilla; elige el centro de la meseta. Una forma explícita es suavizar la superficie (cada celda pasa a valer la media de su vecindad de 3 × 3) y quedarse con el máximo de la suavizada. La celda elegida es la que mejor está rodeada, y su valor suavizado es una estimación más prudente de lo que cabe esperar.
Los bordes de la rejilla no tienen vecindad completa, y una media con menos vecinas es más ruidosa: se dejan fuera. Si el máximo suavizado quedara pegado al borde, la conclusión sería ampliar la rejilla, no elegir el borde.
def suavizar(g):
"""Media de cada celda con sus 8 vecinas. Los bordes quedan
vacíos: no tienen vecindad completa."""
a = g.to_numpy()
f, c = a.shape
s = np.full((f, c), np.nan)
for i in range(1, f - 1):
for j in range(1, c - 1):
s[i, j] = a[i - 1:i + 2, j - 1:j + 2].mean()
return pd.DataFrame(s, index=g.index, columns=g.columns)
def elegir(g):
"""Pico bruto y centro de la meseta (máximo de la suavizada)."""
return g.stack().idxmax(), suavizar(g).stack().idxmax()
def txt(k):
return f"{k[0]}/{k[1]}"
for nombre, g in (("futuros", g_fut), ("ruido", g_ru)):
pico, centro = elegir(g)
print(f"{nombre}: pico {txt(pico)} ({g.loc[pico]:.2f}); "
f"centro {txt(centro)} ({g.loc[centro]:.2f}, "
f"vecindad {suavizar(g).loc[centro]:.2f})")futuros: pico 20/350 (1.12); centro 30/300 (1.10, vecindad 1.05)
ruido: pico 4/10 (0.57); centro 15/7 (0.08, vecindad 0.10)En los futuros, el pico bruto (20/350) está en el borde, y el centro de la meseta es el cruce 30/300: Sharpe 1,10, con una vecindad que promedia 1,05. Está en la última columna interior: la meseta sigue subiendo hacia las medias lentas y la rejilla se queda corta por ese lado. Es el caso en que la regla pide ampliar la rejilla; el ejercicio 1 lo hace, y el centro apenas se mueve. En el ruido, el centro es 15/7, con 0,08 y una vecindad de 0,10: la regla no encuentra ninguna meseta, y eso ya es un resultado. Si el investigador del ruido hubiera usado esta regla, habría visto que su «mejor estrategia» de 0,57 está rodeada de nada.
La prueba de la regla es fuera de muestra. Se elige con la primera mitad de los datos y se mide en la segunda: en los futuros, 2001-2012 frente a 2013-2025; en el ruido, 2014-2019 frente a 2020-2025 (lección 2.5.2).
casos = {
"futuros": (carteras, ("2001", "2012"), ("2013", "2025")),
"ruido": (carteras_ru, ("2014", "2019"), ("2020", "2025")),
}
filas, dentro_fuera = {}, {}
for nombre, (c, ins, oos) in casos.items():
g_in, g_out = rejilla(c, *ins), rejilla(c, *oos)
dentro_fuera[nombre] = (g_in, g_out)
pico, centro = elegir(g_in)
for regla, k in (("pico", pico), ("centro", centro)):
filas[(nombre, regla, txt(k))] = {"dentro": g_in.loc[k],
"fuera": g_out.loc[k]}
filas[(nombre, "mediana", "todas")] = {
"dentro": g_in.stack().median(),
"fuera": g_out.stack().median()}
rho = np.corrcoef(g_in.to_numpy().ravel(),
g_out.to_numpy().ravel())[0, 1]
print(f"{nombre}: correlación dentro-fuera de la rejilla {rho:.2f}")
pd.DataFrame(filas).T.round(2)futuros: correlación dentro-fuera de la rejilla 0.27
ruido: correlación dentro-fuera de la rejilla 0.15
dentro fuera
futuros pico 20/175 0.96 0.79
centro 20/300 0.89 1.23
mediana todas 0.77 0.99
ruido pico 5/10 0.63 0.13
centro 15/7 0.21 -0.05
mediana todas -0.04 -0.05En los futuros, todo aguanta: el pico elegido en 2001-2012 (20/175, Sharpe 0,96) da 0,79 en 2013-2025; el centro (20/300, 0,89), 1,23; la mediana de las 64 combinaciones pasa de 0,77 a 0,99. La segunda mitad es más favorable a la tendencia que la primera, y sobre todo a las medias lentas largas, que es donde cae el centro. No hay que leer en esto que el centro gane siempre: en una meseta real, elegir el centro en lugar del pico cuesta poco o nada fuera de muestra, y su valor de dentro no es el máximo de un sorteo.
En el ruido, el pico de 2014-2019 (5/10, 0,63) da 0,13 en 2020-2025, y la mediana de la rejilla es −0,04 dentro y −0,05 fuera: la rejilla entera vale cero y el pico era el premio de la lotería. La correlación entre la rejilla de dentro y la de fuera, en cambio, separa poco los dos casos: 0,27 en los futuros y 0,15 en el ruido. En los futuros cambia la forma (dentro de muestra la superficie es plana, con el pico en 175; fuera mandan las lentas largas) aunque el nivel se mantenga. Lo que separa los dos casos es el nivel de toda la rejilla fuera de muestra (mediana de 0,99 frente a −0,05), no el orden de sus celdas.
Fuente Mermaid
flowchart TB
S{¿Meseta ancha?} -- no --> X[Descartar]
S -- sí --> O{¿Aguanta fuera?}
O -- no --> X
O -- sí --> A{¿Otros mercados?}
A -- no --> R[Revisar hipótesis]
A -- sí --> P{¿Otros periodos?}
P -- no --> R
P -- sí --> C[Candidata]Robustez entre activos
Un efecto real debería aparecer en todos los mercados donde la hipótesis dice que está, no solo en la cartera agregada. Una cartera de 24 mercados puede tener un buen Sharpe porque dos o tres mercados extraordinarios compensan a veinte mediocres, y entonces el resultado depende de que esos dos o tres sigan igual. Con los parámetros del centro de la meseta:
esp = pd.read_csv("futuros2_especificaciones.csv",
index_col="mercado")
CENTRO = elegir(g_fut)[1]
pm = por_mercado[CENTRO]
por_merc = pm.apply(sharpe)
tabla = pd.DataFrame({
"mercados": por_merc.groupby(esp["sector"]).size(),
"positivos": (por_merc > 0).groupby(esp["sector"]).sum(),
"sharpe_medio": por_merc.groupby(esp["sector"]).mean(),
"sharpe_sector": pm.T.groupby(esp["sector"]).sum().T.apply(sharpe),
})
print(f"Cruce {txt(CENTRO)}: {(por_merc > 0).sum()} de 24 mercados "
f"con Sharpe positivo")
tabla.round(2)Cruce 30/300: 21 de 24 mercados con Sharpe positivo
mercados positivos sharpe_medio sharpe_sector
sector
agricolas 4 4 0.46 0.85
bonos 5 5 0.38 0.53
divisas 3 2 0.15 0.23
energia 3 2 0.15 0.22
metales 4 3 0.17 0.29
renta_variable 5 5 0.46 0.59Veintiuno de los 24 mercados son positivos (los tres negativos están en las divisas, la energía y los metales), y todos los sectores, como cartera, también: de 0,22 en la energía a 0,85 en los agrícolas. El Sharpe medio por mercado (de 0,15 a 0,46) es mucho menor que el de la cartera (1,10): la diversificación entre mercados poco correlados hace la mayor parte del trabajo. Que un mercado suelto dé un Sharpe de 0,1 o 0,2 no es una señal de alarma; que la mayoría diera negativo, sí.
La comprobación más dura es una clase de activo distinta. El nivel 2 tiene diez divisas al contado en
fx2_carry.parquet, y su README no les planta momentum: su respuesta conocida es un carry (el contado se deprecia
solo un 30 % del diferencial de tipos, así que el carry gana un 70 %) y unas crisis de carry. La misma regla, sin tocar un parámetro:
fx = pd.read_parquet("fx2_carry.parquet")
spot = fx.pivot(index="fecha", columns="divisa", values="spot")
r_fx = spot.pct_change()
vol_fx = r_fx.ewm(span=60, min_periods=60).std() * np.sqrt(252)
f, s = CENTRO
signo_fx = np.sign(spot.rolling(f).mean() - spot.rolling(s).mean())
pnl_fx = ((signo_fx * 0.30 / vol_fx / 10).shift(1) * r_fx)
pnl_fx = pnl_fx.loc["2006-01-02":]
print(f"Divisas, cruce {txt(CENTRO)}: "
f"Sharpe {sharpe(pnl_fx.sum(axis=1)):.2f}")
print(pnl_fx.apply(sharpe).round(2).sort_values().to_dict())Divisas, cruce 30/300: Sharpe -0.03
{'EST': -0.6, 'ISL': -0.16, 'JAR': -0.14, 'HUR': -0.11, 'GAL': -0.06, 'BOR': 0.03, 'ALV': 0.15, 'DUN': 0.23, 'FOR': 0.24, 'CAS': 0.29}En las divisas al contado la regla da −0,03: cinco divisas con Sharpe negativo y cinco con positivo, de −0,60 a 0,29. La deriva del contado por el diferencial de tipos es persistente y una regla de tendencia podría seguirla a ratos, pero aquí no recoge nada: lejos del 1,10 de los futuros. Esto no invalida la estrategia: confirma que el efecto está donde la hipótesis dice y no en otro sitio. La robustez entre activos se exige dentro del dominio de la hipótesis (lección 2.2.1). En mercados reales, la literatura documenta el momentum de series temporales en futuros de índices, bonos, materias primas y divisas1Nota 1 · Moskowitz, Ooi y Pedersen (2012), «Time series momentum», Journal of Financial Economics 104(2), 228-250, doi:10.1016/j.jfineco.2011.11.003: según su resumen, momentum significativo en cada uno de los 58 futuros líquidos estudiados, de índices, divisas, materias primas y bonos.; un efecto que solo apareciera en uno de esos grupos, o solo en la cartera agregada, sería una señal para revisar la hipótesis antes de operar.
Robustez entre periodos
El último eje es el tiempo: unos parámetros buenos en 25 años pueden serlo solo porque una década fue extraordinaria. Se parte la muestra en tramos y se mira el centro de la meseta y la peor combinación de la rejilla en cada uno:
PICO_RU = elegir(dentro_fuera["ruido"][0])[0]
tramos = {"2001-05": ("2001", "2005"), "2006-10": ("2006", "2010"),
"2011-15": ("2011", "2015"), "2016-20": ("2016", "2020"),
"2021-25": ("2021", "2025")}
periodos = pd.DataFrame({
"futuros_centro": {k: sharpe(carteras[CENTRO].loc[a:b])
for k, (a, b) in tramos.items()},
"futuros_peor": {k: carteras.loc[a:b].apply(sharpe).min()
for k, (a, b) in tramos.items()},
})
bloques_ru = {"2014-16": ("2014", "2016"), "2017-19": ("2017", "2019"),
"2020-22": ("2020", "2022"), "2023-25": ("2023", "2025")}
print(periodos.round(2))
print(f"Ruido, pico de 2014-2019 ({txt(PICO_RU)}):",
{k: float(round(sharpe(carteras_ru[PICO_RU].loc[a:b]), 2))
for k, (a, b) in bloques_ru.items()}) futuros_centro futuros_peor
2001-05 1.27 0.56
2006-10 0.62 -0.41
2011-15 1.25 0.28
2016-20 0.88 0.14
2021-25 1.47 0.72
Ruido, pico de 2014-2019 (5/10): {'2014-16': 0.33, '2017-19': 0.93, '2020-22': 0.13, '2023-25': 0.13}El cruce 30/300 es positivo en los cinco quinquenios, de 0,62 en 2006-2010 a 1,47 en 2021-2025. Y la peor de las 64 combinaciones solo es negativa en uno (−0,41 en 2006-2010): la meseta no es un accidente de una época. El quinquenio más fuerte contiene la caída de los bonos que el guion del generador planta en 2022, y el primero (1,27), la caída de la renta variable de 2000-2002: tramos de tendencia larga, que es exactamente lo que debe capturar una regla de tendencia.
El pico del ruido cuenta la historia contraria: 0,33 y 0,93 en los dos trienios con los que se eligió, y 0,13 y 0,13 en los dos siguientes. La mejora dentro de muestra era el criterio de selección; fuera, solo queda ruido.
Resumen para el diario de experimentos de una estrategia candidata:
| Prueba | Cruce 30/300 en futuros | Reversión 5/10 en ruido |
|---|---|---|
| Vecinos/pico | 0,97 | 0,02 |
| Celdas positivas | 100 % | 41 % |
| Rugosidad (ruido blanco: 1,13) | 0,47 | 0,94 |
| Dentro / fuera de muestra (elegido dentro) | 0,89 / 1,23 (20/300) | 0,63 / 0,13 |
| Mercados con Sharpe positivo | 21 de 24 | No aplicable (una serie) |
| Periodos positivos | 5 de 5 | 4 de 4, cayendo de 0,93 a 0,13 |
Ejercicios
- Amplía la rejilla de los futuros con medias lentas de 400 y 500 sesiones y una rápida de 90. ¿Se mueve el centro de la meseta? ¿Cambian las métricas de suavidad?
Solución
RAPIDAS2 = RAPIDAS + [90]
LENTAS2 = LENTAS + [400, 500]
for n in RAPIDAS2 + LENTAS2:
if n not in medias:
medias[n] = px.rolling(n).mean()
carteras2 = pd.DataFrame({
(f, s): pnl_cruce(f, s).sum(axis=1)
for f in RAPIDAS2 for s in LENTAS2}).loc["2003-01-02":]
g2 = rejilla(carteras2)
pico2, centro2 = elegir(g2)
print(f"pico {txt(pico2)} ({g2.loc[pico2]:.2f}); "
f"centro {txt(centro2)} ({g2.loc[centro2]:.2f})")
print(suavidad(g2))pico 20/350 (1.16); centro 20/350 (1.16)
{'en': '20/350', 'pico': 1.16, 'media_vecinos': 1.12, 'vecinos/pico': 0.96, 'positivas': 1.0, 'rugosidad': 0.5}Al ampliar la rejilla (y empezar en 2003 para que la media de 500 sesiones exista desde el principio), el máximo queda en 20/350 con 1,16, ahora rodeado por todos los lados, y pico y centro coinciden: a una celda del 30/300 de la rejilla original. Las medias de 400 y 500 sesiones no mejoran a la de 350: la meseta culmina ahí, a la altura del año que usa el efecto plantado. Las métricas apenas cambian: vecinos/pico 0,96, todas las celdas positivas y rugosidad 0,50. Es lo que se espera de una meseta: añadir parámetros alrededor no la rompe ni la traslada lejos. Un pico, en cambio, suele desaparecer o cambiar de sitio al cambiar la rejilla o el periodo.
- Calcula la rejilla de cruces de medias en el gemelo sin memoria de los futuros de la lección 2.5.5 (las mismas sesiones en otro orden) y sus métricas de suavidad. ¿Basta la suavidad para distinguirlo de los futuros?
Solución
rng = np.random.default_rng(2028)
orden = rng.permutation(len(r) - 1)
r_g = pd.DataFrame(r.to_numpy()[1:][orden], index=r.index[1:],
columns=r.columns)
px_g = (1 + r_g).cumprod()
medias_g = {n: px_g.rolling(n).mean() for n in RAPIDAS + LENTAS}
vol_g = r_g.ewm(span=60, min_periods=60).std() * np.sqrt(252)
esc_g = 0.30 / vol_g / 24
carteras_g = pd.DataFrame({
(f, s): ((np.sign(medias_g[f] - medias_g[s]) * esc_g).shift(1)
* r_g).loc["2001-01-02":].sum(axis=1)
for f in RAPIDAS for s in LENTAS})
g_g = rejilla(carteras_g)
print(suavidad(g_g))
print({k: round(float(v), 2) for k, v in parecido(carteras_g).items()}){'en': '80/150', 'pico': 0.12, 'media_vecinos': -0.02, 'vecinos/pico': -0.18, 'positivas': 0.22, 'rugosidad': 0.68}
{'combinaciones': 64.0, 'correlación_media': 0.75, 'primer_componente': 0.76}El gemelo da una superficie suave (rugosidad 0,68, lejos del 1,13 del ruido blanco, y correlación media de 0,75 entre combinaciones), pero plana y sin nada: solo el 22 % de las celdas es positivo y el pico, 80/150, vale 0,12 con vecinas que promedian −0,02. La suavidad sola no distingue un efecto de su ausencia: con medias largas y muy correladas, cualquier serie da una superficie suave. Lo que distingue a los futuros es que la meseta es suave y alta: todas las celdas positivas y vecinas que conservan el 97 % del pico. Por eso las métricas se leen juntas.
- Un compañero presenta una estrategia con un Sharpe de 1,4 en el mejor de 300 conjuntos de parámetros. Su rejilla tiene una forma suave, pero solo se ha probado en el índice de un país entre 2010 y 2020. ¿Qué tres comprobaciones le pedirías antes de dedicarle más tiempo, y qué resultado de cada una te haría descartarla?
Solución
Primero, el centro de la meseta y su valor suavizado, con los bordes fuera, y la comparación dentro y fuera de muestra eligiendo los parámetros solo con la primera parte: si el centro baja mucho del 1,4 o la rejilla de fuera no se parece a la de dentro, el 1,4 era el máximo de una lotería. Segundo, la robustez entre activos donde su hipótesis predice el efecto (otros índices, otros países): si solo funciona en el que se ha optimizado, no hay efecto general. Tercero, la robustez entre periodos, con tramos anteriores a 2010 y posteriores a 2020: si todo el resultado viene de unos pocos años, depende de un régimen. Y, en paralelo, el número real de pruebas (300, corregido por su correlación) para el Deflated Sharpe de la lección 2.5.4. Una rejilla suave no basta: parámetros vecinos están correlados y la suavidad puede aparecer también sin efecto.
Footnotes
-
Moskowitz, Ooi y Pedersen (2012), «Time series momentum», Journal of Financial Economics 104(2), 228-250, doi:10.1016/j.jfineco.2011.11.003: según su resumen, momentum significativo en cada uno de los 58 futuros líquidos estudiados, de índices, divisas, materias primas y bonos. ↩
Respuesta plegada en cada pregunta
Autoevaluación
En la rejilla de los futuros, el pico (20/350, en el borde) tiene un Sharpe de 1,12 y sus cinco vecinas promedian 1,09. En el ruido, el pico (4/10) tiene 0,57 y sus vecinas, 0,01. ¿Qué dice esa diferencia?
- AQue el ruido tiene un pico más estrecho pero igual de bueno
- BQue en los futuros el resultado no depende del valor exacto de los parámetros (meseta) y en el ruido sí: un pico rodeado de ceros es casi siempre azar
- CQue hay que usar rejillas más finas en el ruido
- DNada: solo importa el Sharpe del pico
Ver respuesta
Respuesta B. Que en los futuros el resultado no depende del valor exacto de los parámetros (meseta) y en el ruido sí: un pico rodeado de ceros es casi siempre azar
Si el efecto es real, parámetros cercanos capturan casi lo mismo y dan resultados parecidos. Un máximo cuyas vecinas no ganan nada es lo que produce el azar cuando se prueba mucho: la razón vecinos/pico es 0,97 en los futuros y 0,02 en el ruido.
Las 64 combinaciones de la rejilla de futuros tienen una correlación media de 0,77 entre sí. ¿Qué implica?
- AQue la rejilla está mal construida
- BQue son, en la práctica, pocas estrategias distintas: es parte de por qué la superficie es suave, y es lo que hay que tener en cuenta al contar las pruebas para el Deflated Sharpe
- CQue hay 64 pruebas independientes
- DQue la estrategia no tiene ventaja
Ver respuesta
Respuesta B. Que son, en la práctica, pocas estrategias distintas: es parte de por qué la superficie es suave, y es lo que hay que tener en cuenta al contar las pruebas para el Deflated Sharpe
Un primer componente que explica el 78 % de la varianza común indica que las 64 variantes se mueven casi a la vez. La suavidad viene en parte de ahí: es necesaria, pero por sí sola no prueba un efecto. Por eso se añaden la comprobación fuera de muestra y la robustez entre mercados y periodos.
¿Por qué la función
suavizarde la lección deja vacíos los bordes de la rejilla?- APara que el código sea más corto
- BPorque una celda del borde no tiene vecindad completa: su media se haría con menos vecinas y saldría más ruidosa. Si el máximo suavizado cae en el borde, hay que ampliar la rejilla
- CPorque los bordes siempre son peores
- DPorque así se elige siempre el centro geométrico
Ver respuesta
Respuesta B. Porque una celda del borde no tiene vecindad completa: su media se haría con menos vecinas y saldría más ruidosa. Si el máximo suavizado cae en el borde, hay que ampliar la rejilla
Con menos vecinas, la media del borde es menos fiable y tiende a ganar por azar. En la rejilla de futuros el pico bruto (20/350) está en el borde y la superficie sube hacia las medias lentas largas: la señal es ampliar la rejilla. Ampliada (ejercicio 1), el máximo queda en 20/350 rodeado por todos los lados.
Elegidos con los datos de 2001-2012, el pico de los futuros (20/175) pasa de 0,96 a 0,79 fuera de muestra y el centro de la meseta (20/300), de 0,89 a 1,23. En el ruido, el pico pasa de 0,63 a 0,13. ¿Qué enseña la regla del centro?
- AQue el centro siempre gana más que el pico fuera de muestra
- BQue no depende de la casualidad de una celda: en una meseta real cuesta poco o nada frente al pico, y en el ruido revela que no hay meseta (la vecindad del centro vale 0,10)
- CQue hay que elegir siempre la media lenta más larga
- DQue evita los costes de transacción
Ver respuesta
Respuesta B. Que no depende de la casualidad de una celda: en una meseta real cuesta poco o nada frente al pico, y en el ruido revela que no hay meseta (la vecindad del centro vale 0,10)
En una meseta real, pico y centro dan resultados parecidos fuera de muestra; aquí el centro incluso gana, porque fuera de muestra mandan las medias lentas largas y el centro cae en esa zona, pero eso no es una regla. Lo que la regla da siempre es una alarma: si el centro es mediocre, el pico era una casualidad.
El cruce 30/300 da Sharpe positivo en 21 de los 24 futuros, pero −0,03 en las divisas al contado de
fx2_carry. ¿Cómo se interpreta?- ALa estrategia no es robusta: hay que descartarla
- BLas divisas de
fx2_carryno llevan el momentum plantado: la robustez entre activos se exige donde la hipótesis dice que está el efecto, y un resultado nulo fuera de ese dominio es coherente con ella - CHay que optimizar otros parámetros para las divisas
- DLas divisas al contado tienen más costes
Ver respuesta
Respuesta B. Las divisas de
fx2_carryno llevan el momentum plantado: la robustez entre activos se exige donde la hipótesis dice que está el efecto, y un resultado nulo fuera de ese dominio es coherente con ellaUna prueba de robustez tiene sentido cuando la hipótesis predice qué debe pasar. El README del nivel 2 planta el momentum en los futuros y no en el contado de divisas; que la regla no funcione allí no la invalida. Lo que sí la invalidaría es que fallara en la mayoría de los futuros.
Ideas clave
- Una superficie de parámetros se lee como un mapa: importa la forma alrededor del máximo, no el máximo.
- En los futuros con efecto plantado, las 64 combinaciones dan Sharpe positivo (0,60 a 1,12) y las vecinas del pico promedian el 97 % de su valor; en el ruido, el pico de 0,57 tiene vecinas que promedian 0,01 y solo el 41 % de las celdas es positivo.
- La rugosidad (salto medio entre celdas contiguas dividido por la desviación típica de la rejilla) vale 0,47 en la meseta y 0,94 en el ruido, cerca del 1,13 de una superficie de ruido blanco.
- Regla práctica: elegir el centro de la meseta (el máximo de la superficie suavizada, lejos de los bordes) y ampliar la rejilla si queda pegado a uno. Fuera de muestra, en el ruido el pico cae de 0,63 a 0,13; en los futuros, pico y centro dan 0,79 y 1,23.
- La suavidad es necesaria pero no suficiente: parámetros vecinos están muy correlados (0,77 de media en los futuros). La prueba siguiente es la robustez entre mercados, sectores y periodos.
- La robustez entre activos se exige donde la hipótesis predice el efecto: 21 de 24 futuros positivos; en las divisas al contado, sin momentum plantado, la misma regla da −0,03.
Recursos
Libros
- Bailey, D. H., Borwein, J., López de Prado, M. y Zhu, Q. J. (2016): The probability of backtest overfitting. The Journal of Computational Finance
- Harvey, C. R. y Liu, Y. (2015): Backtesting. The Journal of Portfolio Management 42(1), 13-28
- Moskowitz, T., Ooi, Y. H. y Pedersen, L. H. (2012): Time series momentum. Journal of Financial Economics 104(2), 228-250