Módulo 6: Casos Prácticos Resueltos
Construcción de Carteras Cuantitativas — Laboratorio en Python
📋 Introducción
Estos ejercicios convierten la teoría de carteras en código ejecutable. Construirás la frontera eficiente, implementarás 1/N, mínima varianza y risk parity, y comprobarás empíricamente por qué lo simple a menudo gana. Ejecuta cada bloque y experimenta.
Requisitos Previos
pip install numpy pandas matplotlib scipy yfinance
Opcional avanzado:
pip install PyPortfolioOpt riskfolio-libpara optimización profesional.
🧪 Caso Práctico 1: Preparar los Datos de la Cartera
Objetivo
Descargar varios activos y calcular los inputs clave: retornos esperados y matriz de covarianza.
Código
import yfinance as yf
import numpy as np
import pandas as pd
# Cartera diversificada de 5 activos
tickers = ["SPY", "TLT", "GLD", "QQQ", "VNQ"] # acciones, bonos, oro, tech, REITs
datos = yf.download(tickers, start="2018-01-01", end="2024-01-01")["Close"].dropna()
retornos = datos.pct_change().dropna()
DIAS = 252
# Los dos inputs de Markowitz
retornos_esperados = retornos.mean() * DIAS # anualizado
matriz_cov = retornos.cov() * DIAS # anualizada
print("=== RETORNOS ESPERADOS (anualizados) ===")
print((retornos_esperados*100).round(2))
print("\n=== VOLATILIDADES INDIVIDUALES ===")
vols = np.sqrt(np.diag(matriz_cov))
for t, v in zip(tickers, vols):
print(f"{t}: {v:.2%}")
print("\n=== MATRIZ DE CORRELACIÓN ===")
print(retornos.corr().round(2))
Interpretación
- Los retornos esperados son la entrada más problemática (muy ruidosa). Aquí usamos la media histórica, pero recuerda: es solo una estimación poco fiable
- La matriz de covarianza es más estable y es el núcleo de todo (conecta con el Módulo 2)
- La matriz de correlación te muestra qué activos diversifican mejor (correlaciones bajas)
🧪 Caso Práctico 2: Construir la Frontera Eficiente
Objetivo
Generar y visualizar la frontera eficiente de Markowitz mediante simulación.
Código
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
n_activos = len(tickers)
n_carteras = 10000
resultados = np.zeros((3, n_carteras)) # retorno, riesgo, sharpe
pesos_guardados = []
for i in range(n_carteras):
# Pesos aleatorios que suman 1
w = np.random.random(n_activos)
w /= w.sum()
pesos_guardados.append(w)
# Retorno y riesgo de la cartera (fórmulas del Módulo 2)
ret = np.dot(w, retornos_esperados)
vol = np.sqrt(w.T @ matriz_cov.values @ w)
sharpe = (ret - 0.03) / vol # rf = 3%
resultados[0, i] = ret
resultados[1, i] = vol
resultados[2, i] = sharpe
# Cartera de máximo Sharpe (tangente)
idx_sharpe = resultados[2].argmax()
# Cartera de mínima varianza
idx_minvar = resultados[1].argmin()
plt.figure(figsize=(12, 7))
sc = plt.scatter(resultados[1]*100, resultados[0]*100, c=resultados[2],
cmap="viridis", s=8, alpha=0.5)
plt.colorbar(sc, label="Sharpe Ratio")
plt.scatter(resultados[1, idx_sharpe]*100, resultados[0, idx_sharpe]*100,
c="red", marker="*", s=400, label="Máximo Sharpe (tangente)")
plt.scatter(resultados[1, idx_minvar]*100, resultados[0, idx_minvar]*100,
c="blue", marker="*", s=400, label="Mínima varianza")
plt.title("Frontera Eficiente (10.000 carteras simuladas)")
plt.xlabel("Volatilidad anual (%)")
plt.ylabel("Retorno esperado anual (%)")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print("=== CARTERA DE MÁXIMO SHARPE ===")
for t, w in zip(tickers, pesos_guardados[idx_sharpe]):
print(f"{t}: {w:.1%}")
Interpretación
Qué observas:
- La nube de puntos forma la característica “bala” de Markowitz
- El borde superior izquierdo es la frontera eficiente: las mejores carteras
- La estrella roja (máximo Sharpe) y la azul (mínima varianza) son los dos puntos especiales
Advertencia clave: la cartera de máximo Sharpe depende de los retornos esperados, que son muy ruidosos. Si cambiaras ligeramente el periodo de datos, esa estrella roja se movería drásticamente. Esto ilustra por qué el Markowitz puro es frágil.
🧪 Caso Práctico 3: Optimización de Mínima Varianza
Objetivo
Encontrar la cartera de mínimo riesgo usando optimización real (no simulación).
Código
import numpy as np
from scipy.optimize import minimize
n = len(tickers)
Sigma = matriz_cov.values
# Función objetivo: la varianza de la cartera (wᵀΣw)
def varianza_cartera(w):
return w.T @ Sigma @ w
# Restricciones: los pesos suman 1
restricciones = {"type": "eq", "fun": lambda w: np.sum(w) - 1}
# Límites: sin posiciones cortas (cada peso entre 0 y 1)
limites = tuple((0, 1) for _ in range(n))
# Punto de partida: equal weight
w0 = np.array([1/n] * n)
# Optimizar
resultado = minimize(varianza_cartera, w0, method="SLSQP",
bounds=limites, constraints=restricciones)
pesos_minvar = resultado.x
vol_minvar = np.sqrt(varianza_cartera(pesos_minvar))
print("=== CARTERA DE MÍNIMA VARIANZA ===")
for t, w in zip(tickers, pesos_minvar):
print(f"{t}: {w:.1%}")
print(f"\nVolatilidad de la cartera: {vol_minvar:.2%}")
print("\nNota: NO usamos retornos esperados, solo la covarianza.")
print("Por eso esta cartera es más robusta que el Markowitz completo.")
Interpretación
- La mínima varianza solo necesita la matriz de covarianza, no los retornos esperados (la entrada ruidosa)
- Por eso es mucho más robusta que el Markowitz completo
- Tiende a concentrarse en los activos de menor volatilidad (típicamente bonos)
- Es una de las estrategias “robustas” más usadas en la práctica
🧪 Caso Práctico 4: Implementar Risk Parity
Objetivo
Construir una cartera donde cada activo contribuye por igual al riesgo total.
Código
import numpy as np
from scipy.optimize import minimize
n = len(tickers)
Sigma = matriz_cov.values
def contribuciones_riesgo(w):
"""Contribución de cada activo al riesgo total."""
vol_cartera = np.sqrt(w.T @ Sigma @ w)
# Contribución marginal × peso
contrib = w * (Sigma @ w) / vol_cartera
return contrib
def objetivo_risk_parity(w):
"""Minimizar la diferencia entre contribuciones (queremos que sean iguales)."""
contrib = contribuciones_riesgo(w)
objetivo = np.mean(contrib)
return np.sum((contrib - objetivo)**2)
restricciones = {"type": "eq", "fun": lambda w: np.sum(w) - 1}
limites = tuple((0.001, 1) for _ in range(n))
w0 = np.array([1/n] * n)
resultado = minimize(objetivo_risk_parity, w0, method="SLSQP",
bounds=limites, constraints=restricciones)
pesos_rp = resultado.x
print("=== CARTERA RISK PARITY ===")
contrib = contribuciones_riesgo(pesos_rp)
contrib_pct = contrib / contrib.sum()
print(f"{'Activo':<8}{'Peso':>10}{'Contrib. riesgo':>18}")
for t, w, c in zip(tickers, pesos_rp, contrib_pct):
print(f"{t:<8}{w:>9.1%}{c:>17.1%}")
print("\nObserva: los pesos son distintos, pero la contribución")
print("al riesgo es similar (~igual) para todos los activos.")
Interpretación
Qué observas:
- Los pesos son diferentes entre activos (más peso a los menos volátiles)
- Pero la contribución al riesgo es aproximadamente igual (~20% cada uno con 5 activos)
- Esto es exactamente risk parity: igualar el riesgo, no el capital
Lección: compara estos pesos con el 1/N. Los bonos (TLT) y el oro (GLD) reciben más peso que las acciones (QQQ), porque son menos volátiles. Así cada activo “arriesga” lo mismo.
🧪 Caso Práctico 5: La Gran Comparación — ¿Gana lo Simple?
Objetivo
Comparar 1/N, mínima varianza y risk parity fuera de muestra (el test que importa).
Código
import numpy as np
import pandas as pd
# Dividir en periodo de ESTIMACIÓN (in-sample) y EVALUACIÓN (out-of-sample)
fecha_corte = "2022-01-01"
ret_train = retornos[retornos.index < fecha_corte]
ret_test = retornos[retornos.index >= fecha_corte]
# Estimar covarianza solo con datos de entrenamiento
Sigma_train = ret_train.cov().values * DIAS
n = len(tickers)
# --- Estrategia 1: Equal Weight (1/N) ---
w_1n = np.array([1/n] * n)
# --- Estrategia 2: Mínima Varianza ---
from scipy.optimize import minimize
restr = {"type": "eq", "fun": lambda w: np.sum(w) - 1}
lim = tuple((0, 1) for _ in range(n))
w_minvar = minimize(lambda w: w.T @ Sigma_train @ w, w_1n,
method="SLSQP", bounds=lim, constraints=restr).x
# --- Estrategia 3: Risk Parity (simplificado: inverso de volatilidad) ---
vols = np.sqrt(np.diag(Sigma_train))
w_rp = (1/vols) / (1/vols).sum()
# Evaluar cada estrategia OUT-OF-SAMPLE
def evaluar(pesos, ret_test):
ret_cartera = (ret_test * pesos).sum(axis=1)
ret_anual = ret_cartera.mean() * DIAS
vol_anual = ret_cartera.std() * np.sqrt(DIAS)
sharpe = ret_anual / vol_anual
return ret_anual, vol_anual, sharpe
print("=== RESULTADOS FUERA DE MUESTRA (2022-2024) ===")
print(f"{'Estrategia':<20}{'Retorno':>10}{'Volatilidad':>14}{'Sharpe':>9}")
for nombre, w in [("1/N (equal weight)", w_1n),
("Mínima varianza", w_minvar),
("Risk parity", w_rp)]:
r, v, s = evaluar(w, ret_test)
print(f"{nombre:<20}{r:>9.1%}{v:>13.1%}{s:>9.2f}")
Interpretación
Qué demuestra:
- Comparamos las estrategias en datos que NO se usaron para estimarlas (out-of-sample, el test honesto del Módulo 1)
- Muy a menudo, el humilde 1/N rinde de forma competitiva o incluso supera a las estrategias optimizadas
- Esto reproduce el famoso resultado de DeMiguel et al.
Lección crítica: la optimización añade complejidad pero no siempre valor. Antes de presumir de un modelo sofisticado, compáralo siempre con el 1/N fuera de muestra. Si no lo bate, tu complejidad es ruido.
🧪 Caso Práctico 6: Estimar Exposición a Factores
Objetivo
Calcular la exposición de un activo a los factores de mercado mediante regresión.
Código
import yfinance as yf
import numpy as np
import statsmodels.api as sm
# Descargar la acción y proxies de factores
# Mercado: SPY | Tamaño: IWM (small caps) | Valor: IWD (value)
tickers_factor = ["AAPL", "SPY", "IWM", "IWD"]
datos_f = yf.download(tickers_factor, start="2019-01-01", end="2024-01-01")["Close"]
ret_f = datos_f.pct_change().dropna()
# Construir factores aproximados (versión didáctica)
mercado = ret_f["SPY"]
tamano = ret_f["IWM"] - ret_f["SPY"] # small minus big (aprox.)
valor = ret_f["IWD"] - ret_f["SPY"] # value minus market (aprox.)
# Variable dependiente: retorno de AAPL
Y = ret_f["AAPL"]
X = pd.DataFrame({"mercado": mercado, "tamano": tamano, "valor": valor})
X = sm.add_constant(X)
modelo = sm.OLS(Y, X).fit()
print("=== EXPOSICIÓN DE AAPL A FACTORES ===")
print(f"Alpha: {modelo.params['const']:.5f}")
print(f"Beta mercado: {modelo.params['mercado']:.3f}")
print(f"Beta tamaño: {modelo.params['tamano']:.3f}")
print(f"Beta valor: {modelo.params['valor']:.3f}")
print(f"R²: {modelo.rsquared:.3f}")
print("\nInterpretación:")
print(f"- Beta mercado {modelo.params['mercado']:.2f}: {'amplifica' if modelo.params['mercado']>1 else 'sigue'} el mercado")
print(f"- Beta tamaño {modelo.params['tamano']:.2f}: {'expuesta a small caps' if modelo.params['tamano']>0 else 'sesgo large cap'}")
print(f"- Beta valor {modelo.params['valor']:.2f}: {'sesgo value' if modelo.params['valor']>0 else 'sesgo growth'}")
Interpretación
- Esta es la regresión múltiple del Módulo 3 aplicada a factores
- Las betas miden la exposición de AAPL a cada factor
- AAPL típicamente muestra: beta de mercado >1 (volátil), sesgo large-cap (beta tamaño negativo) y sesgo growth (beta valor negativo)
- Nota: estos factores son aproximaciones didácticas. Los factores académicos reales (Kenneth French Data Library) son más rigurosos
🎓 Proyecto del Módulo
Enunciado
Construye y compara carteras con un universo de activos a tu elección:
- Elige 4-6 activos diversos
- Calcula los inputs (retornos esperados y covarianza)
- Construye tres carteras: 1/N, mínima varianza y risk parity
- Divide los datos en train/test y evalúa las tres fuera de muestra
- Calcula el Sharpe de cada una out-of-sample
- Escribe un párrafo: ¿ganó lo simple (1/N)? ¿Por qué crees que sí o no?
Solución de Referencia (Plantilla)
import yfinance as yf
import numpy as np
from scipy.optimize import minimize
mis_tickers = ["SPY", "TLT", "GLD", "VNQ"] # cámbialos
datos = yf.download(mis_tickers, start="2018-01-01", end="2024-01-01")["Close"].dropna()
ret = datos.pct_change().dropna()
DIAS = 252
n = len(mis_tickers)
# Train / test
corte = "2022-01-01"
train = ret[ret.index < corte]
test = ret[ret.index >= corte]
Sigma = train.cov().values * DIAS
# Tres carteras
w_1n = np.array([1/n]*n)
restr = {"type":"eq","fun":lambda w: np.sum(w)-1}
lim = tuple((0,1) for _ in range(n))
w_mv = minimize(lambda w: w.T@Sigma@w, w_1n, method="SLSQP",
bounds=lim, constraints=restr).x
vols = np.sqrt(np.diag(Sigma))
w_rp = (1/vols)/(1/vols).sum()
# Evaluar out-of-sample
def sharpe_oos(w):
rc = (test*w).sum(axis=1)
return (rc.mean()*DIAS)/(rc.std()*np.sqrt(DIAS))
print(f"Sharpe 1/N: {sharpe_oos(w_1n):.2f}")
print(f"Sharpe mínima var: {sharpe_oos(w_mv):.2f}")
print(f"Sharpe risk parity: {sharpe_oos(w_rp):.2f}")
# Tu análisis aquí
Criterios de Evaluación
- Construcción de las tres carteras: correcta (30%)
- Evaluación out-of-sample: bien hecha (la división train/test es clave) (30%)
- Cálculo del Sharpe: correcto (15%)
- Análisis crítico: reflexión razonada sobre simple vs. complejo (25%)
📝 Resumen del Módulo Práctico
Has aprendido a:
✓ Preparar los inputs de Markowitz (retornos esperados y covarianza)
✓ Construir y visualizar la frontera eficiente
✓ Optimizar la cartera de mínima varianza con scipy
✓ Implementar risk parity igualando las contribuciones al riesgo
✓ Comparar estrategias fuera de muestra (el test que de verdad importa)
✓ Estimar la exposición de un activo a factores con regresión
“Has construido las mismas carteras que gestionan billones de euros. Pero el aprendizaje más valioso es el más incómodo: que tu optimizador sofisticado a menudo no bate a repartir el dinero en partes iguales. Esa humildad, comprobada empíricamente, es lo que separa al quant maduro del aprendiz enamorado de sus modelos.”
En el Módulo 7 pasaremos a la acción: trading algorítmico, estrategias de trend following y mean reversion, y el diseño de tu primera estrategia completa de principio a fin.
Fin de los Casos Prácticos del Módulo 6