Portfolio & Risk·Portfolio Rebalancing·Intermediate

Threshold Rebalancing

Implement threshold-triggered portfolio rebalancing that only executes offsetting trades when actual asset allocation weights drift beyond user-specified tolerance bands around target weights, dramatically reducing unnecessary trading costs compared to rigid fixed-calendar rebalancing schedules.

asset-allocationrisk-managementspot-trading

Threshold Rebalancing — Portfolio Rebalancing

What This Notebook Does

Threshold rebalancing (also called tolerance-band or range rebalancing) only rebalances when an asset's weight drifts beyond a pre-set band around its target weight. Unlike calendar rebalancing, it responds to actual drift — not the calendar.

Rebalance asset i if: |w_i(t) - w_i*| > threshold

Two common threshold approaches:

  • Absolute band: rebalance if weight drifts more than ±5% from target (e.g., 25% target → rebalance if below 20% or above 30%)
  • Relative band: rebalance if weight drifts more than ±20% relative to target (e.g., 25% target → rebalance if below 20% or above 30%)

Benefits over calendar rebalancing:

  • Fewer trades in stable markets — lower transaction costs
  • Faster response to large moves — keeps risk in check
  • Momentum capture — assets are allowed to run before being trimmed

This notebook:

  1. Fetches data (Yahoo Finance or synthetic)
  2. Simulates threshold-triggered rebalancing with configurable bands
  3. Counts rebalancing events and turnover vs calendar rebalancing
  4. Compares performance and cost across threshold levels
  5. Visualises weight drift, rebalance events, and equity curves
  6. Exports rebalance log and portfolio returns
[ ]
!pip install numpy pandas matplotlib seaborn 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.

Configuration

THRESHOLD defines the maximum allowed absolute deviation from target weight before rebalancing is triggered. For example, a threshold of 0.05 means a 25% target asset triggers a rebalance if its weight reaches 30% or falls to 20%. TRANSACTION_COST is the one-way cost applied to each rebalanced unit of portfolio value.

[ ]
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS       = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD']
START_DATE    = '2022-01-01'
END_DATE      = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS           = ['BTC','ETH','SOL','BNB']
TARGET_WEIGHTS   = np.array([0.40, 0.30, 0.20, 0.10])  # target allocation
THRESHOLDS       = [0.03, 0.05, 0.10, 0.15]  # test multiple thresholds
TRANSACTION_COST = 0.001
print('Config ready.')
Config ready.

Data Acquisition

We fetch daily price data to track realistic weight drift. The synthetic path introduces large BTC and SOL moves to ensure rebalancing is triggered during the simulation — otherwise a tight threshold may never fire on flat data.

[ ]
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]) / np.sqrt(252)
    mu   = np.array([0.50, 0.45, 0.80, 0.35]) / 252
    corr = np.array([[1,.85,.70,.75],[.85,1,.75,.70],[.70,.75,1,.65],[.75,.70,.65,1]])
    cov  = np.outer(vols,vols)*corr
    L    = np.linalg.cholesky(cov)
    data = rng.standard_normal((n,4)) @ 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])
    print(f'Synthetic data: {n} days')

returns = prices.pct_change().dropna()
print(f'Return matrix: {returns.shape}')
Synthetic data: 730 days
Return matrix: (729, 4)

Section 3 — Threshold Rebalancing Simulator

The simulator tracks portfolio weights daily. At each step: (1) update weights by applying today's returns; (2) check if any weight has breached the threshold band; (3) if yes, fully rebalance back to target weights and apply transaction costs proportional to the total weight moved. We test multiple threshold levels and record performance and trade count for each.

[ ]
def simulate_threshold_rebalancing(returns: pd.DataFrame, target: np.ndarray,
                                    threshold: float, tc: float = 0.001) -> dict:
    """
    Simulate threshold-triggered portfolio rebalancing.

    Parameters
    ----------
    returns   : pd.DataFrame  Daily asset returns.
    target    : np.ndarray    Target portfolio weights (sums to 1).
    threshold : float         Absolute weight deviation that triggers rebalancing.
    tc        : float         One-way transaction cost fraction.

    Returns
    -------
    dict  keys: 'portfolio_rets', 'rebal_dates', 'weight_history', 'turnover'
    """
    w = target.copy()
    port_rets   = []
    rebal_dates = []
    weight_hist = []
    total_turnover = 0.0

    for date, row in returns.iterrows():
        # Daily portfolio return before rebalancing
        port_ret = float(w @ row.values)
        # Update weights for price movement
        w = w * (1 + row.values)
        if w.sum() > 0:
            w /= w.sum()
        weight_hist.append(w.copy())

        # Check if any weight breaches the threshold band
        deviation = np.abs(w - target)
        if deviation.max() > threshold:
            turnover = deviation.sum() / 2  # one-way turnover
            cost     = turnover * tc
            port_ret -= cost
            total_turnover += turnover
            w = target.copy()  # rebalance to target
            rebal_dates.append(date)

        port_rets.append(port_ret)

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


results = {}
for thr in THRESHOLDS:
    results[thr] = simulate_threshold_rebalancing(returns, TARGET_WEIGHTS, thr, TRANSACTION_COST)
    n_rebal = len(results[thr]['rebal_dates'])
    total_ret = (1 + results[thr]['portfolio_rets']).prod() - 1
    print(f'Threshold {thr:.0%}: {n_rebal} rebalances | Total return: {total_ret:.1%} | Turnover: {results[thr]["total_turnover"]:.1%}')
Threshold 3%: 57 rebalances | Total return: -52.1% | Turnover: 210.7%
Threshold 5%: 20 rebalances | Total return: -52.0% | Turnover: 120.6%
Threshold 10%: 5 rebalances | Total return: -52.8% | Turnover: 61.2%
Threshold 15%: 2 rebalances | Total return: -53.8% | Turnover: 31.7%

Section 4 — Visualisation

The left panel shows weight drift over time for the 5% threshold strategy — dashed lines mark the threshold band, and vertical grey lines show rebalance events. The right panel compares equity curves across all threshold levels, highlighting the cost-performance trade-off.

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

thr_show  = 0.05
res_show  = results[thr_show]
ax1 = axes[0]
colors = ['#1976d2','#e53935','#43a047','#ff9800']
for asset, tw, color in zip(ASSETS, TARGET_WEIGHTS, colors):
    ax1.plot(res_show['weight_history'].index,
              res_show['weight_history'][asset], color=color, lw=1, label=asset)
    ax1.axhline(tw, color=color, ls=':', lw=0.8, alpha=0.6)
    ax1.axhline(tw + thr_show, color=color, ls='--', lw=0.6, alpha=0.4)
    ax1.axhline(max(tw - thr_show, 0), color=color, ls='--', lw=0.6, alpha=0.4)
for rd in res_show['rebal_dates']:
    ax1.axvline(rd, color='grey', lw=0.5, alpha=0.3)
ax1.legend(fontsize=8); ax1.set_ylabel('Weight')
ax1.set_title(f'Weight Drift (threshold={thr_show:.0%})')

ax2 = axes[1]
colors_line = ['#e53935','#1976d2','#43a047','#ff9800']
for thr, color in zip(THRESHOLDS, colors_line):
    equity = (1 + results[thr]['portfolio_rets']).cumprod()
    n_r    = len(results[thr]['rebal_dates'])
    ax2.plot(equity.index, equity, lw=1.5, color=color,
              label=f'Threshold {thr:.0%} ({n_r} trades)')
ax2.set_ylabel('Growth of $1'); ax2.legend(fontsize=8)
ax2.set_title('Equity Curves by Threshold')

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

Conclusion

This notebook demonstrates threshold rebalancing, comparing its behavior and performance against various threshold levels. Key takeaways could include observations on trade frequency, transaction costs, and overall portfolio growth relative to the chosen rebalancing strategy.

Threshold Rebalancing · BitPredict