← Back to articles
July 13, 2026
5 min read

Volatility Targeting and Trading with GARCH Forecasts

#volatility
#GARCH
#volatility-targeting
#backtesting
#risk
#crypto
#algorithmic-trading

The first three parts of this series taught you to forecast volatility. We built a univariate GARCH(1,1) in Part 1, added leverage and fat tails with GJR and Student-t innovations in Part 2, and modeled a whole covariance matrix through time with DCC-GARCH in Part 3. At the end of each, we printed a number: tomorrow's expected volatility. And then, if we are honest, we stopped — as if producing the forecast were the point.

It is not. A volatility forecast is not a P&L. Nobody has ever been paid for a low QLIKE score. A forecast only becomes worth something at the exact moment it changes a decision you would otherwise have made differently — how much to buy, when to cut, how much capital to allocate. If your forecast does not move a position, its statistical accuracy is a private hobby.

This final part is about closing that loop. We take the forecasts from Parts 1-3 and put them to work in the cleanest possible decision: volatility targeting — sizing a position so that the portfolio's realized volatility hits a constant target. Then we do the thing this blog cares about more than any single model: we evaluate honestly. We benchmark GARCH against dumb-but-strong baselines (rolling realized vol, EWMA), we use loss functions that are robust to the fact that we can never observe true volatility, we run a walk-forward backtest with costs and no look-ahead, and we state plainly what vol targeting does and does not buy you. Spoiler: it improves risk-adjusted returns and tames drawdowns far more reliably than it manufactures alpha.

Why Volatility Targeting Is the Right Test

There are fancier ways to use a volatility forecast — option pricing, VaR limits, dynamic hedging — but volatility targeting is the one that isolates the forecast's value with the least contamination from other bets. The idea is a single equation.

Hold a risky asset with a signal-implied direction (for now, just "long"). Instead of a fixed position, scale exposure inversely to forecast volatility:

wt=σtargetσ^t(then capped)w_t = \frac{\sigma_{\text{target}}}{\hat{\sigma}_{t}} \quad \text{(then capped)}

where σ^t\hat{\sigma}_t is your forecast of next-period volatility (made using only data up to tt), and σtarget\sigma_{\text{target}} is the annualized volatility you want the strategy to run at — say 15% or 20%. When the model forecasts a calm market, you lever up toward (or past) 1.0; when it forecasts a storm, you shrink. The realized volatility of the scaled position is, to first order,

Vol(wtrt+1)=wtσt+1=σtargetσ^tσt+1σtarget\text{Vol}(w_t r_{t+1}) = w_t \, \sigma_{t+1} = \frac{\sigma_{\text{target}}}{\hat\sigma_t}\, \sigma_{t+1} \approx \sigma_{\text{target}}

whenever σ^tσt+1\hat\sigma_t \approx \sigma_{t+1}. So the entire quality of the exercise rests on one thing: how close your forecast σ^t\hat\sigma_t is to next period's actual volatility σt+1\sigma_{t+1}. A better forecast produces a flatter realized-volatility profile and — as we will see — a better Sharpe ratio. That is why this is the right test. The forecast is not decoration; it is the denominator.

Why this raises Sharpe and controls drawdowns

Two empirical facts do the work here.

Volatility is far more forecastable than returns. The direction of tomorrow's BTC return is close to a coin flip at daily frequency; the magnitude is not. Volatility clusters — big moves follow big moves — which is the entire reason GARCH exists (Part 1 derived the AR(1)-in-variance structure that encodes this). An R2R^2 of 0.4-0.6 for one-day-ahead variance is routine; the same number for returns would be a Renaissance-tier signal. Vol targeting exploits the forecastable quantity and stays agnostic about the unforecastable one.

Sharpe ratios are not constant through time; they fall when volatility spikes. High-volatility regimes in crypto — deleveraging cascades, exchange failures, the days everything gaps 30% — tend to have worse return-per-unit-risk, not better. By mechanically cutting exposure exactly when forecast vol is high, you underweight the periods that contribute most to drawdowns and least to compounded return. Moreira and Muir (2017) showed for equities that volatility-managed portfolios — this exact 1/σ21/\sigma^2 scaling — raise Sharpe ratios and produce positive alphas against the unmanaged factor. The mechanism is not magic; it is refusing to hold a constant dollar position into predictably turbulent windows.

The drawdown benefit is even more direct. Maximum drawdown is dominated by the tail of the position-return distribution. Since wtrt+1w_t r_{t+1} has volatility pinned near σtarget\sigma_{\text{target}}, the fat left tail that a fixed-notional strategy suffers during a vol explosion is compressed: you were already small going in. Vol targeting does not predict crashes, but it is systematically underexposed when the market is agitated, and agitation is when crashes happen.

Relationship to Kelly and fractional sizing

Volatility targeting is a first cousin of the Kelly criterion. For a single asset with expected excess return μ\mu and variance σ2\sigma^2, the growth-optimal (full-Kelly) fraction is

f=μσ2.f^\star = \frac{\mu}{\sigma^2}.

If you assume the Sharpe ratio μ/σ\mu/\sigma is roughly constant — a strong assumption, but the one implicit in "the market pays a stable price for risk" — then μ=Sσ\mu = S\sigma and f=S/σf^\star = S/\sigma, which is exactly volatility targeting with σtarget=S(Kelly fraction)\sigma_{\text{target}} = S \cdot (\text{Kelly fraction}). In other words, volatility targeting is Kelly sizing under the assumption that expected return scales with volatility. We work through full and fractional Kelly, and why nobody sane trades full Kelly, in Kelly criterion and strategy sizing. The practical lesson from there carries over: use a fraction of the theoretical size, because estimation error in the denominator (your vol forecast) and especially the numerator (expected return) makes full sizing dangerously aggressive.

Two more connections worth holding in mind. First, vol targeting sizes on the symmetric dispersion of returns, but crypto payoffs are not symmetric — the cost of a 20% down day is not the mirror of a 20% up day once leverage and liquidation are involved. We treat that asymmetry directly in loss-profit asymmetry, and a GJR/EGARCH forecast (Part 2) already bakes some of it into σ^t\hat\sigma_t by reacting more to negative shocks. Second, a vol forecast is a point estimate; a more complete risk view attaches an interval to it. Conformal prediction for trading shows how to turn model outputs into distribution-free intervals you can size against, which pairs naturally with the honest-evaluation theme of this article.

The Competitors: What GARCH Has to Beat

Here is the discipline that separates a real evaluation from a demo. Before you crown GARCH, you must give it opponents that are cheap, obvious, and surprisingly hard to beat. If your elaborate GJR-t model cannot outperform a five-line EWMA, you have learned something valuable and saved yourself a lot of production complexity.

We benchmark four forecasters of next-period volatility.

(a) Trailing realized volatility (rolling standard deviation)

The most naive forecast: tomorrow's volatility equals the sample standard deviation of the last nn daily returns.

σ^tRV=1n1i=1n(rti+1rˉ)2\hat\sigma_{t}^{\text{RV}} = \sqrt{\frac{1}{n-1}\sum_{i=1}^{n}\bigl(r_{t-i+1}-\bar r\bigr)^2}

It has one hyperparameter (the window nn, typically 20-60 days) and no model. Its flaw is that every observation in the window gets equal weight and then abruptly falls off a cliff when it exits — the "ghosting" or "echo" effect, where a single crash day inflates the forecast for exactly nn days and then vanishes overnight, whether or not the market has actually calmed.

(b) EWMA / RiskMetrics (λ=0.94\lambda = 0.94)

The exponentially weighted moving average fixes the echo problem by giving geometrically decaying weight to older squared returns:

σ^t2=λσ^t12+(1λ)rt2\hat\sigma_{t}^{2} = \lambda\,\hat\sigma_{t-1}^{2} + (1-\lambda)\,r_{t}^{2}

This is the RiskMetrics (J.P. Morgan, 1996) estimator. With the canonical λ=0.94\lambda = 0.94 for daily data, the effective memory is roughly 1/(1λ)171/(1-\lambda) \approx 17 days, but the decay is smooth — no cliff. Note what EWMA actually is: it is an integrated GARCH(1,1) with ω=0\omega = 0 and α+β=1\alpha + \beta = 1, i.e. GARCH with no mean reversion and no long-run variance. It has zero free parameters if you accept λ=0.94\lambda = 0.94, and it is the single toughest baseline in this entire article. A large fraction of "GARCH beats X" papers quietly fail to beat EWMA out of sample.

import numpy as np
import pandas as pd

def ewma_vol(returns: pd.Series, lam: float = 0.94, sigma0: float | None = None) -> pd.Series:
    """RiskMetrics EWMA conditional volatility (returns in decimal, e.g. 0.03).

    sigma2_t = lam * sigma2_{t-1} + (1 - lam) * r_t^2
    Returns a series aligned to `returns` where value at t uses info up to t.
    """
    r2 = np.square(returns.values)
    var = np.empty_like(r2)
    var[0] = sigma0**2 if sigma0 is not None else r2[0]
    for t in range(1, len(r2)):
        var[t] = lam * var[t - 1] + (1.0 - lam) * r2[t - 1]  # note: r_{t-1}, no look-ahead
    return pd.Series(np.sqrt(var), index=returns.index, name="ewma_vol")

The one subtlety that trips people up: the forecast for period tt (usable to size a position held over tt) must be built from returns observed before tt. In the recursion above, var[t] uses r2[t-1], so the series is a genuine one-step-ahead forecast. Getting this index right is the difference between a backtest and a fantasy — more on that in the walk-forward section.

(c) GARCH(1,1) and GJR-t (Parts 1-2)

Our protagonists. Standard GARCH(1,1):

σt2=ω+αϵt12+βσt12,ω>0, α,β0, α+β<1\sigma_t^2 = \omega + \alpha\, \epsilon_{t-1}^2 + \beta\,\sigma_{t-1}^2, \qquad \omega>0,\ \alpha,\beta\ge0,\ \alpha+\beta<1

with long-run variance σˉ2=ω/(1αβ)\bar\sigma^2 = \omega/(1-\alpha-\beta) and the one-step forecast falling straight out of the recursion (Part 1). The GJR-GARCH extension adds a leverage term so negative shocks raise variance more than positive ones:

σt2=ω+(α+γ1[ϵt1<0])ϵt12+βσt12\sigma_t^2 = \omega + (\alpha + \gamma\, \mathbb{1}[\epsilon_{t-1}<0])\,\epsilon_{t-1}^2 + \beta\,\sigma_{t-1}^2

and paired with Student-t innovations to handle crypto's fat tails, this is the GJR-t of Part 2. The reason GARCH can beat EWMA is mean reversion: after a shock, GARCH pulls the forecast back toward σˉ2\bar\sigma^2 at a rate governed by α+β\alpha+\beta, whereas EWMA (being integrated) never reverts. When volatility spikes and then normalizes — the common case — GARCH's forecast decays back faster and more accurately. When volatility is genuinely persistent, the two are nearly indistinguishable.

(d) HAR-RV on realized variance (if you have intraday data)

If you have intraday bars — and in 24/7 crypto markets you almost always do — you can construct a far less noisy volatility proxy than daily squared returns: realized variance, the sum of squared intraday returns over the day.

RVt=i=1Mrt,i2(e.g. M=288 five-minute bars)RV_t = \sum_{i=1}^{M} r_{t,i}^2 \qquad (\text{e.g. } M = 288 \text{ five-minute bars})

The Heterogeneous Autoregressive model of Corsi (2009) forecasts tomorrow's realized variance from daily, weekly, and monthly averages of past RVRV — a crude but remarkably effective way to capture long-memory persistence with three regressors:

RVt+1=β0+βdRVt+βwRVt(5)+βmRVt(22)+ut+1RV_{t+1} = \beta_0 + \beta_d\, RV_t + \beta_w\, \overline{RV}_t^{(5)} + \beta_m\, \overline{RV}_t^{(22)} + u_{t+1}

where RVt(5)\overline{RV}_t^{(5)} and RVt(22)\overline{RV}_t^{(22)} are the trailing 5-day and 22-day averages of daily RVRV. It is a plain OLS regression, it exploits the higher-quality intraday proxy, and it is frequently the best daily-vol forecaster of the four — often beating GARCH precisely because RVRV is a cleaner target than r2r^2.

import numpy as np
import pandas as pd

def realized_variance(intraday_returns: pd.Series, day_index) -> pd.Series:
    """Daily realized variance = sum of squared intraday (log) returns per day.
    `intraday_returns` indexed by timestamp; `day_index` maps to calendar day.
    """
    return intraday_returns.pow(2).groupby(day_index).sum()

def har_features(rv: pd.Series) -> pd.DataFrame:
    """Build HAR regressors from a daily realized-variance series."""
    df = pd.DataFrame({"rv": rv})
    df["rv_d"] = df["rv"].shift(1)                          # yesterday
    df["rv_w"] = df["rv"].shift(1).rolling(5).mean()        # trailing week
    df["rv_m"] = df["rv"].shift(1).rolling(22).mean()       # trailing month
    df["target"] = df["rv"]                                 # predict today's RV
    return df.dropna()

def fit_har(rv: pd.Series):
    """Fit HAR-RV by OLS. Returns (coef_dict, predict_fn)."""
    df = har_features(rv)
    X = np.column_stack([np.ones(len(df)), df["rv_d"], df["rv_w"], df["rv_m"]])
    y = df["target"].values
    beta, *_ = np.linalg.lstsq(X, y, rcond=None)
    names = ["const", "rv_d", "rv_w", "rv_m"]

    def predict(rv_d, rv_w, rv_m):
        return float(beta @ np.array([1.0, rv_d, rv_w, rv_m]))

    return dict(zip(names, beta)), predict

A note on log-HAR: because RVRV is right-skewed and strictly positive, many practitioners regress logRVt+1\log RV_{t+1} on log HAR features, which improves fit and guarantees positive forecasts. When you exponentiate back you should add a half-variance Jensen correction, RV^=exp(y^+12σ^u2)\widehat{RV} = \exp(\hat{y} + \tfrac{1}{2}\hat\sigma_u^2), or you will systematically under-forecast.

With four forecasters in hand — RV, EWMA, GARCH/GJR-t, HAR — the question becomes: how do we decide which is best, when we can never see the thing they are all trying to predict?

Evaluating a Volatility Forecast Honestly

This is the core teaching of the article, so slow down here.

You want to compare forecasts σ^t2\hat\sigma_t^2 against true conditional variance σt2\sigma_t^2. But σt2\sigma_t^2 is a latent quantity — it is a parameter of the data-generating process, never directly observed. All you get is one realized return per day. So every volatility evaluation is really a comparison of your forecast against a noisy proxy for the truth. The two standard proxies:

  • Squared returns rt2r_t^2. Unbiased for σt2\sigma_t^2 under a zero-mean model (E[rt2Ft1]=σt2\mathbb{E}[r_t^2 \mid \mathcal{F}_{t-1}] = \sigma_t^2), but extremely noisy: a single daily return is a one-observation estimate of a standard deviation. The proxy rt2r_t^2 can be 0 (a flat day) even when true vol is high, or huge on a lucky tail day.
  • Realized variance RVtRV_t from intraday data. Much less noisy — the intraday sampling averages out the idiosyncratic single-return noise — which is exactly why HAR-RV works and why you should use RVRV as your proxy if you have intraday data at all.

The subtlety that catches almost everyone: because the proxy is noisy, the choice of loss function is not innocent. Rank two forecasts with the wrong loss and the noisy proxy can flip the ranking, telling you the worse forecast is better. Patton (2011) worked out precisely which loss functions are "robust" in the sense that ranking forecasts by expected loss on the noisy proxy gives the same ranking you would get on the true (unobservable) variance. Only a specific family qualifies. Two members matter in practice.

MSE vs QLIKE

The mean squared error on variance:

LMSE(σ2,h)=(σ2h)2L_{\text{MSE}}(\sigma^2, h) = (\sigma^2 - h)^2

where h=σ^2h = \hat\sigma^2 is the forecast and σ2\sigma^2 is proxied by r2r^2 or RVRV. MSE is robust in Patton's sense (its ranking is proxy-consistent), but it is symmetric and scale-dependent: it penalizes an over-forecast and an under-forecast of the same absolute size equally, and it weights errors during high-volatility periods enormously more than errors during calm periods. A model that nails the calm 95% of days but blows up variance forecasts on the three crisis days will look terrible under MSE, even if its crisis behavior is what you actually want.

The QLIKE (quasi-likelihood) loss is the workhorse:

LQLIKE(σ2,h)=σ2hlogσ2h1L_{\text{QLIKE}}(\sigma^2, h) = \frac{\sigma^2}{h} - \log\frac{\sigma^2}{h} - 1

It is the loss implied by a Gaussian likelihood on the variance, it is also robust in Patton's sense, and it has two properties that make it the preferred choice for volatility. First, it is asymmetric in the right direction: it penalizes under-prediction of variance more than over-prediction. For a risk manager or a vol-targeter, that is the correct asymmetry — under-forecasting vol means you took on too much size right before it mattered, which is the expensive error. Second, it is (roughly) scale-invariant: because it depends on the ratio σ2/h\sigma^2/h, a 10% forecast error costs about the same whether it happens on a calm day or a crisis day, so the evaluation is not hijacked by a handful of high-variance observations the way MSE is. That robustness to the heteroskedasticity of the proxy is exactly what you want when the whole point is that volatility varies wildly.

Note LQLIKE0L_{\text{QLIKE}} \ge 0, with equality iff h=σ2h = \sigma^2. Lower is better, same as MSE.

import numpy as np

def qlike(proxy: np.ndarray, forecast_var: np.ndarray) -> np.ndarray:
    """Per-observation QLIKE loss. `proxy` is r^2 or RV (a variance proxy),
    `forecast_var` is h = sigma_hat^2. Both strictly positive, same units.
    """
    ratio = proxy / forecast_var
    return ratio - np.log(ratio) - 1.0

def mse_var(proxy: np.ndarray, forecast_var: np.ndarray) -> np.ndarray:
    """Per-observation MSE on the variance scale."""
    return np.square(proxy - forecast_var)

Two operational warnings. Keep proxy and forecast in identical units (both daily variances, or both annualized) or the ratio is meaningless. And never let forecast_var hit zero — clip it to a small floor, because log0\log 0 will poison the whole average.

Mincer-Zarnowitz regression

A single loss number tells you which forecast is better; it does not tell you how a forecast is wrong. The Mincer-Zarnowitz (1969) regression does. Regress the proxy on the forecast:

rt2  =  a+bσ^t2+ut.r_t^2 \;=\; a + b\,\hat\sigma_t^2 + u_t.

Under an optimal, unbiased forecast, a=0a = 0 and b=1b = 1: on average the realized variance equals the forecast. Deviations diagnose the pathology:

  • b<1b < 1 with a>0a > 0: the classic signature of a forecast that is too volatile — it over-reacts, predicting extremes that do not fully materialize. Very common for raw squared-return-driven models.
  • b>1b > 1: the forecast under-reacts, scaling too little with true variance.
  • Low regression R2R^2: even if a,ba,b look fine on average, the forecast tracks variance poorly day to day. Because the proxy is so noisy, do not be alarmed that MZ R2R^2 against r2r^2 is often only 0.05-0.20; against RVRV it will be much higher. The R2R^2 against r2r^2 is bounded well below 1 no matter how good the forecast, purely because of proxy noise.

A joint FF-test of H0:(a,b)=(0,1)H_0: (a,b) = (0,1) gives a formal calibration check. In practice, use MZ as a diagnostic to understand a forecast, and QLIKE to rank forecasts.

Diebold-Mariano: is the difference real?

Suppose GARCH's mean QLIKE is 0.183 and EWMA's is 0.191. GARCH "wins." But is 0.008 a real edge or sampling noise? The Diebold-Mariano (1995) test answers exactly this. Define the per-period loss differential

dt=L(proxyt,htA)L(proxyt,htB)d_t = L(\text{proxy}_t, h^A_t) - L(\text{proxy}_t, h^B_t)

for two forecasts AA and BB (here LL = QLIKE). The null is equal predictive accuracy, H0:E[dt]=0H_0: \mathbb{E}[d_t] = 0. The statistic is the mean differential standardized by its long-run (HAC) standard error, because dtd_t is serially correlated:

DM=dˉLRV^(dt)/T  d  N(0,1)\text{DM} = \frac{\bar d}{\sqrt{\widehat{\mathrm{LRV}}(d_t)/T}} \;\xrightarrow{d}\; \mathcal{N}(0,1)

where LRV^\widehat{\mathrm{LRV}} is a Newey-West-type long-run variance estimate. A DM statistic beyond ±1.96\pm 1.96 rejects equal accuracy at 5%. Crucially, DM is a test about forecasts, not nested models, and it handles the serial dependence in the loss series that a naive tt-test on dtd_t would ignore.

import numpy as np
from scipy import stats

def diebold_mariano(loss_a: np.ndarray, loss_b: np.ndarray, h: int = 1):
    """Diebold-Mariano test of equal predictive accuracy.
    loss_a, loss_b: per-period losses (e.g. QLIKE) for forecasts A and B.
    h: forecast horizon; Newey-West lag = h - 1 (>=0), plus a small-sample buffer.
    Returns (DM_stat, p_value). Negative DM => A has lower loss (A better).
    """
    d = np.asarray(loss_a) - np.asarray(loss_b)
    T = len(d)
    d_bar = d.mean()

    lag = max(h - 1, 0)
    gamma0 = np.mean((d - d_bar) ** 2)
    lrv = gamma0
    for k in range(1, lag + 1):
        w = 1.0 - k / (lag + 1)
        cov = np.mean((d[k:] - d_bar) * (d[:-k] - d_bar))
        lrv += 2.0 * w * cov

    dm = d_bar / np.sqrt(lrv / T)

    adj = np.sqrt((T + 1 - 2 * h + h * (h - 1) / T) / T)
    dm *= adj
    p = 2.0 * (1.0 - stats.t.cdf(abs(dm), df=T - 1))
    return float(dm), float(p)

The illustrative result in that comment is the honest and common outcome, and it is the whole reason this section exists: GARCH often posts a slightly lower average loss than EWMA, and just as often that edge fails to clear the DM significance bar. If you only ever report the mean QLIKE, you will convince yourself of edges that a DM test would have vetoed. Report the DM statistic. This is the same discipline we apply to strategy returns in honest evaluation with no robust edge — a point estimate that beats a benchmark is not an edge until you have ruled out that it is noise.

The Backtest: A Walk-Forward Vol-Targeted Strategy

Now we combine the two halves — a forecaster and a sizing rule — into a strategy and evaluate it the only way that means anything: walk-forward, out of sample, with costs.

The strategy is deliberately simple, because simplicity is what lets us attribute the result to the vol forecast rather than to a clever signal. Long-only, vol-targeted BTC. Each day, forecast next-day volatility, set the position to wt=min(σtarget/σ^t, wmax)w_t = \min(\sigma_{\text{target}}/\hat\sigma_t,\ w_{\max}), hold overnight, and repeat. A long/flat variant gates the position off when a trend filter is negative; a small-portfolio variant sizes on the DCC covariance matrix from Part 3 instead of a single asset's variance. We describe the long-only case in full and note the extensions.

Walk-forward mechanics and the no-look-ahead contract

The single most important property of this backtest is that every quantity used to size the position on day t+1t+1 is computable using only data available at the close of day tt. GARCH parameters are re-estimated on a rolling window ending at tt; the forecast is the one-step-ahead σ^t+1\hat\sigma_{t+1} from that fit; the position is set from that forecast; and the return earned is wt+1rt+1w_{t+1} r_{t+1}, where rt+1r_{t+1} is the next day's return, which the model never saw. Refitting GARCH on the full sample and then "forecasting" the past is the most common way people accidentally fabricate a great backtest. We treat this look-ahead trap and the general methodology in walk-forward optimization.

import numpy as np
import pandas as pd
from arch import arch_model

def walk_forward_voltarget(
    returns: pd.Series,          # daily log returns in decimal (e.g. 0.021)
    proxy_var: pd.Series,        # variance proxy aligned to returns (RV or r^2)
    target_vol_annual: float = 0.20,
    window: int = 750,           # rolling estimation window (days)
    refit_every: int = 5,        # refit GARCH weekly, forecast daily (cost saver)
    w_max: float = 3.0,          # leverage cap
    cost_bps: float = 5.0,       # per-unit-turnover cost in basis points
    ann: int = 365,              # crypto trades 365 days/yr
):
    """Long-only vol-targeted BTC, GARCH(1,1)-t forecasts, strictly walk-forward.
    Returns a DataFrame with position, forecast vol, net returns, and turnover.
    """
    idx = returns.index
    target_daily = target_vol_annual / np.sqrt(ann)

    fcast_vol = pd.Series(index=idx, dtype=float, name="fcast_vol")
    last_res = None

    for i in range(window, len(returns) - 1):
        if (i - window) % refit_every == 0 or last_res is None:
            train = returns.iloc[i - window:i + 1] * 100.0   # scale for the optimizer
            am = arch_model(train, mean="Constant", vol="GARCH",
                            p=1, o=1, q=1, dist="t")           # GJR-t, Part 2
            last_res = am.fit(disp="off")

        f = last_res.forecast(horizon=1, reindex=False)
        var_next = f.variance.values[-1, 0] / (100.0 ** 2)    # unscale
        fcast_vol.iloc[i + 1] = np.sqrt(var_next)

    raw_w = (target_daily / fcast_vol).clip(upper=w_max)
    position = raw_w.shift(0)                                  # w_{i+1} known at close i
    gross_ret = position * returns                            # earned on day i+1

    turnover = position.diff().abs().fillna(0.0)
    cost = turnover * (cost_bps / 1e4)
    net_ret = (gross_ret - cost).dropna()

    out = pd.DataFrame({
        "position": position,
        "fcast_vol": fcast_vol,
        "gross_ret": gross_ret,
        "turnover": turnover,
        "net_ret": net_ret,
    }).dropna()
    return out

def performance_stats(net_ret: pd.Series, ann: int = 365) -> dict:
    """Sharpe, realized vol, max drawdown, CAGR, annual turnover."""
    mu = net_ret.mean() * ann
    sigma = net_ret.std() * np.sqrt(ann)
    sharpe = mu / sigma if sigma > 0 else np.nan
    equity = (1.0 + net_ret).cumprod()
    peak = equity.cummax()
    max_dd = (equity / peak - 1.0).min()
    cagr = equity.iloc[-1] ** (ann / len(net_ret)) - 1.0
    return {
        "sharpe": round(sharpe, 2),
        "realized_vol": round(sigma, 3),
        "max_drawdown": round(max_dd, 3),
        "cagr": round(cagr, 3),
    }

A few implementation notes that matter more than they look:

  • Scaling for the optimizer. arch fits are numerically happier when returns are in percent, hence the * 100 and the matching / 100**2 when unscaling the variance. Forget the unscale and your target vol is off by 10,000x.
  • Refit cadence. Re-estimating GARCH parameters every single day is expensive and adds almost nothing — the parameters are stable week to week. Refitting weekly (refit_every=5) while forecasting daily (the recursion updates σt2\sigma_t^2 from new returns even without refitting) is the standard compromise. This mirrors the caching advice from the copula pipeline in copula models for joint risk.
  • The cap wmaxw_{\max} is not cosmetic. When forecast vol collapses in a dead-calm regime, σtarget/σ^t\sigma_{\text{target}}/\hat\sigma_t can explode to 5x, 10x leverage. Uncapped vol targeting will happily hand you catastrophic leverage right before a volatility regime change — the exact moment the forecast is about to be most wrong. Cap it (3x here) and recognize that the cap will bind precisely in the calmest, most dangerous-in-hindsight periods.
  • Costs scale with turnover, and vol targeting is a turnover machine. Every wobble in the forecast re-sizes the position. On a low-vol asset with a jumpy forecast you can churn the book daily. The cost_bps term is not a rounding detail; for a high-turnover vol-target it can eat a meaningful fraction of the gross Sharpe improvement.

What the output looks like (illustrative)

Running this on BTC daily data over a multi-year window, comparing the four forecasters as the sizing denominator, tends to produce a table with the following shape. The numbers below are illustrative — hand-chosen to show the typical pattern, not the output of a real backtest — but the ordering and magnitudes are representative of what practitioners report.

Sizing forecast Sharpe Realized vol Target vol Max drawdown Ann. turnover
Fixed notional (no targeting) 0.71 0.68 -0.78 0.1x
Rolling RV (60d) 0.94 0.24 0.20 -0.41 12x
EWMA (λ=0.94\lambda=0.94) 1.02 0.21 0.20 -0.35 19x
GARCH(1,1)-t 1.05 0.21 0.20 -0.33 22x
HAR-RV (intraday proxy) 1.09 0.20 0.20 -0.31 20x

Two variants worth building

The long-only case isolates the forecast, but two extensions are common enough to show explicitly.

Long/flat with a trend gate. Vol targeting sizes the position but takes no directional view — it is always long. A cheap, honest improvement is to gate the position off when a slow trend filter turns negative, so you hold the vol-targeted long only in uptrends and sit flat otherwise. This keeps the sizing logic identical and layers a crude regime filter on top; it does not pretend to time entries, only to avoid holding through obvious downtrends.

def apply_trend_gate(position: pd.Series, price: pd.Series, fast: int = 20, slow: int = 100):
    """Zero out the (already vol-targeted) position when the fast SMA is below
    the slow SMA. Both SMAs use only past prices, so no look-ahead is introduced.
    """
    fast_ma = price.rolling(fast).mean().shift(1)   # shift: known at prior close
    slow_ma = price.rolling(slow).mean().shift(1)
    gate = (fast_ma > slow_ma).astype(float)        # 1.0 in uptrend, 0.0 otherwise
    return position * gate.reindex(position.index).fillna(0.0)

The trend gate cuts turnover on the downside (you stop churning a shrinking position in a bear market) but adds its own regime risk — it whipsaws in choppy sideways markets and lags at turns. Whether it helps is an empirical question you must answer with the same walk-forward, DM-tested rigor as the sizing rule itself; a trend filter is exactly the kind of add-on that looks great in-sample and evaporates out of sample.

Portfolio vol targeting on the DCC covariance. For a book of several assets, the scalar forecast σ^t\hat\sigma_t becomes portfolio volatility wΣtw\sqrt{w^\top \Sigma_t w}, where Σt\Sigma_t is the time-varying covariance matrix from the DCC-GARCH of Part 3. You choose base weights w0w_0 (equal weight, market cap, or a mean-variance tilt), compute the portfolio's forecast vol under Σt\Sigma_t, and scale the entire weight vector to hit the portfolio target.

def portfolio_voltarget_weights(base_w, cov_forecast, target_vol_daily, w_gross_max=3.0):
    """Scale a base weight vector so forecast portfolio vol hits the target.
    base_w:        (n,) base allocation (need not sum to 1).
    cov_forecast:  (n, n) one-step-ahead covariance from DCC-GARCH (daily units).
    """
    base_w = np.asarray(base_w, dtype=float)
    port_var = float(base_w @ cov_forecast @ base_w)
    port_vol = np.sqrt(max(port_var, 1e-12))
    scale = min(target_vol_daily / port_vol, w_gross_max / np.abs(base_w).sum())
    return scale * base_w

This is the natural bridge to the portfolio-construction literature: the base weights w0w_0 can come from Markowitz mean-variance or a risk-based method like HRP/CVaR, and vol targeting then sits on top as an overlay that scales the aggregate risk to a constant. The DCC matrix matters because correlations spike in crashes (Part 3) — a portfolio that looks diversified in calm markets can have much higher forecast vol than a static covariance implies exactly when it matters, and the overlay cuts gross exposure in response.

Diagnostics you should always plot

Never trust the summary table alone. For any vol-target, plot three things and eyeball them before you believe any Sharpe number. First, realized rolling vol of the strategy against the target line — it should hug the target; systematic drift above it means your forecast is biased low (the expensive direction). Second, the position/leverage series — look for the cap binding and for leverage spikes right before drawdowns, the signature of a forecast that got caught out by a regime change. Third, the forecast-vs-proxy scatter (the Mincer-Zarnowitz picture) — a cloud with slope far from 1 tells you the forecast is mis-scaled in a way the QLIKE average can hide. These three plots catch more bugs and more self-deception than any single statistic.

Read this table the way you should read every backtest table: look at what is robust and what is marginal. The robust facts jump out. Every vol-targeted variant crushes fixed notional on Sharpe and, more dramatically, on drawdown and realized-vol stability — the fixed-notional strategy runs at 68% annualized vol with a 78% drawdown, which is simply un-investable. And every targeting method delivers realized vol close to the 20% target, which is the whole promise of the mechanic working. The marginal facts are the differences among the forecasters: HAR edges GARCH edges EWMA edges rolling RV, but the gaps are small — a tenth of a Sharpe point — and, tested with Diebold-Mariano on the forecasts or a bootstrap on the returns, would frequently fail to clear significance. That small, fragile, regime-dependent gap between the sophisticated and the naive forecaster is the honest headline of this whole series.

Being Honest About What This Buys You

This blog has an entire collection on backtesting without fooling yourself, so let us apply it to our own result rather than quietly hoping you do not.

Volatility targeting improves risk-adjusted return and drawdowns. It does not manufacture alpha from nothing. Look again at the table. The Sharpe improvement from targeting is real and it is worth having — but decompose it and most of it comes from not holding a constant position into high-vol regimes, which mechanically avoids the worst drawdowns and stabilizes the compounding path. The strategy is still long BTC; it has no view the market does not hand it. If BTC has a negative Sharpe over your sample, vol targeting will hand you a less bad negative Sharpe, not a positive one. It reshapes the return distribution — thinner tails, steadier vol, better geometric compounding — but the raw directional edge is whatever the underlying long is. Do not let a beautiful equity curve trick you into believing you have found alpha when you have found risk management. Moreira-Muir found genuine alpha in equity factors from vol management, but that result is about the factor's time-varying risk-return tradeoff, and it does not automatically transfer to a single crypto asset over a different sample.

The forecast-quality edge of GARCH over EWMA is often small and regime-dependent. This is the uncomfortable payoff of Parts 1-3. You built increasingly sophisticated models — leverage terms, Student-t tails, dynamic correlations — and the marginal contribution of each to a vol-targeting P&L, over a naive EWMA, is frequently within the noise band. GARCH's advantage (mean reversion after shocks) shows up mainly in specific regimes: sharp spikes that then normalize. In grinding trends or persistent high-vol regimes it barely differs from EWMA. This does not make GARCH useless — the mean-reversion structure, the interpretable parameters, the ability to simulate forward paths and price options against the forecast all have value EWMA lacks — but if your only use is sizing, run the DM test before you pay the complexity cost, and know that regime detection is telling you the same thing from a different angle: the winning model depends on the regime.

Backtest Sharpe is an upper bound on live Sharpe, and vol targeting widens the gap. Because the strategy is turnover-heavy and leverage-scaled, it is unusually sensitive to the frictions a naive backtest omits: your fills are worse than the close you sized on, funding costs on levered perp positions accrue continuously, and the leverage cap interacts with margin and liquidation mechanics that a simple w * return ignores. Every one of these makes live worse than backtest. We treat this gap systematically in backtest-live parity; for vol targeting specifically, budget for it by using conservative costs, a realistic (low) leverage cap, and by executing on the next bar's open rather than the close you computed the signal from.

An aside on the volatility risk premium. Everything above forecasts realized volatility. There is a parallel, tradable object: implied volatility, priced into options, which on average sits above subsequent realized vol — the volatility risk premium, compensation for bearing the risk of a vol spike. That gap is itself a source of return (selling variance harvests it, buying it hedges tail risk), and it is a genuinely different game from vol targeting: it is a bet on the price of volatility rather than a use of a forecast of volatility. We do not pursue it here, but the machinery starts with the pricing model in Black-Scholes options pricing, and a good realized-vol forecast (Parts 1-2) is exactly the input you need to judge whether implied vol is rich or cheap. Comparing your GARCH forecast to the options market's implied vol is one of the more honest uses of everything you built in this series.

Practical Considerations

A grab-bag of things that separate a working vol-target from a fragile one.

  • Estimate on the right horizon. If you size a position held for one day, forecast one-day vol. If you rebalance weekly, forecast (and target) weekly vol, or aggregate the daily GARCH forecast over the horizon — the multi-step GARCH forecast mean-reverts toward σˉ2\bar\sigma^2, which the naive "hdaily\sqrt{h}\cdot\text{daily}" scaling ignores. Part 1 covers multi-step GARCH forecasting.
  • Annualization in 24/7 markets. Crypto trades 365 days a year with no weekends or holidays, so annualize daily vol with 365\sqrt{365}, not the 252\sqrt{252} from equities. Getting this wrong silently mis-scales your target by ~20%.
  • The denominator can be a covariance matrix. For a multi-asset book, replace the scalar σ^t\hat\sigma_t with portfolio vol wΣtw\sqrt{w^\top \Sigma_t w} from the DCC-GARCH of Part 3, and scale the whole vector of weights to hit portfolio target vol. This connects vol targeting to mean-variance sizing (Markowitz for crypto) and risk-based allocation (HRP and CVaR pipelines) — vol targeting is the single-asset special case of scaling to a portfolio risk budget.
  • Vol targeting is procyclical in a subtle way. When everyone runs the same 1/σ1/\sigma rule, a vol spike forces synchronized deleveraging, which pushes prices down, which raises realized vol, which forces more deleveraging. This feedback (well documented in the 2018 "volmageddon" and various crypto deleveraging cascades) means the rule works less well exactly when many players use it. It is not a reason to abandon it, but it is a reason to cap leverage and to not assume your fills during a vol spike will resemble calm-market fills.
  • Floor and clip the forecast. A zero or near-zero forecast vol produces infinite leverage. Always floor σ^t\hat\sigma_t at a sane minimum and cap the position, and log how often each binds — if the cap binds most of the time, your target is too aggressive for the asset.

Summary

  • A volatility forecast has no value until it changes a decision. Volatility targeting — sizing exposure as wt=σtarget/σ^tw_t = \sigma_{\text{target}}/\hat\sigma_t (capped) — is the cleanest test of a forecast's worth, because forecast quality maps directly to a flatter realized-vol profile and a higher Sharpe.
  • Vol targeting raises risk-adjusted return and, especially, controls drawdowns, because volatility is forecastable (it clusters) while direction is not, and because Sharpe ratios fall in high-vol regimes that the rule automatically underweights. It is Kelly sizing under the assumption that expected return scales with volatility.
  • Benchmark GARCH honestly against strong baselines: rolling realized vol, EWMA/RiskMetrics (λ=0.94\lambda=0.94, an IGARCH with zero free parameters), and HAR-RV on an intraday realized-variance proxy. EWMA and HAR are hard to beat.
  • You cannot observe true volatility, so evaluate against a proxy (r2r^2 or, far better, RVRV) using loss functions robust to proxy noise. Prefer QLIKE over MSE: it penalizes under-forecasting more (the expensive error) and is scale-invariant, so it is not hijacked by a few high-vol days. Use Mincer-Zarnowitz to diagnose bias and the Diebold-Mariano test to decide whether one forecast's edge is real or noise.
  • In a walk-forward, cost-aware backtest, vol targeting reliably beats fixed notional on Sharpe and drawdown, and the four forecasters cluster close together — the GARCH-over-EWMA edge is small, regime-dependent, and often not statistically significant. Report the DM test, not just the mean loss.
  • Be honest: vol targeting is risk management, not alpha. It reshapes the return distribution of whatever directional bet you already had; it does not create edge from nothing. And it is turnover-heavy and leverage-scaled, so live results lag backtest more than usual.

References:

  • Patton, A. J. (2011). Volatility forecast comparison using imperfect volatility proxies. Journal of Econometrics, 160(1), 246-256. DOI
  • Corsi, F. (2009). A simple approximate long-memory model of realized volatility. Journal of Financial Econometrics, 7(2), 174-196. DOI
  • Diebold, F. X., & Mariano, R. S. (1995). Comparing predictive accuracy. Journal of Business & Economic Statistics, 13(3), 253-263. DOI
  • Mincer, J., & Zarnowitz, V. (1969). The evaluation of economic forecasts. In Economic Forecasts and Expectations, NBER.
  • Moreira, A., & Muir, T. (2017). Volatility-managed portfolios. Journal of Finance, 72(4), 1611-1644. DOI
  • J.P. Morgan / Reuters (1996). RiskMetrics Technical Document, 4th ed.
  • Bollerslev, T. (1986). Generalized autoregressive conditional heteroskedasticity. Journal of Econometrics, 31(3), 307-327. DOI
  • Glosten, L. R., Jagannathan, R., & Runkle, D. E. (1993). On the relation between the expected value and the volatility of the nominal excess return on stocks. Journal of Finance, 48(5), 1779-1801. DOI
  • Harvey, D., Leybourne, S., & Newbold, P. (1997). Testing the equality of prediction mean squared errors. International Journal of Forecasting, 13(2), 281-291. DOI
Disclaimer: The information provided in this article is for educational and informational purposes only and does not constitute financial, investment, or trading advice. Trading cryptocurrencies involves significant risk of loss.

MarketMaker.cc Team

Quantitative Research & Strategy

Discuss in Telegram
Newsletter

Stay Ahead of the Market

Subscribe to our newsletter for exclusive AI trading insights, market analysis, and platform updates.

We respect your privacy. Unsubscribe at any time.