Saltar al contenido principal
Módulo 6 · Fundamentos

Casos prácticos

Construcción de Carteras Cuantitativas

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-lib para 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:

  1. Elige 4-6 activos diversos
  2. Calcula los inputs (retornos esperados y covarianza)
  3. Construye tres carteras: 1/N, mínima varianza y risk parity
  4. Divide los datos en train/test y evalúa las tres fuera de muestra
  5. Calcula el Sharpe de cada una out-of-sample
  6. 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