Markowitz en Python: frontera eficiente y optimización de carteras | Quantum Club

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
  1. Rentabilidad y riesgo de una cartera
  2. Los datos
  3. Una nube de carteras aleatorias
  4. La cartera de mínima varianza
  5. La cartera tangente (máximo Sharpe)
  6. Optimización con restricciones en scipy
  7. El gran problema: Markowitz maximiza los errores
  8. Cómo lo resuelven los profesionales
  9. Resumen

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} \]

10 20 30 40 50 Número de activos en la cartera (a partes iguales) 0 % 5 % 10 % 15 % 20 % 25 % Volatilidad de la cartera ρ = 0,6 ρ = 0,3 ρ = 0,0 Correlación ρ = 0,6 ρ = 0,3 ρ = 0,0
Volatilidad de una cartera equiponderada según el número de activos, con volatilidad individual del 25 %. Con correlación 0 el riesgo tiende a cero; con correlación positiva nunca baja de un suelo: el riesgo de mercado no se diversifica.

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.

Python
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()
Python
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:

Python
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.

Python
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})} \]

Python
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:

Python
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:

Python
# 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")
0 % 5 % 10 % 15 % 20 % 25 % 30 % Volatilidad anual 2 % 4 % 6 % 8 % 10 % 12 % 14 % Rentabilidad esperada Cartera tangente (máximo Sharpe) Mínima varianza Carteras aleatorias (sin cortos) Frontera eficiente Línea del mercado de capitales
Frontera eficiente con cinco activos de ejemplo: la nube gris son carteras con pesos aleatorios, la línea azul las mejores para cada nivel de riesgo si se permiten posiciones cortas (por eso queda por encima de la nube). La tangente es donde la recta desde el tipo sin riesgo toca 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.

Python
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.

Python
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.

Quantum Pablo

Sobre Quantum Pablo

Físico de partículas y quant. Enseño finanzas cuantitativas en español desde la intuición y con código: más de 400 alumnos en más de 25 países se han formado en Quantum Club. También en YouTube e Instagram.

Etapa 3 de 4 · Nivel hedge fund

  1. 10Optimización de carteras de Markowitz y frontera eficiente en Python
  2. 11El modelo GARCH: cómo predecir la volatilidad (con Python)
  3. 12Pairs trading y cointegración en Python: de la idea al backtest
  4. 13El filtro de Kalman en trading: un hedge ratio que se adapta (con Python)
  5. 14Por qué tu backtest miente: sobreajuste y cómo evitarlo
  6. 15La cópula gaussiana y la crisis de 2008: la fórmula que hundió Wall Street
Ver el recorrido completo →