Volatility Target Rebalancing
Implement volatility-targeted dynamic rebalancing that continuously adjusts portfolio leverage or net exposure level to maintain a constant ex-ante portfolio volatility target, automatically scaling down risk exposure during turbulent markets and scaling up during calm conditions for stable risk budgeting.
Volatility-Targeted Rebalancing — Portfolio Rebalancing
What This Notebook Does
Volatility targeting dynamically adjusts the total portfolio exposure so that its realised volatility stays near a pre-set target (e.g. 15% annualised). Instead of fixed weights, the portfolio scales up in calm markets and scales down in turbulent markets:
scale(t) = vol_target / rolling_vol(t-1)
w(t) = w_base × scale(t) [capped at 1.0 — no leverage]
Benefits:
- Drawdown reduction: automatically de-risks during crashes (high vol → lower exposure)
- Consistent risk: Sharpe ratio improves because variance is kept constant, not return
- Counter-cyclical: buys more in low-vol environments (often bull markets)
This notebook:
- Fetches data (Yahoo Finance or synthetic)
- Constructs a base equal-weight portfolio
- Applies volatility targeting with a configurable rolling window and target vol
- Compares vol-targeted vs fixed-weight on drawdown and Sharpe
- Shows the scaling factor over time alongside portfolio volatility
- Exports results
!pip install numpy pandas matplotlib seaborn scipy yfinance --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
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
VOL_TARGET is the desired annualised portfolio volatility. VOL_WINDOW is the lookback in days for estimating current portfolio volatility — 21 days (≈1 month) reacts quickly; 63 days (≈3 months) is smoother. MAX_LEVERAGE caps the scale factor — set to 1.0 for a long-only unconstrained portfolio.
# ── 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']
VOL_TARGET = 0.20 # annualised target volatility (20%)
VOL_WINDOW = 21 # rolling window to estimate current vol (days)
MAX_LEVERAGE = 1.0 # cap leverage at 1 — fully invested but never levered
print('Config ready.')Config ready.
Data Acquisition
We build the base equal-weight portfolio first, then apply vol targeting on top of it. The synthetic data includes a volatility cycle — a high-vol period in the middle (simulating a crash) — so the scaling mechanism is clearly visible in the output.
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)
returns = prices.pct_change().dropna()
print(f'Live data: {len(returns)} days')
else:
rng = np.random.default_rng(42)
n = 730
# Simulate a volatility regime: calm → crash → recovery
vol_regime = np.ones(n)
vol_regime[200:350] = 3.0 # 1.5x higher vol crash period
vols_base = np.array([0.65, 0.75, 1.20, 0.70, 1.10]) / np.sqrt(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_base = np.outer(vols_base, vols_base) * corr
L = np.linalg.cholesky(cov_base)
mu = np.array([0.50, 0.45, 0.80, 0.35, 0.70]) / 252
data = np.zeros((n, 5))
for i in range(n):
data[i] = rng.standard_normal(5) @ (L * vol_regime[i]).T + mu
idx = pd.date_range('2022-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
print(f'Synthetic data with volatility regime: {n} days')
# Base equal-weight portfolio returns
port_base = returns.mean(axis=1)
print(f'Base EW annualised vol: {port_base.std()*np.sqrt(252):.1%}')Synthetic data with volatility regime: 730 days Base EW annualised vol: 127.7%
Volatility Targeting
The scaling factor is computed as VOL_TARGET / realised_vol(t-1), where realised_vol is the annualised rolling standard deviation of the base portfolio over VOL_WINDOW days. We use the previous day's vol estimate (no look-ahead). The scale is capped at MAX_LEVERAGE and has a floor of 0.1 to avoid fully exiting the market.
daily_vol = port_base.rolling(VOL_WINDOW).std() * np.sqrt(252)
scale = (VOL_TARGET / daily_vol.shift(1)).clip(lower=0.10, upper=MAX_LEVERAGE)
scale.fillna(1.0, inplace=True)
port_vt = port_base * scale # vol-targeted portfolio
# Realised rolling vol of both portfolios
rv_base = port_base.rolling(VOL_WINDOW).std() * np.sqrt(252)
rv_vt = port_vt.rolling(VOL_WINDOW).std() * np.sqrt(252)
equity_base = (1 + port_base).cumprod()
equity_vt = (1 + port_vt).cumprod()
def sharpe(r): return r.mean() / (r.std() + 1e-9) * np.sqrt(252)
def max_dd(r):
eq = (1+r).cumprod(); return ((eq - eq.cummax()) / eq.cummax()).min()
print('Performance comparison:')
for name, r in [('Equal Weight (no targeting)', port_base), ('Vol Targeted', port_vt)]:
print(f' {name}: Sharpe={sharpe(r):.2f} | Max DD={max_dd(r):.1%} | '
f'Ann Vol={r.std()*np.sqrt(252):.1%}')Performance comparison: Equal Weight (no targeting): Sharpe=-0.33 | Max DD=-99.4% | Ann Vol=127.7% Vol Targeted: Sharpe=-0.14 | Max DD=-47.8% | Ann Vol=24.7%
Visualisation
The top panel shows the scaling factor over time — it drops sharply during the high-vol crash period (200–350 days) and rises back up during recovery. The bottom left shows rolling realised volatility for both portfolios — the vol-targeted portfolio stays much closer to the 20% target line. The bottom right shows equity curves — vol targeting typically sacrifices some upside in bull markets but greatly reduces drawdowns.
fig, axes = plt.subplots(2, 2, figsize=(14, 9))
fig.suptitle('Volatility-Targeted Rebalancing', fontsize=13, fontweight='bold')
axes[0, 0].plot(scale.index, scale, color='#7b1fa2', lw=1.5)
axes[0, 0].axhline(1.0, color='black', ls='--', lw=0.8, label='100% exposure')
axes[0, 0].set_ylabel('Scale Factor'); axes[0, 0].legend(fontsize=8)
axes[0, 0].set_title('Portfolio Scaling Factor Over Time')
axes[0, 1].plot(rv_base.index, rv_base, color='#e53935', lw=1.5, label='Equal Weight')
axes[0, 1].plot(rv_vt.index, rv_vt, color='#1976d2', lw=1.5, label='Vol Targeted')
axes[0, 1].axhline(VOL_TARGET, color='green', ls='--', lw=1,
label=f'Target {VOL_TARGET:.0%}')
axes[0, 1].set_ylabel(f'{VOL_WINDOW}d Rolling Vol (Ann)')
axes[0, 1].legend(fontsize=8); axes[0, 1].set_title('Realised Volatility')
axes[1, 0].plot(equity_base.index, equity_base, color='#e53935', lw=1.5, label='Equal Weight')
axes[1, 0].plot(equity_vt.index, equity_vt, color='#1976d2', lw=1.5, label='Vol Targeted')
axes[1, 0].set_ylabel('Growth of $1'); axes[1, 0].legend(fontsize=8)
axes[1, 0].set_title('Equity Curves')
# Drawdown comparison
for eq, color, label in [(equity_base,'#e53935','Equal Weight'),(equity_vt,'#1976d2','Vol Targeted')]:
dd = (eq - eq.cummax()) / eq.cummax()
axes[1, 1].fill_between(dd.index, dd, 0, alpha=0.35, color=color, label=label)
axes[1, 1].set_ylabel('Drawdown'); axes[1, 1].legend(fontsize=8)
axes[1, 1].set_title('Drawdown Comparison')
plt.tight_layout(); plt.show()