Módulo 10: Plantilla del Proyecto Final
Estrategia Cuantitativa Completa End-to-End
📋 Introducción
Esta plantilla implementa una estrategia cuantitativa completa, integrando todo lo aprendido en el curso. Está estructurada en los 7 pasos de la guía, y puedes usarla como esqueleto para tu propio proyecto: sustituye la hipótesis y la señal por las tuyas.
El ejemplo implementado es una estrategia de momentum con filtro de régimen, pero la estructura sirve para cualquier estrategia.
Requisitos Previos
pip install numpy pandas matplotlib yfinance scipy
Esta plantilla reúne técnicas de TODOS los módulos: limpieza de datos (M8), señales (M7), prevención de look-ahead (M8), costos (M7), métricas de riesgo (M5) y análisis crítico (M1).
📐 La Estrategia de Ejemplo
Hipótesis (Módulo 1): Los activos con momentum positivo (que han subido en los últimos meses) tienden a seguir subiendo a corto plazo, debido a la reacción tardía de los inversores a la información. Aplicaremos un filtro de régimen para evitar operar en mercados de alta volatilidad, donde el momentum suele fallar.
Por qué debería funcionar: el momentum es uno de los factores más robustos documentados (Jegadeesh & Titman, 1993; Módulo 6), con respaldo en múltiples mercados y épocas. El filtro de régimen mitiga su principal debilidad: los crashes de momentum en mercados turbulentos.
🧩 El Código Completo
"""
PROYECTO FINAL - Estrategia de Momentum con Filtro de Régimen
Curso Básico para Quants - Módulo 10
Integra: hipótesis económica (M1), señal de momentum (M6/M7),
filtro de régimen (M7), prevención de look-ahead (M8),
costos realistas (M7), métricas de riesgo (M5).
"""
import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
# =============================================================
# PASO 1: HIPÓTESIS (documentada arriba)
# =============================================================
# =============================================================
# PASO 2: ADQUISICIÓN Y LIMPIEZA DE DATOS
# =============================================================
def obtener_datos(ticker, inicio, fin):
"""Descarga datos ajustados y los limpia."""
datos = yf.download(ticker, start=inicio, end=fin, auto_adjust=True)
precio = datos["Close"].dropna()
# Verificación de cordura: sin huecos grandes ni valores absurdos
assert (precio > 0).all(), "Hay precios no positivos (datos corruptos)"
print(f"{ticker}: {len(precio)} observaciones, "
f"de {precio.index[0].date()} a {precio.index[-1].date()}")
return precio
# =============================================================
# PASO 3: CONSTRUIR LA SEÑAL/MODELO
# =============================================================
def construir_senal(precio, ventana_momentum=126, ventana_vol=20, umbral_vol=0.30):
"""
Señal de momentum con filtro de régimen.
- momentum: retorno de los últimos `ventana_momentum` días (~6 meses)
- filtro: solo operar si la volatilidad anualizada < umbral
"""
df = pd.DataFrame({"precio": precio})
df["ret"] = precio.pct_change()
# Señal de momentum: ¿el retorno de los últimos 6 meses es positivo?
df["momentum"] = precio.pct_change(ventana_momentum)
df["senal_momentum"] = (df["momentum"] > 0).astype(int)
# Filtro de régimen: volatilidad reciente anualizada
df["vol"] = df["ret"].rolling(ventana_vol).std() * np.sqrt(252)
df["filtro_regimen"] = (df["vol"] < umbral_vol).astype(int)
# Señal combinada
df["senal"] = df["senal_momentum"] * df["filtro_regimen"]
return df
# =============================================================
# PASO 4: BACKTEST RIGUROSO
# =============================================================
def backtest(df, costo_por_operacion=0.001):
"""
Backtest con prevención de look-ahead y costos realistas.
"""
# CLAVE: la posición de hoy se basa en la señal de AYER (anti look-ahead, M8)
df["posicion"] = df["senal"].shift(1).fillna(0)
# Detectar operaciones (cambios de posición) para aplicar costos
df["cambio"] = df["posicion"].diff().abs()
# Retorno de la estrategia: posición × retorno − costos
df["ret_estrategia"] = df["posicion"] * df["ret"] - df["cambio"] * costo_por_operacion
return df
# =============================================================
# PASO 5: EVALUAR (métricas del Módulo 5)
# =============================================================
def calcular_metricas(retornos, rf_anual=0.03, dias=252):
"""Calcula todas las métricas de performance y riesgo."""
r = retornos.dropna()
rf_d = rf_anual / dias
ret_anual = r.mean() * dias
vol_anual = r.std() * np.sqrt(dias)
sharpe = (r.mean() - rf_d) / r.std() * np.sqrt(dias) if r.std() > 0 else 0
# Sortino (downside)
downside = r[r < 0].std()
sortino = (r.mean() - rf_d) / downside * np.sqrt(dias) if downside > 0 else 0
# Drawdown
acum = (1 + r).cumprod()
dd = (acum - acum.cummax()) / acum.cummax()
max_dd = dd.min()
calmar = ret_anual / abs(max_dd) if max_dd != 0 else 0
return {
"Retorno anual": ret_anual,
"Volatilidad": vol_anual,
"Sharpe": sharpe,
"Sortino": sortino,
"Calmar": calmar,
"Max Drawdown": max_dd
}
def comparar_con_benchmark(df):
"""Compara la estrategia con buy & hold (benchmark)."""
m_estrat = calcular_metricas(df["ret_estrategia"])
m_bh = calcular_metricas(df["ret"])
print("\n" + "="*55)
print(f"{'MÉTRICA':<18}{'ESTRATEGIA':>18}{'BUY & HOLD':>18}")
print("="*55)
for k in m_estrat:
v_e, v_b = m_estrat[k], m_bh[k]
if "Sharpe" in k or "Sortino" in k or "Calmar" in k:
print(f"{k:<18}{v_e:>18.2f}{v_b:>18.2f}")
else:
print(f"{k:<18}{v_e:>17.1%}{v_b:>17.1%}")
print("="*55)
return m_estrat, m_bh
# =============================================================
# PASO 6: VISUALIZACIÓN
# =============================================================
def visualizar(df):
"""Curva de capital y drawdown."""
acum_e = (1 + df["ret_estrategia"].fillna(0)).cumprod()
acum_b = (1 + df["ret"].fillna(0)).cumprod()
dd = (acum_e - acum_e.cummax()) / acum_e.cummax()
fig, axes = plt.subplots(3, 1, figsize=(13, 11), sharex=True)
# Curva de capital
axes[0].plot(acum_e.index, acum_e, label="Estrategia", linewidth=1.5, color="navy")
axes[0].plot(acum_b.index, acum_b, label="Buy & Hold", linewidth=1.2, color="gray", alpha=0.7)
axes[0].set_title("Curva de capital: Estrategia vs Buy & Hold")
axes[0].set_ylabel("Capital (1€ inicial)")
axes[0].legend(); axes[0].grid(True, alpha=0.3)
# Posición a lo largo del tiempo
axes[1].fill_between(df.index, df["posicion"], 0, alpha=0.4, color="green", step="post")
axes[1].set_title("Posición (1 = invertido, 0 = en efectivo)")
axes[1].set_ylabel("Posición")
axes[1].grid(True, alpha=0.3)
# Drawdown
axes[2].fill_between(dd.index, dd*100, 0, alpha=0.4, color="crimson")
axes[2].set_title(f"Drawdown de la estrategia (máx: {dd.min():.1%})")
axes[2].set_ylabel("Drawdown (%)")
axes[2].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# =============================================================
# EJECUCIÓN PRINCIPAL
# =============================================================
if __name__ == "__main__":
# Paso 2: datos
print("=== PASO 2: DATOS ===")
precio = obtener_datos("SPY", "2010-01-01", "2024-01-01")
# Paso 3: señal
print("\n=== PASO 3: SEÑAL ===")
df = construir_senal(precio)
print(f"Días invertido (señal=1): {df['senal'].sum()} de {len(df)} "
f"({df['senal'].mean():.1%})")
# Paso 4: backtest
print("\n=== PASO 4: BACKTEST ===")
df = backtest(df, costo_por_operacion=0.001)
print(f"Número de operaciones: {df['cambio'].sum():.0f}")
# Paso 5: evaluar
print("\n=== PASO 5: EVALUACIÓN ===")
m_e, m_b = comparar_con_benchmark(df)
# Paso 6: visualizar
visualizar(df)
# Paso 7: análisis crítico (impreso)
print("\n=== PASO 7: ANÁLISIS CRÍTICO ===")
bate = m_e["Sharpe"] > m_b["Sharpe"]
print(f"¿Bate al buy & hold en Sharpe? {'SÍ' if bate else 'NO'}")
print(f"El filtro de régimen reduce el drawdown: "
f"{m_e['Max Drawdown']:.1%} vs {m_b['Max Drawdown']:.1%} del B&H")
print("\nLimitaciones reconocidas:")
print("- El momentum puede sufrir 'crashes' en giros bruscos del mercado")
print("- Los parámetros (126d, 20d, 30%) podrían estar ligeramente ajustados")
print("- Solo se ha probado un activo; faltaría validar en más")
print("- Los costos reales podrían ser mayores en activos menos líquidos")
print("- No se ha hecho validación out-of-sample estricta (ver mejora abajo)")
🔬 Validación Out-of-Sample (Mejora Recomendada)
Para un proyecto de máxima calidad, añade validación out-of-sample explícita (Módulo 8):
def validacion_out_of_sample(precio, fecha_corte="2020-01-01"):
"""
Diseña/optimiza en in-sample, valida en out-of-sample.
"""
precio_is = precio[precio.index < fecha_corte]
precio_oos = precio[precio.index >= fecha_corte]
# Optimizar la ventana de momentum SOLO en in-sample
mejor_v, mejor_sharpe = 126, -np.inf
for v in [63, 126, 189, 252]: # 3, 6, 9, 12 meses
df_is = construir_senal(precio_is, ventana_momentum=v)
df_is = backtest(df_is)
s = calcular_metricas(df_is["ret_estrategia"])["Sharpe"]
if s > mejor_sharpe:
mejor_sharpe, mejor_v = s, v
print(f"Mejor ventana in-sample: {mejor_v} días (Sharpe {mejor_sharpe:.2f})")
# Aplicar esa ventana en out-of-sample (SIN reoptimizar)
df_oos = construir_senal(precio_oos, ventana_momentum=mejor_v)
df_oos = backtest(df_oos)
sharpe_oos = calcular_metricas(df_oos["ret_estrategia"])["Sharpe"]
print(f"Sharpe out-of-sample con esa ventana: {sharpe_oos:.2f}")
caida = mejor_sharpe - sharpe_oos
print(f"Caída del Sharpe (señal de overfitting): {caida:.2f}")
if caida > 1.0:
print("⚠️ Caída grande: posible overfitting de la ventana")
else:
print("✓ Caída contenida: la estrategia parece razonablemente robusta")
# Ejecutar:
# validacion_out_of_sample(precio)
📝 Cómo Adaptar Esta Plantilla a Tu Proyecto
- Cambia la hipótesis: reemplaza el momentum por tu idea (mean reversion, factores, ML…)
- Modifica
construir_senal(): implementa tu lógica de señal - Ajusta el universo: cambia el ticker o usa varios activos
- Mantén el resto: el backtest, las métricas y la visualización sirven para casi cualquier estrategia
- Añade tu análisis crítico: sé honesto sobre las limitaciones
Checklist Antes de Entregar
- ¿Tengo una hipótesis económica clara? (Módulo 1)
- ¿Uso datos ajustados y limpios? (Módulo 8)
- ¿Aplico
.shift(1)para evitar look-ahead? (Módulo 8) - ¿Incluyo costos de transacción realistas? (Módulo 7)
- ¿Comparo con un benchmark (buy & hold / 1/N)? (Módulos 5, 6)
- ¿Calculo métricas de riesgo completas? (Módulo 5)
- ¿Hago validación out-of-sample? (Módulo 8)
- ¿Reconozco honestamente las limitaciones? (Módulo 1)
- ¿Mi código es limpio y reproducible?
- ¿Mi Sharpe es realista (no sospechosamente alto)? (Módulo 8)
🎯 Ejemplo de README para tu Proyecto
# Estrategia de Momentum con Filtro de Régimen
## Hipótesis
Los activos con momentum positivo tienden a seguir subiendo a corto plazo
(Jegadeesh & Titman, 1993). Aplico un filtro de volatilidad para evitar los
crashes de momentum en mercados turbulentos.
## Metodología
- Universo: SPY (2010-2024)
- Señal: momentum a 6 meses + filtro de régimen (vol < 30%)
- Backtest: con prevención de look-ahead y costos del 0.1% por operación
- Validación: out-of-sample (corte en 2020)
## Resultados
- Sharpe estrategia: X.XX vs X.XX del buy & hold
- Máximo drawdown: -XX% vs -XX%
- [Curva de capital y drawdown]
## Limitaciones
- El momentum puede sufrir crashes en giros bruscos
- Parámetros posiblemente ligeramente ajustados
- Solo probado en un activo
- Costos podrían ser mayores en activos menos líquidos
## Conclusión
[Tu análisis honesto: ¿confiarías en esta estrategia con dinero real?]
## Reproducir
`python estrategia.py`
📝 Resumen
Esta plantilla integra todo el curso en un proyecto reproducible:
✓ Hipótesis económica clara (M1) ✓ Datos ajustados y limpios (M8) ✓ Señal con filtro de régimen (M6, M7) ✓ Backtest sin look-ahead y con costos (M7, M8) ✓ Métricas de riesgo completas (M5) ✓ Comparación con benchmark (M5, M6) ✓ Validación out-of-sample (M8) ✓ Análisis crítico honesto (M1)
“Este es el aspecto de un proyecto cuantitativo honesto. No promete oro: promete rigor. Y en finanzas cuantitativas, el rigor es lo único que perdura. Adáptalo, mejóralo, hazlo tuyo — y tendrás la primera pieza sólida de tu portfolio profesional.”
¡Enhorabuena por llegar al final del curso!
Fin de la Plantilla del Proyecto Final