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.

Fig. — Equity weight (50% target) in a five-asset portfolio never rebalanced and with a 5-point band per asset, month-end (2006-2025)
Level 4 lessons
  1. 4.1 · Portfolio theory and optimisation

    1. 4.1.1 Markowitz: efficient frontier and real constraints
    2. 4.1.2 Covariance estimation: shrinkage, factor models and denoising
    3. 4.1.3 Black-Litterman: from market equilibrium to views
    4. 4.1.4 Risk parity, minimum variance, HRP and HERC
    5. 4.1.5 Optimization with CVaR, drawdown, turnover and costs
    6. 4.1.6 Factor models: CAPM, Fama-French, PCA and attribution
    7. 4.1.7 Rebalancing and strategy portfolios
    8. 4.1.8 Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt
  2. 4.2 · Risk management and position sizing

    1. 4.2.1 Risk per trade, stops and risk of ruin
    2. 4.2.2 Kelly criterion and volatility targeting: how much to risk
    3. 4.2.3 VaR, Expected Shortfall and VaR backtesting
    4. 4.2.4 Stress testing and correlation, liquidity, gap and counterparty risk
    5. 4.2.5 Drawdown control and kill switch
    6. 4.2.6 Derivatives risk: portfolio Greeks, margin and liquidations
  3. 4.3 · Microstructure and execution

    1. 4.3.1 Implementation shortfall, VWAP, TWAP and Almgren-Chriss
    2. 4.3.2 Order flow imbalance, microstructure signals and latency
  4. 4.4 · Brokers and APIs

    1. 4.4.1 Choosing a broker from Spain or the EU: regulation, assets, costs and API
    2. 4.4.2 Interactive Brokers: TWS and IB Gateway, ib_async, contracts, data and orders
    3. 4.4.3 Alpaca, Tradier, MetaTrader 5 with Python and OANDA v20
    4. 4.4.4 Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase
    5. 4.4.5 Secure credentials: environment variables, secret managers and least privilege
  5. 4.5 · Live trading system

    1. 4.5.1 Live system architecture: from data to reconciliation
    2. 4.5.2 Order management: states, partial fills, cancellations and reconnections
    3. 4.5.3 Paper trading, shadow trading and slippage monitoring
    4. 4.5.4 Task scheduling and alerts: cron, APScheduler, Prefect, Telegram, Slack and email
    5. 4.5.5 Fault tolerance: idempotency, persistent state and reconciliation at startup
    6. 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:

wi,t+1=wi,t 1+ri,t+11+rp,t+1,rp,t+1=∑jwj,t rj,t+1w_{i,t+1} = w_{i,t}\,\frac{1 + r_{i,t+1}}{1 + r_{p,t+1}}, \qquad r_{p,t+1} = \sum_j w_{j,t}\, r_{j,t+1}

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.

calendaroutside bandnoPrices of the dayDrifted weightsTime to trade?Trade to targetWaitCostsNew weights
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:

IN [01]python
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)
OUT [01]The target portfolio and its returns
         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.575

Untouched 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%):

IN [02]python
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):

IN [03]python
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)
OUT [03]Seven rules with USD 1 million
                 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.69

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

IN [04]python
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)
OUT [04]Fixed commissions with a small account
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.52
Fig. — Annual cost of rebalancing a USD 25,000 five-ETF portfolio with a fixed USD 4.95 commission per order plus half the spread, by rule (2006-2025)

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

IN [05]python
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))
OUT [05]The equity weight without rebalancing
       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.2

Untouched, 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:

IN [06]python
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)
OUT [06]The rules in crises
                 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.9

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

IN [07]python
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")
OUT [07]Return difference by five-year period
         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 year

Never 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 12(∑iwiσi2−σp2)\tfrac12(\sum_i w_i\sigma_i^2 - \sigma_p^2): 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).

Nine strategiesVol. and covarianceMonth-end weightsPortfolio with driftTarget vol.Kill switch 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:

IN [08]python
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))
OUT [08]Nine strategies and their correlations
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.00

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

IN [09]python
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)
OUT [09]Month-end weights without looking at the future
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.201

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

IN [10]python
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
OUT [10]The three allocations, with drift between month ends
              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-13

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

IN [11]python
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
OUT [11]Resetting the weights every day or every month
        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-20

With 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

IN [12]python
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))
OUT [12]The worst stretch of the strategy portfolio
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: float64
Fig. — Worst stretch of the nine-strategy inverse-volatility portfolio (2007-04-02 to 2009-02-20): change in each strategy and in the portfolio

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

IN [13]rebalanceo_bt.pypython
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

  1. 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
IN [14]python
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)
OUT [14]Wider and narrower bands
        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.47

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

  1. Add the degradada strategy 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
IN [15]python
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} %")
OUT [15]The degraded strategy inside the portfolio
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.

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

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

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

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

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

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

Resources

Data