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