Lección 2.5.8 · 70 min
Checklist de «backtest honesto» antes de pasar a paper trading
Un backtest no se aprueba por su Sharpe, sino por las pruebas que ha superado. Esta lección reúne las del módulo en una lista, programa un auditor que ejecuta ocho de ellas sobre un objeto de resultado y lo aplica a dos estrategias: una con un efecto plantado y un placebo optimizado que, mirado de una en una, parece casi igual de bueno.
- Módulo
- 2.5 · Sesgos y validación
- 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
Una puerta antes del dinero
Las lecciones de este módulo han presentado los errores de un backtest uno a uno: anticipación y supervivencia (2.5.1), selección dentro de muestra (2.5.2 y 2.5.3), número de pruebas (2.5.4), azar del orden (2.5.5), picos de parámetros (2.5.6) y escenarios que la muestra no tiene (2.5.7). Esta lección las convierte en una puerta: una lista que todo backtest debe pasar antes de que una estrategia toque dinero, primero simulado y después real. Harvey y Liu (2015)1Nota 1 · Harvey, C. R. y Liu, Y. (2015), «Backtesting», The Journal of Portfolio Management 42(1), 13-28, doi:10.3905/jpm.2015.42.1.013. lo plantean de forma parecida: un resultado de backtest tiene que ajustarse por todo lo que se probó antes de llegar a él.
Fuente Mermaid
flowchart TB
B[Backtest candidato] --> A{Auditor}
A -- falla --> I[Investigar más]
A -- pasa --> M{Revisión manual}
M -- falla --> I
M -- pasa --> P[Paper trading]
P --> C{¿Coincide?}
C -- no --> I
C -- sí --> R[Real, pequeño]La puerta tiene dos partes. Una se puede automatizar: son pruebas que se calculan con los datos y el código del backtest, y que conviene ejecutar siempre igual, sin decidir caso por caso. La otra exige criterio: la hipótesis, la calidad de los datos, la revisión del código. Las dos están en la lista.
La lista
| # | Comprobación | Pregunta | Cómo | Umbral orientativo | Auditor | Lección |
|---|---|---|---|---|---|---|
| 1 | Hipótesis previa | ¿De dónde sale la ventaja y quién pierde al otro lado? | Escrita antes de ver resultados | Una frase verificable | Manual | 2.2.1 |
| 2 | Diario de experimentos | ¿Cuántas cosas se probaron de verdad? | Registro de cada prueba, también las descartadas | Número total de pruebas | Manual | 2.2.4 |
| 3 | Universo point-in-time | ¿El universo incluye las empresas que desaparecieron? | Deslistados y su rentabilidad final | Ninguno fuera | Sí | 1.4.4 y 2.5.1 |
| 4 | Ejecución en la barra siguiente | ¿La señal usa solo datos pasados? | Truncado de datos y un periodo más de retardo | Truncado igual; Sharpe con retardo ≥ 50 % | Sí | 2.5.1 |
| 5 | Costes | ¿Sobrevive a costes peores de lo previsto? | Costes ×2 | Sharpe ≥ 0,5 | Sí | 2.4.7 |
| 6 | Fills | ¿Son realistas las órdenes limitadas, los stops y los huecos? | Modelo de fills | Sin fills imposibles | Manual | 2.4.8 |
| 7 | Fuera de muestra | ¿Aguanta lo elegido en datos no usados para elegir? | Corte o walk-forward | Fuera ≥ 50 % de dentro | Sí | 2.5.2 |
| 8 | Número de pruebas | ¿Supera el máximo esperable por azar? | Deflated Sharpe | DSR ≥ 0,95 | Sí | 2.5.4 |
| 9 | Selección | ¿Elegir el mejor sirve, o hace daño? | PBO (CSCV) y probabilidad de pérdida | Pérdida ≤ 10 % | Sí | 2.5.3 y 2.5.4 |
| 10 | Meseta | ¿Los parámetros vecinos dan algo parecido? | Vecinas del elegido | ≥ 70 % del elegido | Sí | 2.5.6 |
| 11 | Robustez | ¿Funciona en otros activos y periodos? | Por mercado, sector y tramo | Mayoría positiva | Manual | 2.5.6 |
| 12 | Capacidad | ¿Cabe el capital previsto? | Órdenes frente al volumen diario | p95 ≤ 10 % del volumen | Sí | 2.2.3 |
| 13 | Riesgo | ¿Qué drawdown y qué tamaño? | Bootstrap de bloques, abanico | Ruina aceptable | Manual | 2.5.5 |
| 14 | Estrés | ¿Dónde pierde? | Escenarios sintéticos | Pérdida tolerable | Manual | 2.5.7 |
| 15 | Reproducibilidad | ¿Otro puede obtener las mismas cifras? | Código versionado, datos con huella, semillas | Mismo resultado | Manual | 1.4.8 |
Los umbrales son orientativos: cada equipo fija los suyos, antes de ver los resultados y para todas las estrategias por igual. Un umbral que se ajusta después para que una estrategia pase es otra forma de sobreajuste.
Dos backtests para auditar
Las dos estrategias son largo-corto mensuales sobre las 120 acciones ficticias del nivel 2: compran el tramo superior de una puntuación y venden el inferior, equiponderadas, con los pesos fijados al cierre de cada mes y el rendimiento del mes siguiente. Primero, los rendimientos mensuales. El detalle que importa es el mes en que una empresa deja de cotizar: hay que incluir su última cotización y la rentabilidad de deslistado (−100 % en una quiebra), o el backtest ignora la peor pérdida de cada quiebra (lección 1.4.4):
import itertools
import numpy as np
import pandas as pd
from dataclasses import dataclass, field
from scipy import stats
aj = pd.read_parquet("universo2_ajustados.parquet")
px = aj.pivot(index="fecha", columns="ticker", values="cierre_ajustado")
fin_mes = px.index.to_series().groupby(px.index.to_period("M")).max()
mensual = px.loc[fin_mes.to_numpy()]
R = mensual.pct_change(fill_method=None)
# el mes de un deslistado: hasta la última cotización y luego el pago
eventos = pd.read_csv("universo2_eventos.csv", parse_dates=["fecha"])
deslist = eventos.dropna(subset=["rentabilidad_deslistado"])
for _, ev in deslist.iterrows():
tk = ev["ticker"]
ultima = px[tk].last_valid_index()
mes = R.index[R.index >= ultima][0]
antes = R.index[R.index < ultima][-1]
R.loc[mes, tk] = (px.loc[ultima, tk] / mensual.loc[antes, tk]
* (1 + ev["rentabilidad_deslistado"]) - 1)
print(R.shape, f"{len(deslist)} deslistados con su rentabilidad final")
R.loc["2008-10", ["QXQT"]](240, 120) 10 deslistados con su rentabilidad final
ticker QXQT
fecha
2008-10-31 -1.0Las características son las de universo2_caracteristicas.parquet, que el README define point-in-time: lo que se
sabía al cierre de cada mes. El value es el logaritmo del patrimonio neto sobre la capitalización (solo con patrimonio
positivo):
car = pd.read_parquet("universo2_caracteristicas.parquet").set_index(
["fecha", "ticker"])
def panel(col):
return car[col].unstack().reindex(R.index)
pn, cap = panel("patrimonio_neto"), panel("capitalizacion")
datos = {
"R": R,
"value": np.log(pn.where(pn > 0) / cap),
"esg": panel("puntuacion_esg").astype(float),
"horquilla": panel("horquilla_pb"),
"adv_usd": panel("volumen_medio_21d") * panel("cierre"),
"cotiza": panel("cierre").notna(),
}
datos["value"].iloc[-1].describe().round(2)count 110.00
mean -0.54
std 0.68
min -2.22
25% -0.99
50% -0.60
75% 0.00
max 0.81
Name: 2025-12-31 00:00:00, dtype: float64El motor: puntuaciones normales por rangos, momentum que se salta el último mes, largo-corto por cuantiles y un coste
de media horquilla por unidad de rotación, con la horquilla_pb de cada acción y mes (lección 2.4.7). El retardo
permite ejecutar un mes más tarde, y lo usará el auditor.
def z_rango(df):
"""Puntuación normal por rangos en cada fecha."""
rk = df.rank(axis=1)
n = df.notna().sum(axis=1)
u = (rk - 0.5).div(n, axis=0).to_numpy()
return pd.DataFrame(stats.norm.ppf(u), index=df.index,
columns=df.columns)
def momentum(R, L):
"""Rentabilidad de los meses m-L+1 a m-1 (se salta el último)."""
with np.errstate(divide="ignore"): # log1p(-1) = -inf: quiebra
lr = np.log1p(R)
return np.expm1(lr.shift(1).rolling(L - 1).sum())
def largo_corto(puntos, puede, q):
"""Largo el q superior y corto el q inferior, equiponderados."""
rk = puntos.where(puede).rank(axis=1, pct=True)
lar, cor = (rk > 1 - q).astype(float), (rk <= q).astype(float)
return (lar.div(lar.sum(axis=1), axis=0)
- cor.div(cor.sum(axis=1), axis=0)).fillna(0)
INICIO = "2007-08" # la ventana más larga (18 meses) ya existe
def backtest(d, puntos, q, universo, coste=1.0, retardo=1):
"""Pesos al cierre del mes t; rendimiento del mes t + retardo."""
puede = d["cotiza"] & d["cotiza"].columns.isin(list(universo))
w = largo_corto(puntos, puede, q)
bruto = (w.shift(retardo) * d["R"]).sum(axis=1)
giro = (w - w.shift(1)).abs()
pb = d["horquilla"].fillna(20) / 2 / 1e4 # media horquilla
gasto = (giro * pb).sum(axis=1).shift(retardo) * coste
return (bruto - gasto).loc[INICIO:], w.loc[INICIO:]
def sharpe(x):
x = np.asarray(x, dtype=float)
return x.mean() / x.std(ddof=1) * np.sqrt(12)La estrategia A combina momentum y value. El investigador prueba seis ventanas de momentum y cinco cuantiles, 30 combinaciones, y elige la mejor en la primera mitad de los datos (de 2007-08 a 2016-07):
def senal_vm(d, L):
return z_rango(momentum(d["R"], L)) + z_rango(d["value"])
TODOS = set(R.columns) # universo point-in-time
rejilla_a = pd.DataFrame({
(L, q): backtest(datos, senal_vm(datos, L), q, TODOS)[0]
for L in (3, 6, 9, 12, 15, 18) for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
CORTE = "2016-07" # dentro: 2007-08 a 2016-07
sr_in = rejilla_a.loc[:CORTE].apply(sharpe)
elegido_a = sr_in.idxmax()
print(f"Elegido dentro de muestra: L = {elegido_a[0]} meses, "
f"q = {elegido_a[1]} (Sharpe {sr_in.max():.2f})")
sr_in.unstack().round(2)Elegido dentro de muestra: L = 6 meses, q = 0.4 (Sharpe 0.97)
0.1 0.2 0.3 0.4 0.5
3 0.42 0.69 0.90 0.74 0.85
6 0.69 0.86 0.82 0.97 0.90
9 0.58 0.63 0.50 0.55 0.65
12 0.86 0.72 0.74 0.80 0.87
15 0.47 0.67 0.61 0.64 0.65
18 0.36 0.61 0.73 0.73 0.54Elige 6 meses y el 40 %, con un Sharpe de 0,97 dentro de muestra, en una rejilla en la que las 30 combinaciones son positivas: una meseta. La estrategia B es lo que sale de un proceso menos cuidadoso. El investigador prueba la puntuación ESG suavizada con seis ventanas, con los dos signos (comprar las de mejor o las de peor puntuación) y cinco cuantiles, 60 combinaciones, y lo hace sobre las empresas que cotizan hoy, porque es la lista que tenía a mano:
def senal_esg(d, k, signo):
return signo * d["esg"].rolling(k).mean()
sect = pd.read_csv("universo2_sectores.csv")
SUPERVIVIENTES = set(sect.loc[sect["ultima_sesion"].isna(), "ticker"])
rejilla_b = pd.DataFrame({
(k, s, q): backtest(datos, senal_esg(datos, k, s), q,
SUPERVIVIENTES)[0]
for k in (1, 2, 3, 6, 9, 12) for s in (1, -1)
for q in (0.1, 0.2, 0.3, 0.4, 0.5)})
sr_in_b = rejilla_b.loc[:CORTE].apply(sharpe)
elegido_b = sr_in_b.idxmax()
print(f"{len(SUPERVIVIENTES)} supervivientes de {len(TODOS)}")
print(f"Elegido: media de {elegido_b[0]} meses, signo {elegido_b[1]}, "
f"q = {elegido_b[2]} (Sharpe {sr_in_b.max():.2f})")
sr_in_b.unstack([1, 2]).round(2).iloc[:, :5]110 supervivientes de 120
Elegido: media de 9 meses, signo 1, q = 0.2 (Sharpe 0.53)
1
0.1 0.2 0.3 0.4 0.5
1 0.27 0.32 0.17 0.16 0.17
2 0.35 0.38 0.11 0.16 0.08
3 0.35 0.50 0.20 0.10 0.21
6 0.22 0.43 0.17 0.01 0.22
9 0.41 0.53 0.24 0.16 0.09
12 0.30 0.26 0.15 0.11 -0.00Un Sharpe de 0,53 dentro de muestra, con un signo y una ventana elegidos a la vista de los resultados. Contado así, B parece una estrategia más débil que A, pero no un disparate. El auditor debe decir si lo es.
El objeto que se audita
Para auditar un backtest hacen falta más cosas que su curva de capital. El objeto ResultadoBacktest las guarda
todas: los rendimientos netos, los pesos, una función para volver a ejecutarlo con otros costes o retardos, la función
de la señal (para el truncado), la rejilla completa de todo lo que se probó (para el DSR, el PBO y la meseta), la
combinación elegida, el corte dentro y fuera de muestra, el número de pruebas, el universo y el capital previsto. Si
el investigador no puede rellenar un campo (no sabe cuántas pruebas hizo, no guardó la rejilla), eso ya es un
resultado de la auditoría.
@dataclass
class ResultadoBacktest:
nombre: str
rend: pd.Series # mensual, neto de costes
pesos: pd.DataFrame # al cierre de cada mes
ejecutar: object # (datos, coste, retardo) -> rend
senal: object # datos -> puntuaciones
rejilla: pd.DataFrame # TODAS las combinaciones probadas
elegido: tuple
corte: str # último mes dentro de muestra
n_pruebas: int # incluidas las que no se apuntaron
universo: set
fijos: tuple = () # ejes de la rejilla que no son orden
capital: float = 50e6 # capital previsto (USD)
def resultado(nombre, senal, q, universo, rejilla, elegido, n,
fijos=()):
rend, w = backtest(datos, senal(datos), q, universo)
return ResultadoBacktest(
nombre, rend, w,
lambda d, c=1.0, t=1: backtest(d, senal(d), q, universo,
c, t)[0],
senal, rejilla, elegido, CORTE, n, universo, fijos)
L, q = elegido_a
A = resultado("value + momentum", lambda d: senal_vm(d, L), q,
TODOS, rejilla_a, elegido_a, rejilla_a.shape[1])
k, s, qb = elegido_b
B = resultado("placebo ESG", lambda d: senal_esg(d, k, s), qb,
SUPERVIVIENTES, rejilla_b, elegido_b, rejilla_b.shape[1],
fijos=(1,)) # el signo no tiene vecinos
for res in (A, B):
print(f"{res.nombre}: {len(res.rend)} meses, Sharpe "
f"{sharpe(res.rend):.2f}, {res.n_pruebas} pruebas")value + momentum: 221 meses, Sharpe 1.12, 30 pruebas
placebo ESG: 221 meses, Sharpe 0.54, 60 pruebasfijos=(1,) dice al auditor que el segundo eje de la rejilla de B (el signo) no tiene orden: comprar las de mejor
puntuación no es «vecino» de comprar las de peor.
Las ocho pruebas automáticas
Cada prueba devuelve un valor, su umbral y si pasa. Las tres primeras vuelven a ejecutar el backtest.
Ejecución en la barra siguiente. Dos comprobaciones. El truncado: se borran todos los datos posteriores a una fecha y se recalcula la señal; si la puntuación de esa fecha cambia, la señal usa el futuro. Y el retardo: con un mes más de retraso en la ejecución, una ventaja de horizonte mensual debe perder algo, no desaparecer. Costes: el backtest con el doble de costes. Fuera de muestra: el Sharpe de la combinación elegida después del corte frente al de antes.
def prueba(valor, umbral, pasa, texto):
return {"valor": valor, "umbral": umbral, "pasa": bool(pasa),
"detalle": texto}
def p_ejecucion(res, fechas=("2010-06", "2015-12", "2020-03")):
"""Truncado: la señal de d no cambia si se borra lo posterior a d.
Retardo: con un mes más de retraso, la ventaja no desaparece."""
completa = res.senal(datos)
iguales = all(
np.allclose(res.senal({c: v.loc[:f] for c, v in datos.items()})
.loc[f].to_numpy(), completa.loc[f].to_numpy(),
equal_nan=True) for f in fechas)
s1, s2 = sharpe(res.rend), sharpe(res.ejecutar(datos, 1.0, 2))
return prueba(s2 / s1, ">= 0,5 y truncado igual",
iguales and s2 >= 0.5 * s1,
f"truncado {'igual' if iguales else 'DISTINTO'}; "
f"Sharpe t+1 {s1:.2f}, t+2 {s2:.2f}")
def p_costes(res):
s = sharpe(res.ejecutar(datos, 2.0, 1))
return prueba(s, ">= 0,5", s >= 0.5,
f"Sharpe con costes x2 {s:.2f}")
def p_fuera(res):
r = res.rejilla[res.elegido]
s_in, s_out = sharpe(r.loc[:res.corte]), sharpe(r.loc[res.corte:]
.iloc[1:])
return prueba(s_out / s_in, ">= 0,5", s_out >= 0.5 * s_in > 0,
f"dentro {s_in:.2f}, fuera {s_out:.2f}")
for res in (A, B):
for f in (p_ejecucion, p_costes, p_fuera):
print(f"{res.nombre:>16} | {f.__name__:<11} | "
f"{f(res)['detalle']}")value + momentum | p_ejecucion | truncado igual; Sharpe t+1 1.12, t+2 0.94
value + momentum | p_costes | Sharpe con costes x2 1.08
value + momentum | p_fuera | dentro 0.97, fuera 1.27
placebo ESG | p_ejecucion | truncado igual; Sharpe t+1 0.54, t+2 0.36
placebo ESG | p_costes | Sharpe con costes x2 0.53
placebo ESG | p_fuera | dentro 0.53, fuera 0.56Las dos estrategias pasan las tres. A pierde 0,18 de Sharpe con un mes de retraso (de 1,12 a 0,94), casi nada con el doble de costes (1,08) y mejora fuera de muestra (de 0,97 a 1,27). B también pasa: 0,36 con retraso, 0,53 con el doble de costes y 0,56 fuera de muestra frente a 0,53 dentro. Esto merece una pausa. El placebo no tiene ninguna ventaja por construcción, y aun así su Sharpe fuera de muestra es tan bueno como dentro. La razón: en estos datos, por azar, la puntuación ESG está relacionada con las rentabilidades a lo largo de toda la muestra, y una relación casual estable no se rompe al partir la muestra en dos. La comprobación fuera de muestra detecta el sobreajuste a una época; no detecta un efecto espurio que dura toda la historia disponible ni la selección entre muchas pruebas.
Deflated Sharpe (lección 2.5.4): la probabilidad de que el Sharpe verdadero supere al máximo que cabría esperar por azar entre pruebas,
con la varianza de los Sharpes de todas las pruebas y la constante de Euler-Mascheroni. PBO por validación cruzada combinatoria simétrica (CSCV): la muestra se parte en 10 bloques, se toman las 252 formas de elegir 5 como «dentro», y en cada una se mira en qué puesto queda fuera la mejor de dentro. El auditor calcula también la probabilidad de pérdida: en qué fracción de las particiones la elegida pierde fuera.
EULER = np.euler_gamma # constante de Euler-Mascheroni
def p_dsr(res):
"""Deflated Sharpe Ratio (Bailey y López de Prado, 2014)."""
r = res.rend.to_numpy()
T, sr = len(r), r.mean() / r.std(ddof=1) # por mes
g3, g4 = stats.skew(r), stats.kurtosis(r, fisher=False)
pruebas = res.rejilla.apply(lambda c: c.mean() / c.std(ddof=1))
N = res.n_pruebas
sr0 = np.sqrt(pruebas.var(ddof=1)) * (
(1 - EULER) * stats.norm.ppf(1 - 1 / N)
+ EULER * stats.norm.ppf(1 - 1 / (N * np.e)))
z = (sr - sr0) * np.sqrt(T - 1) / np.sqrt(
1 - g3 * sr + (g4 - 1) / 4 * sr ** 2)
dsr = stats.norm.cdf(z)
return prueba(dsr, ">= 0,95", dsr >= 0.95,
f"Sharpe {sr * np.sqrt(12):.2f} frente a un máximo "
f"esperado por azar de {sr0 * np.sqrt(12):.2f}")
def p_pbo(res, S=10):
"""CSCV: la mitad de los bloques dentro, la otra mitad fuera."""
M = res.rejilla.to_numpy()
T, N = M.shape
bloques = np.array_split(np.arange(T), S)
logits, pierde = [], []
for dentro in itertools.combinations(range(S), S // 2):
i = np.concatenate([bloques[j] for j in dentro])
o = np.concatenate([bloques[j] for j in range(S)
if j not in dentro])
sr_i = M[i].mean(0) / M[i].std(0, ddof=1)
sr_o = M[o].mean(0) / M[o].std(0, ddof=1)
n = sr_i.argmax()
w = stats.rankdata(sr_o)[n] / (N + 1)
logits.append(np.log(w / (1 - w)))
pierde.append(sr_o[n] <= 0)
pbo, p_perd = np.mean(np.array(logits) <= 0), np.mean(pierde)
return prueba(p_perd, "pérdida <= 10 %", p_perd <= 0.10,
f"PBO {pbo:.2f}; el elegido pierde fuera en el "
f"{p_perd:.0%} de las {len(logits)} particiones")
for res in (A, B):
for f in (p_dsr, p_pbo):
print(f"{res.nombre:>16} | {f.__name__:<5} | "
f"{f(res)['detalle']}")value + momentum | p_dsr | Sharpe 1.12 frente a un máximo esperado por azar de 0.30
value + momentum | p_pbo | PBO 0.55; el elegido pierde fuera en el 0% de las 252 particiones
placebo ESG | p_dsr | Sharpe 0.54 frente a un máximo esperado por azar de 0.85
placebo ESG | p_pbo | PBO 0.14; el elegido pierde fuera en el 17% de las 252 particionesAquí se separan. El Sharpe de A, 1,12, está muy por encima del 0,30 que darían 30 pruebas sin ventaja con la dispersión de su rejilla: DSR de 1,00. El de B, 0,54, está por debajo del 0,85 que cabe esperar como máximo de 60 pruebas sin ventaja, porque su rejilla tiene mucha dispersión (incluye los dos signos): DSR de 0,09. Contadas todas las pruebas, el placebo no es distinguible del azar.
El PBO da un resultado que confunde si se lee solo: 0,55 para A y 0,14 para B. En la rejilla de A todas las combinaciones son buenas y se parecen, así que cuál queda primera fuera de muestra es una lotería y el PBO ronda 0,5; pero la elegida no pierde fuera en ninguna de las 252 particiones. En B, la relación casual es estable y la mejor de dentro tiende a ser buena fuera (PBO bajo), pero pierde en el 17 % de las particiones. Por eso el auditor decide con la probabilidad de pérdida y enseña el PBO como contexto, igual que recomienda la lección 2.5.4.
Meseta (lección 2.5.6): la media de las vecinas de la combinación elegida en la rejilla de dentro de muestra, frente a la elegida. Capacidad: el capital al que el percentil 95 de las órdenes de rebalanceo llega al 10 % del volumen medio diario de la acción, frente al capital previsto (50 M USD). Supervivencia: si el universo deja fuera algún valor que dejó de cotizar.
def p_meseta(res):
"""Vecinas del elegido en la rejilla de dentro de muestra."""
sr_in = res.rejilla.loc[:res.corte].apply(sharpe)
ejes = [sorted({k[i] for k in sr_in.index})
for i in range(len(res.elegido))]
pos = {k: [ejes[i].index(v) for i, v in enumerate(k)]
for k in sr_in.index}
yo = pos[res.elegido]
vec = [sr_in[k] for k, p in pos.items() if k != res.elegido
and max(abs(a - b) for a, b in zip(p, yo)) <= 1
and all(k[i] == res.elegido[i] for i in res.fijos)]
ratio = np.mean(vec) / sr_in[res.elegido]
return prueba(ratio, ">= 0,7", ratio >= 0.7,
f"{len(vec)} vecinas: media {np.mean(vec):.2f} "
f"frente a {sr_in[res.elegido]:.2f}")
def p_capacidad(res, limite=0.10):
"""Capital al que el percentil 95 de las órdenes es el 10 %
del volumen diario medio de la acción."""
giro = (res.pesos - res.pesos.shift(1)).abs()
part = (giro / datos["adv_usd"].loc[INICIO:]).stack()
p95 = part[part > 0].quantile(0.95)
cap = limite / p95
return prueba(cap / 1e6, f">= {res.capital / 1e6:.0f} M USD",
cap >= res.capital,
f"capacidad {cap / 1e6:.0f} M USD")
def p_supervivencia(res):
"""¿El universo incluye los valores que dejaron de cotizar?"""
fuera = set(deslist["ticker"]) - res.universo
return prueba(len(fuera), "0 excluidos", not fuera,
f"{len(fuera)} deslistados fuera del universo")
for res in (A, B):
for f in (p_meseta, p_capacidad, p_supervivencia):
print(f"{res.nombre:>16} | {f.__name__:<15} | "
f"{f(res)['detalle']}")value + momentum | p_meseta | 8 vecinas: media 0.74 frente a 0.97
value + momentum | p_capacidad | capacidad 54 M USD
value + momentum | p_supervivencia | 0 deslistados fuera del universo
placebo ESG | p_meseta | 8 vecinas: media 0.27 frente a 0.53
placebo ESG | p_capacidad | capacidad 56 M USD
placebo ESG | p_supervivencia | 10 deslistados fuera del universoA pasa las tres: sus vecinas conservan el 76 % del Sharpe de la elegida (0,74 frente a 0,97), cabe un capital de 54 M USD y su universo incluye los 10 deslistados. B falla la meseta (0,27 frente a 0,53: la ventana y el cuantil elegidos destacan sobre sus vecinos) y la supervivencia (los 10 deslistados están fuera), y pasa la capacidad (56 M USD).
El veredicto
El auditor completo ejecuta las ocho pruebas y solo declara apta una estrategia que las pasa todas:
PRUEBAS = {"ejecución t+1": p_ejecucion, "costes x2": p_costes,
"fuera de muestra": p_fuera, "Deflated Sharpe": p_dsr,
"PBO y pérdida": p_pbo, "meseta": p_meseta,
"capacidad": p_capacidad, "supervivencia": p_supervivencia}
def auditar(res):
filas = {n: f(res) for n, f in PRUEBAS.items()}
tabla = pd.DataFrame(filas).T[["valor", "umbral", "pasa"]]
veredicto = "APTA" if tabla["pasa"].all() else "NO APTA"
return tabla, veredicto
informe = {}
for res in (A, B):
tabla, veredicto = auditar(res)
informe[res.nombre] = tabla
print(f"{res.nombre}: {veredicto} "
f"({tabla['pasa'].sum()} de {len(tabla)} pruebas)")
pd.concat({n: t["pasa"].map({True: "pasa", False: "FALLA"})
for n, t in informe.items()}, axis=1)value + momentum: APTA (8 de 8 pruebas)
placebo ESG: NO APTA (4 de 8 pruebas)
value + momentum placebo ESG
ejecución t+1 pasa pasa
costes x2 pasa pasa
fuera de muestra pasa pasa
Deflated Sharpe pasa FALLA
PBO y pérdida pasa FALLA
meseta pasa FALLA
capacidad pasa pasa
supervivencia pasa FALLALa figura de la cabecera resume el resultado. A, la estrategia con el efecto plantado, pasa las ocho. B, el placebo, pasa cuatro y falla cuatro, y ninguna de las que falla habría bastado por sí sola si se hubiera mirado aislada: el DSR exige saber cuántas pruebas se hicieron, la meseta exige guardar la rejilla, la supervivencia exige conocer el universo. El informe completo de B:
t = informe["placebo ESG"].copy()
t["valor"] = t["valor"].astype(float).round(2)
t valor umbral pasa
ejecución t+1 0.66 >= 0,5 y truncado igual True
costes x2 0.53 >= 0,5 True
fuera de muestra 1.05 >= 0,5 True
Deflated Sharpe 0.09 >= 0,95 False
PBO y pérdida 0.17 pérdida <= 10 % False
meseta 0.51 >= 0,7 False
capacidad 56.04 >= 50 M USD True
supervivencia 10.00 0 excluidos FalseLa prueba de supervivencia es de procedimiento: falla aunque el efecto sobre el resultado fuera pequeño, porque un backtest con supervivientes no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. En este caso, además, el universo de supervivientes inflaba el placebo:
con_todos, _ = backtest(datos, senal_esg(datos, k, s), qb, TODOS)
print(f"Placebo con supervivientes: {sharpe(B.rend):.2f}; "
f"con el universo completo: {sharpe(con_todos):.2f}")Placebo con supervivientes: 0.54; con el universo completo: 0.43La figura muestra el problema de juzgar por la curva: el placebo, en ámbar, sube también después del corte. Visto solo, y con el universo de supervivientes, es una estrategia «que funciona fuera de muestra». Las pruebas que la delatan no se ven en la curva.
Lo que el auditor no ve, y el paper trading
Ocho pruebas automáticas cubren una parte de la lista. El resto exige criterio, y conviene hacerlo por escrito:
- Hipótesis (lección 2.2.1): una explicación de por qué existe la ventaja, escrita antes de mirar resultados. B no la tenía: «las empresas con mejor ESG suben» no dice quién pierde al otro lado ni por qué el mercado no lo ha corregido.
- Datos: errores, huecos, ajustes por splits y dividendos, zonas horarias (módulo 1.4).
- Código: revisión por otra persona, pruebas unitarias del motor, comparación con un motor independiente (lecciones 2.4.1 y 2.4.2).
- Riesgo y estrés (lecciones 2.5.5 y 2.5.7): drawdown esperable, tamaño, escenarios donde pierde.
Y después, el paper trading: la estrategia corre en tiempo real, con datos en vivo y órdenes simuladas, durante un periodo fijado de antemano. No sirve para medir el Sharpe (unos meses no bastan, lección 2.5.5) sino para comprobar que lo que el backtest supone ocurre: que la señal calculada en vivo coincide con la que daría el backtest sobre los mismos datos, que los fills y los costes reales se parecen a los modelados y que la operativa (datos que llegan tarde, órdenes rechazadas, festivos) no rompe nada. Las diferencias entre paper y backtest son el último hallazgo de la auditoría; si son grandes, se vuelve a investigar. Solo después viene el dinero real, con un tamaño pequeño.
Ejercicios
- Un compañero calcula el momentum con
np.log1p(R).shift(-1)por error (un desplazamiento hacia atrás). Construye su resultado con el resto igual que la estrategia A y pásale la prueba de ejecución.
Solución
def momentum_malo(R, L):
with np.errstate(divide="ignore"):
lr = np.log1p(R)
return np.expm1(lr.shift(-1).rolling(L - 1).sum())
def senal_mala(d):
return z_rango(momentum_malo(d["R"], L)) + z_rango(d["value"])
M = resultado("con anticipación", senal_mala, q, TODOS, rejilla_a,
elegido_a, rejilla_a.shape[1])
print(f"Sharpe: {sharpe(M.rend):.2f}")
print(p_ejecucion(M)["detalle"])Sharpe: 8.79
truncado DISTINTO; Sharpe t+1 8.79, t+2 1.24Con el error, el momentum de cada mes incluye la rentabilidad del mes siguiente, y el Sharpe sube a 8,79: un número que ya debería bastar como alarma. La prueba de truncado lo confirma: al borrar los datos posteriores a cada fecha, la puntuación cambia. La de retardo también lo señala, porque con un mes más de retraso el Sharpe cae a 1,24 (de 8,79), muy por debajo del 50 %. Un error de anticipación casi nunca es tan evidente; por eso la prueba de truncado se ejecuta siempre, también cuando el Sharpe parece razonable.
- Repite la estrategia A sobre los supervivientes y audítala. ¿Qué prueba falla y cuánto cambia el Sharpe?
Solución
rejilla_s = pd.DataFrame({
(Ls, qs): backtest(datos, senal_vm(datos, Ls), qs,
SUPERVIVIENTES)[0]
for Ls in (3, 6, 9, 12, 15, 18)
for qs in (0.1, 0.2, 0.3, 0.4, 0.5)})
S = resultado("A con supervivientes", lambda d: senal_vm(d, L), q,
SUPERVIVIENTES, rejilla_s, elegido_a, 30)
tabla_s, veredicto_s = auditar(S)
print(f"Sharpe {sharpe(S.rend):.2f} frente a {sharpe(A.rend):.2f}: "
f"{veredicto_s}")
print(tabla_s.loc[~tabla_s["pasa"].astype(bool)].index.tolist())Sharpe 1.18 frente a 1.12: NO APTA
['supervivencia']Sobre los supervivientes el Sharpe apenas cambia (1,18 frente a 1,12) y solo falla la prueba de supervivencia. La estrategia sigue teniendo su efecto plantado, pero el procedimiento ya no es posible en tiempo real, y el auditor la declara no apta hasta que se rehaga con el universo point-in-time. Que la diferencia sea pequeña aquí no garantiza nada: en el placebo, el mismo error subía el Sharpe de 0,43 a 0,54, y en una estrategia que compre empresas en dificultades puede cambiar el signo del resultado.
- ¿Qué tendría que añadir el auditor para cubrir la robustez entre activos y periodos (punto 11 de la lista)? Describe
la prueba, su umbral y qué campos nuevos necesitaría el objeto
ResultadoBacktest.
Solución
Necesitaría los rendimientos por activo (o por grupo: sector, país, clase de activo) de la combinación elegida, y un campo con la partición temporal (por ejemplo, tramos de cinco años). La prueba calcularía el Sharpe en cada grupo y en cada tramo y pasaría si la mayoría son positivos (por ejemplo, al menos dos tercios de los grupos y de los tramos) y si ningún grupo explica por sí solo la mayor parte del resultado. Como en la lección 2.5.6, el umbral tiene que fijarse dentro del dominio de la hipótesis: si la ventaja solo debería existir en un tipo de activo, la prueba se aplica a los activos de ese tipo. Y, como todos los umbrales, se fija antes de ver los resultados.
Footnotes
-
Harvey, C. R. y Liu, Y. (2015), «Backtesting», The Journal of Portfolio Management 42(1), 13-28, doi:10.3905/jpm.2015.42.1.013. ↩
Respuesta plegada en cada pregunta
Autoevaluación
El placebo ESG pasa la prueba fuera de muestra: Sharpe de 0,53 dentro y de 0,56 fuera. ¿Por qué no basta?
- APorque el periodo fuera de muestra es demasiado corto
- BPorque una relación casual que se mantiene en toda la muestra también se mantiene fuera de ella: la comprobación fuera de muestra detecta el sobreajuste a una época, no un efecto espurio estable ni la selección entre 60 pruebas
- CPorque la prueba está mal programada
- DSí basta: el placebo es una estrategia válida
Ver respuesta
Respuesta B. Porque una relación casual que se mantiene en toda la muestra también se mantiene fuera de ella: la comprobación fuera de muestra detecta el sobreajuste a una época, no un efecto espurio estable ni la selección entre 60 pruebas
La puntuación ESG es independiente de todo por construcción, pero en estos datos su relación con las rentabilidades es positiva por azar en los dos periodos. Lo delatan el Deflated Sharpe (0,09: su Sharpe de 0,54 queda por debajo del máximo esperado por azar con 60 pruebas, 0,85), la meseta y la probabilidad de pérdida fuera de muestra.
La estrategia value + momentum tiene un PBO de 0,55 y el placebo, de 0,14. ¿Cómo se explica?
- AEl placebo está menos sobreajustado que la estrategia real
- BEn la rejilla de value + momentum todas las combinaciones son buenas y parecidas: cuál queda primera fuera de muestra es azar y el PBO ronda 0,5, sin que la elección haga daño (el elegido no pierde en ninguna partición). Por eso el PBO se lee con la probabilidad de pérdida
- CHay un error en el cálculo de los rangos
- DEl PBO solo funciona con datos diarios
Ver respuesta
Respuesta B. En la rejilla de value + momentum todas las combinaciones son buenas y parecidas: cuál queda primera fuera de muestra es azar y el PBO ronda 0,5, sin que la elección haga daño (el elegido no pierde en ninguna partición). Por eso el PBO se lee con la probabilidad de pérdida
El PBO mide si elegir el mejor dentro de muestra sirve para quedar por encima de la mediana fuera. En una meseta, todos son igual de buenos y elegir no aporta: PBO ≈ 0,5. Lo que importa entonces es si el elegido pierde fuera: 0 % de las 252 particiones en la estrategia real, 17 % en el placebo.
¿Qué detecta la prueba de truncado de la señal?
- AQue los costes están bien calculados
- BQue la puntuación de una fecha usa datos posteriores: si al borrar todo lo que viene después de la fecha la puntuación cambia, la señal mira al futuro
- CQue la estrategia tiene capacidad suficiente
- DQue el universo incluye los deslistados
Ver respuesta
Respuesta B. Que la puntuación de una fecha usa datos posteriores: si al borrar todo lo que viene después de la fecha la puntuación cambia, la señal mira al futuro
Es la prueba directa de anticipación. En el ejercicio 1, un
shift(-1)por error da un Sharpe de 8,79; la prueba de truncado lo marca como distinto. Una prueba de retardo (ejecutar un periodo más tarde) complementa: una ventaja que desaparece con un mes de retraso es sospechosa.La estrategia value + momentum recalculada sobre los supervivientes tiene un Sharpe de 1,18 en lugar de 1,12, y el auditor la declara no apta. ¿Por qué falla si el Sharpe es parecido?
- APorque el Sharpe ha subido
- BPorque su universo excluye los 10 valores que dejaron de cotizar: el resultado no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. La prueba es de procedimiento, no de resultado
- CPorque la meseta cambia
- DPorque los costes son mayores
Ver respuesta
Respuesta B. Porque su universo excluye los 10 valores que dejaron de cotizar: el resultado no puede reproducirse en tiempo real, cuando no se sabe qué empresas van a desaparecer. La prueba es de procedimiento, no de resultado
Un backtest con supervivientes usa información del futuro (quién sobrevive), aunque en un caso concreto el efecto sobre el Sharpe sea pequeño. En el placebo, el universo de supervivientes sube el Sharpe de 0,43 a 0,54. La prueba no pregunta cuánto cambia el resultado, sino si el procedimiento es posible en tiempo real.
La estrategia value + momentum pasa las ocho pruebas. ¿Qué sigue?
- AOperarla con todo el capital previsto
- BLa revisión de lo que el auditor no ve (hipótesis, datos, código, estrés) y un periodo de paper trading que compare señales, fills y costes con lo que dice el backtest, antes de operar en real con un tamaño pequeño
- COptimizar más parámetros para subir el Sharpe
- DNada: está demostrado que funciona
Ver respuesta
Respuesta B. La revisión de lo que el auditor no ve (hipótesis, datos, código, estrés) y un periodo de paper trading que compare señales, fills y costes con lo que dice el backtest, antes de operar en real con un tamaño pequeño
El auditor es una puerta necesaria, no suficiente. En los datos del curso, además, la estrategia gana porque el efecto está plantado; en datos reales, pasar el auditor solo dice que el backtest no tiene los errores que el auditor busca.
Ideas clave
- Un backtest honesto se demuestra con pruebas, no con un Sharpe: ejecución en la barra siguiente, costes, fuera de muestra, número de pruebas (DSR), PBO y pérdida, meseta, capacidad y universo point-in-time.
- Las pruebas se programan sobre un objeto de resultado que guarda todo lo que hace falta: rendimientos, pesos, la rejilla completa de lo probado, el corte dentro y fuera de muestra, el universo y el capital previsto.
- La estrategia con efecto plantado pasa las ocho; el placebo ESG optimizado pasa cuatro (ejecución, costes, fuera de muestra y capacidad) y falla el Deflated Sharpe (0,09), la pérdida fuera de muestra (17 %), la meseta (0,51) y la supervivencia.
- Ninguna prueba sola basta: cada una detecta un error distinto, y un falso positivo estable en el tiempo puede pasar la comprobación fuera de muestra y tener un PBO bajo.
- El auditor no sustituye a la revisión manual (hipótesis, datos, código, estrés) ni al paper trading, que comprueba que señales, fills y costes en tiempo real coinciden con el backtest.
Recursos
Research
Documentación
Libros
- Bailey, D. H. y López de Prado, M. (2014): The Deflated Sharpe Ratio. The Journal of Portfolio Management 40(5), 94-107
- 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