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:
- Elige un activo y un tipo de estrategia (trend o reversión)
- Define la señal de entrada y salida
- Aplica el
.shift(1)para evitar look-ahead - Añade costos de transacción realistas
- Evalúa con Sharpe, drawdown y nº de operaciones
- Compara con buy & hold
- 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