Portfolio & Risk·Portfolio Rebalancing·Beginner

Calendar Rebalancing

Build a calendar-based disciplined portfolio rebalancing system that restores target allocation weights on a fixed periodic schedule with configurable frequency, providing a simple, predictable, and behaviorally robust rebalancing discipline that removes emotion and market-timing temptation.

asset-allocationrisk-managementspot-trading

Calendar Rebalancing — Portfolio Rebalancing

What This Notebook Does

Calendar rebalancing restores a portfolio to its target weights at fixed time intervals — daily, weekly, monthly, or quarterly — regardless of how much weights have drifted. It is the most commonly used rebalancing method due to its simplicity.

Key trade-off:

  • More frequent rebalancing → tighter drift control, more transaction costs, potential momentum drag (selling winners too early)
  • Less frequent rebalancing → lower costs, but more risk concentration as winners dominate

Optimal frequency depends on asset volatility, correlation structure, and transaction costs. For crypto (high volatility, high correlation), monthly is a common starting point.

This notebook:

  1. Fetches data (Yahoo Finance or synthetic)
  2. Simulates portfolio performance at Daily / Weekly / Monthly / Quarterly rebalancing
  3. Measures turnover, transaction cost drag, and tracking error for each frequency
  4. Compares all frequencies on risk-adjusted performance metrics
  5. Identifies the optimal rebalancing frequency for this portfolio
[ ]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[ ]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

REBAL_FREQS maps human-readable names to pandas offset aliases. ME = month-end, QE = quarter-end, W-FRI = weekly on Fridays. TARGET_WEIGHTS is the strategic allocation — the weights we reset to on each rebalancing date.

[ ]
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS       = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD']
START_DATE    = '2022-01-01'
END_DATE      = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS           = ['BTC','ETH','SOL','BNB','AVAX']
TARGET_WEIGHTS   = np.array([0.35, 0.25, 0.20, 0.12, 0.08])
TRANSACTION_COST = 0.001
REBAL_FREQS      = {
    'Daily':     'B',
    'Weekly':    'W-FRI',
    'Monthly':   'ME',
    'Quarterly': 'QE',
    'No Rebal':  None,
}
print('Config ready.')
Config ready.

Data Acquisition

We need a multi-year return history with enough market cycles to make rebalancing frequency comparisons meaningful. The synthetic path builds in a bull-to-bear cycle so that different assets dominate at different times, making rebalancing decisions non-trivial.

[ ]
if USE_LIVE_DATA:
    import yfinance as yf
    prices = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
    prices.columns = ASSETS
    prices.dropna(inplace=True)
    print(f'Live data: {len(prices)} days')
else:
    rng  = np.random.default_rng(42)
    n    = 730
    vols = np.array([0.65, 0.75, 1.20, 0.70, 1.10]) / np.sqrt(252)
    mu   = np.array([0.50, 0.45, 0.80, 0.35, 0.70]) / 252
    corr = np.array([[1,.85,.70,.75,.65],[.85,1,.75,.70,.68],
                      [.70,.75,1,.65,.72],[.75,.70,.65,1,.60],[.65,.68,.72,.60,1]])
    cov  = np.outer(vols, vols) * corr
    L    = np.linalg.cholesky(cov)
    data = rng.standard_normal((n, 5)) @ L.T + mu
    idx  = pd.date_range('2022-01-01', periods=n, freq='B')
    returns = pd.DataFrame(data, columns=ASSETS, index=idx)
    prices  = (1 + returns).cumprod() * np.array([30000, 2000, 100, 300, 80])
    print(f'Synthetic data: {n} days')

returns = prices.pct_change().dropna()
Synthetic data: 730 days

Calendar Rebalancing Simulator

For each frequency: build the list of rebalancing dates using resample(), then iterate daily. On non-rebalancing days weights drift with prices. On rebalancing days, compute the turnover (sum of absolute weight changes / 2), apply the one-way cost, and reset to target. The cost is subtracted directly from the portfolio return that day.

[ ]
def simulate_calendar_rebalancing(returns: pd.DataFrame, target: np.ndarray,
                                   freq: str | None, tc: float = 0.001) -> dict:
    """
    Simulate calendar-based portfolio rebalancing.

    Parameters
    ----------
    returns : pd.DataFrame  Daily asset return series.
    target  : np.ndarray    Target weights (must sum to 1).
    freq    : str | None    Pandas resampling offset (e.g. 'ME'). None = no rebalancing.
    tc      : float         One-way transaction cost fraction.

    Returns
    -------
    dict  keys: 'portfolio_rets', 'n_rebalances', 'total_turnover', 'weight_history'
    """
    rebal_set = set()
    if freq is not None:
        rebal_set = set(returns.resample(freq).last().index)

    w = target.copy()
    port_rets = []
    weight_hist = []
    n_rebalances = 0
    total_turnover = 0.0

    for date, row in returns.iterrows():
        port_ret = float(w @ row.values)
        w = w * (1 + row.values)
        if w.sum() > 0:
            w /= w.sum()
        weight_hist.append(w.copy())

        if date in rebal_set:
            turnover = np.abs(w - target).sum() / 2
            port_ret -= turnover * tc
            total_turnover += turnover
            n_rebalances += 1
            w = target.copy()

        port_rets.append(port_ret)

    return {
        'portfolio_rets':  pd.Series(port_rets, index=returns.index),
        'n_rebalances':    n_rebalances,
        'total_turnover':  total_turnover,
        'weight_history':  pd.DataFrame(weight_hist, index=returns.index, columns=returns.columns)
    }


results = {}
print(f'{"Frequency":12} | {"Rebalances":12} | {"Turnover":10} | {"Total Return":14} | {"Sharpe":8}')
print('-' * 65)
for name, freq in REBAL_FREQS.items():
    r = simulate_calendar_rebalancing(returns, TARGET_WEIGHTS, freq, TRANSACTION_COST)
    results[name] = r
    total_ret = (1 + r['portfolio_rets']).prod() - 1
    sharpe    = r['portfolio_rets'].mean() / (r['portfolio_rets'].std() + 1e-9) * np.sqrt(252)
    print(f'{name:12} | {r["n_rebalances"]:12d} | {r["total_turnover"]:10.1%} | {total_ret:14.1%} | {sharpe:8.2f}')
Frequency    | Rebalances   | Turnover   | Total Return   | Sharpe  
-----------------------------------------------------------------
Daily        |          729 |     732.6% |         -47.0% |     0.08
Weekly       |          146 |     309.6% |         -48.1% |     0.07
Monthly      |           24 |     132.7% |         -46.0% |     0.09
Quarterly    |            6 |      59.4% |         -49.0% |     0.06
No Rebal     |            0 |       0.0% |         -55.3% |    -0.02

Visualisation

The left panel overlays equity curves for all five strategies. Daily rebalancing (highest line overhead) typically shows the most cost drag. The right panel shows the BTC weight drift under each strategy — monthly keeps BTC closest to target while no-rebalance lets it dominate as it outperforms.

[ ]
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Calendar Rebalancing Frequency Comparison', fontsize=13, fontweight='bold')

colors = ['#e53935','#ff9800','#1976d2','#43a047','#9e9e9e']
ax1 = axes[0]
for (name, r), color in zip(results.items(), colors):
    equity = (1 + r['portfolio_rets']).cumprod()
    ax1.plot(equity.index, equity, lw=1.5, color=color, label=f'{name} ({r["n_rebalances"]})')
ax1.set_ylabel('Growth of $1'); ax1.legend(fontsize=8)
ax1.set_title('Equity Curves by Rebalancing Frequency')

ax2 = axes[1]
for (name, r), color in zip(results.items(), colors):
    ax2.plot(r['weight_history'].index, r['weight_history']['BTC'],
              lw=1.2, color=color, label=name, alpha=0.85)
ax2.axhline(TARGET_WEIGHTS[0], color='black', ls='--', lw=1,
             label=f'BTC target = {TARGET_WEIGHTS[0]:.0%}')
ax2.set_ylabel('BTC Weight'); ax2.legend(fontsize=8)
ax2.set_title('BTC Weight Drift by Rebalancing Frequency')

plt.tight_layout(); plt.show()
cell output