Módulo 8: Casos Prácticos Resueltos
Backtesting Riguroso — Laboratorio en Python
📋 Introducción
Estos ejercicios te enseñan a detectar y combatir los sesgos del backtesting con código. Verás look-ahead bias en acción, medirás el survivorship bias, demostrarás el overfitting y aplicarás walk-forward. Ejecuta cada bloque: muchos te sorprenderán.
Requisitos Previos
pip install numpy pandas matplotlib yfinance scikit-learn
🧪 Caso Práctico 1: Look-Ahead Bias en Acción
Objetivo
Demostrar cómo el look-ahead bias infla artificialmente los resultados, y cómo corregirlo.
Código
import yfinance as yf
import numpy as np
import pandas as pd
datos = yf.download("SPY", start="2018-01-01", end="2024-01-01")
precio = datos["Close"]
df = pd.DataFrame({"precio": precio})
df["ret"] = precio.pct_change()
df["media"] = precio.rolling(20).mean()
# === VERSIÓN CON LOOK-AHEAD (INCORRECTA) ===
# Error: usa la señal de HOY para operar HOY (no tendrías ese dato a tiempo)
df["senal"] = np.where(df["precio"] > df["media"], 1, 0)
df["ret_malo"] = df["senal"] * df["ret"] # SIN shift → look-ahead
# === VERSIÓN CORRECTA ===
# La posición de hoy se basa en la señal de AYER
df["ret_bueno"] = df["senal"].shift(1) * df["ret"] # CON shift
def sharpe(r): r = r.dropna(); return r.mean()/r.std()*np.sqrt(252)
print("=== IMPACTO DEL LOOK-AHEAD BIAS ===")
print(f"Sharpe CON look-ahead (incorrecto): {sharpe(df['ret_malo']):.2f}")
print(f"Sharpe SIN look-ahead (correcto): {sharpe(df['ret_bueno']):.2f}")
print(f"\nLa diferencia es el sesgo: el look-ahead infla artificialmente")
print(f"el resultado porque 'sabe' la señal antes de que esté disponible.")
Interpretación
Qué demuestra:
- La versión con look-ahead da un Sharpe sistemáticamente mayor (a veces mucho mayor)
- La diferencia es pura ilusión: en la realidad NO conocerías la señal a tiempo
- El simple
.shift(1)separa la fantasía de la realidad
Lección: este es el error más común y sutil en backtesting. Una sola línea de código (con o sin .shift(1)) cambia un resultado de fantasía a realidad. Vigílalo obsesivamente.
🧪 Caso Práctico 2: Demostrar el Overfitting
Objetivo
Mostrar cómo optimizar parámetros sobre datos in-sample produce resultados que no se mantienen out-of-sample.
Código
import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
datos = yf.download("SPY", start="2012-01-01", end="2024-01-01")
precio = datos["Close"]
ret = precio.pct_change().dropna()
# Dividir: in-sample (diseño) vs out-of-sample (validación)
corte = "2020-01-01"
precio_is = precio[precio.index < corte]
precio_oos = precio[precio.index >= corte]
def backtest_media(precio, ventana):
"""Estrategia: comprar si precio > media móvil."""
media = precio.rolling(ventana).mean()
senal = (precio > media).astype(int).shift(1)
r = (senal * precio.pct_change()).dropna()
return r.mean()/r.std()*np.sqrt(252) if r.std()>0 else 0
# Optimizar la ventana SOLO en in-sample
ventanas = range(5, 200, 5)
sharpes_is = [backtest_media(precio_is, v) for v in ventanas]
mejor_ventana = list(ventanas)[np.argmax(sharpes_is)]
print(f"Mejor ventana in-sample: {mejor_ventana} días (Sharpe {max(sharpes_is):.2f})")
# Aplicar esa "mejor" ventana out-of-sample
sharpe_oos = backtest_media(precio_oos, mejor_ventana)
print(f"Sharpe de esa ventana out-of-sample: {sharpe_oos:.2f}")
# Visualizar: Sharpe in-sample vs out-of-sample por ventana
sharpes_oos = [backtest_media(precio_oos, v) for v in ventanas]
plt.figure(figsize=(12, 6))
plt.plot(list(ventanas), sharpes_is, "o-", label="In-sample (diseño)", color="steelblue")
plt.plot(list(ventanas), sharpes_oos, "s-", label="Out-of-sample (real)", color="crimson")
plt.axvline(mejor_ventana, color="green", linestyle="--", label=f"Mejor IS: {mejor_ventana}")
plt.title("Overfitting: el óptimo in-sample NO es el óptimo out-of-sample")
plt.xlabel("Ventana de la media móvil (días)")
plt.ylabel("Sharpe Ratio")
plt.legend(); plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Interpretación
Qué demuestra:
- La ventana “óptima” elegida in-sample rara vez es la mejor out-of-sample
- Las dos curvas (azul y roja) tienen picos en lugares distintos
- Optimizar agresivamente in-sample es perseguir el ruido, no la señal
Lección crítica: el óptimo in-sample es una ilusión. Cuanto más optimizas sobre datos pasados, más memorizas su ruido específico, que no se repetirá. Por eso los modelos simples y robustos suelen ganar.
🧪 Caso Práctico 3: El Multiple Testing Genera Falsos Ganadores
Objetivo
Reproducir cómo probar muchas estrategias aleatorias produce “ganadores” sin edge real.
Código
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(123)
# Simular 1000 estrategias SIN edge real (señales aleatorias)
n_estrategias = 1000
n_dias = 252 * 4 # 4 años
sharpes = []
for i in range(n_estrategias):
# Retornos de mercado aleatorios
ret_mercado = np.random.normal(0.0003, 0.01, n_dias)
# Señal completamente aleatoria (sin información)
senal = np.random.choice([0, 1], n_dias)
ret_estrategia = senal[:-1] * ret_mercado[1:] # posición de ayer
s = ret_estrategia.mean()/ret_estrategia.std()*np.sqrt(252) if ret_estrategia.std()>0 else 0
sharpes.append(s)
sharpes = np.array(sharpes)
mejor = sharpes.max()
print("=== MULTIPLE TESTING ===")
print(f"Estrategias probadas: {n_estrategias}")
print(f"Estrategias con Sharpe > 1.0: {(sharpes > 1.0).sum()}")
print(f"Estrategias con Sharpe > 1.5: {(sharpes > 1.5).sum()}")
print(f"MEJOR Sharpe encontrado (puro azar): {mejor:.2f}")
print(f"\nNINGUNA tiene edge real, pero la 'mejor' parece impresionante.")
print(f"Si solo publicaras la ganadora, parecería una estrategia genial.")
plt.figure(figsize=(12, 5))
plt.hist(sharpes, bins=50, color="coral", alpha=0.7)
plt.axvline(mejor, color="red", linestyle="--", label=f"Mejor por azar: {mejor:.2f}")
plt.title("Sharpes de 1000 estrategias SIN edge real")
plt.xlabel("Sharpe Ratio"); plt.ylabel("Frecuencia")
plt.legend(); plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
Interpretación
Qué demuestra:
- Aunque NINGUNA estrategia tiene edge real, la “mejor” entre 1000 puede tener un Sharpe llamativo
- Si solo publicas la ganadora (como hacen muchos), parece una estrategia genial
- Esto es exactamente el data snooping / multiple testing
Lección: “encontré una estrategia con Sharpe 1.5” no significa nada si probaste 1000. La pregunta correcta es: “¿cuántas probaste?”. El Deflated Sharpe Ratio (siguiente caso) corrige esto.
🧪 Caso Práctico 4: Deflated Sharpe Ratio
Objetivo
Ajustar un Sharpe observado por el número de pruebas realizadas.
Código
import numpy as np
from scipy.stats import norm
def deflated_sharpe_ratio(sharpe_observado, n_pruebas, n_observaciones,
skew=0, kurt=3):
"""
Versión simplificada del Deflated Sharpe Ratio (López de Prado).
Estima la probabilidad de que el Sharpe sea real dado el multiple testing.
"""
# Sharpe esperado máximo por azar tras n_pruebas (aproximación)
euler = 0.5772
sharpe_esperado_azar = np.sqrt(
(1 - euler)*norm.ppf(1 - 1/n_pruebas) +
euler*norm.ppf(1 - 1/(n_pruebas*np.e))
) / np.sqrt(n_observaciones) # normalizado
# Estadístico DSR (probabilidad de que el Sharpe verdadero > umbral azar)
numerador = (sharpe_observado - sharpe_esperado_azar) * np.sqrt(n_observaciones - 1)
denominador = np.sqrt(1 - skew*sharpe_observado + (kurt-1)/4*sharpe_observado**2)
dsr = norm.cdf(numerador / denominador)
return dsr, sharpe_esperado_azar
# Ejemplo: Sharpe de 1.5 obtenido tras probar distintos números de estrategias
sharpe_obs = 1.5
n_obs = 252 * 4 # 4 años de datos diarios
print("=== DEFLATED SHARPE RATIO ===")
print(f"Sharpe observado: {sharpe_obs}\n")
print(f"{'Pruebas':<12}{'Sharpe azar':>14}{'Prob. real (DSR)':>20}")
for n_pruebas in [1, 10, 100, 1000]:
dsr, s_azar = deflated_sharpe_ratio(sharpe_obs, n_pruebas, n_obs)
print(f"{n_pruebas:<12}{s_azar:>14.3f}{dsr:>19.1%}")
print("\nObserva: el mismo Sharpe de 1.5 es muy creíble si probaste 1 estrategia,")
print("pero cada vez menos creíble cuantas más estrategias probaste.")
Interpretación
Qué demuestra:
- El mismo Sharpe observado (1.5) tiene distinta credibilidad según cuántas estrategias probaste
- Con 1 prueba: alta probabilidad de ser real
- Con 1000 pruebas: la probabilidad de que sea real cae drásticamente
Lección: el Deflated Sharpe Ratio formaliza la intuición del Módulo 1. Siempre debes preguntarte (y declarar) cuántas estrategias probaste. Ocultarlo es engañarse a uno mismo y a los demás.
🧪 Caso Práctico 5: Validación Walk-Forward
Objetivo
Implementar una validación walk-forward que imita la operativa real.
Código
import yfinance as yf
import numpy as np
import pandas as pd
datos = yf.download("SPY", start="2014-01-01", end="2024-01-01")
precio = datos["Close"]
ret = precio.pct_change()
def backtest_ventana(precio_train, precio_test):
"""Optimiza la ventana en train, la aplica en test."""
# Optimizar ventana en train
mejor_v, mejor_s = 50, -np.inf
for v in range(10, 150, 10):
media = precio_train.rolling(v).mean()
senal = (precio_train > media).astype(int).shift(1)
r = (senal * precio_train.pct_change()).dropna()
s = r.mean()/r.std()*np.sqrt(252) if r.std()>0 else 0
if s > mejor_s:
mejor_s, mejor_v = s, v
# Aplicar en test (SIN reoptimizar)
media = precio_test.rolling(mejor_v).mean()
senal = (precio_test > media).astype(int).shift(1)
r = (senal * precio_test.pct_change()).dropna()
return r, mejor_v
# Walk-forward: ventanas de 2 años train, 1 año test
años = range(2016, 2024)
resultados_oos = []
print("=== WALK-FORWARD ANALYSIS ===")
print(f"{'Periodo test':<15}{'Ventana óptima':>16}{'Sharpe OOS':>14}")
for año in años:
train = precio[(precio.index.year >= año-2) & (precio.index.year < año)]
test = precio[precio.index.year == año]
if len(train) < 100 or len(test) < 50: continue
r_test, v = backtest_ventana(train, test)
s_test = r_test.mean()/r_test.std()*np.sqrt(252) if r_test.std()>0 else 0
resultados_oos.append(r_test)
print(f"{año:<15}{v:>16}{s_test:>14.2f}")
# Sharpe combinado de todos los periodos out-of-sample
ret_combinado = pd.concat(resultados_oos)
sharpe_total = ret_combinado.mean()/ret_combinado.std()*np.sqrt(252)
print(f"\nSharpe combinado walk-forward (estimación honesta): {sharpe_total:.2f}")
Interpretación
Qué demuestra:
- En cada periodo, la ventana óptima cambia (el mercado no es estacionario, Módulo 3)
- El Sharpe combinado de los periodos out-of-sample es la estimación HONESTA del rendimiento futuro
- Suele ser más bajo (y realista) que un backtest in-sample optimizado
Lección: el walk-forward imita exactamente cómo operarías: reoptimizando periódicamente con datos disponibles y operando “a ciegas” en el futuro inmediato. Es el estándar de validación honesta en trading.
🧪 Caso Práctico 6: Construir un Checklist Anti-Sesgos
Objetivo
Crear una función que audite un backtest buscando señales de los sesgos comunes.
Código
import numpy as np
import pandas as pd
def auditar_backtest(retornos, n_estrategias_probadas=1, usa_shift=None,
incluye_costos=None, universo_pointintime=None):
"""
Audita un backtest buscando señales de sesgos comunes.
Devuelve una lista de advertencias.
"""
advertencias = []
retornos = pd.Series(retornos).dropna()
# 1. Sharpe sospechosamente alto (overfitting)
sharpe = retornos.mean()/retornos.std()*np.sqrt(252)
if sharpe > 3:
advertencias.append(f"⚠️ Sharpe={sharpe:.1f} sospechosamente alto (>3): posible overfitting")
elif sharpe > 2:
advertencias.append(f"⚡ Sharpe={sharpe:.1f} alto (>2): verificar con cuidado")
# 2. Multiple testing
if n_estrategias_probadas > 10:
advertencias.append(f"⚠️ {n_estrategias_probadas} estrategias probadas: riesgo de multiple testing. "
f"Aplica Deflated Sharpe.")
# 3. Look-ahead
if usa_shift is False:
advertencias.append("🚨 NO usa shift en las señales: probable LOOK-AHEAD BIAS")
elif usa_shift is None:
advertencias.append("❓ No se especificó si usa shift: verifica el look-ahead")
# 4. Costos
if incluye_costos is False:
advertencias.append("🚨 NO incluye costos de transacción: resultado irreal")
elif incluye_costos is None:
advertencias.append("❓ No se especificó si incluye costos: verifícalo")
# 5. Survivorship
if universo_pointintime is False:
advertencias.append("🚨 Universo NO point-in-time: probable SURVIVORSHIP BIAS")
elif universo_pointintime is None:
advertencias.append("❓ No se especificó el universo: verifica survivorship bias")
return sharpe, advertencias
# Ejemplo de uso: auditar un backtest sospechoso
np.random.seed(1)
retornos_ejemplo = np.random.normal(0.001, 0.008, 1000) # Sharpe alto artificial
sharpe, avisos = auditar_backtest(
retornos_ejemplo,
n_estrategias_probadas=50, # ¡probó muchas!
usa_shift=False, # ¡sin shift!
incluye_costos=False, # ¡sin costos!
universo_pointintime=False # ¡survivorship!
)
print(f"=== AUDITORÍA DEL BACKTEST ===")
print(f"Sharpe reportado: {sharpe:.2f}\n")
print("ADVERTENCIAS:")
for a in avisos:
print(f" {a}")
print(f"\nVeredicto: con {len(avisos)} banderas, este backtest NO es creíble.")
Interpretación
Qué hace:
- Sistematiza la “regla de oro” del módulo en un checklist automático
- Detecta las señales de los sesgos principales
- Te obliga a declarar las decisiones clave (shift, costos, universo, nº de pruebas)
Lección: convierte el escepticismo en un proceso. Antes de creer cualquier backtest (tuyo o ajeno), pásalo por este tipo de auditoría. La disciplina sistemática vence al autoengaño.
🎓 Proyecto del Módulo
Enunciado
Toma una estrategia (la tuya del Módulo 7 o una nueva) y sométela a una validación rigurosa:
- Implementa la estrategia con prevención de look-ahead (
.shift(1)) - Añade costos de transacción realistas
- Divide en in-sample y out-of-sample
- Optimiza SOLO en in-sample y reporta el Sharpe
- Aplica esa configuración out-of-sample y reporta el Sharpe
- Calcula cuánto cae el Sharpe (señal de overfitting)
- Escribe un párrafo: ¿es robusta tu estrategia? ¿Confiarías en ella con dinero real?
Solución de Referencia (Plantilla)
import yfinance as yf
import numpy as np
import pandas as pd
datos = yf.download("SPY", start="2014-01-01", end="2024-01-01")["Close"]
corte = "2021-01-01"
precio_is = datos[datos.index < corte]
precio_oos = datos[datos.index >= corte]
def backtest(precio, ventana, costo=0.001):
media = precio.rolling(ventana).mean()
senal = (precio > media).astype(int)
pos = senal.shift(1).fillna(0) # anti look-ahead
cambio = pos.diff().abs()
r = (pos*precio.pct_change() - cambio*costo).dropna()
return r.mean()/r.std()*np.sqrt(252) if r.std()>0 else 0
# Optimizar en in-sample
ventanas = range(10, 150, 10)
sharpes_is = {v: backtest(precio_is, v) for v in ventanas}
mejor_v = max(sharpes_is, key=sharpes_is.get)
print(f"Mejor ventana IS: {mejor_v}, Sharpe IS: {sharpes_is[mejor_v]:.2f}")
# Aplicar out-of-sample
sharpe_oos = backtest(precio_oos, mejor_v)
print(f"Sharpe OOS con esa ventana: {sharpe_oos:.2f}")
print(f"Caída del Sharpe: {sharpes_is[mejor_v] - sharpe_oos:.2f}")
# Tu análisis aquí: ¿confiarías en esta estrategia?
Criterios de Evaluación
- Prevención de look-ahead: uso correcto del
.shift(1)(20%) - Costos incluidos: realistas (20%)
- Validación in/out-of-sample: correctamente separada (30%)
- Medición del overfitting: cálculo de la caída del Sharpe (15%)
- Análisis crítico: juicio honesto sobre la robustez (15%)
📝 Resumen del Módulo Práctico
Has aprendido a:
✓ Detectar y medir el look-ahead bias (el poder del .shift(1))
✓ Demostrar el overfitting (el óptimo in-sample ≠ óptimo out-of-sample)
✓ Reproducir el multiple testing y sus falsos ganadores
✓ Aplicar el Deflated Sharpe Ratio para corregir por nº de pruebas
✓ Implementar una validación walk-forward honesta
✓ Construir un checklist automático anti-sesgos
“Has aprendido la lección más incómoda y valiosa del curso: que tu mayor enemigo en finanzas cuantitativas eres tú mismo, y tu herramienta más poderosa es el escepticismo despiadado hacia tus propios resultados. Quien domina el backtesting honesto tiene una ventaja que el 90% de los aficionados nunca alcanzará: la capacidad de no engañarse.”
En el Módulo 9 aplicaremos machine learning a finanzas, donde el riesgo de overfitting es mayor que nunca. Todo lo que has aprendido aquí será tu armadura.
Fin de los Casos Prácticos del Módulo 8