Lección 4.1.7 · 65 min
Rebalanceo y carteras de estrategias
Unos pesos óptimos solo lo son el día en que se fijan: al día siguiente los precios ya los han movido. Esta lección mide qué cuesta devolverlos a su sitio, cada cuánto conviene hacerlo y qué se arriesga si no se hace, primero en una cartera de cinco ETF y después en una cartera de nueve estrategias, donde los pesos se estiman con datos del pasado y se rehacen cada mes.
- Módulo
- 4.1 · Teoría y optimización de carteras
- Duración
- 65 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
Los pesos se mueven solos
Las lecciones anteriores del módulo calculan pesos: de Markowitz (4.1.1), con covarianzas mejor estimadas (4.1.2), con visiones (4.1.3), por riesgo (4.1.4), con restricciones de coste (4.1.5). Todos son pesos de un día. En cuanto los precios se mueven, el peso de cada activo cambia sin que nadie opere:
Lo que sube gana peso y lo que baja lo pierde. Una cartera que no se toca acaba siendo, con los años, una apuesta por lo que más ha subido. Rebalancear es devolver los pesos a su objetivo, y la pregunta práctica tiene tres partes: cuándo (por calendario o cuando la desviación pasa de un umbral), hasta dónde (al objetivo o al borde de la banda) y a qué coste.
Fuente Mermaid
flowchart TB
P[Precios del día] --> D[Pesos derivados]
D --> C{¿Toca operar?}
C -- calendario --> O[Operar al objetivo]
C -- fuera de banda --> O
C -- no --> E[Esperar]
O --> K[Costes]
K --> N[Pesos nuevos]La cartera de la lección es una cartera clásica de cinco ETF del universo del nivel (README de los datos): 40 % de
renta variable de EE. UU. (RV_EEUU), 10 % de emergentes (RV_EMG), 30 % de bonos del Tesoro a 7-10 años
(RF_T710), 10 % de crédito con grado de inversión (RF_CIG) y 10 % de oro (MP_ORO). Los datos son sintéticos,
de 2006 a 2025, con las crisis del guion (2008, 2020, 2022) y una liquidez por activo que incluye la horquilla en
calma y en estrés:
import numpy as np
import pandas as pd
precios = pd.read_parquet("universo4_precios.parquet").set_index(
"fecha")
fac = pd.read_parquet("universo4_factores_diarios.parquet").set_index(
"fecha")
liq = pd.read_csv("riesgo4_liquidez.csv", index_col="activo")
OBJ = pd.Series({"RV_EEUU": 0.40, "RV_EMG": 0.10, "RF_T710": 0.30,
"RF_CIG": 0.10, "MP_ORO": 0.10})
A = list(OBJ.index)
# rendimiento simple total (con el tipo libre); base 100 la víspera
R = precios[A] / precios[A].shift(1).fillna(100) - 1
rf = fac["rf"]
crisis = fac["regimen"].ne("calma").to_numpy()
crece = (1 + R).prod()
final = OBJ * crece / (OBJ * crece).sum()
pd.DataFrame({"objetivo": OBJ, "sin_tocar_2025": final,
"media_horquilla_pb": liq.loc[A, "horquilla_pb"] / 2,
"en_estres_pb": liq.loc[A, "horquilla_pb_estres"] / 2}
).round(3) objetivo sin_tocar_2025 media_horquilla_pb en_estres_pb
RV_EEUU 0.4 0.554 0.435 1.745
RV_EMG 0.1 0.052 0.485 1.950
RF_T710 0.3 0.117 1.390 11.120
RF_CIG 0.1 0.054 1.355 10.835
MP_ORO 0.1 0.222 2.715 32.575Sin tocarla en 20 años, la cartera terminaría con un 55,4 % en la bolsa de EE. UU. (frente al 40 % pedido), un 22,2 % en oro (frente al 10 %) y solo un 11,7 % en el bono del Tesoro (frente al 30 %). Ya no sería la cartera que se eligió: tendría más del doble de oro y casi un tercio de los bonos. La horquilla, en cambio, es pequeña: media horquilla de 0,435 pb en el ETF de la bolsa de EE. UU. y 2,7 pb en el de oro en calma. En los regímenes de estrés del guion se multiplica por 4 en la renta variable, por 8 en los bonos y por 12 en el oro (de 2,7 a 32,6 pb): rebalancear en plena crisis es más caro, y es justo cuando más se desvían los pesos.
Un simulador con las dos familias de reglas
El simulador sigue la cartera día a día en dólares: aplica los rendimientos, calcula los pesos y, si la regla lo pide, opera al cierre hasta el objetivo. Cada orden paga media horquilla sobre su importe (la de estrés fuera de los tramos de calma) y, si se indica, una comisión fija por orden. Las reglas de calendario operan el último día de cada periodo pase lo que pase; las de banda miran cada cierre y operan solo si algún activo se ha salido de su banda, que puede ser absoluta (5 puntos: un 40 % puede ir de 35 % a 45 %) o relativa (20 % del peso: un 10 % puede ir de 8 % a 12 %):
def cortes(fechas, freq):
"""True en la última sesión de cada periodo (M, Q o Y)."""
p = fechas.to_period(freq)
return np.r_[p[1:] != p[:-1], False]
def rebalanceo(regla, capital=1e6, fijo=0.0, banda=0.05,
relativa=False):
"""Cartera con pesos objetivo OBJ, decisiones al cierre.
regla: 'diario', 'mensual', 'trimestral', 'anual', 'nunca' o
'banda' (se mira cada cierre). Coste: media horquilla (la de
estrés fuera de la calma) sobre el efectivo + `fijo` USD por
orden. Devuelve el rendimiento diario y un resumen."""
w = OBJ.to_numpy()
hs = liq.loc[A, "horquilla_pb"].to_numpy() / 2e4
he = liq.loc[A, "horquilla_pb_estres"].to_numpy() / 2e4
toca = {"diario": np.ones(len(R), bool),
"mensual": cortes(R.index, "M"),
"trimestral": cortes(R.index, "Q"),
"anual": cortes(R.index, "Y"),
"nunca": np.zeros(len(R), bool)}.get(regla)
lim = banda * w if relativa else np.full(len(w), banda)
x = capital * w
rend, peso_rv = np.empty(len(R)), np.empty(len(R))
ordenes = giro = coste = desv = 0.0
for t, r in enumerate(R.to_numpy()):
v0 = x.sum()
x = x * (1 + r)
v = x.sum()
peso = x / v
desv = max(desv, np.abs(peso - w).max())
hacer = toca[t] if toca is not None else (
np.abs(peso - w) > lim).any()
if hacer:
orden = w * v - x
n = (np.abs(orden) > 0.01).sum()
c = (np.abs(orden) * (he if crisis[t] else hs)).sum()
c += fijo * n
ordenes += n
giro += np.abs(orden).sum() / v / 2
coste += c / v
x = w * (v - c)
rend[t] = x.sum() / v0 - 1
peso_rv[t] = x[:2].sum() / x.sum()
años = len(R) / 252
exc = pd.Series(rend, R.index) - rf
capital_t = (1 + pd.Series(rend, R.index)).cumprod()
return exc, pd.Series(peso_rv, R.index), {
"ordenes_año": ordenes / años,
"giro_pct_año": 100 * giro / años,
"coste_pb_año": 1e4 * coste / años,
"exceso_pct": 100 * exc.mean() * 252,
"vol_pct": 100 * exc.std() * np.sqrt(252),
"sharpe": exc.mean() / exc.std() * np.sqrt(252),
"caida_max_pct": 100 * (capital_t / capital_t.cummax()
- 1).min(),
"desv_max_pp": 100 * desv}Tres detalles del código que importan en un sistema real. El rendimiento de la cartera se calcula en dinero, con
rendimientos simples, no con una media ponderada de rendimientos logarítmicos (que infravalora el Sharpe, como se vio
en 3.1.4). La rotación (giro) cuenta la mitad del importe negociado, la convención habitual: vender un 5 % de un
activo y comprar un 5 % de otro es una rotación del 5 %. Y el coste se descuenta del valor antes de fijar los pesos
nuevos, así que una cartera que rebalancea mucho tiene algo menos de capital invertido que otra que no lo hace.
Calendario frente a bandas
La referencia ideal es la cartera con los pesos exactos todos los días y sin costes, R @ OBJ: lo que se llama a
veces la cartera «de pesos constantes». Cada regla se compara con ella por el error de seguimiento (la volatilidad
anual de la diferencia de rendimientos):
REGLAS = {"diario": {"regla": "diario"},
"mensual": {"regla": "mensual"},
"trimestral": {"regla": "trimestral"},
"anual": {"regla": "anual"},
"nunca": {"regla": "nunca"},
"banda 5 pp": {"regla": "banda", "banda": 0.05},
"banda 20 % rel.": {"regla": "banda", "banda": 0.20,
"relativa": True}}
ideal = R @ OBJ - rf # pesos exactos cada día, sin costes
res, rend, pesos = {}, {}, {}
for nombre, kw in REGLAS.items():
exc, peso_rv, m = rebalanceo(**kw)
m["error_seg_pct"] = 100 * (exc - ideal).std() * np.sqrt(252)
res[nombre], rend[nombre], pesos[nombre] = m, exc, peso_rv
tabla = pd.DataFrame(res).T
tabla.columns = ["ordenes", "giro_%", "coste_pb", "exceso_%", "vol_%",
"sharpe", "caida_%", "desv_pp", "error_%"]
tabla.drop(columns=["exceso_%", "vol_%"]).round(2) ordenes giro_% coste_pb sharpe caida_% desv_pp error_%
diario 1260.00 66.92 4.21 0.49 -33.39 2.40 0.01
mensual 59.86 16.65 1.00 0.51 -33.14 8.26 0.54
trimestral 19.79 9.98 0.59 0.52 -32.08 9.26 0.81
anual 4.76 5.38 0.31 0.53 -29.94 12.10 1.10
nunca 0.00 0.00 0.00 0.55 -29.64 20.33 2.31
banda 5 pp 5.76 8.06 0.81 0.56 -31.24 6.55 0.79
banda 20 % rel. 6.76 7.36 0.66 0.53 -32.44 8.39 0.69Con un millón de USD en ETF muy líquidos, el coste no decide nada: rebalancear cada día, con 1.260 órdenes y una rotación del 67 % anual, cuesta 4,2 pb al año; cada mes, 1,0 pb; una banda de 5 puntos, 0,8 pb. Lo que separa a las reglas es el control de los pesos:
- Por calendario, cuanto más espaciado, menos rotación y más desviación: la desviación máxima de un activo pasa de 2,4 puntos (diario) a 8,3 (mensual), 9,3 (trimestral) y 12,1 (anual), y el error de seguimiento de 0,01 % a 1,10 %. Sin rebalancear, la desviación llega a 20,3 puntos y el error a 2,31 %.
- Por bandas, la desviación queda acotada por construcción (6,55 puntos como máximo con la banda de 5: el exceso sobre 5 es el movimiento del último día) con muy pocas órdenes: 5,8 al año, menos que el rebalanceo anual. La banda de 5 puntos tiene un error de seguimiento (0,79 %) como el trimestral (0,81 %) con menos de un tercio de órdenes. La banda relativa del 20 % protege más los pesos pequeños (un 10 % no puede pasar del 12 %) y menos los grandes.
La banda gana porque solo opera cuando la desviación importa: en años tranquilos casi no toca la cartera y en una crisis opera varias veces seguidas. El calendario opera igual en los dos casos.
Cuando la comisión es fija
Con una cuenta pequeña el problema cambia de naturaleza. Muchos brókeres minoristas cobran una comisión mínima o fija
por orden, y entonces el coste depende del número de órdenes y no de su tamaño. El plan fijo del bróker ficticio Brio
de los datos del nivel 2 (costes2_brokers.csv, lección 2.4.7) cobra 4,95 USD por orden:
brokers = pd.read_csv("costes2_brokers.csv")
fijo = float(brokers.loc[brokers["plan"] == "fijo", "tarifa"].iloc[0])
pequena = {}
for nombre in ["mensual", "trimestral", "anual", "banda 5 pp",
"banda 20 % rel."]:
exc, _, m = rebalanceo(capital=25_000, fijo=fijo, **REGLAS[nombre])
pequena[nombre] = {k: m[k] for k in ["ordenes_año",
"coste_pb_año", "sharpe"]}
print(f"comisión fija: {fijo} USD por orden")
pd.DataFrame(pequena).T.round(2)comisión fija: 4.95 USD por orden
ordenes_año coste_pb_año sharpe
mensual 59.86 76.44 0.44
trimestral 19.79 24.21 0.50
anual 4.76 5.75 0.53
banda 5 pp 5.76 7.19 0.55
banda 20 % rel. 6.76 8.83 0.52Con 25.000 USD, el rebalanceo mensual cuesta 76 pb al año y baja el Sharpe de 0,51 a 0,44; el trimestral, 24 pb. La banda de 5 puntos cuesta 7,2 pb con prácticamente el mismo Sharpe que sin costes (0,55 frente a 0,56). El rebalanceo diario ni se incluye: 1.260 órdenes al año a 4,95 USD son 6.237 USD, una cuarta parte de la cuenta. El orden de magnitud es lo que cuenta: con comisiones fijas, la frecuencia de rebalanceo tiene que bajar hasta que el número de órdenes deje de pesar, y una banda es la forma natural de conseguirlo sin dejar los pesos a su suerte.
Fuera de la simulación hay un coste más que suele pesar más que todos estos: los impuestos. Vender lo que ha subido para rebalancear realiza una ganancia, y en una cuenta sujeta a impuestos eso adelanta el pago. Las reglas fiscales dependen del país, del vehículo (fondo, ETF, cuenta de pensiones) y cambian con el tiempo: hay que consultarlas en la fuente oficial antes de elegir la regla. Una forma de reducir ventas es rebalancear con el dinero que entra (aportaciones y dividendos) hacia lo que está por debajo de su peso.
El riesgo deriva aunque la rentabilidad no lo diga
La figura de la cabecera dibuja el peso de la renta variable (bolsa de EE. UU. más emergentes, objetivo 50 %) sin rebalancear y con la banda de 5 puntos. A fin de año:
rv = pd.DataFrame(pesos)[["nunca", "anual", "banda 5 pp"]]
fin_año = rv.groupby(rv.index.year).last()
print(fin_año.loc[[2007, 2008, 2013, 2019, 2021, 2022, 2025]]
.mul(100).round(1)) nunca anual banda 5 pp
fecha
2007 55.1 50.0 48.4
2008 38.8 50.0 47.6
2013 36.5 50.0 49.0
2019 52.6 50.0 49.8
2021 56.2 50.0 54.5
2022 53.0 50.0 46.5
2025 60.7 48.9 50.2Sin tocarla, la cartera pasó de un 55,1 % de renta variable a finales de 2007 a un 38,8 % un año después, seguía en un 36,5 % en 2013 y terminó en un 60,7 % en 2025; el mínimo mensual fue un 32,6 % en febrero de 2009 y el máximo, un 65,2 % en noviembre de 2024. Es decir, la cartera sin rebalancear tuvo menos renta variable justo después del desplome, cuando estaba barata, y más que nunca tras quince años de subidas. La regla anual vuelve al 50 % cada diciembre (en 2025 marca 48,9 % porque el último día de la muestra no se rebalancea) y la banda se mueve entre el 41 % y el 56 %.
¿Qué hacen las reglas en una crisis? Rebalancear obliga a comprar lo que cae:
TRAMOS = {"2008-09 a 2009-03": ("2008-09-02", "2009-03-09"),
"rebote 2009": ("2009-03-10", "2009-12-31"),
"2020-02 a 03": ("2020-02-20", "2020-03-23"),
"2022": ("2022-01-04", "2022-10-12")}
total = {k: v + rf for k, v in rend.items()}
pd.DataFrame({t: {k: 100 * ((1 + s.loc[a:b]).prod() - 1)
for k, s in total.items()}
for t, (a, b) in TRAMOS.items()}).round(1) 2008-09 a 2009-03 rebote 2009 2020-02 a 03 2022
diario -25.5 30.1 -18.7 -20.8
mensual -25.5 30.7 -17.7 -20.8
trimestral -24.6 30.6 -17.9 -20.6
anual -21.9 29.4 -17.9 -20.5
nunca -21.0 24.3 -18.9 -19.9
banda 5 pp -23.9 31.3 -18.2 -20.4
banda 20 % rel. -24.8 31.4 -18.4 -20.9En la caída de septiembre de 2008 a marzo de 2009, la cartera rebalanceada a diario pierde un 25,5 % y la que no se toca, un 21,0 %: la segunda llega con menos renta variable (la caída ya le había quitado peso) y no compra en la bajada. En el rebote de 2009 pasa lo contrario: 30,1 % frente a 24,3 %. Es el coste y el peor episodio de la regla: rebalancear empeora la caída de una crisis larga y mejora la recuperación; en una caída corta y seguida de rebote (2020) apenas cambia nada (de −17,7 % a −18,9 %), y en 2022, con acciones y bonos cayendo a la vez, tampoco (de −19,9 % a −20,9 %): cuando todo cae, no hay nada que vender caro para comprar barato.
Lo que no se puede concluir: la rentabilidad
La tabla anterior da a «nunca» el mejor Sharpe por calendario (0,55). Antes de concluir nada, la diferencia frente al mensual por quinquenios, y una comparación con la teoría:
dif = pd.DataFrame({k: rend[k] - rend["mensual"]
for k in ["nunca", "anual", "banda 5 pp"]})
quinq = dif.groupby((dif.index.year - 2006) // 5).mean() * 252e4
quinq.index = ["2006-10", "2011-15", "2016-20", "2021-25"]
print(quinq.round(0))
años = len(R) / 252
crec = np.log1p(R).sum() / años # crecimiento log anual
diario = np.log1p(R @ OBJ).sum() / años
nunca = np.log((OBJ * (1 + R).prod()).sum()) / años
bono = 0.5 * (OBJ @ R.var() - OBJ @ R.cov() @ OBJ) * 252
print(f"media ponderada de los activos {100 * (OBJ @ crec):.2f} %, "
f"diario {100 * diario:.2f} %, nunca {100 * nunca:.2f} %")
print(f"½(Σ w·σ² − σ²_cartera) = {1e4 * bono:.0f} pb al año") nunca anual banda 5 pp
2006-10 39.0 99.0 90.0
2011-15 -114.0 -37.0 12.0
2016-20 -165.0 -17.0 70.0
2021-25 248.0 18.0 28.0
media ponderada de los activos 5.75 %, diario 6.54 %, nunca 6.74 %
½(Σ w·σ² − σ²_cartera) = 79 pb al añoNo rebalancear rinde 39 pb al año más que el mensual en 2006-2010, 114 y 165 menos en los dos quinquenios siguientes y 248 más en el último. El signo cambia con quién gana en cada tramo. No hay una regla que rinda más de forma fiable; hay reglas que controlan mejor el riesgo.
La segunda parte del resultado aclara un argumento que circula mucho, el «bono del rebalanceo». Booth y Fama (1992) mostraron que el crecimiento compuesto de una cartera de pesos constantes supera a la media ponderada del crecimiento de sus activos en aproximadamente : el rendimiento de diversificación. En los datos, la media ponderada del crecimiento logarítmico de los cinco activos es un 5,75 % anual y la cartera rebalanceada cada día crece un 6,54 %: 79 pb de diferencia, exactamente lo que da la fórmula. Pero la cartera que no se rebalancea crece un 6,74 %, todavía más. No hay contradicción: la cartera sin tocar no crece como la media ponderada de sus activos de partida, sino que se va cargando de los que más suben (aquí el oro, con un 10,7 % anual de crecimiento, y la bolsa de EE. UU., con un 8,4 %). Si los ganadores hubieran sido otros, el resultado sería el contrario. El rendimiento de diversificación existe, pero no es una ventaja garantizada frente a no hacer nada.
Una cartera de estrategias
El proyecto del nivel combina estrategias de los niveles 2 y 3, no activos. Una estrategia se puede tratar como un activo más (su serie de rendimientos), con tres diferencias. Primera: sus rendimientos ya están dimensionados por la propia estrategia (cada una con su volatilidad objetivo), así que el «peso» es la fracción del capital que se le asigna. Segunda: rebalancear entre estrategias mueve capital entre sistemas que a su vez operan; el coste depende de lo que negocia cada una. Tercera: las estrategias se degradan, y una cartera que asigna por el pasado sigue dando capital a una estrategia que ha dejado de funcionar (lo trata 4.2.5).
Fuente Mermaid
flowchart TB S[Nueve estrategias] --> V[Vol. y covarianza] V --> W[Pesos a fin de mes] W --> C[Cartera con deriva] C --> T[Vol. objetivo] C --> K[Kill switch 4.2.5]
Los datos del nivel traen nueve estrategias parecidas a las de los niveles 2 y 3 (más una que deja de funcionar en
2019, degradada, que se deja para el segundo ejercicio), con rendimientos diarios simples en exceso del tipo libre
de 2006 a 2025 y eventos fechados: el crash de los quants de agosto de 2007 en pares, el desplome del carry en octubre
de 2008, el crash de momentum de 2009, el Volmageddon de febrero de 2018. La correlación verdadera es baja, y lo que
interesa es si se mantiene en los malos momentos:
est = pd.read_parquet("estrategias4_diarias.parquet").set_index("fecha")
E = est.drop(columns="degradada")
regimen = fac["regimen"]
rho = {g: E[regimen == g].corr() for g in ["calma", "estres"]}
iu = np.triu_indices(E.shape[1], 1)
for g, c in rho.items():
print(f"{g:7s} correlación media {c.to_numpy()[iu].mean():.3f}")
par = ["venta_puts", "venta_volatilidad", "carry_divisas"]
print(rho["calma"].loc[par, par].round(2))
print(rho["estres"].loc[par, par].round(2))calma correlación media 0.082
estres correlación media 0.084
venta_puts venta_volatilidad carry_divisas
venta_puts 1.00 0.57 0.33
venta_volatilidad 0.57 1.00 0.27
carry_divisas 0.33 0.27 1.00
venta_puts venta_volatilidad carry_divisas
venta_puts 1.00 0.61 0.56
venta_volatilidad 0.61 1.00 0.42
carry_divisas 0.56 0.42 1.00La correlación media de las 36 parejas apenas se mueve entre la calma (0,082) y los regímenes de estrés del universo (0,084). La media engaña: entre las tres estrategias que «venden seguros» (venta de puts, venta de volatilidad, carry de divisas), la correlación sube en estrés (de 0,33 a 0,56 entre puts y carry de divisas; de 0,27 a 0,42 entre volatilidad y carry), mientras otras parejas, con la tendencia, bajan. Las pérdidas se concentran.
Los pesos se calculan el último día de cada mes con datos hasta ese día y se aplican desde la sesión siguiente. Tres asignaciones: equiponderada, inversa a la volatilidad de 63 sesiones (la risk parity ingenua, sin correlaciones) y mínima varianza con solo largos y la covarianza de Ledoit-Wolf de un año (lección 4.1.2), optimizada con cvxpy. La risk parity completa, HRP y HERC son de la lección 4.1.4:
import cvxpy as cp
from sklearn.covariance import LedoitWolf
def minima_varianza(S):
w = cp.Variable(len(S))
cp.Problem(cp.Minimize(cp.quad_form(w, cp.psd_wrap(S))),
[cp.sum(w) == 1, w >= 0]).solve(solver="CLARABEL")
return np.clip(w.value, 0, None) / np.clip(w.value, 0, None).sum()
f = E.index
fin_mes = np.flatnonzero(cortes(f, "M"))
PESOS = {"equiponderada": {}, "inversa_vol": {}, "min_varianza": {}}
for i in fin_mes[fin_mes >= 251]:
iv = 1 / E.iloc[i - 62:i + 1].std()
S = LedoitWolf().fit(E.iloc[i - 251:i + 1].to_numpy()).covariance_
d = f[i + 1] # desde la sesión siguiente
PESOS["equiponderada"][d] = np.full(E.shape[1], 1 / E.shape[1])
PESOS["inversa_vol"][d] = (iv / iv.sum()).to_numpy()
PESOS["min_varianza"][d] = minima_varianza(S)
ult = pd.DataFrame({k: v[max(v)] for k, v in PESOS.items()},
index=E.columns)
print("pesos desde", max(PESOS["inversa_vol"]).date())
ult.round(3)pesos desde 2025-12-01
equiponderada inversa_vol min_varianza
tendencia_futuros 0.111 0.086 0.169
carry_futuros 0.111 0.157 0.190
carry_divisas 0.111 0.121 0.071
venta_puts 0.111 0.112 0.040
pares 0.111 0.120 0.169
momentum_acciones 0.111 0.066 0.049
valor_acciones 0.111 0.076 0.109
venta_volatilidad 0.111 0.067 0.000
reversion_corto 0.111 0.195 0.201Los últimos pesos (desde el 1 de diciembre de 2025) muestran la diferencia de criterio. La inversa de la volatilidad da más capital a las estrategias tranquilas (reversión a corto plazo, 19,5 %; carry de futuros, 15,7 %) y menos a las volátiles (momentum, 6,6 %; venta de volatilidad, 6,7 %). La mínima varianza, que además ve las correlaciones, deja la venta de volatilidad a cero y reduce la venta de puts al 4 %: las dos se mueven juntas, y con una basta. Sube la tendencia (16,9 %), que se correlaciona negativamente con casi todas.
def cartera(nuevos, datos=E, coste_pb=2.0):
"""Rendimiento diario con pesos que derivan y se rehacen en las
fechas de `nuevos`; coste por unidad de peso negociado."""
w, rs, idx, giro = None, [], [], 0.0
for d, r in zip(datos.index, datos.to_numpy()):
c = 0.0
if d in nuevos:
obj = nuevos[d]
if w is not None:
giro += np.abs(obj - w).sum()
c = np.abs(obj - w).sum() * coste_pb / 1e4
w = obj
if w is None:
continue
rs.append(w @ r - c)
idx.append(d)
w = w * (1 + r) / (w @ (1 + r))
s = pd.Series(rs, idx)
return s, giro / (len(s) / 252)
def resumen(s, giro=np.nan):
eq = (1 + s).cumprod()
dd = eq / eq.cummax() - 1
m = {"sharpe": s.mean() / s.std() * np.sqrt(252),
"vol_pct": 100 * s.std() * np.sqrt(252),
"exceso_pct": 100 * s.mean() * 252,
"caida_max_pct": 100 * dd.min(),
"giro_pct_año": 100 * giro}
return {**{k: round(v, 2) for k, v in m.items()},
"fecha_peor": dd.idxmin().date()}
CARTERAS = {k: cartera(v) for k, v in PESOS.items()}
pd.DataFrame({k: resumen(*v) for k, v in CARTERAS.items()}).T sharpe vol_pct exceso_pct caida_max_pct giro_pct_año fecha_peor
equiponderada 0.84 4.47 3.77 -14.54 24.68 2009-05-05
inversa_vol 1.0 3.75 3.74 -10.48 142.7 2009-02-20
min_varianza 0.94 3.49 3.29 -8.58 111.26 2008-05-13La deriva entre fines de mes se calcula con los rendimientos en exceso de cada estrategia (todas cobran el mismo tipo libre sobre el capital, así que la aproximación es buena), y cada rebalanceo paga 2 pb por unidad de peso negociado, un supuesto de la lección. De 2007 a 2025, la equiponderada da un Sharpe de 0,84 con un 4,5 % de volatilidad, la inversa a la volatilidad 1,00 con un 3,75 % y la mínima varianza 0,94 con un 3,5 %. Las caídas máximas son −14,5 %, −10,5 % y −8,6 %. Ninguna de estas carteras rinde mucho por sí sola (un 3,3 % a 3,8 % anual en exceso): con correlaciones tan bajas su volatilidad es pequeña, y la forma de llevarla a un riesgo útil es apalancarla hasta una volatilidad objetivo, que es la lección 4.2.2.
El precio de las carteras que miran la volatilidad es la rotación: 143 % al año la inversa a la volatilidad y 111 % la mínima varianza, frente al 25 % de la equiponderada (que solo deshace la deriva). Y cabe preguntarse si conviene recalcular más a menudo:
diario = {}
vol = E.rolling(63).std()
for i in range(fin_mes[fin_mes >= 251][0], len(f) - 1):
iv = 1 / vol.iloc[i]
diario[f[i + 1]] = (iv / iv.sum()).to_numpy()
s_d, giro_d = cartera(diario)
pd.DataFrame({"diaria": resumen(s_d, giro_d),
"mensual": resumen(*CARTERAS["inversa_vol"])}).T sharpe vol_pct exceso_pct caida_max_pct giro_pct_año fecha_peor
diaria 1.04 3.66 3.82 -10.1 359.95 2009-02-20
mensual 1.0 3.75 3.74 -10.48 142.7 2009-02-20Con los pesos rehechos cada día, el Sharpe pasa de 1,00 a 1,04 (el README de los datos mide 1,10 con pesos diarios sin costes y desde 2006) y la rotación de 143 % a 360 % al año. La mejora no viene de «rebalancear más», sino de que la volatilidad de cada estrategia se agrupa en el tiempo: la inversa a la volatilidad recalculada a diario recorta antes la estrategia que acaba de entrar en un tramo agitado, una forma de volatility targeting estrategia a estrategia. Con 2 pb por unidad de peso compensa; con estrategias caras de mover (opciones, crédito), podría no hacerlo.
El peor tramo de la cartera
s = CARTERAS["inversa_vol"][0]
eq = (1 + s).cumprod()
dd = eq / eq.cummax() - 1
pico = eq.loc[:dd.idxmin()].idxmax()
print(f"de {pico.date()} a {dd.idxmin().date()}: "
f"{100 * dd.min():.1f} %")
tramo = E.loc[pico:dd.idxmin()]
print((100 * ((1 + tramo).prod() - 1)).sort_values().round(1))
print(s.nsmallest(3).mul(100).round(2))de 2007-04-02 a 2009-02-20: -10.5 %
venta_puts -45.0
venta_volatilidad -37.2
carry_divisas -22.1
reversion_corto -21.0
carry_futuros -15.6
pares -13.2
valor_acciones -4.9
momentum_acciones 4.7
tendencia_futuros 44.9
dtype: float64
2018-02-05 -1.35
2008-09-25 -1.02
2018-10-03 -0.94
dtype: float64La cartera inversa a la volatilidad cae un 10,5 % desde el 2 de abril de 2007 hasta el 20 de febrero de 2009. En ese tramo, la venta de puts pierde un 45,0 %, la venta de volatilidad un 37,2 % y el carry de divisas un 22,1 %; la tendencia gana un 44,9 % y compensa una parte. El peor día es el 5 de febrero de 2018 (−1,35 %), el del Volmageddon de los datos. Una cartera de estrategias «poco correladas» no está diversificada contra lo que de verdad la amenaza: que varias estrategias sean, en el fondo, la misma apuesta (vender seguros contra caídas), con sus pérdidas en los mismos días. La lección 4.2.4 trata esos escenarios.
Las mismas reglas en bt
bt (la librería de backtesting de carteras de la lección 2.4.6) trae las dos familias de reglas como bloques: los de
calendario (RunMonthly, RunQuarterly) y RunIfOutOfBounds, que en la versión 1.4.1 dispara cuando algún peso se
desvía de su objetivo más que una tolerancia relativa (0,2 = un 20 % del peso objetivo, la banda relativa de la
lección). bt no está en el entorno del curso (como en 2.4.6, su código va sin ejecutar), y el de abajo está comprobado
contra el código fuente de esa versión:
import bt
precios = ... # DataFrame de precios, una columna por activo
pesos = {"RV_EEUU": 0.40, "RV_EMG": 0.10, "RF_T710": 0.30,
"RF_CIG": 0.10, "MP_ORO": 0.10}
trimestral = bt.Strategy("trimestral", [
bt.algos.RunQuarterly(),
bt.algos.SelectAll(),
bt.algos.WeighSpecified(**pesos),
bt.algos.Rebalance()])
bandas = bt.Strategy("bandas_20_rel", [
bt.algos.SelectAll(),
bt.algos.WeighSpecified(**pesos),
bt.algos.Or([bt.algos.RunOnce(),
bt.algos.RunIfOutOfBounds(0.20)]),
bt.algos.Rebalance()])
comision = lambda cantidad, precio: 4.95 # fija por orden
res = bt.run(bt.Backtest(trimestral, precios, commissions=comision,
initial_capital=25_000),
bt.Backtest(bandas, precios, commissions=comision,
initial_capital=25_000))
res.display()El orden de la pila importa: RunIfOutOfBounds necesita los pesos objetivo en temp["weights"], así que va después
de WeighSpecified, y RunOnce compra la cartera el primer día. Backtest usa por defecto posiciones enteras
(integer_positions=True), lo que con 25.000 USD añade un pequeño error de redondeo que el simulador de la lección no
tiene.
Errores típicos
- Comparar reglas por su rentabilidad en un backtest. La diferencia depende de qué ganó en la muestra; compara órdenes, costes, desviación y error de seguimiento.
- Olvidar las comisiones fijas o mínimas. Con cuentas pequeñas deciden la frecuencia.
- Recalcular pesos con datos del día en que se aplican. Los pesos de fin de mes se aplican desde la sesión siguiente.
- Promediar rendimientos logarítmicos para la cartera, o rebalancear «sin deriva» (pesos fijos cada día) en un backtest que dice rebalancear cada mes.
- Fiarse de la correlación media. Mira qué estrategias pierden juntas en los peores tramos.
Ejercicios
- Con la cuenta de 25.000 USD y la comisión fija, compara bandas de 2,5, 5 y 10 puntos: órdenes, coste, desviación máxima y error de seguimiento.
Solución
filas = {}
for b in (0.025, 0.05, 0.10):
exc, _, m = rebalanceo("banda", capital=25_000, fijo=fijo, banda=b)
filas[f"{100 * b:g} pp"] = {
"ordenes_año": m["ordenes_año"],
"coste_pb_año": m["coste_pb_año"],
"desv_max_pp": m["desv_max_pp"],
"error_seg_pct": 100 * (exc - ideal).std() * np.sqrt(252)}
pd.DataFrame(filas).T.round(2) ordenes_año coste_pb_año desv_max_pp error_seg_pct
2.5 pp 17.53 22.51 4.34 0.32
5 pp 5.76 7.19 6.55 0.78
10 pp 1.50 2.13 10.52 1.47Al estrechar la banda a 2,5 puntos las órdenes se triplican (17,5 al año) y el coste también (22,5 pb), con un error de seguimiento de 0,32 %; al ensancharla a 10 puntos bastan 1,5 órdenes al año (2,1 pb), pero la desviación máxima llega a 10,5 puntos y el error a 1,47 %, peor que el rebalanceo anual. La anchura se elige como un compromiso entre coste y control: con esta cuenta, 5 puntos es un punto razonable.
- Añade la estrategia
degradadaa la cartera inversa a la volatilidad y compara el Sharpe antes y después del 1 de julio de 2019 con la cartera de nueve. ¿Cuánto capital recibe después de dejar de funcionar?
Solución
PESOS10 = {}
for i in fin_mes[fin_mes >= 251]:
iv = 1 / est.iloc[i - 62:i + 1].std()
PESOS10[f[i + 1]] = (iv / iv.sum()).to_numpy()
s10, _ = cartera(PESOS10, datos=est)
peso_deg = pd.Series({d: w[-1] for d, w in PESOS10.items()})
s9 = CARTERAS["inversa_vol"][0]
for a, b in [("2007", "2019-06"), ("2019-07", "2025")]:
x10, x9 = s10.loc[a:b], s9.loc[a:b]
print(f"{a} a {b}: con degradada "
f"{x10.mean() / x10.std() * np.sqrt(252):.2f}, sin ella "
f"{x9.mean() / x9.std() * np.sqrt(252):.2f}, peso medio "
f"{100 * peso_deg.loc[a:b].mean():.1f} %")2007 a 2019-06: con degradada 1.10, sin ella 0.74, peso medio 11.6 %
2019-07 a 2025: con degradada 1.37, sin ella 1.50, peso medio 10.7 %Hasta junio de 2019, la estrategia degradada (Sharpe 1,30 en ese tramo según el README) sube el Sharpe de la cartera de 0,74 a 1,10. Después, con media cero, lo baja de 1,50 a 1,37, y la cartera le sigue dando un 10,7 % del capital de media: la inversa a la volatilidad no mira la rentabilidad, solo el riesgo. Asignar por Sharpe pasado tampoco lo arregla bien (un Sharpe de 3 años tiene un error típico cercano a 0,6). Detectar que una estrategia ha dejado de funcionar y retirarla con reglas fijadas de antemano es el tema de la lección 4.2.5.
- Un compañero propone rebalancear la cartera de cinco ETF «solo cuando el mercado ha caído un 20 %», para comprar barato. ¿Qué tiene de banda y qué no? ¿Qué pasaría en 2022?
Solución
Tiene de banda que solo opera cuando algo se ha movido mucho, pero mira el mercado y no los pesos: una caída del 20 % de la bolsa con los bonos subiendo (2008) desvía mucho la cartera y la regla opera; una subida larga de la bolsa (2013-2021) también desvía la cartera, hacia arriba, y la regla no la corrige nunca, así que el riesgo crece sin control. En 2022 la bolsa cayó un 26 % y los bonos un 15 % a la vez (README de los datos): los pesos se desviaron poco y la regla operaría vendiendo bonos que también habían caído, sin «comprar barato» nada. La banda sobre los pesos resuelve las dos cosas, porque opera cuando la cartera ya no es la elegida, en cualquier dirección.
Respuesta plegada en cada pregunta
Autoevaluación
Una cartera 50/50 de acciones y bonos no se toca durante un año en el que las acciones suben un 30 % y los bonos un 0 %. ¿Qué peso tienen las acciones al final?
- A50 %
- BUn 65 %
- CDepende de la volatilidad
- DUn 56,5 %: 0,5·1,3 / (0,5·1,3 + 0,5)
Ver respuesta
Respuesta D. Un 56,5 %: 0,5·1,3 / (0,5·1,3 + 0,5)
Cada peso se multiplica por (1 + su rendimiento) y se divide por (1 + el de la cartera): 0,65 / 1,15 = 0,565. La deriva no depende de la volatilidad sino de los rendimientos realizados.
Con 25.000 USD y una comisión fija de 4,95 USD por orden, el rebalanceo mensual de cinco ETF cuesta 76 pb al año en la lección. ¿Qué lo explica?
- AEl número de órdenes: unas 60 al año, casi todas de importes pequeños, cada una con su comisión fija
- BLa horquilla de los ETF
- CEl impacto en el mercado
- DLos impuestos
Ver respuesta
Respuesta A. El número de órdenes: unas 60 al año, casi todas de importes pequeños, cada una con su comisión fija
Rebalancear cada mes manda órdenes por desviaciones mínimas. Con una comisión fija, el coste depende del número de órdenes y no de su tamaño: 60 × 4,95 USD son 297 USD al año, un 1,2 % de 25.000 USD en los primeros años y algo menos según crece la cuenta. Con una banda de 5 puntos bastan unas 6 órdenes al año.
En la muestra, no rebalancear nunca da un Sharpe de 0,55 y rebalancear cada mes, 0,51. ¿Qué conclusión es correcta?
- AQue no hay que rebalancear
- BQue el rebalanceo mensual es demasiado caro
- CNinguna sobre la rentabilidad: la diferencia cambia de signo por quinquenios (+39, −114, −165 y +248 pb al año) y depende de qué activos ganaron; lo que sí es robusto es que sin rebalancear el peso de la renta variable vagó entre el 33 % y el 65 %
- DQue el Sharpe está mal calculado
Ver respuesta
Respuesta C. Ninguna sobre la rentabilidad: la diferencia cambia de signo por quinquenios (+39, −114, −165 y +248 pb al año) y depende de qué activos ganaron; lo que sí es robusto es que sin rebalancear el peso de la renta variable vagó entre el 33 % y el 65 %
La cartera sin tocar se carga de lo que más sube. En estos 20 años ganaron el oro y la bolsa de EE. UU., y por eso le fue algo mejor; con otro reparto de ganadores habría salido al revés. El riesgo, en cambio, deriva siempre: es la razón de fondo para rebalancear.
¿Por qué la cartera de estrategias con pesos inversos a la volatilidad recalculados cada día (Sharpe 1,04) mejora a la misma cartera recalculada cada mes (1,00)?
- APorque rebalancear cada día siempre es mejor
- BPorque la volatilidad de cada estrategia se agrupa en el tiempo: recalcular a diario recorta antes la estrategia cuya volatilidad acaba de subir, que es una forma de volatility targeting por estrategia, a cambio de una rotación de 360 % al año frente a 143 %
- CPor los costes
- DPor azar: la diferencia es enorme
Ver respuesta
Respuesta B. Porque la volatilidad de cada estrategia se agrupa en el tiempo: recalcular a diario recorta antes la estrategia cuya volatilidad acaba de subir, que es una forma de volatility targeting por estrategia, a cambio de una rotación de 360 % al año frente a 143 %
Los rendimientos de las estrategias tienen GARCH: los días de volatilidad alta vienen juntos. Pesos que se adaptan rápido reducen la exposición en esos tramos. La ganancia (0,04 de Sharpe) es pequeña y cuesta rotación; con costes reales por estrategia altos, podría no compensar.
La correlación media entre las nueve estrategias es 0,082 en calma y 0,084 en estrés. ¿Significa que la diversificación aguanta en las crisis?
- ANo: la media esconde que las estrategias que venden seguros (venta de puts, de volatilidad, carry de divisas) se correlacionan más entre sí en estrés (0,33 a 0,56 entre puts y carry de divisas) mientras la tendencia se mueve al revés; las pérdidas se concentran en el mismo grupo
- BSí, la correlación no cambia
- CNo, porque en estrés todas las correlaciones son 1
- DSí, porque el Sharpe es alto
Ver respuesta
Respuesta A. No: la media esconde que las estrategias que venden seguros (venta de puts, de volatilidad, carry de divisas) se correlacionan más entre sí en estrés (0,33 a 0,56 entre puts y carry de divisas) mientras la tendencia se mueve al revés; las pérdidas se concentran en el mismo grupo
Una media de 36 correlaciones puede quedarse quieta mientras unas suben y otras bajan. Lo que importa es quién pierde a la vez: en el peor tramo, venta de puts −45 %, venta de volatilidad −37 % y carry de divisas −22 %, compensados en parte por la tendencia (+45 %).
Ideas clave
- Sin rebalancear, cada peso se multiplica por (1 + su rendimiento) / (1 + el de la cartera): en 2006-2025 la renta variable de la cartera de la lección (objetivo 50 %) fue del 33 % al 65 % y el oro del 10 % al 22 %.
- Con un millón de USD en ETF líquidos el coste de rebalancear es de 1 a 4 pb al año en cualquier regla; con 25.000 USD y comisión fija por orden manda el número de órdenes: 76 pb al año el mensual frente a 7 pb una banda de 5 puntos.
- Las bandas consiguen con 6 órdenes al año un error de seguimiento (0,79 %) como el del rebalanceo trimestral con 20, porque solo operan cuando la desviación importa.
- La diferencia de rentabilidad entre reglas es ruido de la trayectoria (cambia de signo por quinquenios); lo robusto es el control del riesgo. El rendimiento de diversificación medido, 79 pb al año, coincide con ½(Σw·σ² − σ²) de la teoría.
- Una cartera de nueve estrategias poco correladas (0,08 de media) da un Sharpe de 0,84 equiponderada, 1,00 con pesos inversos a la volatilidad y 0,94 con mínima varianza, todos estimados sin anticipación; su peor tramo, −10,5 %, llega cuando las tres estrategias que venden seguros pierden a la vez.