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

Plantilla del Proyecto Final

Proyecto Final y Siguientes Pasos

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

  1. Cambia la hipótesis: reemplaza el momentum por tu idea (mean reversion, factores, ML…)
  2. Modifica construir_senal(): implementa tu lógica de señal
  3. Ajusta el universo: cambia el ticker o usa varios activos
  4. Mantén el resto: el backtest, las métricas y la visualización sirven para casi cualquier estrategia
  5. 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