Portfolio & Risk·Portfolio Rebalancing·Intermediate

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.

asset-allocationrisk-managementspot-trading

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:

  1. Fetches data (Yahoo Finance or synthetic)
  2. Constructs a base equal-weight portfolio
  3. Applies volatility targeting with a configurable rolling window and target vol
  4. Compares vol-targeted vs fixed-weight on drawdown and Sharpe
  5. Shows the scaling factor over time alongside portfolio volatility
  6. Exports results
[1]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[2]
import 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.

[3]
# ── 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.

[4]
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.

[5]
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.

[6]
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()
cell output