Etapa 3 de 4 · Nivel hedge fund
Optimización de carteras de Markowitz y frontera eficiente en Python
La teoría moderna de carteras de Markowitz paso a paso: rentabilidad y riesgo de una cartera, cartera de mínima varianza, cartera tangente, frontera eficiente con scipy y por qué falla con datos reales.
En este artículo
En 1952 un estudiante de doctorado llamado Harry Markowitz publicó un artículo breve que acabaría dándole el Nobel. Su idea parece obvia hoy: el riesgo de una cartera no es la suma de los riesgos de sus activos, porque depende de cómo se mueven juntos. Y eso convierte la diversificación en un problema matemático que se puede optimizar.
En este artículo vas a construir la frontera eficiente en Python, calcular las dos carteras más famosas de la teoría (la de mínima varianza y la tangente) y, sobre todo, entender por qué los gestores profesionales casi nunca aplican Markowitz tal cual.
Este artículo da por hecho que sabes calcular rentabilidades, volatilidad y el ratio de Sharpe. Si no, repásalo en Python para finanzas.
Rentabilidad y riesgo de una cartera
Sea una cartera con pesos \( w = (w_1,\dots,w_n) \) que suman 1, rentabilidades esperadas \( \mu \) y matriz de covarianzas \( \Sigma \). Entonces:
\[ \mu_p = w^\top \mu, \qquad \sigma_p^2 = w^\top \Sigma\, w = \sum_i \sum_j w_i w_j \sigma_i \sigma_j \rho_{ij} \]
La rentabilidad es una media ponderada, pero la varianza no: depende de todas las correlaciones \( \rho_{ij} \). Con dos activos de la misma volatilidad y correlación \( \rho \), la volatilidad de la cartera 50/50 es \( \sigma\sqrt{(1+\rho)/2} \). Si \( \rho = 1 \) no ganas nada; si \( \rho = 0 \) la volatilidad cae un 29%; si \( \rho = -1 \) desaparece. Eso es la diversificación, cuantificada.
Los datos
Usaremos ocho activos variados: tecnológicas, banca, energía, consumo defensivo, oro y bonos.
import numpy as np
import pandas as pd
import yfinance as yf
nombres = ["AAPL", "MSFT", "JPM", "XOM", "JNJ", "PG", "GLD", "TLT"]
precios = yf.download(nombres, start="2015-01-01", end="2025-12-31",
auto_adjust=True)["Close"][nombres].dropna()rent = precios.pct_change().dropna()
mu = rent.mean() * 252 # rentabilidades esperadas anuales
cov = rent.cov() * 252 # matriz de covarianzas anual
rf = 0.02
print(pd.DataFrame({"mu %": mu * 100, "vol %": np.sqrt(np.diag(cov)) * 100}).round(1))
print(rent.corr().round(2))Mira la matriz de correlaciones: las acciones suelen estar bastante correlacionadas entre sí, mientras que el oro y los bonos se mueven de forma mucho más independiente. Ahí está la materia prima de la diversificación.
Una nube de carteras aleatorias
Antes de optimizar, una forma muy visual de entender el problema es generar miles de carteras con pesos aleatorios y dibujar su riesgo y rentabilidad:
import matplotlib.pyplot as plt
rng = np.random.default_rng(0)
W = rng.dirichlet(np.ones(len(mu)), size=20_000) # pesos positivos que suman 1
ret_sim = W @ mu.values
vol_sim = np.sqrt(np.einsum("ij,jk,ik->i", W, cov.values, W))
sharpe_sim = (ret_sim - rf) / vol_sim
plt.figure(figsize=(9, 6))
plt.scatter(vol_sim, ret_sim, c=sharpe_sim, s=3, cmap="viridis")
plt.colorbar(label="Sharpe")
plt.xlabel("Volatilidad anual"); plt.ylabel("Rentabilidad anual")
plt.title("20.000 carteras aleatorias")
plt.show()La nube tiene un borde superior izquierdo muy nítido: son las carteras que, para cada nivel de riesgo, dan la máxima rentabilidad. Ese borde es la frontera eficiente. Cualquier cartera por debajo es «ineficiente»: existe otra con el mismo riesgo y más rentabilidad.
La cartera de mínima varianza
Si se permiten posiciones cortas (pesos negativos), la cartera con la menor volatilidad posible tiene una solución cerrada:
\[ w_{\text{MV}} = \frac{\Sigma^{-1}\mathbf{1}}{\mathbf{1}^\top \Sigma^{-1}\mathbf{1}} \]
Lo interesante es que no depende de las rentabilidades esperadas, solo de las covarianzas. Esto, como veremos, es una gran ventaja.
inv = np.linalg.inv(cov.values)
unos = np.ones(len(mu))
w_mv = inv @ unos / (unos @ inv @ unos)
def resumen(w):
r = w @ mu.values
v = np.sqrt(w @ cov.values @ w)
return f"rent {r:.2%} | vol {v:.2%} | Sharpe {(r - rf) / v:.2f}"
print(pd.Series(w_mv, index=mu.index).round(3))
print("Mínima varianza:", resumen(w_mv))La cartera tangente (máximo Sharpe)
Si además existe un activo sin riesgo, la mejor combinación de activos con riesgo es la que maximiza el ratio de Sharpe: la cartera tangente. También tiene solución cerrada:
\[ w_{\text{T}} = \frac{\Sigma^{-1}(\mu - r_f\mathbf{1})}{\mathbf{1}^\top\Sigma^{-1}(\mu - r_f\mathbf{1})} \]
exceso = mu.values - rf
w_tan = inv @ exceso / (unos @ inv @ exceso)
print(pd.Series(w_tan, index=mu.index).round(3))
print("Tangente:", resumen(w_tan))Según la teoría, todo inversor racional debería tener esta misma cartera de activos con riesgo y ajustar su nivel de riesgo solo con cuánto pone en ella y cuánto en el activo sin riesgo. La recta que une el tipo sin riesgo con la cartera tangente es la línea del mercado de capitales.
Pero mira los pesos: probablemente verás posiciones muy grandes, algunas negativas y bastante extremas. Es la primera señal de que algo no va bien.
Optimización con restricciones en scipy
En la vida real casi nadie puede ponerse corto sin límite. Con restricciones como «pesos entre 0 y 30%» ya no hay fórmula cerrada y hay que optimizar numéricamente. scipy.optimize.minimize con el método SLSQP resuelve este tipo de problemas cuadráticos sin problema:
from scipy.optimize import minimize
n = len(mu)
limites = [(0.0, 0.30)] * n # sin cortos, máximo 30% por activo
suma_1 = {"type": "eq", "fun": lambda w: w.sum() - 1}
w0 = np.full(n, 1 / n)
def max_sharpe():
obj = lambda w: -(w @ mu.values - rf) / np.sqrt(w @ cov.values @ w)
return minimize(obj, w0, method="SLSQP", bounds=limites, constraints=[suma_1]).x
def min_vol_para(objetivo):
cons = [suma_1, {"type": "eq", "fun": lambda w: w @ mu.values - objetivo}]
res = minimize(lambda w: w @ cov.values @ w, w0, method="SLSQP",
bounds=limites, constraints=cons)
return res.x if res.success else None
w_ms = max_sharpe()
print(pd.Series(w_ms, index=mu.index).round(3))
print("Máx. Sharpe con límites:", resumen(w_ms))Y la frontera eficiente se obtiene resolviendo el problema de mínima volatilidad para una serie de rentabilidades objetivo:
# Rango alcanzable con los límites: de la cartera de mínima volatilidad
# a la de máxima rentabilidad (30% en los tres mejores activos y 10% en el cuarto)
w_minvol = minimize(lambda w: w @ cov.values @ w, w0, method="SLSQP",
bounds=limites, constraints=[suma_1]).x
orden = np.sort(mu.values)[::-1]
r_max = 0.3 * orden[:3].sum() + 0.1 * orden[3]
objetivos = np.linspace(w_minvol @ mu.values, r_max * 0.999, 30)
frontera = []
for obj in objetivos:
w = min_vol_para(obj)
if w is not None:
frontera.append((np.sqrt(w @ cov.values @ w), obj))
frontera = np.array(frontera)
plt.figure(figsize=(9, 6))
plt.scatter(vol_sim, ret_sim, c="lightgray", s=2)
plt.plot(frontera[:, 0], frontera[:, 1], "b-", lw=2, label="Frontera eficiente")
v_ms = np.sqrt(w_ms @ cov.values @ w_ms)
plt.scatter(v_ms, w_ms @ mu.values, c="red", s=80, zorder=3, label="Máximo Sharpe")
plt.plot([0, v_ms * 1.5], [rf, rf + (w_ms @ mu.values - rf) * 1.5], "r--", lw=1)
plt.xlabel("Volatilidad anual"); plt.ylabel("Rentabilidad anual"); plt.legend()
plt.show()
print(f"{len(frontera)} puntos en la frontera")El gran problema: Markowitz maximiza los errores
Todo lo anterior supone que conocemos \( \mu \) y \( \Sigma \). No es así: los estimamos con datos históricos, y con mucho error. Especialmente \( \mu \): el error típico de la media anual estimada con \( T \) años de datos es aproximadamente \( \sigma/\sqrt{T} \). Con una volatilidad del 25% y diez años de datos, el error es de ±8% anual. Es decir, no sabemos casi nada de la rentabilidad esperada.
Y el optimizador no lo sabe: busca los activos con mayor \( \mu \) estimado y menor covarianza estimada, que son justo los que tienen más probabilidad de tener errores a su favor. Por eso a Markowitz se le llama a veces un «maximizador de errores». Veámoslo: calculemos la cartera óptima con la primera mitad de los datos y con la segunda.
mitad = len(rent) // 2
for nombre, trozo in [("1.ª mitad", rent.iloc[:mitad]), ("2.ª mitad", rent.iloc[mitad:])]:
m, c = trozo.mean() * 252, trozo.cov() * 252
obj = lambda w: -(w @ m.values - rf) / np.sqrt(w @ c.values @ w)
w = minimize(obj, w0, method="SLSQP", bounds=limites, constraints=[suma_1]).x
print(nombre, (pd.Series(w, index=mu.index) * 100).round(0).to_dict())Los pesos suelen cambiar radicalmente entre periodos. Una cartera «óptima» que cambia por completo cada vez que añades datos no es óptima: es ruido.
Cómo lo resuelven los profesionales
- Restricciones de pesos. Límites por activo y por sector, como los que hemos puesto. Son una forma bruta pero eficaz de regularizar.
- Olvidarse de \( \mu \). Carteras que solo usan la covarianza, mucho más estable: mínima varianza o paridad de riesgo, donde cada activo aporta el mismo riesgo.
- Encoger las estimaciones (shrinkage). Por ejemplo, Ledoit-Wolf acerca la matriz de covarianzas muestral a una más simple, y reduce mucho el error cuando hay muchos activos.
- Black-Litterman. Parte de las rentabilidades implícitas en el mercado y las ajusta con las opiniones del gestor y su confianza en ellas.
- Validar fuera de muestra. Optimizar con datos pasados y medir el resultado en datos posteriores. Un hecho incómodo, documentado en la literatura académica: la cartera equiponderada \( 1/N \) es sorprendentemente difícil de batir fuera de muestra.
Aquí tienes un ejemplo de shrinkage sencillo, que mezcla la covarianza muestral con una matriz diagonal (activos incorrelados). La intensidad \( \delta \) se fija a mano para ilustrarlo; Ledoit-Wolf la calcula de forma óptima a partir de los datos.
delta = 0.3
cov_shrink = (1 - delta) * cov + delta * np.diag(np.diag(cov))
inv_s = np.linalg.inv(cov_shrink.values)
w_mv_s = inv_s @ unos / (unos @ inv_s @ unos)
print(pd.DataFrame({"MV muestral": w_mv, "MV shrinkage": w_mv_s}, index=mu.index).round(3))Los pesos con shrinkage son más moderados y estables. Es un pequeño cambio con un gran efecto en la práctica.
Resumen
- El riesgo de una cartera es \( w^\top\Sigma w \): las correlaciones lo son todo.
- La frontera eficiente reúne las carteras con máxima rentabilidad para cada riesgo; la tangente maximiza el Sharpe.
- Con restricciones se optimiza numéricamente con
scipy.optimize.minimize. - Con datos reales, Markowitz amplifica los errores de estimación: hay que regularizar, usar solo la covarianza o validar fuera de muestra.
Markowitz trata la volatilidad como un número fijo, pero en los mercados cambia, y mucho. El siguiente paso es modelarla: el modelo GARCH.