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

Casos prácticos

Trading Algorítmico Básico

Módulo 7: Casos Prácticos Resueltos

Trading Algorítmico Básico — Laboratorio en Python


📋 Introducción

Estos ejercicios convierten las estrategias del Módulo 7 en código que opera sobre datos reales. Implementarás un cruce de medias, una estrategia de reversión, medirás el impacto de los costos y construirás un backtest completo. Ejecuta cada bloque y experimenta.

Requisitos Previos

pip install numpy pandas matplotlib yfinance

🧪 Caso Práctico 1: Estrategia de Cruce de Medias (Trend Following)

Objetivo

Implementar la estrategia trend following más clásica: el cruce de medias móviles.

Código

import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

# Datos
datos = yf.download("SPY", start="2015-01-01", end="2024-01-01")
precio = datos["Close"]

# Medias móviles
df = pd.DataFrame({"precio": precio})
df["sma_corta"] = precio.rolling(50).mean()
df["sma_larga"] = precio.rolling(200).mean()

# Señal: 1 si la corta está por encima de la larga (posición larga), 0 si no
df["senal"] = np.where(df["sma_corta"] > df["sma_larga"], 1, 0)
# La posición de hoy se basa en la señal de AYER (evitar look-ahead, Módulo 8)
df["posicion"] = df["senal"].shift(1)

# Retornos
df["ret_activo"] = precio.pct_change()
df["ret_estrategia"] = df["posicion"] * df["ret_activo"]

# Visualizar
fig, axes = plt.subplots(2, 1, figsize=(13, 9), sharex=True)
axes[0].plot(df.index, df["precio"], label="Precio", alpha=0.6)
axes[0].plot(df.index, df["sma_corta"], label="SMA 50", linewidth=1)
axes[0].plot(df.index, df["sma_larga"], label="SMA 200", linewidth=1)
axes[0].set_title("Cruce de medias móviles - SPY")
axes[0].legend(); axes[0].grid(True, alpha=0.3)

# Comparar curva de capital
acum_activo = (1 + df["ret_activo"]).cumprod()
acum_estrat = (1 + df["ret_estrategia"]).cumprod()
axes[1].plot(df.index, acum_activo, label="Buy & Hold", alpha=0.7)
axes[1].plot(df.index, acum_estrat, label="Estrategia cruce medias", linewidth=1.5)
axes[1].set_title("Curva de capital: estrategia vs comprar y mantener")
axes[1].legend(); axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

# Métricas comparadas
def sharpe(ret): return ret.mean()/ret.std()*np.sqrt(252)
print(f"Sharpe Buy & Hold:   {sharpe(df['ret_activo'].dropna()):.2f}")
print(f"Sharpe Estrategia:   {sharpe(df['ret_estrategia'].dropna()):.2f}")

Interpretación

Qué observas:

  • La estrategia está “dentro” del mercado cuando la SMA 50 > SMA 200, y “fuera” (en efectivo) cuando no
  • En mercados alcistas sostenidos, sigue de cerca al buy & hold
  • Su ventaja: a veces evita parte de las grandes caídas (sale cuando empieza la tendencia bajista)
  • Nota el .shift(1): la posición se basa en la señal de ayer, NO de hoy. Esto evita el look-ahead bias (Módulo 8)

🧪 Caso Práctico 2: Estrategia de Reversión (Bandas de Bollinger)

Objetivo

Implementar una estrategia de mean reversion con bandas de Bollinger.

Código

import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

datos = yf.download("SPY", start="2018-01-01", end="2024-01-01")
precio = datos["Close"]

df = pd.DataFrame({"precio": precio})
ventana = 20
df["media"] = precio.rolling(ventana).mean()
df["std"] = precio.rolling(ventana).std()
df["banda_sup"] = df["media"] + 2*df["std"]
df["banda_inf"] = df["media"] - 2*df["std"]

# Z-score: cuántas desviaciones está el precio de su media
df["zscore"] = (df["precio"] - df["media"]) / df["std"]

# Señal de mean reversion:
# Comprar cuando el precio cae por debajo de la banda inferior (sobrevendido)
# Vender/salir cuando vuelve a la media
df["senal"] = 0
df.loc[df["zscore"] < -2, "senal"] = 1   # comprar (sobrevendido)
df.loc[df["zscore"] > 0, "senal"] = 0    # salir al volver a la media
df["posicion"] = df["senal"].shift(1).ffill().fillna(0)

df["ret_activo"] = precio.pct_change()
df["ret_estrategia"] = df["posicion"] * df["ret_activo"]

# Visualizar las bandas y señales
plt.figure(figsize=(13, 6))
plt.plot(df.index, df["precio"], label="Precio", linewidth=1)
plt.plot(df.index, df["media"], label="Media 20", linestyle="--", alpha=0.7)
plt.fill_between(df.index, df["banda_inf"], df["banda_sup"], alpha=0.1, color="gray", label="Bandas ±2σ")
compras = df[df["zscore"] < -2]
plt.scatter(compras.index, compras["precio"], color="green", marker="^", s=40, label="Señal compra", zorder=5)
plt.title("Bandas de Bollinger - Estrategia de reversión")
plt.legend(); plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

def sharpe(ret): return ret.mean()/ret.std()*np.sqrt(252)
print(f"Sharpe Estrategia reversión: {sharpe(df['ret_estrategia'].dropna()):.2f}")
print(f"Sharpe Buy & Hold:           {sharpe(df['ret_activo'].dropna()):.2f}")

Interpretación

Qué observas:

  • Las flechas verdes marcan cuándo el precio cae por debajo de la banda inferior (compra)
  • La estrategia entra en los “dips” esperando que el precio revierta a la media
  • Funciona bien en mercados laterales, pero sufre si el precio sigue cayendo (tendencia bajista fuerte)

Lección: compara con el cruce de medias del Caso 1. Son estrategias opuestas. La reversión compra debilidad; el trend following compra fuerza. Cada una brilla en un régimen distinto.


🧪 Caso Práctico 3: El Impacto Devastador de los Costos

Objetivo

Demostrar cómo los costos de transacción pueden destruir una estrategia rentable.

Código

import numpy as np
import pandas as pd

# Reutilizamos la estrategia de cruce de medias del Caso 1
# (asegúrate de tener 'df' del Caso 1 con 'posicion' y 'ret_activo')

# Detectar operaciones: cada cambio de posición es una operación
df["cambio"] = df["posicion"].diff().abs()
n_operaciones = df["cambio"].sum()
print(f"Número de operaciones: {n_operaciones:.0f}")

def retorno_con_costos(df, costo_por_operacion):
    """Aplica un costo cada vez que cambia la posición."""
    ret_bruto = df["posicion"] * df["ret_activo"]
    costos = df["cambio"] * costo_por_operacion
    return (ret_bruto - costos).dropna()

# Comparar distintos niveles de costo
print("\n=== IMPACTO DE LOS COSTOS (Sharpe anualizado) ===")
def sharpe(ret): return ret.mean()/ret.std()*np.sqrt(252)
for costo in [0, 0.0005, 0.001, 0.002, 0.005]:
    ret_neto = retorno_con_costos(df, costo)
    ret_anual = ret_neto.mean() * 252
    print(f"Costo {costo*100:.2f}% por operación → "
          f"Retorno anual: {ret_anual:>7.2%}, Sharpe: {sharpe(ret_neto):.2f}")

Interpretación

Qué demuestra:

  • Con costo 0 (backtest ingenuo), la estrategia parece buena
  • A medida que añades costos realistas, el retorno y el Sharpe se deterioran
  • Para estrategias de bajo turnover (como el cruce de medias), el impacto es moderado
  • Para estrategias de alto turnover, los costos las destruyen (como vimos en el Caso 2 de la guía)

Lección crítica: SIEMPRE modela costos. Un backtest sin costos es una mentira optimista. Esta es una de las lecciones más importantes de todo el curso.


🧪 Caso Práctico 4: Sizing por Volatilidad (Volatility Targeting)

Objetivo

Ajustar el tamaño de la posición según la volatilidad para mantener un riesgo constante.

Código

import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

datos = yf.download("QQQ", start="2018-01-01", end="2024-01-01")
precio = datos["Close"]
ret = precio.pct_change().dropna()

# Volatilidad objetivo anual (ej: 15%)
vol_objetivo = 0.15

# Volatilidad realizada (ventana móvil de 20 días, anualizada)
vol_realizada = ret.rolling(20).std() * np.sqrt(252)

# Peso: escalar la posición para alcanzar la volatilidad objetivo
# Si la vol realizada es alta, reducimos posición; si es baja, aumentamos
peso = (vol_objetivo / vol_realizada).clip(upper=1.5)  # limitar apalancamiento a 1.5x
peso = peso.shift(1)  # usar info de ayer (no look-ahead)

ret_estrategia = peso * ret

# Comparar volatilidad de la estrategia vs buy & hold
def vol_anual(r): return r.std() * np.sqrt(252)
print(f"Volatilidad Buy & Hold:        {vol_anual(ret):.2%}")
print(f"Volatilidad con vol targeting: {vol_anual(ret_estrategia.dropna()):.2%}")
print(f"(objetivo era {vol_objetivo:.0%})")

# Visualizar el peso a lo largo del tiempo
plt.figure(figsize=(13, 5))
plt.plot(peso.index, peso, color="purple", linewidth=1)
plt.axhline(1, color="gray", linestyle="--", alpha=0.5, label="Sin apalancamiento")
plt.title("Peso de la posición (volatility targeting)")
plt.ylabel("Peso / apalancamiento")
plt.legend(); plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

Interpretación

Qué observas:

  • Cuando la volatilidad sube (crisis), el peso baja → menos exposición → protección
  • Cuando la volatilidad baja (calma), el peso sube → más exposición
  • La volatilidad resultante de la estrategia se acerca al objetivo (15%)

Lección: el volatility targeting es una técnica de sizing muy usada. Mantiene un riesgo constante en lugar de dejar que la volatilidad del mercado lo dicte. Conecta con GARCH (Módulo 3) y la gestión de riesgo (Módulo 5).


🧪 Caso Práctico 5: Backtest Completo con Todos los Componentes

Objetivo

Unir todo: señal, sizing, stop, costos y evaluación en un backtest completo.

Código

import yfinance as yf
import numpy as np
import pandas as pd

datos = yf.download("SPY", start="2015-01-01", end="2024-01-01")
precio = datos["Close"]
df = pd.DataFrame({"precio": precio})

# === 1. SEÑAL (trend following: cruce de medias) ===
df["sma_corta"] = precio.rolling(50).mean()
df["sma_larga"] = precio.rolling(200).mean()
df["senal"] = np.where(df["sma_corta"] > df["sma_larga"], 1, 0)

# === 2. FILTRO DE RÉGIMEN (solo operar si volatilidad no es extrema) ===
df["ret"] = precio.pct_change()
df["vol"] = df["ret"].rolling(20).std() * np.sqrt(252)
df["filtro"] = np.where(df["vol"] < 0.40, 1, 0)  # no operar si vol > 40%

# === 3. POSICIÓN (señal × filtro, con shift para evitar look-ahead) ===
df["posicion"] = (df["senal"] * df["filtro"]).shift(1).fillna(0)

# === 4. COSTOS ===
df["cambio"] = df["posicion"].diff().abs()
costo_op = 0.001  # 0.1% por operación
df["ret_estrategia"] = df["posicion"] * df["ret"] - df["cambio"] * costo_op

# === 5. EVALUACIÓN (métricas del Módulo 5) ===
ret_e = df["ret_estrategia"].dropna()
acum = (1 + ret_e).cumprod()
dd = (acum - acum.cummax()) / acum.cummax()

print("=== RESULTADOS DEL BACKTEST COMPLETO ===")
print(f"Retorno anualizado:  {ret_e.mean()*252:.2%}")
print(f"Volatilidad anual:   {ret_e.std()*np.sqrt(252):.2%}")
print(f"Sharpe Ratio:        {ret_e.mean()/ret_e.std()*np.sqrt(252):.2f}")
print(f"Máximo Drawdown:     {dd.min():.2%}")
print(f"Nº operaciones:      {df['cambio'].sum():.0f}")
print(f"\nComparación Buy & Hold:")
rb = df["ret"].dropna()
print(f"Sharpe B&H:          {rb.mean()/rb.std()*np.sqrt(252):.2f}")

Interpretación

Qué muestra:

  • Este es un backtest completo con los 8 componentes de la guía
  • Incluye: señal (cruce), filtro de régimen (volatilidad), sizing implícito, costos y evaluación
  • El .shift(1) evita look-ahead; los costos hacen el resultado realista

Lección: una estrategia profesional es un sistema, no una señal aislada. Cada componente (especialmente costos y prevención de look-ahead) afecta al resultado. Este código es la plantilla de cómo se evalúa una estrategia de verdad.


🧪 Caso Práctico 6: Comparar Trend Following vs Mean Reversion por Régimen

Objetivo

Demostrar empíricamente que cada estrategia funciona en regímenes opuestos.

Código

import yfinance as yf
import numpy as np
import pandas as pd

# Función para evaluar una estrategia simple
def evaluar_estrategia(precio, tipo):
    df = pd.DataFrame({"precio": precio})
    df["ret"] = precio.pct_change()
    if tipo == "trend":
        # Trend: comprar si el precio está por encima de su media de 50 días
        df["media"] = precio.rolling(50).mean()
        df["posicion"] = np.where(precio > df["media"], 1, 0)
    elif tipo == "reversion":
        # Reversión: comprar si el z-score está muy bajo
        media = precio.rolling(20).mean()
        std = precio.rolling(20).std()
        z = (precio - media) / std
        df["posicion"] = np.where(z < -1, 1, 0)
    df["posicion"] = df["posicion"].shift(1).fillna(0)
    ret_e = (df["posicion"] * df["ret"]).dropna()
    return ret_e.mean()/ret_e.std()*np.sqrt(252) if ret_e.std()>0 else 0

# Probar en distintos activos / periodos
activos = {
    "SPY (índice, tiende)": "SPY",
    "TLT (bonos)": "TLT",
}

print("=== SHARPE POR TIPO DE ESTRATEGIA ===")
print(f"{'Activo':<25}{'Trend':>10}{'Reversión':>12}")
for nombre, ticker in activos.items():
    datos = yf.download(ticker, start="2018-01-01", end="2024-01-01")["Close"]
    s_trend = evaluar_estrategia(datos, "trend")
    s_rev = evaluar_estrategia(datos, "reversion")
    print(f"{nombre:<25}{s_trend:>10.2f}{s_rev:>12.2f}")

print("\nObserva cómo el mismo activo puede favorecer una u otra estrategia")
print("según su comportamiento (tendencial vs lateral) en el periodo.")

Interpretación

Qué demuestra:

  • El mismo activo puede dar mejor Sharpe con trend o con reversión según su comportamiento en el periodo
  • Activos muy tendenciales favorecen el trend following
  • Activos más oscilantes favorecen la reversión

Lección: no existe la estrategia universal. La clave es entender el régimen del activo y elegir la estrategia adecuada (o usar filtros de régimen). Esto justifica por qué los fondos diversifican entre tipos de estrategias.


🎓 Proyecto del Módulo

Enunciado

Diseña y backtestea una estrategia completa:

  1. Elige un activo y un tipo de estrategia (trend o reversión)
  2. Define la señal de entrada y salida
  3. Aplica el .shift(1) para evitar look-ahead
  4. Añade costos de transacción realistas
  5. Evalúa con Sharpe, drawdown y nº de operaciones
  6. Compara con buy & hold
  7. Escribe un párrafo: ¿bate al buy & hold tras costos? ¿En qué condiciones funcionaría mejor?

Solución de Referencia (Plantilla)

import yfinance as yf
import numpy as np
import pandas as pd

mi_ticker = "SPY"  # cámbialo
datos = yf.download(mi_ticker, start="2016-01-01", end="2024-01-01")["Close"]
df = pd.DataFrame({"precio": datos})
df["ret"] = datos.pct_change()

# Señal (trend following ejemplo)
df["media"] = datos.rolling(100).mean()
df["posicion"] = np.where(datos > df["media"], 1, 0)
df["posicion"] = df["posicion"].shift(1).fillna(0)  # evitar look-ahead

# Costos
df["cambio"] = df["posicion"].diff().abs()
df["ret_estrat"] = df["posicion"]*df["ret"] - df["cambio"]*0.001

# Evaluación
ret_e = df["ret_estrat"].dropna()
acum = (1+ret_e).cumprod()
dd = (acum - acum.cummax())/acum.cummax()
print(f"Sharpe estrategia: {ret_e.mean()/ret_e.std()*np.sqrt(252):.2f}")
print(f"Sharpe Buy&Hold:   {df['ret'].dropna().mean()/df['ret'].dropna().std()*np.sqrt(252):.2f}")
print(f"Max Drawdown:      {dd.min():.2%}")
print(f"Nº operaciones:    {df['cambio'].sum():.0f}")

# Tu análisis aquí

Criterios de Evaluación

  • Señal bien definida: entrada y salida claras (20%)
  • Prevención de look-ahead: uso correcto del .shift(1) (25%)
  • Costos modelados: realistas (25%)
  • Evaluación con métricas: Sharpe, drawdown, operaciones (15%)
  • Análisis crítico: comparación honesta con buy & hold (15%)

📝 Resumen del Módulo Práctico

Has aprendido a:

✓ Implementar una estrategia de cruce de medias (trend following) ✓ Implementar una estrategia de bandas de Bollinger (mean reversion) ✓ Medir el impacto devastador de los costos de transacción ✓ Aplicar sizing por volatilidad (volatility targeting) ✓ Construir un backtest completo con todos los componentes ✓ Comprobar que cada estrategia funciona en regímenes opuestos

“Has construido tus primeras estrategias completas. Pero nota cuántas veces ha aparecido el .shift(1) y los costos: son la frontera entre un backtest honesto y una mentira. En el próximo módulo profundizaremos en todos los sesgos que pueden hacer que tu estrategia mienta — porque una estrategia mal validada es peor que ninguna estrategia.”

En el Módulo 8, el backtesting riguroso: cómo evitar el look-ahead, el survivorship bias, el overfitting y todos los errores que arruinan a tantos quants aficionados.


Fin de los Casos Prácticos del Módulo 7