Lesson 4.1.7 · 65 min
Rebalancing and strategy portfolios
Optimal weights are only optimal on the day they are set: by the next day prices have already moved them. This lesson measures what it costs to bring them back, how often it pays to do so and what is at risk if it is not done, first in a portfolio of five ETFs and then in a portfolio of nine strategies, where the weights are estimated with past data and redone every month.
- Module
- 4.1 · Portfolio theory and optimisation
- Duration
- 65 min
- Prerequisites
- 2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing, 2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index, 3.1.4 · Diversified futures systems: Carver's approach
- Author
- Jesús Cuesta
- Updated
Level 4 lessons
4.1 · Portfolio theory and optimisation
- 4.1.1 Markowitz: efficient frontier and real constraints
- 4.1.2 Covariance estimation: shrinkage, factor models and denoising
- 4.1.3 Black-Litterman: from market equilibrium to views
- 4.1.4 Risk parity, minimum variance, HRP and HERC
- 4.1.5 Optimization with CVaR, drawdown, turnover and costs
- 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
- 4.1.7 Rebalancing and strategy portfolios
- 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
4.2 · Risk management and position sizing
- 4.2.1 Risk per trade, stops and risk of ruin
- 4.2.2 Kelly criterion and volatility targeting: how much to risk
- 4.2.3 VaR, Expected Shortfall and VaR backtesting
- 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
- 4.2.5 Drawdown control and kill switch
- 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
4.3 · Microstructure and execution
4.4 · Brokers and APIs
- 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
- 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
- 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
- 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
- 4.4.5 Secure credentials: environment variables, secret managers and least privilege
4.5 · Live trading system
- 4.5.1 Live system architecture: from data to reconciliation
- 4.5.2 Order management: states, partial fills, cancellations and reconnections
- 4.5.3 Paper trading, shadow trading and slippage monitoring
- 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
- 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
- 4.5.6 NautilusTrader or LEAN live: from backtest to production with the same code
Weights move on their own
The previous lessons of the module compute weights: Markowitz (4.1.1), with better-estimated covariances (4.1.2), with views (4.1.3), by risk (4.1.4), with cost constraints (4.1.5). They are all weights for one day. As soon as prices move, the weight of each asset changes without anyone trading:
What rises gains weight and what falls loses it. A portfolio left untouched ends up, over the years, as a bet on whatever has risen most. Rebalancing means bringing the weights back to their target, and the practical question has three parts: when (by calendar or when the deviation passes a threshold), how far (to the target or to the edge of the band) and at what cost.
Mermaid source
flowchart TB
P[Prices of the day] --> D[Drifted weights]
D --> C{Time to trade?}
C -- calendar --> O[Trade to target]
C -- outside band --> O
C -- no --> E[Wait]
O --> K[Costs]
K --> N[New weights]The lesson's portfolio is a classic five-ETF portfolio from the level's universe (data README): 40% US equities
(RV_EEUU), 10% emerging markets (RV_EMG), 30% 7-10 year Treasury bonds (RF_T710), 10% investment-grade credit
(RF_CIG) and 10% gold (MP_ORO). The data are synthetic, from 2006 to 2025, with the crises of the script (2008,
2020, 2022) and a liquidity per asset that includes the bid-ask spread in calm and in stress:
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)
# total simple return (including the risk-free rate); base 100 the day before
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.575Untouched for 20 years, the portfolio would end with 55.4% in US equities (versus the 40% requested), 22.2% in gold (versus 10%) and only 11.7% in the Treasury bond (versus 30%). It would no longer be the portfolio that was chosen: it would have more than twice the gold and about a third of the bonds. The bid-ask spread, on the other hand, is small: a half spread of 0.435 bp in the US equity ETF and 2.7 bp in the gold one in calm. In the stress regimes of the script it is multiplied by 4 in equities, by 8 in bonds and by 12 in gold (from 2.7 to 32.6 bp): rebalancing in the middle of a crisis is more expensive, and that is precisely when the weights deviate most.
A simulator with both families of rules
The simulator follows the portfolio day by day in dollars: it applies the returns, computes the weights and, if the rule calls for it, trades at the close back to the target. Each order pays half the spread on its amount (the stress one outside the calm stretches) and, if specified, a fixed commission per order. Calendar rules trade on the last day of each period no matter what; band rules look at every close and trade only if some asset has left its band, which can be absolute (5 points: a 40% can go from 35% to 45%) or relative (20% of the weight: a 10% can go from 8% to 12%):
def cortes(fechas, freq):
"""True on the last session of each period (M, Q or 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):
"""Portfolio with target weights OBJ, decisions at the close.
regla: 'diario', 'mensual', 'trimestral', 'anual', 'nunca' or
'banda' (checked at every close). Cost: half spread (the stress
one outside calm) on the traded cash + `fijo` USD per
order. Returns the daily return and a summary."""
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}Three details of the code that matter in a real system. The portfolio return is computed in money, with simple
returns, not with a weighted average of log returns (which underestimates the Sharpe, as seen in 3.1.4). Turnover
(giro) counts half the traded amount, the usual convention: selling 5% of one asset and buying 5% of another is a 5%
turnover. And the cost is deducted from the value before setting the new weights, so a portfolio that rebalances a lot
has somewhat less capital invested than one that does not.
Calendar versus bands
The ideal benchmark is the portfolio with the exact weights every day and no costs, R @ OBJ: what is sometimes called
the “constant weights” portfolio. Each rule is compared with it by tracking error (the annual volatility of the
difference in returns):
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 # exact weights every day, no costs
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.69With USD 1 million in very liquid ETFs, cost decides nothing: rebalancing every day, with 1,260 orders and a turnover of 67% a year, costs 4.2 bp a year; every month, 1.0 bp; a 5-point band, 0.8 bp. What separates the rules is the control of the weights:
- By calendar, the more spaced out, the less turnover and the more deviation: the maximum deviation of an asset goes from 2.4 points (daily) to 8.3 (monthly), 9.3 (quarterly) and 12.1 (annual), and the tracking error from 0.01% to 1.10%. Without rebalancing, the deviation reaches 20.3 points and the error 2.31%.
- By bands, the deviation stays bounded by construction (6.55 points at most with the 5-point band: the excess over 5 is the move of the last day) with very few orders: 5.8 a year, fewer than annual rebalancing. The 5-point band has a tracking error (0.79%) like the quarterly one (0.81%) with less than a third of the orders. The 20% relative band protects small weights more (a 10% cannot go above 12%) and large ones less.
The band wins because it only trades when the deviation matters: in quiet years it barely touches the portfolio and in a crisis it trades several times in a row. The calendar trades the same way in both cases.
When the commission is fixed
With a small account the problem changes nature. Many retail brokers charge a minimum or fixed commission per order,
and then the cost depends on the number of orders and not on their size. The fixed plan of the fictitious broker Brio
from the level 2 data (costes2_brokers.csv, lesson 2.4.7) charges USD 4.95 per order:
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"fixed commission: {fijo} USD per order")
pd.DataFrame(pequena).T.round(2)fixed commission: 4.95 USD per order
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.52With USD 25,000, monthly rebalancing costs 76 bp a year and lowers the Sharpe from 0.51 to 0.44; quarterly, 24 bp. The 5-point band costs 7.2 bp with practically the same Sharpe as without costs (0.55 versus 0.56). Daily rebalancing is not even included: 1,260 orders a year at USD 4.95 is USD 6,237, a quarter of the account. The order of magnitude is what counts: with fixed commissions, the rebalancing frequency has to come down until the number of orders stops weighing, and a band is the natural way to achieve it without leaving the weights to chance.
Outside the simulation there is one more cost that usually weighs more than all of these: taxes. Selling what has risen in order to rebalance realizes a gain, and in a taxable account that brings the payment forward. Tax rules depend on the country and on the vehicle (fund, ETF, pension account) and change over time: check them in the official source before choosing the rule. One way to reduce sales is to rebalance with incoming money (contributions and dividends) toward whatever is below its weight.
Risk drifts even if return does not show it
The header figure draws the equity weight (US equities plus emerging markets, target 50%) without rebalancing and with the 5-point band. At year end:
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.2Untouched, the portfolio went from 55.1% equities at the end of 2007 to 38.8% a year later, was still at 36.5% in 2013 and ended at 60.7% in 2025; the monthly minimum was 32.6% in February 2009 and the maximum 65.2% in November 2024. In other words, the unrebalanced portfolio had less equity right after the crash, when it was cheap, and more than ever after fifteen years of gains. The annual rule goes back to 50% every December (in 2025 it shows 48.9% because the last day of the sample is not rebalanced) and the band moves between 41% and 56%.
What do the rules do in a crisis? Rebalancing forces buying what is falling:
TRAMOS = {"2008-09 to 2009-03": ("2008-09-02", "2009-03-09"),
"2009 rebound": ("2009-03-10", "2009-12-31"),
"2020-02 to 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 to 2009-03 2009 rebound 2020-02 to 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.9In the decline from September 2008 to March 2009, the portfolio rebalanced daily loses 25.5% and the untouched one 21.0%: the second arrives with less equity (the decline had already taken weight away from it) and does not buy on the way down. In the 2009 rebound the opposite happens: 30.1% versus 24.3%. That is the cost and the worst episode of the rule: rebalancing worsens the drawdown of a long crisis and improves the recovery; in a short decline followed by a rebound (2020) it barely changes anything (from −17.7% to −18.9%), and in 2022, with stocks and bonds falling at the same time, neither (from −19.9% to −20.9%): when everything falls, there is nothing to sell high to buy low.
What cannot be concluded: return
The previous table gives nunca (never) the best Sharpe among the calendar rules (0.55). Before concluding anything,
the difference against monthly by five-year period, and a comparison with theory:
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 # annual log growth
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"weighted average of the assets {100 * (OBJ @ crec):.2f} %, "
f"daily {100 * diario:.2f} %, never {100 * nunca:.2f} %")
print(f"½(Σ w·σ² − σ²_portfolio) = {1e4 * bono:.0f} bp a year") 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
weighted average of the assets 5.75 %, daily 6.54 %, never 6.74 %
½(Σ w·σ² − σ²_portfolio) = 79 bp a yearNever rebalancing returns 39 bp a year more than monthly in 2006-2010, 114 and 165 less in the next two five-year periods and 248 more in the last one. The sign changes with who wins in each stretch. There is no rule that reliably returns more; there are rules that control risk better.
The second part of the result clarifies an argument that circulates widely, the “rebalancing bonus”. Booth and Fama (1992) showed that the compound growth of a constant-weight portfolio exceeds the weighted average of the growth of its assets by approximately : the diversification return. In the data, the weighted average of the log growth of the five assets is 5.75% a year and the portfolio rebalanced every day grows 6.54%: a difference of 79 bp, exactly what the formula gives. But the portfolio that is not rebalanced grows 6.74%, even more. There is no contradiction: the untouched portfolio does not grow like the weighted average of its starting assets, but keeps loading up on those that rise most (here gold, with 10.7% annual growth, and US equities, with 8.4%). Had the winners been others, the result would have been the opposite. The diversification return exists, but it is not a guaranteed advantage over doing nothing.
A portfolio of strategies
The level project combines strategies from levels 2 and 3, not assets. A strategy can be treated as one more asset (its return series), with three differences. First: its returns are already sized by the strategy itself (each with its own target volatility), so the “weight” is the fraction of capital allocated to it. Second: rebalancing between strategies moves capital between systems that themselves trade; the cost depends on what each one trades. Third: strategies degrade, and a portfolio that allocates based on the past keeps giving capital to a strategy that has stopped working (covered in 4.2.5).
Mermaid source
flowchart TB S[Nine strategies] --> V[Vol. and covariance] V --> W[Month-end weights] W --> C[Portfolio with drift] C --> T[Target vol.] C --> K[Kill switch 4.2.5]
The level data come with nine strategies similar to those of levels 2 and 3 (plus one that stops working in 2019,
degradada, which is kept for the second exercise), with simple daily returns in excess of the risk-free rate from
2006 to 2025 and dated events: the quant crash of August 2007 in pairs, the carry collapse of October 2008, the
momentum crash of 2009, the Volmageddon of February 2018. The true correlation is low, and what matters is whether it
holds in bad times:
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} average correlation {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 average correlation 0.082
estres average correlation 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.00The average correlation of the 36 pairs barely moves between calm (0.082) and the stress regimes of the universe (0.084). The average is misleading: among the three strategies that “sell insurance” (put selling, volatility selling, currency carry), correlation rises in stress (from 0.33 to 0.56 between puts and currency carry; from 0.27 to 0.42 between volatility and carry), while other pairs, those with trend, fall. Losses concentrate.
The weights are computed on the last day of each month with data up to that day and applied from the next session. Three allocations: equal weight, inverse volatility over 63 sessions (naive risk parity, without correlations) and long-only minimum variance with the one-year Ledoit-Wolf covariance (lesson 4.1.2), optimized with cvxpy. Full risk parity, HRP and HERC belong to lesson 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] # from the next session
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("weights from", max(PESOS["inversa_vol"]).date())
ult.round(3)weights from 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.201The latest weights (from December 1, 2025) show the difference in criterion. Inverse volatility gives more capital to the calm strategies (short-term reversal, 19.5%; futures carry, 15.7%) and less to the volatile ones (momentum, 6.6%; volatility selling, 6.7%). Minimum variance, which also sees the correlations, leaves volatility selling at zero and cuts put selling to 4%: the two move together, and one is enough. Trend goes up (16.9%), since it is negatively correlated with almost all of them.
def cartera(nuevos, datos=E, coste_pb=2.0):
"""Daily return with weights that drift and are reset on the
dates in `nuevos`; cost per unit of weight traded."""
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-13The drift between month ends is computed with the excess returns of each strategy (all of them earn the same risk-free rate on capital, so the approximation is good), and each rebalance pays 2 bp per unit of weight traded, an assumption of the lesson. From 2007 to 2025, equal weight gives a Sharpe of 0.84 with 4.5% volatility, inverse volatility 1.00 with 3.75% and minimum variance 0.94 with 3.5%. The maximum drawdowns are −14.5%, −10.5% and −8.6%. None of these portfolios returns much on its own (3.3% to 3.8% a year in excess): with such low correlations their volatility is small, and the way to bring it to a useful level of risk is to lever it up to a target volatility, which is lesson 4.2.2.
The price of the portfolios that look at volatility is turnover: 143% a year for inverse volatility and 111% for minimum variance, versus 25% for equal weight (which only undoes the drift). And it is worth asking whether it pays to recompute more often:
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-20With the weights reset every day, the Sharpe goes from 1.00 to 1.04 (the data README measures 1.10 with daily weights without costs and from 2006) and turnover from 143% to 360% a year. The improvement does not come from “rebalancing more”, but from the fact that the volatility of each strategy clusters in time: inverse volatility recomputed daily cuts sooner the strategy that has just entered a turbulent stretch, a form of strategy-by-strategy volatility targeting. At 2 bp per unit of weight it pays; with strategies that are expensive to move (options, credit), it might not.
The worst stretch of the portfolio
s = CARTERAS["inversa_vol"][0]
eq = (1 + s).cumprod()
dd = eq / eq.cummax() - 1
pico = eq.loc[:dd.idxmin()].idxmax()
print(f"from {pico.date()} to {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))from 2007-04-02 to 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: float64The inverse-volatility portfolio falls 10.5% from April 2, 2007 to February 20, 2009. In that stretch, put selling loses 45.0%, volatility selling 37.2% and currency carry 22.1%; trend gains 44.9% and offsets part of it. The worst day is February 5, 2018 (−1.35%), the Volmageddon of the data. A portfolio of “weakly correlated” strategies is not diversified against what really threatens it: that several strategies are, at bottom, the same bet (selling insurance against declines), with their losses on the same days. Lesson 4.2.4 covers those scenarios.
The same rules in bt
bt (the portfolio backtesting library of lesson 2.4.6) comes with both families of rules as building blocks: the
calendar ones (RunMonthly, RunQuarterly) and RunIfOutOfBounds, which in version 1.4.1 fires when some weight
deviates from its target by more than a relative tolerance (0.2 = 20% of the target weight, the lesson's relative
band). bt is not in the course environment (as in 2.4.6, its code is not run), and the code below has been checked
against the source code of that version:
import bt
precios = ... # DataFrame of prices, one column per asset
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 # fixed per order
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()The order of the stack matters: RunIfOutOfBounds needs the target weights in temp["weights"], so it goes after
WeighSpecified, and RunOnce buys the portfolio on the first day. Backtest uses integer positions by default
(integer_positions=True), which with USD 25,000 adds a small rounding error that the lesson's simulator does not
have.
Common mistakes
- Comparing rules by their return in a backtest. The difference depends on what won in the sample; compare orders, costs, deviation and tracking error.
- Forgetting fixed or minimum commissions. With small accounts they decide the frequency.
- Recomputing weights with data from the day they are applied. Month-end weights are applied from the next session.
- Averaging log returns for the portfolio, or rebalancing “without drift” (fixed weights every day) in a backtest that claims to rebalance every month.
- Trusting the average correlation. Look at which strategies lose together in the worst stretches.
Exercises
- With the USD 25,000 account and the fixed commission, compare bands of 2.5, 5 and 10 points: orders, cost, maximum deviation and tracking error.
Solution
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.47Narrowing the band to 2.5 points triples the orders (17.5 a year) and the cost too (22.5 bp), with a tracking error of 0.32%; widening it to 10 points needs only 1.5 orders a year (2.1 bp), but the maximum deviation reaches 10.5 points and the error 1.47%, worse than annual rebalancing. The width is chosen as a compromise between cost and control: with this account, 5 points is a reasonable choice.
- Add the
degradadastrategy to the inverse-volatility portfolio and compare the Sharpe before and after July 1, 2019 with the nine-strategy portfolio. How much capital does it receive after it stops working?
Solution
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} to {b}: with degradada "
f"{x10.mean() / x10.std() * np.sqrt(252):.2f}, without it "
f"{x9.mean() / x9.std() * np.sqrt(252):.2f}, average weight "
f"{100 * peso_deg.loc[a:b].mean():.1f} %")2007 to 2019-06: with degradada 1.10, without it 0.74, average weight 11.6 %
2019-07 to 2025: with degradada 1.37, without it 1.50, average weight 10.7 %Until June 2019, the degraded strategy (Sharpe 1.30 in that stretch according to the README) raises the portfolio's Sharpe from 0.74 to 1.10. Afterward, with zero mean, it lowers it from 1.50 to 1.37, and the portfolio keeps giving it 10.7% of the capital on average: inverse volatility does not look at return, only at risk. Allocating by past Sharpe does not fix it well either (a 3-year Sharpe has a standard error close to 0.6). Detecting that a strategy has stopped working and retiring it with rules set in advance is the subject of lesson 4.2.5.
- A colleague proposes rebalancing the five-ETF portfolio “only when the market has fallen 20%”, to buy low. What does it have in common with a band and what not? What would happen in 2022?
Solution
It is like a band in that it only trades when something has moved a lot, but it looks at the market and not at the weights: a 20% fall in equities with bonds rising (2008) moves the portfolio a lot and the rule trades; a long rally in equities (2013-2021) also moves the portfolio, upward, and the rule never corrects it, so risk grows unchecked. In 2022 equities fell 26% and bonds 15% at the same time (data README): the weights deviated little and the rule would trade by selling bonds that had also fallen, without “buying low” anything. A band on the weights solves both problems, because it trades when the portfolio is no longer the one chosen, in either direction.
Each answer is folded under its question
Self-assessment
A 50/50 portfolio of stocks and bonds is left untouched for a year in which stocks rise 30% and bonds 0%. What weight do stocks have at the end?
- A50%
- B65%
- CIt depends on volatility
- D56.5%: 0.5·1.3 / (0.5·1.3 + 0.5)
Show answer
Answer D. 56.5%: 0.5·1.3 / (0.5·1.3 + 0.5)
Each weight is multiplied by (1 + its return) and divided by (1 + the portfolio's): 0.65 / 1.15 = 0.565. Drift does not depend on volatility but on realized returns.
With USD 25,000 and a fixed commission of USD 4.95 per order, monthly rebalancing of five ETFs costs 76 bp a year in the lesson. What explains it?
- AThe number of orders: about 60 a year, almost all of small amounts, each with its fixed commission
- BThe bid-ask spread of the ETFs
- CMarket impact
- DTaxes
Show answer
Answer A. The number of orders: about 60 a year, almost all of small amounts, each with its fixed commission
Rebalancing every month sends orders for minimal deviations. With a fixed commission, the cost depends on the number of orders and not on their size: 60 × USD 4.95 is USD 297 a year, 1.2% of USD 25,000 in the first years and somewhat less as the account grows. With a 5-point band, about 6 orders a year are enough.
In the sample, never rebalancing gives a Sharpe of 0.55 and rebalancing every month, 0.51. Which conclusion is correct?
- AThat one should not rebalance
- BThat monthly rebalancing is too expensive
- CNone about return: the difference changes sign by five-year period (+39, −114, −165 and +248 bp a year) and depends on which assets won; what is robust is that without rebalancing the equity weight wandered between 33% and 65%
- DThat the Sharpe is computed wrongly
Show answer
Answer C. None about return: the difference changes sign by five-year period (+39, −114, −165 and +248 bp a year) and depends on which assets won; what is robust is that without rebalancing the equity weight wandered between 33% and 65%
The untouched portfolio loads up on whatever rises most. In these 20 years gold and US equities won, and that is why it did somewhat better; with another set of winners it would have come out the other way. Risk, on the other hand, always drifts: that is the underlying reason to rebalance.
Why does the strategy portfolio with inverse-volatility weights recomputed every day (Sharpe 1.04) beat the same portfolio recomputed every month (1.00)?
- ABecause rebalancing every day is always better
- BBecause the volatility of each strategy clusters in time: recomputing daily cuts sooner the strategy whose volatility has just risen, a form of volatility targeting per strategy, in exchange for a turnover of 360% a year versus 143%
- CBecause of costs
- DBy chance: the difference is huge
Show answer
Answer B. Because the volatility of each strategy clusters in time: recomputing daily cuts sooner the strategy whose volatility has just risen, a form of volatility targeting per strategy, in exchange for a turnover of 360% a year versus 143%
Strategy returns have GARCH: high-volatility days come together. Weights that adapt quickly reduce exposure in those stretches. The gain (0.04 of Sharpe) is small and costs turnover; with high real costs per strategy, it might not pay.
The average correlation among the nine strategies is 0.082 in calm and 0.084 in stress. Does it mean that diversification holds up in crises?
- ANo: the average hides that the strategies that sell insurance (put selling, volatility selling, currency carry) become more correlated with each other in stress (0.33 to 0.56 between puts and currency carry) while trend moves the other way; losses concentrate in the same group
- BYes, the correlation does not change
- CNo, because in stress all correlations are 1
- DYes, because the Sharpe is high
Show answer
Answer A. No: the average hides that the strategies that sell insurance (put selling, volatility selling, currency carry) become more correlated with each other in stress (0.33 to 0.56 between puts and currency carry) while trend moves the other way; losses concentrate in the same group
An average of 36 correlations can stay still while some rise and others fall. What matters is who loses at the same time: in the worst stretch, put selling −45%, volatility selling −37% and currency carry −22%, partly offset by trend (+45%).
Key points
- Without rebalancing, each weight is multiplied by (1 + its return) / (1 + the portfolio's): in 2006-2025 the equity share of the lesson's portfolio (target 50%) went from 33% to 65% and gold from 10% to 22%.
- With USD 1 million in liquid ETFs the cost of rebalancing is 1 to 4 bp a year under any rule; with USD 25,000 and a fixed commission per order the number of orders rules: 76 bp a year for monthly versus 7 bp for a 5-point band.
- Bands achieve with 6 orders a year a tracking error (0.79%) like that of quarterly rebalancing with 20, because they only trade when the deviation matters.
- The return difference between rules is path noise (it changes sign by five-year period); what is robust is risk control. The measured diversification return, 79 bp a year, matches the theoretical ½(Σw·σ² − σ²).
- A portfolio of nine weakly correlated strategies (0.08 on average) gives a Sharpe of 0.84 equal-weighted, 1.00 with inverse-volatility weights and 0.94 with minimum variance, all estimated without look-ahead; its worst stretch, −10.5%, comes when the three strategies that sell insurance lose at the same time.