Saltar al contenido principal
Módulo 8 · Aplicación práctica

Casos prácticos

Backtesting Riguroso

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:

  1. Implementa la estrategia con prevención de look-ahead (.shift(1))
  2. Añade costos de transacción realistas
  3. Divide en in-sample y out-of-sample
  4. Optimiza SOLO en in-sample y reporta el Sharpe
  5. Aplica esa configuración out-of-sample y reporta el Sharpe
  6. Calcula cuánto cae el Sharpe (señal de overfitting)
  7. 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