Lección 4.1.5 · 75 min
Optimización con CVaR, drawdown, rotación y costes
La varianza castiga igual las subidas que las bajadas y no ve la trayectoria. Esta lección optimiza carteras con medidas de cola (CVaR) y de caída (CDaR y caída máxima) formuladas como programas lineales en cvxpy, las contrasta con skfolio y Riskfolio-Lib, mide qué aguantan fuera de muestra y añade lo que decide el resultado neto en real: los costes y la rotación.
- Módulo
- 4.1 · Teoría y optimización de carteras
- Duración
- 75 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
Lo que la varianza no ve
La varianza mide la dispersión alrededor de la media: una subida del 3 % la aumenta tanto como una caída del 3 %, y le da igual que las pérdidas lleguen repartidas o seguidas. Para quien gestiona dinero las dos cosas importan: lo que duele es la cola de pérdidas y la caída acumulada desde el último máximo. Para una distribución normal da igual, porque la varianza lo resume todo; para una estrategia que vende seguros, no.
Los datos de esta lección son los rendimientos diarios en exceso del tipo libre de nueve estrategias sintéticas
(estrategias4_diarias.parquet), parecidas a las de los niveles 2 y 3, con crisis, agrupamiento de volatilidad y
eventos fechados que imitan episodios reales: el crash de los quants de agosto de 2007, el desplome del carry en
octubre de 2008, el crash de momentum de 2009, el Volmageddon de febrero de 2018 y marzo de 2020. Son sintéticos: los
números sirven para comparar métodos, no como evidencia de que una estrategia funcione. La décima columna,
degradada, se queda fuera (es para la lección 4.2.5).
import numpy as np
import pandas as pd
est = pd.read_parquet("estrategias4_diarias.parquet")
est = est.set_index("fecha").drop(columns="degradada")
R = est.values # rendimientos simples en exceso
T, n = R.shape
def cvar_hist(x, beta):
"""Pérdida media en la peor fracción 1 − beta de los días."""
perdida = -np.asarray(x)
var = np.quantile(perdida, beta)
return perdida[perdida >= var].mean()
tabla = pd.DataFrame({
"vol_%": 100 * np.sqrt(252) * est.std(),
"asimetria": est.skew(),
"peor_dia_%": 100 * est.min(),
"cvar95_%": [100 * cvar_hist(est[c], 0.95) for c in est],
"cvar99_%": [100 * cvar_hist(est[c], 0.99) for c in est],
})
print(T, "sesiones,", n, "estrategias")
tabla.round(2)5031 sesiones, 9 estrategias
vol_% asimetria peor_dia_% cvar95_% cvar99_%
tendencia_futuros 10.73 0.08 -4.94 1.52 2.53
carry_futuros 7.54 -0.18 -3.19 1.09 1.77
carry_divisas 9.09 -0.20 -4.59 1.31 2.18
venta_puts 11.90 -0.35 -6.56 1.73 2.93
pares 5.96 -0.46 -3.90 0.87 1.47
momentum_acciones 12.19 -0.56 -8.04 1.77 3.10
valor_acciones 9.04 0.08 -3.18 1.28 1.95
venta_volatilidad 16.37 -2.76 -19.29 2.45 4.71
reversion_corto 7.07 -0.03 -4.01 1.02 1.75La venta de volatilidad tiene la mayor volatilidad (16,4 %), una asimetría de −2,76 y un peor día del −19,3 %: es la estrategia de cola. Su CVaR al 99 % (4,71 %) es un 50 % mayor que el siguiente, el del momentum de acciones (3,10 %). La venta de puts y el momentum de acciones tienen colas más moderadas pero también largas.
VaR y CVaR
Dado un nivel de confianza β (0,95 o 0,99) y la pérdida diaria :
- El VaR al nivel β es el cuantil β de las pérdidas: la pérdida que solo se supera en un de los días.
- El CVaR (conditional value at risk, también expected shortfall) es la pérdida media cuando se supera el VaR: la media del peor de los días.
El VaR dice dónde empieza la cola y nada de lo que hay dentro; el CVaR la promedia. Además el CVaR es una medida coherente en el sentido de Artzner, Delbaen, Eber y Heath (1999): entre otras propiedades, es subaditiva (el riesgo de una cartera no supera la suma de los riesgos) y convexa en los pesos. El VaR no es subaditivo en general, y minimizarlo es un problema no convexo con muchos mínimos locales. Cómo estimar el VaR y el CVaR, y cómo comprobar si un modelo de VaR acierta, son la lección 4.2.3; aquí interesan como objetivos de optimización.
El problema práctico del CVaR es que depende de un cuantil, y los cuantiles no son funciones cómodas para un optimizador. Rockafellar y Uryasev (2000) resolvieron el problema con una función auxiliar:
Para unos pesos fijos, es convexa en α, su mínimo se alcanza en el VaR y su valor mínimo es el CVaR. Lo mejor: minimizar a la vez en y en α da la cartera de mínimo CVaR, sin calcular ningún cuantil. Antes de optimizar, la comprobación con la cartera 1/N:
from scipy.optimize import minimize_scalar
w_1n = np.full(n, 1 / n)
p = R @ w_1n # rendimiento de la cartera 1/N
def F(alfa, beta=0.95):
"""Función de Rockafellar-Uryasev: su mínimo en alfa es el CVaR."""
return alfa + np.maximum(-p - alfa, 0).mean() / (1 - beta)
res = minimize_scalar(F, bounds=(0, 0.05), method="bounded",
options={"xatol": 1e-10})
print(f"VaR 95 % (cuantil): {np.quantile(-p, 0.95):.5f}")
print(f"alfa óptimo: {res.x:.5f}")
print(f"CVaR 95 % directo: {cvar_hist(p, 0.95):.5f}")
print(f"mínimo de F: {res.fun:.5f}")VaR 95 % (cuantil): 0.00429
alfa óptimo: 0.00430
CVaR 95 % directo: 0.00622
mínimo de F: 0.00622El α que minimiza (0,00430) coincide con el cuantil del 95 % de las pérdidas (0,00429; la diferencia es la discretización del cuantil entre dos observaciones) y el mínimo de , 0,00622, con el CVaR calculado a mano. La figura enseña por qué: a la izquierda del VaR, subir α resta más de lo que suma; a la derecha, la función crece con pendiente 1.
El mínimo CVaR como programa lineal
El término se linealiza con una variable auxiliar por escenario, y . Queda un programa lineal con variables:
Fuente Mermaid
flowchart TB E[Escenarios r_t] --> U[Exceso u_t] A[Umbral alfa] --> U W[Pesos w] --> U U --> O[alfa + media u_t] O --> S[Solver LP]
Cada día de la muestra es un escenario. Con 5.031 días son 5.041 variables y unas 10.000 restricciones: para un
solver lineal como HiGHS, un problema pequeño. La función cvar_cp devuelve la expresión y sus restricciones para
poder usarla como objetivo o como restricción:
import cvxpy as cp
import riskfolio as rp
from skfolio import RiskMeasure
from skfolio.optimization import MeanRisk, ObjectiveFunction
def cvar_cp(w, R, beta):
"""CVaR como LP: devuelve la expresión y sus restricciones."""
alfa = cp.Variable()
u = cp.Variable(R.shape[0])
expr = alfa + cp.sum(u) / ((1 - beta) * R.shape[0])
return expr, [u >= -R @ w - alfa, u >= 0]
def min_cvar(R, beta=0.95):
w = cp.Variable(R.shape[1])
expr, extra = cvar_cp(w, R, beta)
cp.Problem(cp.Minimize(expr),
[cp.sum(w) == 1, w >= 0] + extra).solve(solver="HIGHS")
return np.clip(w.value, 0, None)
propia = min_cvar(R)
skf = MeanRisk(objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.CVAR, cvar_beta=0.95).fit(est)
port = rp.Portfolio(returns=est, alpha=0.05)
port.assets_stats(method_mu="hist", method_cov="hist")
rkf = port.optimization(model="Classic", rm="CVaR", obj="MinRisk",
hist=True)
print(f"CVaR 95 %: {100 * cvar_hist(R @ propia, 0.95):.4f} %")
print(f"skfolio: {np.abs(skf.weights_ - propia).max():.1e}")
print(f"riskfolio: {np.abs(rkf['weights'] - propia).max():.1e}")
pd.Series(propia, est.columns).round(3)CVaR 95 %: 0.4627 %
skfolio: 7.0e-10
riskfolio: 3.7e-10
tendencia_futuros 0.116
carry_futuros 0.151
carry_divisas 0.082
venta_puts 0.022
pares 0.256
momentum_acciones 0.071
valor_acciones 0.145
venta_volatilidad 0.000
reversion_corto 0.157
dtype: float64Los tres coinciden hasta : skfolio (cvar_beta=0.95) y Riskfolio-Lib (alpha=0.05, hist=True) resuelven
la misma formulación. La cartera reparte el peso entre pares, reversión a corto, valor, carry de futuros y tendencia,
y deja la venta de volatilidad a cero. Su CVaR al 95 % es del 0,46 % diario.
¿Cambia algo frente a la mínima varianza?
def min_var(R):
w = cp.Variable(R.shape[1])
L = np.linalg.cholesky(np.cov(R.T))
cp.Problem(cp.Minimize(cp.sum_squares(L.T @ w)),
[cp.sum(w) == 1, w >= 0]).solve(solver="CLARABEL")
return np.clip(w.value, 0, None)
def metricas(w, R):
p = R @ w
curva = np.cumprod(1 + p)
return {
"vol_%": 100 * np.sqrt(252) * p.std(),
"cvar95_%": 100 * cvar_hist(p, 0.95),
"cvar99_%": 100 * cvar_hist(p, 0.99),
"max_caida_%": 100 * (curva / np.maximum.accumulate(curva)
- 1).min(),
"sharpe": np.sqrt(252) * p.mean() / p.std(),
}
carteras = {"1/N": w_1n, "min_var": min_var(R),
"min_cvar95": min_cvar(R, 0.95),
"min_cvar99": min_cvar(R, 0.99)}
pesos = pd.DataFrame(carteras, index=est.columns)
print(pd.DataFrame({k: metricas(w, R) for k, w in carteras.items()})
.T.round(2))
pesos.round(3) vol_% cvar95_% cvar99_% max_caida_% sharpe
1/N 4.49 0.62 0.96 -14.59 0.84
min_var 3.45 0.47 0.67 -8.94 0.98
min_cvar95 3.46 0.46 0.67 -9.38 0.98
min_cvar99 3.51 0.47 0.65 -10.25 1.01
1/N min_var min_cvar95 min_cvar99
tendencia_futuros 0.111 0.116 0.116 0.123
carry_futuros 0.111 0.137 0.151 0.115
carry_divisas 0.111 0.068 0.082 0.118
venta_puts 0.111 0.024 0.022 0.039
pares 0.111 0.253 0.256 0.195
momentum_acciones 0.111 0.090 0.071 0.086
valor_acciones 0.111 0.150 0.145 0.142
venta_volatilidad 0.111 0.001 0.000 0.000
reversion_corto 0.111 0.162 0.157 0.183Poco. La mínima varianza y el mínimo CVaR al 95 % tienen casi los mismos pesos y las mismas métricas; el mínimo CVaR al 99 % se separa algo (más reversión, menos pares) y baja el CVaR al 99 % de 0,67 % a 0,65 %. La razón es que la mínima varianza ya descarta la estrategia de cola gruesa: la venta de volatilidad tiene tanta varianza que pesa cero, o casi, con cualquier medida, y la cartera que queda, de ocho estrategias poco correladas, tiene colas poco asimétricas. Cuando la asimetría está en activos que la varianza no penaliza lo suficiente, las dos medidas se separan más; con estos datos, la diferencia práctica está en otra parte.
Lo que el CVaR histórico no ve
La diferencia aparece cuando se busca rendimiento. El experimento: con los datos de junio de 2009 a enero de 2018 (un periodo sin crisis grandes en los datos), se elige la cartera de máximo rendimiento medio con un 5 % de volatilidad (media-varianza) y la de máximo rendimiento con el mismo CVaR al 99 % que esa cartera (media-CVaR). Las dos tienen el mismo riesgo de cola en la muestra. Después se miden de febrero de 2018 a 2025, con el Volmageddon y marzo de 2020 dentro.
dentro = est.loc["2009-06":"2018-01"].values
fuera = est.loc["2018-02":].values
mu, Sig = dentro.mean(0), np.cov(dentro.T)
# media-varianza: máximo rendimiento con un 5 % de volatilidad
w = cp.Variable(n)
cp.Problem(cp.Maximize(mu @ w),
[cp.quad_form(w, Sig) * 252 <= 0.05**2,
cp.sum(w) == 1, w >= 0]).solve(solver="CLARABEL")
w_mv = np.clip(w.value, 0, None)
# media-CVaR: máximo rendimiento con el mismo CVaR 99 % en muestra
tope = cvar_hist(dentro @ w_mv, 0.99)
w = cp.Variable(n)
expr, extra = cvar_cp(w, dentro, 0.99)
cp.Problem(cp.Maximize(mu @ w),
[expr <= tope, cp.sum(w) == 1, w >= 0] + extra
).solve(solver="HIGHS")
w_cv = np.clip(w.value, 0, None)
print(pd.DataFrame({"media_var": w_mv, "media_cvar": w_cv},
index=est.columns).round(2))
filas = {}
for nombre, wt in [("media_var", w_mv), ("media_cvar", w_cv)]:
for tramo, X in [("dentro", dentro), ("fuera", fuera)]:
m = metricas(wt, X)
m["peor_dia_%"] = 100 * (X @ wt).min()
filas[(nombre, tramo)] = m
pd.DataFrame(filas).T.round(2) media_var media_cvar
tendencia_futuros 0.05 0.00
carry_futuros 0.00 0.00
carry_divisas 0.00 0.00
venta_puts 0.22 0.32
pares 0.07 0.00
momentum_acciones 0.24 0.16
valor_acciones 0.14 0.20
venta_volatilidad 0.10 0.07
reversion_corto 0.19 0.26
vol_% cvar95_% cvar99_% max_caida_% sharpe peor_dia_%
media_var dentro 5.00 0.68 1.05 -7.04 1.31 -1.68
fuera 5.15 0.72 1.26 -11.22 0.90 -2.37
media_cvar dentro 5.56 0.74 1.04 -8.47 1.23 -1.57
fuera 5.54 0.78 1.30 -13.51 0.79 -2.61Dentro de la muestra, la media-CVaR cumple lo que promete: el mismo CVaR al 99 % (1,04 % frente a 1,05 %), un peor día menos malo (−1,57 % frente a −1,68 %) y algo más de volatilidad, que no le importa. Para conseguirlo sube la venta de puts del 22 % al 32 %. Fuera de muestra, la media-CVaR pierde en todo: CVaR al 99 % del 1,30 %, caída máxima del 13,5 % frente al 11,2 % y un peor día del −2,61 %. Los peores días cuentan la historia:
fechas_f = est.loc["2018-02":].index
pf = pd.DataFrame({"media_var": fuera @ w_mv,
"media_cvar": fuera @ w_cv,
"venta_puts": est.loc["2018-02":, "venta_puts"]},
index=fechas_f)
sesgo = est.loc["2009-06":"2018-01", "venta_puts"].skew()
print(f"asimetría de venta_puts en 2009-2018: {sesgo:.2f}")
(100 * pf.nsmallest(4, "media_cvar")).round(2)asimetría de venta_puts en 2009-2018: 0.32
media_var media_cvar venta_puts
fecha
2020-03-16 -2.37 -2.61 -6.55
2020-03-12 -1.15 -1.95 -5.81
2018-10-03 -1.42 -1.89 -4.90
2018-02-05 -2.15 -1.81 -2.66Marzo de 2020: la venta de puts pierde un 6,55 % el día 16 y un 5,81 % el 12. La media-CVaR, con más venta de puts, lo sufre más. En la muestra de 2009-2018 la venta de puts había tenido una cola moderada (asimetría ligeramente positiva en ese tramo), y el CVaR histórico solo puede ver las pérdidas que ocurrieron. El optimizador hizo lo que se le pidió: buscó el activo cuyo riesgo de cola medido era bajo para su rendimiento, que es justo el que tenía el riesgo escondido. Es el mismo fenómeno de «maximizar el error» de la lección 4.1.1, ahora en las colas.
Drawdown: CDaR y caída máxima
La caída (drawdown) en es la distancia entre el máximo acumulado hasta y el valor en . La caída máxima es la mayor de todas; el CDaR (conditional drawdown at risk, Chekhlov, Uryasev y Zabarankin, 2005) es la media del peor de las caídas, el equivalente del CVaR sobre la trayectoria.
Para que sea lineal se usa la suma acumulada sin capitalizar de los rendimientos, , lineal en . El máximo acumulado se modela con un techo : , , . La caída es (el techo mínimo compatible con las restricciones es exactamente el máximo acumulado, y el optimizador no tiene incentivo para subirlo). Con eso, el CDaR se linealiza con el mismo truco de Rockafellar-Uryasev y la caída máxima es el máximo de . Con rendimientos diarios pequeños, la suma sin capitalizar y el logaritmo del valor capitalizado se parecen; las métricas de la tabla se calculan sobre el valor capitalizado.
El experimento: optimizar con 2006-2015 y medir en 2016-2025.
def caidas_cp(w, R):
"""Caídas de la suma acumulada (sin capitalizar) como restricciones
lineales: techo ≥ suma y techo no decreciente."""
c = cp.cumsum(R @ w)
techo = cp.Variable(len(R))
restr = [techo >= c, techo[1:] >= techo[:-1], techo[0] >= 0]
return techo - c, restr
def min_cdar(R, beta=0.95):
w, alfa, z = cp.Variable(R.shape[1]), cp.Variable(), \
cp.Variable(len(R))
dd, restr = caidas_cp(w, R)
obj = alfa + cp.sum(z) / ((1 - beta) * len(R))
cp.Problem(cp.Minimize(obj),
restr + [z >= dd - alfa, z >= 0, cp.sum(w) == 1,
w >= 0]).solve(solver="HIGHS")
return np.clip(w.value, 0, None)
def min_maxdd(R):
w = cp.Variable(R.shape[1])
dd, restr = caidas_cp(w, R)
cp.Problem(cp.Minimize(cp.max(dd)),
restr + [cp.sum(w) == 1, w >= 0]).solve(solver="HIGHS")
return np.clip(w.value, 0, None)
ant, post = est.loc[:"2015"].values, est.loc["2016":].values
cart = {"1/N": w_1n, "min_var": min_var(ant),
"min_cvar99": min_cvar(ant, 0.99), "min_cdar95": min_cdar(ant),
"min_max_caida": min_maxdd(ant)}
filas = {}
for k, w in cart.items():
a, b = metricas(w, ant), metricas(w, post)
filas[k] = {"caida_dentro_%": a["max_caida_%"],
"caida_fuera_%": b["max_caida_%"],
"sharpe_dentro": a["sharpe"],
"sharpe_fuera": b["sharpe"],
"n_ef": 1 / (w ** 2).sum()}
pd.DataFrame(filas).T.round(2) caida_dentro_% caida_fuera_% sharpe_dentro sharpe_fuera n_ef
1/N -14.59 -9.67 0.50 1.18 9.00
min_var -8.52 -5.03 0.68 1.30 6.57
min_cvar99 -9.85 -5.19 0.63 1.34 6.29
min_cdar95 -5.70 -9.43 0.89 0.99 5.49
min_max_caida -5.33 -8.54 0.88 0.97 5.23La figura de la cabecera pone estas caídas una al lado de otra. Las dos carteras que optimizan la caída consiguen la menor dentro de la muestra (5,70 % y 5,33 %, frente al 8,52 % de la mínima varianza) y un Sharpe más alto (0,89 y 0,88). Fuera, sus caídas casi se duplican (9,43 % y 8,54 %), muy por encima de la mínima varianza (5,03 %) y del mínimo CVaR al 99 % (5,19 %), y sus Sharpes (0,99 y 0,97) son los dos más bajos de las cinco. La 1/N cae más dentro que ninguna (14,59 %) y fuera un 9,67 %, apenas más que el mínimo CDaR.
La explicación es estadística. La caída máxima de diez años la deciden dos o tres episodios. Minimizarla es elegir la combinación que mejor esquivó esos episodios, y con nueve estrategias hay combinaciones que los esquivan en parte por azar. La varianza y el CVaR usan todos los días o todo el 5 % peor de los días: miles o cientos de observaciones frente a unas pocas trayectorias de caída. Es el problema de las pruebas múltiples (lección 2.1.8) dentro de un optimizador. La caída sirve como restricción de control (lección 4.2.5) o como métrica de evaluación, con cuidado; como objetivo de optimización con pocos años de datos, sobreajusta.
Costes y rotación
Todo lo anterior supone que rebalancear es gratis. No lo es, y en una cartera grande los costes de ejecución pueden comerse el rendimiento de una estrategia de bajo riesgo. Un modelo razonable de coste, en fracción de la cartera, para mover del activo con una cartera de valor :
El primer término es la media horquilla . El segundo es el impacto de la raíz cuadrada: el coste por
unidad negociada crece con la raíz de la fracción del volumen diario que se negocia, , y
multiplicado por lo negociado da un exponente 3/2 (lecciones 2.4.7 y 4.3.1). Los dos términos son convexos, así que
caben en cvxpy: cp.abs y cp.power(·, 1.5). riesgo4_liquidez.csv da, para cada activo del universo como ETF
ficticio, el volumen medio diario, la horquilla, la volatilidad diaria y el coeficiente .
precios = pd.read_parquet("universo4_precios.parquet")
precios = precios.set_index("fecha")
fact = pd.read_parquet("universo4_factores_diarios.parquet")
fact = fact.set_index("fecha")
r = precios.pct_change()
r.iloc[0] = precios.iloc[0] / 100 - 1
cols = [c for c in precios if c not in ("MONET", "RF_T13")]
r = r[cols]
exceso = r.sub(fact["rf"], axis=0)
liq = pd.read_csv("riesgo4_liquidez.csv", index_col="activo").loc[cols]
CARTERA = 5e9 # USD
a = liq["horquilla_pb"].values / 2 / 1e4 # media horquilla
b = (liq["impacto_y"] * liq["vol_diaria"]
* np.sqrt(CARTERA / liq["adv_usd"])).values
def coste(dw):
"""Coste en fracción de la cartera: horquilla + raíz cuadrada."""
dw = np.abs(dw)
return a @ dw + b @ dw**1.5
ej = ["RV_EEUU", "RF_T37", "MP_ENE", "INFRA"]
pb = [1e4 * coste(np.where(np.array(cols) == c, 0.01, 0)) / 0.01
for c in ej]
pd.DataFrame({"adv_musd": liq.loc[ej, "adv_usd"] / 1e6,
"dias_adv": 0.01 * CARTERA / liq.loc[ej, "adv_usd"],
"coste_pb_de_lo_movido": pb}, index=ej).round(2) adv_musd dias_adv coste_pb_de_lo_movido
RV_EEUU 7302.01 0.01 5.94
RF_T37 798.84 0.06 4.66
MP_ENE 99.76 0.50 88.17
INFRA 23.48 2.13 99.61Con 5.000 millones de USD, mover un 1 % de la cartera en el ETF de la bolsa de EE. UU. es una centésima de su volumen diario y cuesta 5,94 pb de lo movido; en el bono a 3-7 años, 4,66 pb. En energía son medio día de volumen y 88 pb; en infraestructuras, más de dos días de volumen y casi 100 pb. Un optimizador sin costes trata igual un punto de cada uno.
Hay dos formas de meter los costes en el problema:
- Penalizar el coste en el objetivo: minimizar , con la cartera actual. κ traduce costes a unidades de riesgo: con κ = 1, un punto básico de coste pesa lo mismo que un punto básico de CVaR diario.
- Limitar la rotación: añadir y no tocar el objetivo.
Fuente Mermaid
flowchart TB
P[Cartera actual w0] --> M{"¿Mejora > coste?"}
M -- sí --> T[Operar]
M -- no --> N[No operar]
T --> F[Nueva cartera]
N --> FCon el coste en el objetivo aparece una zona sin operaciones: el término tiene una pendiente distinta de cero en , así que solo compensa mover un activo si la mejora marginal de riesgo supera su coste marginal, y la zona es más ancha cuanto más caro es el activo. Es lo que hace, de forma explícita, el rebalanceo por umbrales de la lección 4.1.7.
El experimento: mínimo CVaR al 95 % de los 36 activos con riesgo del universo (sin el monetario ni la letra a 1-3
años, como en la lección 4.1.4), reoptimizado el primer día de cada mes con el año anterior, con 5.000 millones y
cuatro valores de κ, más un tope de rotación del 5 % al mes sin coste en el objetivo. Los costes se cobran el día del
cambio. El problema se construye una vez por variante con parámetros de cvxpy (cp.Parameter) para los escenarios y
la cartera actual; cada mes solo cambian sus valores y cvxpy reutiliza la compilación. La columna no_optimos cuenta
las veces que el solver no terminó con estado optimal.
V = 252
fechas = exceso.index
primeras = pd.Series(fechas, fechas).groupby(
fechas.to_period("M")).first()
rebal = [d for d in primeras if fechas.get_loc(d) >= V]
m = len(cols)
def problema(kappa, tope=None):
"""Mínimo CVaR 95 % + kappa · coste de pasar de w0 a w."""
Rp, w0 = cp.Parameter((V, m)), cp.Parameter(m)
w = cp.Variable(m)
expr, extra = cvar_cp(w, Rp, 0.95)
dw = w - w0
c = a @ cp.abs(dw) + b @ cp.power(cp.abs(dw), 1.5)
restr = [cp.sum(w) == 1, w >= 0] + extra
if tope is not None:
restr.append(cp.norm1(dw) <= tope)
return cp.Problem(cp.Minimize(expr + kappa * c), restr), Rp, w0, w
def resolver(prob, Rp, w0, w, X, previo):
Rp.value, w0.value = X, previo
prob.solve(solver="CLARABEL")
wn = np.clip(w.value, 0, None)
return wn / wn.sum(), prob.status != "optimal"
inicial = problema(0) # la primera cartera, sin costes
def rebalanceo(kappa, tope=None):
prob = problema(kappa, tope)
previo, trozos, rot, gasto, malos = None, [], [], [], 0
for j, d in enumerate(rebal):
i = fechas.get_loc(d)
X = exceso.iloc[i - V : i].values
if previo is None:
previo, _ = resolver(*inicial, X, np.full(m, 1 / m))
wn, malo = resolver(*prob, X, previo)
malos += malo
dw = wn - previo
rot.append(np.abs(dw).sum())
gasto.append(coste(dw))
fin = rebal[j + 1] if j + 1 < len(rebal) else None
bl = r.loc[d:fin]
bl = bl.iloc[:-1] if fin is not None else bl
g = (1 + bl).cumprod().values
valor = g @ wn
rr = valor / np.r_[1, valor[:-1]] - 1
rr[0] -= gasto[-1] # el coste, el día del cambio
trozos.append(pd.Series(rr, bl.index))
previo = g[-1] * wn / valor[-1]
p = pd.concat(trozos)
p = p - fact["rf"].loc[p.index]
return {"exceso_%": 25200 * p.mean(),
"vol_%": 100 * np.sqrt(252) * p.std(),
"cvar95_%": 100 * cvar_hist(p, 0.95),
"rotacion_%": 1200 * np.mean(rot),
"coste_pb": 12e4 * np.mean(gasto), "no_optimos": malos}
resultado = {f"kappa={k}": rebalanceo(k) for k in (0, 0.3, 1, 2)}
resultado["tope 5 %"] = rebalanceo(0, tope=0.05)
print(rebal[0].date(), "a", fechas[-1].date())
pd.DataFrame(resultado).T.round(2)2007-02-01 a 2025-12-31
exceso_% vol_% cvar95_% rotacion_% coste_pb no_optimos
kappa=0 0.49 3.41 0.50 164.07 55.16 0.0
kappa=0.3 0.56 3.35 0.49 56.79 11.85 0.0
kappa=1 0.48 3.28 0.49 29.12 3.63 0.0
kappa=2 0.55 3.26 0.48 16.40 1.41 0.0
tope 5 % 0.47 3.35 0.49 58.40 15.48 0.0Sin costes en el objetivo (κ = 0), el mínimo CVaR mensual rota un 164 % al año y paga 55 pb anuales. La solución de
un programa lineal está en un vértice: un cambio pequeño en los escenarios la hace saltar a otro vértice, aunque el
CVaR apenas cambie. Con κ = 0,3 la rotación baja al 57 % y el coste a 11,9 pb; con κ = 1, al 29 % y 3,6 pb; con κ = 2,
al 16 % y 1,4 pb. El CVaR fuera de muestra no empeora: de 0,50 % a 0,48 %. La volatilidad, tampoco (de 3,41 % a
3,26 %). El solver termina en optimal en todos los meses de todas las variantes. El exceso neto se mueve entre el
0,47 % y el 0,56 % sin un orden claro: la diferencia de costes, de hasta 54 pb al año, es del mismo tamaño que el
ruido del rendimiento de estas carteras en 19 años, y no se distingue en un backtest. Que no se vea no quiere decir
que no se pague: el coste es seguro y la diferencia de rendimiento, no.
El tope del 5 % al mes da una rotación parecida a κ = 0,3 (58 %) pero cuesta más (15,5 pb): limita cuánto se mueve, no qué se mueve, y gasta su presupuesto en los activos caros si mejoran el CVaR. La penalización sabe lo que cuesta cada activo.
¿Qué κ elegir? No hay uno correcto. Lo honesto es fijarlo antes de mirar el resultado fuera de muestra, o elegirlo con validación cruzada temporal (lección 2.5.2), y no buscar el que maximiza el Sharpe del backtest. Boyd y otros (2017) desarrollan este enfoque para varios periodos: costes, impacto y restricciones en un mismo problema convexo que se resuelve en cada rebalanceo.
Errores típicos
- Optimizar la medida que se va a enseñar. La caída máxima y el CVaR de la muestra salen bien porque se optimizaron sobre ella; fuera de muestra, el sobreajuste aparece.
- CVaR con una ventana sin crisis. Solo ve las colas que hubo. Hay que comprobar qué episodios contiene la ventana y completar con estrés.
- Ignorar el estado del solver. Un
optimal_inaccuratede cvxpy con cono de potencia es frecuente cuando la solución está en la zona sin operaciones ( es el vértice del cono). Hay que comprobarprob.statusen cada rebalanceo y no usar pesos de un problema que no convergió. - Costes como tope de rotación. Un tope igual para todos los activos no distingue el ETF líquido del ilíquido.
- Rendimientos logarítmicos en la cartera. La suma de pesos por rendimientos solo es exacta con rendimientos simples; con logarítmicos, CVaR y Sharpe de la cartera salen sesgados.
Ejercicios
- Repite el mínimo CVaR de las nueve estrategias al 99 % con
min_cvary con skfolio (cvar_beta=0.99), compara los pesos y di qué estrategia gana más peso al pasar del 95 % al 99 %.
Solución
w99 = min_cvar(R, 0.99)
skf99 = MeanRisk(objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.CVAR,
cvar_beta=0.99).fit(est)
dif = np.abs(skf99.weights_ - w99).max()
print(f"diferencia con skfolio: {dif:.1e}")
(pd.Series(w99 - min_cvar(R, 0.95), est.columns)
.sort_values().round(3))diferencia con skfolio: 4.8e-09
pares -0.061
carry_futuros -0.036
valor_acciones -0.003
venta_volatilidad 0.000
tendencia_futuros 0.006
momentum_acciones 0.015
venta_puts 0.017
reversion_corto 0.025
carry_divisas 0.036
dtype: float64Las dos implementaciones coinciden (diferencia de ). Al 99 % el CVaR solo mira el 1 % peor de los días, unos 50 escenarios, y la cartera se desplaza hacia las estrategias que mejor se portaron en esos días concretos: gana peso el carry de divisas (+3,6 puntos) y la reversión a corto (+2,5), y lo pierden los pares (−6,1) y el carry de futuros (−3,6). Con tan pocos escenarios, el mínimo CVaR al 99 % es más sensible a la muestra que al 95 %.
- Con una cartera de 500 millones en lugar de 5.000, ¿cuánto cuesta mover un 1 % en cada uno de los cuatro activos del ejemplo? ¿Qué término del coste cambia y por qué no cambia en proporción?
Solución
b_500 = (liq["impacto_y"] * liq["vol_diaria"]
* np.sqrt(5e8 / liq["adv_usd"])).values
def coste_500(dw):
dw = np.abs(dw)
return a @ dw + b_500 @ dw**1.5
pd.Series([1e4 * coste_500(np.where(np.array(cols) == c, 0.01, 0))
/ 0.01 for c in ej], index=ej).round(2)RV_EEUU 2.18
RF_T37 2.15
MP_ENE 30.63
INFRA 39.20
dtype: float64Solo cambia el impacto: con una cartera diez veces menor, el 1 % es una décima parte de lo que era en volumen diario y
el impacto por unidad se divide por . La media horquilla no depende del tamaño. Por eso ningún
coste baja a la décima parte: RV_EEUU pasa de 5,94 a 2,18 pb e INFRA de 99,61 a 39,20 pb. Los ilíquidos siguen
siendo caros: mover un 1 % en infraestructuras cuesta 18 veces más que en la bolsa de EE. UU. La capacidad de una estrategia (lección 2.2.3) es justo esto:
el tamaño a partir del cual el impacto se come el rendimiento.
- Sin código: un compañero propone elegir cada mes la cartera de mínima caída máxima de los últimos tres años, «porque es lo que más le importa al cliente». Explica con los resultados de la lección qué esperarías y qué propondrías en su lugar.
Solución
La caída máxima de tres años depende de uno o dos episodios. La cartera que la minimiza es la que mejor esquivó esos episodios, en parte por azar, y cambiará mucho de un mes a otro (cada mes entra y sale un episodio de la ventana), así que rotará y pagará costes. En la lección, la mínima caída máxima de 2006-2015 cayó un 5,3 % en muestra y un 8,5 % después, más que la mínima varianza (5,0 %). Mejor: optimizar una medida que use muchos datos (varianza o CVaR al 95 %), con costes en el objetivo, y controlar la caída con una regla explícita y evaluada aparte, como reducir la exposición o detener la cartera al superar un umbral (lección 4.2.5), o con escenarios de estrés (4.2.4). La caída que le importa al cliente se gestiona, no se optimiza en la muestra.
Respuesta plegada en cada pregunta
Autoevaluación
En la función de Rockafellar-Uryasev F(α) = α + E[max(−r − α, 0)] / (1 − β), ¿qué son el α que la minimiza y su valor mínimo?
- AEl VaR al nivel β y el CVaR al nivel β
- BLa media y la varianza de las pérdidas
- CEl CVaR y el VaR, en ese orden
- DDos parámetros que hay que fijar a mano
Ver respuesta
Respuesta A. El VaR al nivel β y el CVaR al nivel β
Con la cartera 1/N de las nueve estrategias, el α óptimo sale 0,00430 y el cuantil del 95 % de las pérdidas 0,00429; el mínimo de F vale 0,00622, igual que la media de las pérdidas del 5 % peor de los días. Minimizar F en α y en los pesos a la vez minimiza el CVaR sin calcular cuantiles.
¿Por qué el mínimo CVaR de las nueve estrategias se parece tanto a la mínima varianza?
- APorque el CVaR y la varianza son la misma medida
- BPorque las dos descartan casi por completo la estrategia de cola gruesa (venta de volatilidad) y, una vez fuera, la cartera diversificada tiene colas poco asimétricas
- CPor un error del solver
- DPorque el CVaR solo usa el 5 % de los datos
Ver respuesta
Respuesta B. Porque las dos descartan casi por completo la estrategia de cola gruesa (venta de volatilidad) y, una vez fuera, la cartera diversificada tiene colas poco asimétricas
La venta de volatilidad tiene una asimetría de −2,76 y un 16,4 % de volatilidad: las dos medidas le dan peso cero. Con el resto, la cartera tiene un CVaR al 95 % de 0,46-0,47 % en los dos casos y la diferencia de pesos es de pocos puntos.
La cartera de mínima caída máxima de 2006-2015 cae un 5,3 % en esa muestra. ¿Qué pasa en 2016-2025?
- ACae menos, porque el optimizador aprendió a evitar las caídas
- BLo mismo, un 5,3 %
- CCae un 8,5 %, más que la mínima varianza (5,0 %): la caída máxima de la muestra depende de unos pocos episodios y el optimizador se ajusta a ellos
- DNo se puede calcular fuera de muestra
Ver respuesta
Respuesta C. Cae un 8,5 %, más que la mínima varianza (5,0 %): la caída máxima de la muestra depende de unos pocos episodios y el optimizador se ajusta a ellos
La caída máxima es una estadística de una sola trayectoria. Minimizarla elige la combinación que mejor esquivó las caídas concretas de 2006-2015, en parte por azar. Fuera de muestra, la mínima varianza, que usa todos los días, cae menos.
Con 5.000 millones de USD, mover un 1 % de la cartera en
INFRAcuesta unos 100 pb de lo movido y enRV_EEUUunos 6. ¿Por qué tanta diferencia?- APor la comisión del broker
- BPorque
INFRAes más volátil - CPor los impuestos
- DPorque el 1 % de la cartera son 2,13 días de volumen de
INFRAy 0,01 deRV_EEUU: el impacto crece con la raíz cuadrada de esa fracción, y la horquilla deINFRAes además 26 veces mayor
Ver respuesta
Respuesta D. Porque el 1 % de la cartera son 2,13 días de volumen de
INFRAy 0,01 deRV_EEUU: el impacto crece con la raíz cuadrada de esa fracción, y la horquilla deINFRAes además 26 veces mayorEl modelo de coste es media horquilla más Y·σ·√(Q/ADV).
INFRAtiene un volumen medio de 23,5 millones de USD al día y una horquilla de 22,5 pb;RV_EEUU, 7.302 millones y 0,87 pb.En el mínimo CVaR mensual, un tope de rotación del 5 % al mes da un 58 % de rotación anual y 15,5 pb de coste; una penalización de costes con κ = 0,3 da un 57 % y 11,9 pb. ¿Por qué la penalización sale más barata con la misma rotación?
- APor azar
- BPorque el tope limita cuánto se mueve pero no qué: gasta el presupuesto en activos caros si mejoran el CVaR; la penalización sabe lo que cuesta cada activo y mueve los baratos
- CPorque el tope no es convexo
- DPorque la penalización reduce el CVaR
Ver respuesta
Respuesta B. Porque el tope limita cuánto se mueve pero no qué: gasta el presupuesto en activos caros si mejoran el CVaR; la penalización sabe lo que cuesta cada activo y mueve los baratos
El tope trata igual un punto de
RV_EEUUque uno deINFRA. La penalización lleva el coste de cada activo dentro del objetivo: solo compensa operar si la mejora de riesgo supera el coste marginal, y eso crea una zona sin operaciones distinta para cada activo.
Ideas clave
- El CVaR al nivel β es la pérdida media en el peor (1 − β) de los días. Es coherente y convexo; el VaR no.
- Rockafellar-Uryasev convierte el mínimo CVaR en un programa lineal con una variable por escenario; cvxpy, skfolio y Riskfolio-Lib dan los mismos pesos (diferencias por debajo de una milmillonésima).
- El CVaR histórico solo ve las colas que hubo en la muestra: la media-CVaR de 2009-2018 cargó más venta de puts que la media-varianza y perdió más en marzo de 2020.
- CDaR y caída máxima también son lineales, pero optimizar una estadística de una sola trayectoria sobreajusta: caída del 5,3 % dentro y del 8,5 % fuera, peor que la mínima varianza.
- Con costes de horquilla e impacto de raíz cuadrada en el objetivo, la rotación del mínimo CVaR mensual baja del 164 % al 29 % al año y el coste de 55 a 3,6 pb, con el mismo CVaR fuera de muestra.
Recursos
Documentación
Libros
- Rockafellar, R. T. y Uryasev, S. (2000): Optimization of Conditional Value-at-Risk. Journal of Risk 2(3), 21-41
- Rockafellar, R. T. y Uryasev, S. (2002): Conditional value-at-risk for general loss distributions. Journal of Banking & Finance 26(7), 1443-1471
- Chekhlov, A., Uryasev, S. y Zabarankin, M. (2005): Drawdown Measure in Portfolio Optimization. International Journal of Theoretical and Applied Finance 8(1), 13-58
- Artzner, P., Delbaen, F., Eber, J.-M. y Heath, D. (1999): Coherent Measures of Risk. Mathematical Finance 9(3), 203-228
- Boyd, S. y otros (2017): Multi-Period Trading via Convex Optimization. Foundations and Trends in Optimization 3(1), 1-76