Quant Memo

Build It Yourself

← All guides

Add a Volatility-Targeting Overlay

Take any strategy's return stream and scale exposure to hold volatility constant, estimate rolling risk with EWMA, lever and delever to a target, and measure the effect on Sharpe, drawdown, and turnover, plus the gap risk that leverage buys you.

Most strategies don't have a volatility problem so much as a volatility-variation problem. A momentum book might run at 8% annualized vol in calm years and 25% in a crisis, same signal, wildly different risk. That instability wrecks position sizing, blows through risk limits, and produces drawdowns clustered exactly when you can least afford them. A volatility-targeting overlay fixes it with one idea: forecast next period's risk, then scale your exposure up or down so realized vol stays roughly constant.

This works because volatility, unlike return, is genuinely forecastable. It clusters, calm follows calm, storms follow storms, which is the whole premise of GARCH. You can't predict tomorrow's return, but you can predict tomorrow's risk well enough to size against it. This guide wraps the overlay around any return stream (bring your reversal or dual-momentum series), on the backtest skeleton you know.

A base return stream with realistic risk

We need a return series whose volatility moves. A GARCH-style process gives clustered vol, and we add a mild leverage effect, returns run lower when vol spikes, because crashes and turbulence arrive together:

import numpy as np
import pandas as pd

rng = np.random.default_rng(11)
n = 1800
omega, alpha, beta = 2e-6, 0.09, 0.89
sig2 = np.empty(n); eps = np.empty(n)
sig2[0] = 1e-4; eps[0] = rng.normal(0, np.sqrt(sig2[0]))
for t in range(1, n):
    sig2[t] = omega + alpha*eps[t-1]**2 + beta*sig2[t-1]   # GARCH(1,1) variance
    eps[t]  = rng.normal(0, np.sqrt(sig2[t]))
# leverage effect: expected return is lower in high-vol regimes
drift = 0.0006 - 7.0*(sig2 - sig2.mean())
r = pd.Series(drift + eps, index=pd.bdate_range("2017-01-01", periods=n), name="strat")

# --- real data: r = your_strategy_returns ---

On this series the base runs at 15.5% annualized vol with a Sharpe of 1.06, respectable, but its risk swings across the sample.

Estimating risk: EWMA volatility

To scale exposure we need a forecast of next period's vol from information available today. The workhorse is the RiskMetrics exponentially weighted moving average of squared returns, which weights recent observations more and reacts fast to regime change:

σt2  =  λσt12+(1λ)rt12,σ^tann=252σt2\sigma^2_t \;=\; \lambda\,\sigma^2_{t-1} + (1-\lambda)\,r_{t-1}^2, \qquad \hat\sigma^{\text{ann}}_t = \sqrt{252\,\sigma^2_t}

with decay λ=0.94\lambda = 0.94 (about a one-month effective window). In pandas that's one line, and the adjust=False argument makes ewm follow exactly the recursion above:

lam = 0.94
ewma_var = r.pow(2).ewm(alpha=1-lam, adjust=False).mean()
realized_vol = np.sqrt(ewma_var * 252)                  # annualized forecast

EWMA is deliberately simple; a fitted GARCH forecasts marginally better but adds estimation risk and rarely changes the sizing decision. Start here.

The overlay: scale to target

Pick a target vol and set today's leverage to the ratio of target to your most recent risk estimate. The scaling factor is simply

Lt  =  σtargetσ^t1annL_t \;=\; \frac{\sigma_{\text{target}}}{\hat\sigma^{\text{ann}}_{t-1}}

The shift(1) is the no-lookahead rule again: you size today's position using yesterday's close-of-day vol estimate, never today's. We also cap leverage so a freakishly calm estimate can't put you at 20×:

target, cap = 0.10, 3.0
lev = (target / realized_vol.shift(1)).clip(upper=cap)   # no lookahead + leverage cap
r_tgt = lev * r

When realized vol sits above 10% the overlay delevers (holds less than one unit); when markets are calm it levers up to keep risk at target. Across our sample average leverage is 0.74, the base is riskier than target, so on balance the overlay is dialing exposure down.

Did it work?

def sharpe(x, ppy=252):
    x = x.dropna()
    return np.nan if x.std()==0 else (x.mean()/x.std())*np.sqrt(ppy)
def maxdd(eq):
    return (eq/eq.cummax()-1).min()

print(round(r_tgt.std()*np.sqrt(252), 3))               # realized vol of overlay
print(sharpe(r), sharpe(r_tgt))
print(maxdd((1+r).cumprod()), maxdd((1+r_tgt.fillna(0)).cumprod()))
MetricBase strategyVol-targeted (10%)
Realized vol15.5%10.5%
Sharpe1.061.19
Max drawdown−18.5%−10.7%

Three things happened. First, realized vol landed at 10.5%, essentially on target, the overlay does its core job. Second, drawdown nearly halved, from −18.5% to −10.7%, because the overlay was busy delevering into the high-vol regimes where the worst returns lived. Third, and this is the subtle one, Sharpe rose from 1.06 to 1.19.

That Sharpe gain is not free and not guaranteed. It appeared only because we built a leverage effect into the data: bad returns cluster in high-vol periods, so cutting exposure when vol rises dodges them. Strip that effect out, make returns independent of the vol regime, and vol targeting leaves Sharpe essentially unchanged while still stabilizing risk and cutting tail drawdowns (Harvey et al., 2018). The honest summary: vol targeting reliably delivers constant risk and smaller crashes; the Sharpe bonus is a contingent gift from the volatility–return relationship that happens to hold for equities.

The overlay isn't free: its own turnover

Every time your vol estimate moves, your target exposure moves, and you trade to follow it. That churn has a cost, charged on the change in leverage:

overlay_turn = lev.diff().abs()
print(round(overlay_turn.mean(), 4))                    # 0.0228 per day
for bps in [1, 5]:
    net = r_tgt - overlay_turn.fillna(0)*bps/10000
    print(bps, round(sharpe(net), 3))                   # 1.19 -> 1.17

Average daily change in leverage is a modest 0.023, so at 1–5 bps the overlay costs almost nothing, Sharpe slips from 1.19 only to about 1.17. That's the happy case: vol moves slowly, so the sizing trades are small. But if you target vol on a fast, expensive underlying, or use a jumpy estimator with a short window, the overlay's own turnover can quietly eat the risk benefit. Always cost the overlay, not just the base.

Where this breaks: leverage and gap risk

The overlay's dangerous edge is the lever-up side. When markets are calm the estimator reads low vol and tells you to increase exposure, precisely the setup for a volatility gap. A calm-then-crash event (think February 2018's "Volmageddon", or any overnight shock) hits you at maximum leverage, before the EWMA can react, because your estimate is always one step behind.

  • The cap is load-bearing. Our 3× cap isn't decoration, it's the difference between a bad day and a blowup. Size it to survive a gap, not to maximize the backtest.
  • Estimation lag is real risk. EWMA reacts in days; a crash happens in minutes. Vol targeting is a slow risk control, not a stop-loss.
  • Leverage costs money. Levering up means borrowing (financing spread) and posting margin; the Kelly-style "just lever to target" ignores funding, margin calls, and the fact that returns are fat-tailed, not Gaussian.
  • Path dependence. Delevering after a drop locks in losses and can miss the rebound, the same mechanical trap that turned the 2007 quant quake into a spiral. Constant risk is not the same as constant wisdom.

Used sensibly, vol targeting is one of the highest-value overlays in the toolkit: it turns an erratic risk profile into a stable one and reliably tames the left tail. The machinery, EWMA forecast, shift-clean scaling factor, capped leverage, and honest overlay costs, bolts onto any return stream. Take it straight to a multi-strategy book in the Volatility-Targeting Across Strategies entry, or point it at the factor screen you build next.

The theory behind it

Related strategies

Further reading

  • Harvey et al., The Impact of Volatility Targeting (2018)
  • Moreira & Muir, Volatility-Managed Portfolios (Journal of Finance, 2017)
  • Grinold & Kahn, Active Portfolio Management (2000)
  • Bollerslev, Generalized Autoregressive Conditional Heteroskedasticity (1986)

Code is for education, not investment advice. Test everything on paper before risking capital.