---
title: "4.1.7 Rebalancing and strategy portfolios"
description: "Calendar and band rebalancing with bid-ask spread, fixed commissions and weight drift, and a portfolio of nine strategies with look-ahead-free weights."
url: "https://pythonparatrading.com/en/python-trading-course/level-4/rebalancing-and-strategy-portfolios/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-4/rebalancing-and-strategy-portfolios.png"
---

# 4.1.7 · 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.

- Course: [Level 4: Portfolios, risk, execution and live trading](https://pythonparatrading.com/en/python-trading-course/level-4/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/), [2.6.1 · CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index](https://pythonparatrading.com/en/python-trading-course/level-2/return-and-risk-metrics/), [3.1.4 · Diversified futures systems: Carver's approach](https://pythonparatrading.com/en/python-trading-course/level-3/diversified-futures-systems-carver/)

## By the end of this lesson

- Explain why the weights of a portfolio drift and how far they move from the target in 20 years if it is left untouched.
- Simulate calendar rebalancing (daily to annual) and band rebalancing (absolute and relative) with normal and stress bid-ask spreads and a fixed commission per order.
- Compare the rules by orders, turnover, cost, tracking error, maximum drawdown and behavior in crises, telling what is robust from what is noise.
- Build a portfolio of strategies with equal weights, inverse-volatility weights and minimum variance weights estimated without look-ahead, and measure its worst stretch.

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

$$
w_{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.

```mermaid
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:

```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)
```
```output
         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%):

```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):

```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)
```
```output
                 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.

<aside class="callout" data-kind="nota">

Carver's buffer in lesson 3.1.4 traded **up to the edge** of the band, not up to the target. There each market is
independent (a future does not consume capital from the others) and taking it to the edge saves trades. In a portfolio
of weights that sum to 1, taking one asset to the edge forces the difference to be spread among the others, which stay
close to their edges and drift out again right away: in a variant of this simulator that traded to the edge, orders
went from 6 to 123 a year. That is why here trades go up to the target.

</aside>

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

```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)
```
```output
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).* Horizontal bars with the cost in basis points per year and orders per year. Monthly rebalancing (highlighted): 76.4 bp and 59.9 orders. Quarterly: 24.2 bp and 19.8 orders. Annual: 5.8 bp and 4.8 orders. 5-point band: 7.2 bp and 5.8 orders. 20% relative band: 8.8 bp and 6.8 orders.

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:

```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))
```
```output
       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:

```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)
```
```output
                 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:

```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")
```
```output
         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 $\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).

```mermaid
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:

```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))
```
```output
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:

```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)
```
```output
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.

```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
```
```output
              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:

```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
```
```output
        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

```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))
```
```output
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.* Horizontal bars with the cumulative change in percent. The portfolio (highlighted) falls 10.5%. Put selling −45.0%, volatility selling −37.2%, FX carry −22.1%, short-term reversal −21.0%, futures carry −15.6%, pairs −13.2%, value −4.9%, equity momentum +4.7% and futures trend +44.9%.

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.

<aside class="callout" data-kind="riesgo">

The data are synthetic. The strategies have their target Sharpe planted and the generator chose a seed with realized
means close to the truth (data README): in a real 20-year sample the measured Sharpes could be much further from the
true ones, which are not known. A Sharpe of 1.0 in a strategy portfolio is the result of this experiment under these
assumptions, not an expectation for real strategies.

</aside>

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

```python skip title="rebalanceo_bt.py"
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.**

```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)
```
```output
        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.

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

```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} %")
```
```output
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.

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

## 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?

   - A. 50%
   - B. 65%
   - C. It depends on volatility
   - D. 56.5%: 0.5·1.3 / (0.5·1.3 + 0.5)

   *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?

   - A. The number of orders: about 60 a year, almost all of small amounts, each with its fixed commission
   - B. The bid-ask spread of the ETFs
   - C. Market impact
   - D. Taxes

   *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?

   - A. That one should not rebalance
   - B. That monthly rebalancing is too expensive
   - 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%
   - D. That the Sharpe is computed wrongly

   *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)?

   - A. Because rebalancing every day is always better
   - 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%
   - C. Because of costs
   - D. By chance: the difference is huge

   *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?

   - 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
   - B. Yes, the correlation does not change
   - C. No, because in stress all correlations are 1
   - D. Yes, because the Sharpe is high

   *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

### Research

- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [bt v1.4.1: rebalancing algorithms (RunQuarterly, RunIfOutOfBounds, WeighSpecified)](https://github.com/pmorissette/bt/blob/v1.4.1/bt/algos.py) · github.com
- [scikit-learn: LedoitWolf](https://scikit-learn.org/stable/modules/generated/sklearn.covariance.LedoitWolf.html) · scikit-learn.org

### Books

- [Booth and Fama (1992): Diversification Returns and Asset Contributions, Financial Analysts Journal 48(3)](https://doi.org/10.2469/faj.v48.n3.26) · doi.org

### Data

- [Level 4 data: prices of the multi-asset universe (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/universo4_precios.parquet) · Download · universo4_precios.parquet
- [Level 4 data: daily returns of the strategies (Parquet)](https://pythonparatrading.com/curso/datos/nivel-4/estrategias4_diarias.parquet) · Download · estrategias4_diarias.parquet

Previous lesson: [4.1.6 · Factor models: CAPM, Fama-French, PCA and attribution](https://pythonparatrading.com/en/python-trading-course/level-4/factor-models-capm-fama-french-pca/)

Next lesson: [4.1.8 · Portfolio tools: Riskfolio-Lib, PyPortfolioOpt, skfolio and bt](https://pythonparatrading.com/en/python-trading-course/level-4/riskfolio-pyportfolioopt-skfolio-and-bt/)
