Lección 4.1.1 · 70 min
Markowitz: frontera eficiente y restricciones reales
La optimización de media-varianza es exacta en lo matemático y frágil en lo estadístico: da la mejor cartera para unos rendimientos esperados y una covarianza que nunca se conocen. Esta lección la resuelve con la fórmula cerrada y con cvxpy, le añade las restricciones de un mandato real y mide, con un universo sintético cuya verdad está publicada, cuánto se aleja la frontera estimada de la que se obtiene de verdad.
- Módulo
- 4.1 · Teoría y optimización de carteras
- Duración
- 70 min
Lecciones del nivel 4
4.1 · Teoría y optimización de carteras
- 4.1.1 Markowitz: frontera eficiente y restricciones reales
- 4.1.2 Estimación de covarianzas: shrinkage, modelos factoriales y denoising
- 4.1.3 Black-Litterman: del equilibrio del mercado a las visiones
- 4.1.4 Risk parity, mínima varianza, HRP y HERC
- 4.1.5 Optimización con CVaR, drawdown, rotación y costes
- 4.1.6 Modelos factoriales: CAPM, Fama-French, PCA y atribución
- 4.1.7 Rebalanceo y carteras de estrategias
- 4.1.8 Herramientas de carteras: Riskfolio-Lib, PyPortfolioOpt, skfolio y bt
4.2 · Gestión del riesgo y tamaño de posición
- 4.2.1 Riesgo por operación, stops y riesgo de ruina
- 4.2.2 Criterio de Kelly y volatility targeting: cuánto arriesgar
- 4.2.3 VaR, Expected Shortfall y backtesting del VaR
- 4.2.4 Stress testing y riesgos de correlación, liquidez, gap y contraparte
- 4.2.5 Control del drawdown y kill switch
- 4.2.6 Riesgo de derivados: griegas de cartera, margen y liquidaciones
4.3 · Microestructura y ejecución
4.4 · Brokers y APIs
- 4.4.1 Elegir bróker desde España o la UE: regulación, activos, costes y API
- 4.4.2 Interactive Brokers: TWS e IB Gateway, ib_async, contratos, datos y órdenes
- 4.4.3 Alpaca, Tradier, MetaTrader 5 con Python y OANDA v20
- 4.4.4 APIs de exchanges cripto: ccxt, Binance, Bybit, Kraken y Coinbase
- 4.4.5 Credenciales seguras: variables de entorno, gestores de secretos y permisos mínimos
4.5 · Sistema de trading en vivo
- 4.5.1 Arquitectura de un sistema en vivo: de los datos a la reconciliación
- 4.5.2 Gestión de órdenes: estados, fills parciales, cancelaciones y reconexiones
- 4.5.3 Paper trading, shadow trading y seguimiento del slippage
- 4.5.4 Programación de tareas y alertas: cron, APScheduler, Prefect, Telegram, Slack y email
- 4.5.5 Tolerancia a fallos: idempotencia, estado persistente y reconciliación al arrancar
- 4.5.6 NautilusTrader o LEAN en real: del backtest a producción con el mismo código
Una cartera es un problema cuadrático
Harry Markowitz (1952) convirtió la elección de una cartera en un problema de optimización: entre todas las carteras con el mismo rendimiento esperado, la mejor es la que tiene menos varianza. Con activos, un vector de pesos , los rendimientos esperados en exceso del tipo libre y la matriz de covarianzas :
La cartera de media-varianza para un objetivo resuelve
Es un programa cuadrático: el objetivo es una forma cuadrática convexa (porque es semidefinida positiva) y las restricciones son lineales. Al barrer se obtiene la frontera eficiente, la curva de las carteras que no pueden mejorar su rendimiento sin aumentar su riesgo. Cualquier punto por debajo de ella es una cartera ineficiente.
El problema tiene dos entradas, y , y ninguna se conoce. Se estiman con datos pasados, y el optimizador las trata como si fueran exactas. Esa es la idea que recorre toda la lección:
Fuente Mermaid
flowchart TB H[Historia] --> E["Estimar μ y Σ"] E --> O[Optimizador] O --> W[Pesos] W --> R[Resultado real] V["μ y Σ verdaderas"] --> R
Lo que el optimizador ve son las estimaciones; lo que la cartera obtiene depende de los valores verdaderos. En los mercados reales los verdaderos no se conocen nunca. En los datos del nivel 4 sí: el universo sale de un modelo factorial publicado, así que se puede medir la distancia entre lo que se promete y lo que se obtiene.
El universo y su verdad
Los datos son 38 índices sintéticos de rentabilidad total en USD (acciones por regiones, sectores y estilos, bonos, crédito, inmobiliario, materias primas y monetario) de 2006 a 2025, con las sesiones reales de la NYSE. No son datos de mercado: imitan el perfil de índices reales (volatilidad, crisis, correlaciones), con las crisis de 2008, 2020 y 2022 escritas en un guion. Para que las figuras se lean, la lección trabaja con diez de ellos, uno o dos por clase de activo. El rendimiento en exceso de cada día es el rendimiento simple menos el tipo libre del día:
import numpy as np
import pandas as pd
precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha")
fac = pd.read_parquet("universo4_factores_diarios.parquet")
rf = fac.set_index("fecha")["rf"]
r = precios.pct_change()
r.iloc[0] = precios.iloc[0] / 100 - 1 # base 100 la víspera
exceso = r.sub(rf, axis=0)
activos = ["RV_EEUU", "RV_EUR", "RV_JAP", "RV_EMG", "INM_EEUU",
"RF_T710", "RF_T20", "RF_CIG", "RF_CHY", "MP_ORO"]
R = exceso[activos]
print(R.shape, R.index[0].date(), R.index[-1].date())(5031, 10) 2006-01-03 2025-12-31Los rendimientos son simples, no logarítmicos. El rendimiento de una cartera es la suma ponderada de los rendimientos simples de sus activos; con logaritmos esa suma no es exacta y el Sharpe de la cartera sale más bajo de lo que es.
La «verdad» está en dos ficheros: universo4_verdad.csv trae el rendimiento esperado en exceso de cada activo en el
periodo (mu_verdadero) y universo4_cov_verdad.csv, la covarianza anual verdadera por régimen y la total (total),
que es la que estima la muestra completa:
verdad = pd.read_csv("universo4_verdad.csv", index_col="activo")
cov_v = pd.read_csv("universo4_cov_verdad.csv")
total = cov_v[cov_v["regimen"] == "total"].set_index("activo")
mu_v = verdad.loc[activos, "mu_verdadero"].to_numpy()
S_v = total.loc[activos, activos].to_numpy()
mu_m = R.mean().to_numpy() * 252
S_m = R.cov().to_numpy() * 252
tabla = pd.DataFrame({
"mu_verdad": mu_v, "mu_muestra": mu_m,
"vol_verdad": np.sqrt(np.diag(S_v)),
"vol_muestra": np.sqrt(np.diag(S_m))}, index=activos)
print((100 * tabla).round(2))
ee = np.sqrt(np.diag(S_m) / 20)
print("error típico de la media (20 años):", (100 * ee).round(1)) mu_verdad mu_muestra vol_verdad vol_muestra
RV_EEUU 9.62 8.79 20.37 20.59
RV_EUR 5.82 3.20 22.63 22.81
RV_JAP 5.45 3.84 21.86 22.00
RV_EMG 3.80 5.46 26.54 27.16
INM_EEUU 9.17 8.98 23.35 23.46
RF_T710 1.60 0.49 5.92 5.90
RF_T20 3.68 1.54 13.69 13.58
RF_CIG 2.76 2.13 5.56 5.59
RF_CHY 4.96 4.68 10.92 10.48
MP_ORO 9.98 10.03 14.59 14.17
error típico de la media (20 años): [4.6 5.1 4.9 6.1 5.2 1.3 3. 1.3 2.3 3.2]Las volatilidades se estiman bien: con 5.031 sesiones, la muestral está a menos de 0,7 puntos de la verdadera en
todos los activos. Las medias, no. RV_EUR rinde 3,20 % en la muestra frente a un 5,82 % esperado; el bono largo,
1,54 % frente a 3,68 %. No es un fallo de los datos: el error típico de una media anual es la volatilidad dividida
por la raíz del número de años, y con 20 años y una volatilidad del 20 % sale de casi 5 puntos. Es la ley que gobierna
el resto de la lección: la varianza se estima con días; la media, con años.
La fórmula cerrada
Sin más restricciones que el objetivo y la suma de pesos, el problema se resuelve con multiplicadores de Lagrange (Merton, 1972). Con las constantes
la cartera de la frontera con rendimiento y su varianza son
La varianza es una parábola en , así que la frontera en el plano (volatilidad, rendimiento) es una hipérbola. Su vértice es la cartera de mínima varianza, , con volatilidad y rendimiento . Y como es lineal en , toda cartera eficiente es una combinación de dos carteras eficientes cualesquiera: es el teorema de los dos fondos.
iS = np.linalg.inv(S_v)
uno = np.ones(len(activos))
A = uno @ iS @ uno
B = uno @ iS @ mu_v
C = mu_v @ iS @ mu_v
D = A * C - B**2
w_mv = iS @ uno / A
def w_frontera(m):
return ((C - B * m) * iS @ uno + (A * m - B) * iS @ mu_v) / D
w5 = w_frontera(0.05)
cerrada = pd.DataFrame({"min_var": w_mv, "frontera_5": w5},
index=activos)
print((100 * cerrada).round(1))
print(f"mín. varianza: vol {np.sqrt(1 / A):.2%}, mu {B / A:.2%}")
print(f"frontera 5 %: vol {np.sqrt(w5 @ S_v @ w5):.2%}, "
f"fórmula {np.sqrt((A * .05**2 - 2 * B * .05 + C) / D):.2%}") min_var frontera_5
RV_EEUU 2.7 41.9
RV_EUR 0.2 -7.3
RV_JAP 0.4 -2.0
RV_EMG -0.6 -18.2
INM_EEUU -1.8 -3.9
RF_T710 152.9 67.6
RF_T20 -67.1 -35.0
RF_CIG 14.0 46.2
RF_CHY -1.7 -8.2
MP_ORO 1.0 19.0
mín. varianza: vol 1.81%, mu 0.48%
frontera 5 %: vol 4.33%, fórmula 4.33%La cartera de mínima varianza tiene una volatilidad del 1,81 %, menos que el bono de 7-10 años solo (5,9 %). Lo consigue con un 153 % en ese bono y un −67 % en el de más de 20 años: los dos cargan sobre el mismo factor de tipos y, en la proporción adecuada, se cancelan. Es correcto sobre el papel y es una cartera apalancada que depende de que esa correlación sea exactamente la estimada. La cartera eficiente del 5 % también vende en corto: emergentes (−18 %), el bono largo (−35 %) y Europa.
Lo mismo con cvxpy
La fórmula cerrada deja de existir en cuanto aparece una desigualdad (solo largos, una cota). Para eso está la optimización convexa. cvxpy permite escribir el problema casi como en la pizarra y elige un solver adecuado; aquí se fija CLARABEL, un solver de punto interior para problemas cónicos que viene con cvxpy. Antes de añadir nada, conviene comprobar que reproduce la fórmula:
import cvxpy as cp
w = cp.Variable(len(activos))
objetivo = cp.Parameter()
riesgo = cp.quad_form(w, cp.psd_wrap(S_v))
prob = cp.Problem(cp.Minimize(riesgo),
[cp.sum(w) == 1, mu_v @ w == objetivo])
objetivo.value = 0.05
prob.solve(solver="CLARABEL")
print(prob.status)
print("máx. diferencia con la fórmula < 1e-6:",
np.abs(w.value - w5).max() < 1e-6)optimal
máx. diferencia con la fórmula < 1e-6: Truecp.psd_wrap le dice a cvxpy que la matriz es semidefinida positiva, en lugar de comprobarlo con una
descomposición que, con matrices casi singulares, puede fallar por redondeo. cp.Parameter permite cambiar el
objetivo y volver a resolver sin reconstruir el problema, que es lo que hace falta para barrer la frontera.
Restricciones reales
Un mandato real nunca deja vender en corto un 35 % del bono largo. Las restricciones habituales se escriben en una línea cada una:
- Solo largos: . La de casi todos los fondos de inversión y planes de pensiones.
- Cotas por activo: , para que ninguna posición domine.
- Grupos: la renta variable (acciones e inmobiliario) entre el 20 % y el 60 %, por ejemplo en un perfil mixto.
- Apalancamiento bruto: , una cartera 130/30 (130 % largo, 30 % corto). En cvxpy,
cp.norm1(w) <= 1.6.
rv = [0, 1, 2, 3, 4] # acciones e inmobiliario
reglas = {
"sin límites": [],
"130/30": [cp.norm1(w) <= 1.6],
"solo largos": [w >= 0],
"mandato": [w >= 0, w <= 0.30, cp.sum(w[rv]) >= 0.2,
cp.sum(w[rv]) <= 0.6],
}
problemas = {
k: cp.Problem(cp.Minimize(riesgo),
[cp.sum(w) == 1, mu_v @ w >= objetivo] + extra)
for k, extra in reglas.items()}
filas = {}
for k, pr in problemas.items():
pr.solve(solver="CLARABEL")
filas[k] = list(w.value) + [np.sqrt(pr.value)]
pesos = pd.DataFrame(filas, index=activos + ["vol"])
print((100 * pesos).round(1) + 0) # + 0 quita los -0.0 sin límites 130/30 solo largos mandato
RV_EEUU 41.9 26.3 11.4 20.0
RV_EUR -7.3 0.0 0.0 0.0
RV_JAP -2.0 0.0 0.0 0.0
RV_EMG -18.2 -15.3 0.0 0.0
INM_EEUU -3.9 -1.1 0.0 0.0
RF_T710 67.6 63.0 25.9 30.0
RF_T20 -35.0 -13.5 0.0 3.0
RF_CIG 46.2 13.8 35.2 30.0
RF_CHY -8.2 8.3 4.6 0.8
MP_ORO 19.0 18.5 23.0 16.2
vol 4.3 4.7 5.8 6.1El rendimiento objetivo pasa a ser una desigualdad (): por debajo del rendimiento de la cartera de mínima varianza de cada conjunto de reglas, el óptimo es esa cartera, y la frontera se corta en su vértice. La cartera 130/30 conserva parte de las posiciones cortas (emergentes, bono largo) y su volatilidad queda en el 4,7 %. Con solo largos desaparecen todas y la cartera se concentra en cinco activos, cuatro con más del 10 %: bono de 7-10 años, crédito con grado de inversión, oro y acciones de EE. UU. La volatilidad sube al 5,8 %. El mandato obliga a llevar un 20 % de renta variable y recorta el bono y el crédito al 30 %: 6,1 %. La frontera completa enseña dónde muerde cada regla:
objetivos = np.round(np.arange(0.03, 0.0951, 0.01), 3)
fr = {}
for k, pr in problemas.items():
vols = []
for m in objetivos:
objetivo.value = m
pr.solve(solver="CLARABEL")
ok = pr.status == "optimal"
vols.append(np.sqrt(pr.value) if ok else np.nan)
fr[k] = vols
fr = pd.DataFrame(fr, index=objetivos)
fr.index.name = "objetivo"
print((100 * fr).round(2)) sin límites 130/30 solo largos mandato
objetivo
0.03 2.84 3.48 4.83 6.06
0.04 3.56 4.05 5.11 6.06
0.05 4.33 4.72 5.83 6.11
0.06 5.14 5.46 6.82 6.91
0.07 5.96 6.23 8.02 8.22
0.08 6.80 7.08 9.27 10.31
0.09 7.64 8.12 10.75 13.73Cada restricción desplaza la frontera a la derecha: para el mismo rendimiento, más volatilidad. Y la recorta: con solo largos, ningún objetivo puede superar el rendimiento del mejor activo (el oro, 9,98 %); con el mandato, que limita cada posición al 30 %, el máximo baja de ese 9,98 % y un objetivo del 9 % exige una volatilidad del 13,7 %. Con los parámetros verdaderos las restricciones solo pueden empeorar el resultado; la pregunta interesante es qué pasa cuando los parámetros son estimados.
Las restricciones caben en tres familias, según el solver que piden:
Fuente Mermaid
flowchart LR P[Restricciones] --> L[Lineales] P --> C[Convexas] P --> M[Enteras] L --> L1["w ≥ 0, cotas"] L --> L2[Grupos] C --> C1[Apalancamiento] C --> C2[Tracking error] M --> M1[Cardinalidad] M --> M2[Lote mínimo]
Las lineales y las convexas (el apalancamiento es una norma 1; el error de seguimiento frente a un índice, , una restricción cuadrática) las resuelve cualquier solver cónico. Las enteras, como «como mucho cinco activos» o «lotes de 100 acciones», convierten el problema en entero mixto:
import warnings
warnings.filterwarnings("ignore", category=UserWarning,
message="Your problem is mixed-integer")
z = cp.Variable(len(activos), boolean=True)
cardinal = cp.Problem(cp.Minimize(riesgo), [
cp.sum(w) == 1, mu_v @ w >= 0.05, w >= 0, w <= z,
cp.sum(z) <= 3])
try:
cardinal.solve()
except cp.error.SolverError as e:
print(str(e).split(".")[0])You need a mixed-integer solver for this modelEl filtro de avisos silencia, por su mensaje, el UserWarning con el que cvxpy sugiere instalar SCIP; sin él, el
aviso saldría con la ruta del fichero en la salida. Con los solvers de este entorno (CLARABEL, OSQP, SCS, HiGHS y
SciPy) no hay ninguno que resuelva un problema
cuadrático entero mixto: HiGHS resuelve programas lineales enteros y cuadráticos continuos, pero no las dos cosas a
la vez. Las opciones son instalar uno que sí (SCIP es libre; Gurobi, MOSEK y CPLEX, comerciales) o una heurística
razonable: resolver el problema continuo, quedarse con los activos de mayor peso y volver a optimizar solo con ellos.
Los costes de transacción y el turnover, que también cambian la forma del problema, son el tema de la lección 4.1.5.
Máximo Sharpe
Si se puede invertir en el activo libre de riesgo, la mejor cartera arriesgada es la que maximiza el Sharpe, la cartera tangente. Sin restricciones su dirección es ; el tamaño lo fija el riesgo que se quiera asumir. Con restricciones, el cociente no es cóncavo, pero se convierte en un programa cuadrático con un cambio de variable: se minimiza con y las restricciones homogéneas sobre (por ejemplo ), y luego .
def sharpe(w, mu, S):
return w @ mu / np.sqrt(w @ S @ w)
def max_sharpe(mu, S):
y = cp.Variable(len(mu))
pr = cp.Problem(cp.Minimize(cp.quad_form(y, cp.psd_wrap(S))),
[mu @ y == 1, y >= 0])
pr.solve(solver="CLARABEL")
return y.value / y.value.sum()
w_tan = iS @ mu_v
w_tan = w_tan * 0.10 / np.sqrt(w_tan @ S_v @ w_tan) # vol. 10 %
w_lo = max_sharpe(mu_v, S_v)
tang = pd.DataFrame({"sin_limites_vol10": w_tan, "solo_largos": w_lo},
index=activos)
print((100 * tang).round(1))
print("Sharpe sin límites:", sharpe(w_tan, mu_v, S_v).round(3))
print("Sharpe solo largos:", sharpe(w_lo, mu_v, S_v).round(3))
print("suma de pesos sin límites:", w_tan.sum().round(2)) sin_limites_vol10 solo_largos
RV_EEUU 100.3 14.6
RV_EUR -18.4 0.0
RV_JAP -5.4 0.0
RV_EMG -44.3 0.0
INM_EEUU -7.5 0.0
RF_T710 -20.4 0.0
RF_T20 -4.2 0.0
RF_CIG 97.0 52.9
RF_CHY -18.3 0.0
MP_ORO 45.9 32.5
Sharpe sin límites: 1.178
Sharpe solo largos: 0.879
suma de pesos sin límites: 1.25Con los parámetros verdaderos, la cartera sin límites alcanza un Sharpe de 1,18, y la de solo largos, 0,88. La diferencia sale de posiciones largas-cortas: comprar crédito con grado de inversión (97 %) y vender bonos del Tesoro, emergentes y Europa. Explota diferencias de rendimiento esperado de uno o dos puntos entre activos muy correlados. Con la verdad delante, esas diferencias existen. Con 20 años de datos, el error típico de cada media es de 1,3 a 6 puntos: la cartera sin límites apuesta justo por lo que peor se mide.
La suma de pesos sin límites para una volatilidad del 10 % no es 1: con un activo libre de riesgo, la diferencia hasta 1 va a liquidez o se pide prestada al tipo libre (aquí la cartera suma 1,25: un 25 % financiado). Normalizar la tangente para que sume 1, , es peligroso con medias estimadas: si el denominador se acerca a cero, los pesos explotan, y si cambia de signo la cartera se invierte.
El error de estimación
Ahora el experimento que importa. Un gestor que optimiza en una fecha usa los datos que tiene, por ejemplo los últimos cinco años. Para cada ventana de cinco años, se calcula la cartera de máximo Sharpe de solo largos con la media y la covarianza de la ventana, y se mide su Sharpe dos veces: el que promete (con las estimaciones de la ventana) y el que tiene de verdad (con y verdaderos):
filas = []
for a0, a1 in [("2006", "2010"), ("2011", "2015"),
("2016", "2020"), ("2021", "2025")]:
Rv = R.loc[a0:a1]
mu_e = Rv.mean().to_numpy() * 252
S_e = Rv.cov().to_numpy() * 252
w_e = max_sharpe(mu_e, S_e)
filas.append({"ventana": f"{a0}-{a1}",
"sharpe_estimado": sharpe(w_e, mu_e, S_e),
"sharpe_real": sharpe(w_e, mu_v, S_v),
"activos": int((w_e > 0.01).sum()),
"mayor_peso": activos[int(w_e.argmax())]})
ventanas = pd.DataFrame(filas)
print(ventanas.round(2))
print("máximo verdadero:", sharpe(w_lo, mu_v, S_v).round(2)) ventana sharpe_estimado sharpe_real activos mayor_peso
0 2006-2010 1.75 0.76 4 RF_CIG
1 2011-2015 0.49 0.53 3 RF_T20
2 2016-2020 1.16 0.71 5 MP_ORO
3 2021-2025 1.11 0.80 3 MP_ORO
máximo verdadero: 0.88La ventana de 2006-2010 promete un Sharpe de 1,75 y entrega 0,76. Ninguna ventana alcanza el máximo verdadero de 0,88: es imposible por construcción, porque ese máximo es el de los pesos óptimos con la verdad, y cualquier otro peso queda por debajo. Lo que sí es evitable es creerse el número prometido. Tres de las cuatro ventanas prometen más de lo que dan, y cada una elige una cartera distinta, de tres a cinco activos, con el mayor peso en crédito, en el bono largo o en oro según la ventana. La única que promete menos (2011-2015) lo hace porque en esa ventana todo rindió poco.
La figura de la cabecera lo enseña sobre la frontera entera, con la ventana de 2021-2025: la frontera estimada (lo que el gestor ve en su pantalla) queda por encima de la verdadera en casi todo su recorrido (solo se cruzan por debajo del 6 % de volatilidad), y la realizada (los mismos pesos evaluados con la verdad) queda por debajo. La frontera estimada es optimista por dos razones que se suman. La primera, el error de la media: en esa ventana el oro rindió un 13,7 % y el bono largo un −11,6 % anual en exceso, lejos de sus 10,0 % y 3,7 % esperados. La segunda, la selección: el optimizador elige exactamente los activos cuyo error fue favorable. Michaud (1989) llamó a la optimización de media-varianza un «maximizador de errores» por eso.
Rv = R.loc["2021":"2025"]
mu_e = Rv.mean().to_numpy() * 252
S_e = Rv.cov().to_numpy() * 252
est = cp.Problem(cp.Minimize(cp.quad_form(w, cp.psd_wrap(S_e))),
[cp.sum(w) == 1, mu_e @ w >= objetivo, w >= 0])
ver = problemas["solo largos"]
filas = []
print("oro y bono largo en la ventana:",
(100 * mu_e[[9, 6]]).round(1))
for m in [0.04, 0.07, 0.09]:
objetivo.value = m
est.solve(solver="CLARABEL")
w_e = w.value.copy()
ver.solve(solver="CLARABEL")
filas.append({"objetivo": m,
"vol_estimada": np.sqrt(w_e @ S_e @ w_e),
"mu_real": w_e @ mu_v,
"vol_real": np.sqrt(w_e @ S_v @ w_e),
"vol_verdadera_al_objetivo": np.sqrt(ver.value)})
print((100 * pd.DataFrame(filas)).round(2))oro y bono largo en la ventana: [ 13.7 -11.6]
objetivo vol_estimada mu_real vol_real vol_verdadera_al_objetivo
0 4.0 5.89 4.97 6.60 5.11
1 7.0 7.12 6.11 8.33 8.02
2 9.0 8.20 7.21 9.29 10.75Para un 7 % de objetivo, la ventana dice que basta con una volatilidad del 7,1 %. Esos pesos rinden de verdad un 6,1 % con un 8,3 % de volatilidad; con la verdad, un 7 % se consigue con un 8,0 %. El gestor pierde por los dos lados: menos rendimiento y más riesgo del que esperaba.
Monte Carlo desde la verdad
Las ventanas reales mezclan dos cosas: el error de estimación y los cambios de régimen del guion (la crisis de 2008 cae entera en la primera ventana). Para aislar el error de estimación, se simulan muestras de rendimientos normales independientes con exactamente y verdaderas, se optimiza con cada muestra y se mide el Sharpe real de cada cartera. Doscientas muestras de cinco y de veinte años:
rng = np.random.default_rng(41)
L = np.linalg.cholesky(S_v / 252)
for anos in (5, 20):
res = []
for _ in range(200):
X = mu_v / 252 + rng.standard_normal((252 * anos, 10)) @ L.T
m_e = X.mean(axis=0) * 252
if (m_e <= 0).all():
continue
S_e = np.cov(X, rowvar=False) * 252
w_e = max_sharpe(m_e, S_e)
res.append((sharpe(w_e, m_e, S_e), sharpe(w_e, mu_v, S_v)))
res = np.array(res)
p5, p95 = np.percentile(res[:, 1], [5, 95])
print(f"{anos} años: prometido {np.median(res[:, 0]):.2f}, "
f"real {np.median(res[:, 1]):.2f} ({p5:.2f} a {p95:.2f}), "
f"promete de más {np.mean(res[:, 0] > res[:, 1]):.0%}")5 años: prometido 1.17, real 0.73 (0.49 a 0.85), promete de más 85%
20 años: prometido 0.98, real 0.82 (0.73 a 0.87), promete de más 78%Con cinco años, la mediana del Sharpe prometido es 1,17 y la del real, 0,73; en el 85 % de las muestras se promete más de lo que se obtiene, y una de cada veinte carteras se queda por debajo de 0,49. Con veinte años, la brecha se reduce (0,98 frente a 0,82) pero no desaparece. Eso con datos ideales: normales, independientes y sin cambios de régimen. En mercados reales, con colas gruesas y correlaciones que cambian en las crisis, el error es mayor.
Qué hacer con el error
La media-varianza no se abandona; se protege. Las defensas habituales, y dónde se ven en el curso:
| Defensa | Qué ataca | Lección |
|---|---|---|
| Restricciones (solo largos, cotas, grupos) | Apuestas largas-cortas sobre diferencias pequeñas | Esta |
| Mejores estimadores de Σ (shrinkage, factores, denoising) | Error de la covarianza | 4.1.2 |
| Equilibrio y visiones (Black-Litterman) | Error de la media | 4.1.3 |
| Carteras que no usan μ (mínima varianza, risk parity, HRP) | Ignoran la parte más frágil | 4.1.4 |
| Penalizar el turnover y los costes | Rebalanceos que persiguen ruido | 4.1.5 |
Jagannathan y Ma (2003) mostraron que imponer solo largos en una cartera de mínima varianza equivale a encoger las covarianzas de los activos que el optimizador querría vender: una restricción «equivocada» desde la teoría que actúa como un estimador mejor. Por eso un mandato con cotas razonables no es solo una exigencia comercial; también es una forma de regularización.
Errores típicos al optimizar:
- Usar rendimientos log para medias y covarianzas de cartera, o anualizar la media log como si fuera simple.
- Mezclar rendimientos totales y en exceso: el Sharpe y la tangente se calculan en exceso del tipo libre.
- Normalizar la tangente con cuando ese número está cerca de cero.
- Creerse la frontera estimada: es la frontera de las estimaciones, no la del mercado.
- No fijar el solver ni comprobar
prob.status: un problema inviable devuelveNoneen los pesos yinfeasibleen el estado, no una excepción.
Ejercicios
- Repite la cartera de mínima varianza sin límites con los 38 activos del universo y la covarianza verdadera total.
¿Qué peso recibe el monetario (
MONET) y por qué?
Solución
todos = list(total.index)
S38 = total.loc[todos, todos].to_numpy()
iS38 = np.linalg.inv(S38)
u38 = np.ones(len(todos))
w38 = pd.Series(iS38 @ u38 / (u38 @ iS38 @ u38), index=todos)
print(f"vol: {np.sqrt(1 / (u38 @ iS38 @ u38)):.3%}")
print((100 * w38).round(1).sort_values().iloc[[0, 1, -2, -1]])vol: 0.152%
RF_T710 -2.2
RF_T20 -1.2
RF_T13 8.8
MONET 92.5
dtype: float64El monetario tiene una volatilidad del 0,2 % y casi ninguna correlación con lo demás: la cartera de mínima varianza lo toma casi entero, y la volatilidad cae por debajo de la suya. Es la respuesta correcta a la pregunta equivocada. Si existe un activo casi sin riesgo, la mínima varianza sobre activos arriesgados se reduce a él; por eso las carteras se optimizan en exceso del tipo libre y sin el monetario dentro del universo arriesgado.
- Repite el experimento de las ventanas con una cota del 30 % por activo además de solo largos. ¿Se reduce la brecha entre el Sharpe prometido y el real?
Solución
def max_sharpe_cota(mu, S, cota=0.30):
y = cp.Variable(len(mu))
k = cp.Variable(nonneg=True) # y = k·w, así que y <= cota·k
pr = cp.Problem(cp.Minimize(cp.quad_form(y, cp.psd_wrap(S))),
[mu @ y == 1, y >= 0, y <= cota * k,
cp.sum(y) == k])
pr.solve(solver="CLARABEL")
return y.value / y.value.sum()
filas = []
for a0, a1 in [("2006", "2010"), ("2011", "2015"),
("2016", "2020"), ("2021", "2025")]:
Rv = R.loc[a0:a1]
mu_e = Rv.mean().to_numpy() * 252
S_e = Rv.cov().to_numpy() * 252
w_c = max_sharpe_cota(mu_e, S_e)
filas.append({"ventana": f"{a0}-{a1}",
"estimado": sharpe(w_c, mu_e, S_e),
"real": sharpe(w_c, mu_v, S_v)})
print(pd.DataFrame(filas).round(2)) ventana estimado real
0 2006-2010 1.72 0.75
1 2011-2015 0.48 0.54
2 2016-2020 1.16 0.70
3 2021-2025 1.02 0.67La cota es una restricción no homogénea (), así que en el cambio de variable hay que multiplicarla por el factor de escala . La cota apenas cambia las tres primeras ventanas (las carteras ya estaban repartidas) y en 2021-2025 baja el Sharpe real de 0,80 a 0,67: limita el oro, que con la verdad es justo el activo que más conviene. Una restricción no recupera la verdad: reduce lo que se apuesta por el error de cada ventana, y eso a veces cuesta. Su valor está en el peor caso, no en la media de cuatro ventanas.
- Con 20 años de datos, el error típico de la media de
RV_EEUUes de unos 4,6 puntos. ¿Cuántos años harían falta para bajarlo a 1 punto? ¿Y por qué no sirve usar datos diarios en lugar de anuales para acortar ese plazo?
Solución
Con años de datos, el error típico de la media anual es : con , bajar a 1 punto exige años. Usar datos diarios no ayuda: la media anual estimada con días es , con error típico , que solo depende del número de años que cubre la muestra, no de cuántas observaciones tiene dentro. La frecuencia sí mejora la estimación de la varianza, que depende del número de observaciones. Por eso las carteras que no necesitan (lección 4.1.4) y los métodos que la toman del equilibrio (lección 4.1.3) existen.
Respuesta plegada en cada pregunta
Autoevaluación
La cartera de máximo Sharpe estimada con los datos de 2006-2010 promete un Sharpe de 1,75. ¿Qué Sharpe tiene con los parámetros verdaderos?
- A1,75: la optimización es exacta
- B0,76, por debajo incluso del máximo verdadero (0,88): el optimizador eligió los activos que mejor salieron en esa ventana, en parte por azar
- CMás de 1,75, porque la muestra es pesimista
- DNo se puede saber
Ver respuesta
Respuesta B. 0,76, por debajo incluso del máximo verdadero (0,88): el optimizador eligió los activos que mejor salieron en esa ventana, en parte por azar
El optimizador maximiza sobre estimaciones con error y escoge justo los activos cuyo error fue favorable. El Sharpe estimado en la muestra de la propia optimización está sesgado hacia arriba; el real de esos pesos queda por debajo del máximo verdadero. En Monte Carlo con cinco años, el estimado supera al real en el 85 % de los casos.
¿Por qué la cartera de mínima varianza sin límites de los diez activos tiene un 153 % en el bono de 7-10 años y un −67 % en el de más de 20?
- APor un error del optimizador
- BPorque el bono largo tiene rentabilidad esperada negativa
- CPorque la fórmula cerrada obliga a usar todos los activos
- DPorque los dos bonos cargan sobre el mismo factor de tipos: la combinación larga-corta cancela gran parte de ese riesgo y queda una cartera de volatilidad muy baja, apalancada
Ver respuesta
Respuesta D. Porque los dos bonos cargan sobre el mismo factor de tipos: la combinación larga-corta cancela gran parte de ese riesgo y queda una cartera de volatilidad muy baja, apalancada
La mínima varianza solo mira la covarianza. Dos activos muy correlados con volatilidades distintas permiten construir una posición casi sin riesgo comprando uno y vendiendo el otro en la proporción adecuada. Es correcta sobre el papel y depende de que esa correlación se mantenga.
¿Qué hace una restricción de solo largos con el error de estimación?
- ANada: solo empeora la frontera
- BLo agrava, porque deja menos grados de libertad
- CLo acota: impide que el optimizador construya posiciones largas-cortas grandes sobre diferencias estimadas con ruido; Jagannathan y Ma (2003) mostraron que equivale a encoger la covarianza
- DLo elimina por completo
Ver respuesta
Respuesta C. Lo acota: impide que el optimizador construya posiciones largas-cortas grandes sobre diferencias estimadas con ruido; Jagannathan y Ma (2003) mostraron que equivale a encoger la covarianza
Con los parámetros verdaderos, la restricción solo puede empeorar la frontera (Sharpe máximo de 1,18 a 0,88). Con parámetros estimados, evita las apuestas largas-cortas más sensibles al error y suele mejorar el resultado fuera de la muestra.
cvxpy rechaza un problema con varianza como objetivo y un límite de cinco activos con variables binarias si solo están instalados CLARABEL, OSQP, SCS, HIGHS y SCIPY. ¿Por qué?
- APorque es un problema cuadrático entero mixto (MIQP) y ninguno de esos solvers lo resuelve: HiGHS hace programación lineal entera y cuadrática continua, pero no las dos a la vez
- BPorque la cardinalidad no se puede escribir en cvxpy
- CPorque la varianza no es convexa
- DPorque faltan datos
Ver respuesta
Respuesta A. Porque es un problema cuadrático entero mixto (MIQP) y ninguno de esos solvers lo resuelve: HiGHS hace programación lineal entera y cuadrática continua, pero no las dos a la vez
El problema es válido pero no convexo en sentido estricto por las variables enteras. Hace falta un solver MIQP (SCIP, Gurobi, MOSEK, CPLEX) o una heurística: resolver la versión continua, quedarse con los mayores pesos y volver a optimizar.
Para un objetivo del 5 % de exceso anual, la cartera sin límites tiene una volatilidad del 4,3 % y la de solo largos, del 5,8 %. ¿Cuál conviene elegir?
- ALa sin límites, que domina sobre el papel
- BDepende de cuánto te fíes de los parámetros: con los verdaderos domina la sin límites; con estimados, su ventaja está construida con posiciones cortas que dependen de diferencias de rendimiento que no se pueden medir con precisión
- CLa de solo largos, porque siempre tiene menos riesgo
- DNinguna: hay que usar la equiponderada
Ver respuesta
Respuesta B. Depende de cuánto te fíes de los parámetros: con los verdaderos domina la sin límites; con estimados, su ventaja está construida con posiciones cortas que dependen de diferencias de rendimiento que no se pueden medir con precisión
La frontera sin límites es la mejor si μ y Σ son exactos. En la práctica se trabaja con estimaciones, y el error típico de la media de un índice de acciones en 20 años ronda los 5 puntos: mucho más que las diferencias que explota la cartera larga-corta.
Ideas clave
- La media-varianza minimiza w′Σw con un rendimiento objetivo y la suma de pesos igual a 1. Sin más restricciones tiene solución cerrada: la frontera es una hipérbola y cualquier cartera eficiente combina la de mínima varianza y la tangente.
- cvxpy resuelve el mismo problema (diferencia de pesos menor de una millonésima) y admite restricciones lineales y convexas: solo largos, cotas, grupos, apalancamiento, error de seguimiento. La cardinalidad necesita un solver entero mixto.
- Cada restricción desplaza la frontera hacia la derecha con los parámetros verdaderos: Sharpe máximo 1,18 sin límites, 0,88 con solo largos.
- Con parámetros estimados, la frontera de la muestra es optimista: el máximo Sharpe estimado en 2006-2010 promete 1,75 y da 0,76; en Monte Carlo con cinco años, una mediana de 1,17 prometido frente a 0,73 real.
- Los rendimientos esperados son la parte más frágil: con 20 años, el error típico de la media de un índice de acciones ronda los 5 puntos. Las lecciones 4.1.2 y 4.1.3 atacan la covarianza y la media.
Recursos
Libros
- Markowitz, H. (1952): Portfolio Selection. The Journal of Finance 7(1), 77-91
- Merton, R. C. (1972): An Analytic Derivation of the Efficient Portfolio Frontier. Journal of Financial and Quantitative Analysis 7(4), 1851-1872
- Michaud, R. O. (1989): The Markowitz Optimization Enigma: Is 'Optimized' Optimal? Financial Analysts Journal 45(1), 31-42
- Jagannathan, R. y Ma, T. (2003): Risk Reduction in Large Portfolios: Why Imposing the Wrong Constraints Helps. The Journal of Finance 58(4), 1651-1683