\[ % MathJax has no bm package; redefine \bm in terms of \boldsymbol, which it supports natively \newcommand{\bm}[1]{\boldsymbol{#1}} % --- Operators ----------------------------------------------------------- % Expectation operator \DeclareMathOperator{\E}{E} % Variance operator \DeclareMathOperator{\Var}{Var} % Covariance operator \DeclareMathOperator{\Cov}{Cov} % Correlation operator \DeclareMathOperator{\Corr}{Corr} % Rank operator (Spearman rank correlation) \DeclareMathOperator{\Rank}{Rank} % Skewness operator \DeclareMathOperator{\Skewness}{Skewness} % Kurtosis operator \DeclareMathOperator{\Kurtosis}{Kurtosis} % Difference/differential operator (upright d per ISO 80000-2) \newcommand{\Diff}{\mathrm{d}} % --- Risk Measures ------------------------------------------------------- % Value at Risk \DeclareMathOperator{\VaR}{VaR} % Expected Shortfall \DeclareMathOperator{\ES}{ES} % Marginal Value at Risk \DeclareMathOperator{\MVaR}{MVaR} % Component Value at Risk \DeclareMathOperator{\CompVaR}{CVaR} % Incremental Value at Risk \DeclareMathOperator{\IVaR}{IVaR} % Component Expected Shortfall \DeclareMathOperator{\CompES}{CES} % Weighted sensitivity for risk factor in FRTB SBM bucket \DeclareMathOperator{\WS}{WS} % Hedge-benefit ratio for FRTB DRC bucket \DeclareMathOperator{\HBR}{HBR} % Stress scenario risk measure for FRTB NMRF capital add-on \DeclareMathOperator{\SES}{SES} % Jump-to-default exposure for obligor (FRTB DRC) \DeclareMathOperator{\JTD}{JTD} % Loss given default for obligor (FRTB DRC) \DeclareMathOperator{\LGD}{LGD} % Default risk weight for obligor (FRTB DRC) \DeclareMathOperator{\RW}{RW} % ES ratio function \newcommand{\ESratio}{\lambda} % Arbitrary risk measure function (coherence axioms) \newcommand{\RiskMeasure}{\varphi} % Asset position (coherence axioms) \newcommand{\Asset}{A} % First named asset in examples \newcommand{\AssetA}{A} % Second named asset in examples \newcommand{\AssetB}{B} % Third named asset in examples \newcommand{\AssetC}{C} % Constant (risk measure axioms) \newcommand{\Constant}{c} % Bucket-level aggregate sensitivity in FRTB SBM \newcommand{\BucketAgg}{S} % Cross-bucket correlation in FRTB SBM \newcommand{\CrossBucketCorr}{\gamma} % Notional amount for an instrument (FRTB DRC and RRAO) \newcommand{\Notional}{\text{Notional}} % --- Distributions ------------------------------------------------------- % Binomial distribution \DeclareMathOperator{\Binomial}{Binomial} % Uniform distribution \DeclareMathOperator{\Uniform}{Uniform} % Normal distribution \newcommand{\NormalDist}{\mathcal{N}} % Student-t CDF \newcommand{\StudentCDF}{t} % Student-t PDF \newcommand{\StudentPDF}{f} % --- Returns ------------------------------------------------------------- % Price \newcommand{\Price}{P} % Simple (arithmetic) return \newcommand{\SimpleReturns}{R} % Log (compound) return \newcommand{\CompoundReturns}{y} % Matrix of compound returns \newcommand{\ReturnMatrix}{\bm{y}} % Dividend payment \newcommand{\dividend}{d} % --- Volatility ---------------------------------------------------------- % Realised variance \DeclareMathOperator{\RealisedVar}{RV} % Realised volatility (square root of realised variance) \DeclareMathOperator{\RealisedVol}{RVol} % Volatility \newcommand{\Vol}{\sigma} % GARCH intercept parameter \newcommand{\GARCHconst}{\omega} % ARCH coefficient (weight on past squared returns) \newcommand{\ARCHcoeff}{\alpha} % GARCH coefficient (weight on past variance) \newcommand{\GARCHcoeff}{\beta} % EWMA decay factor (typically 0.94) \newcommand{\EWMAdecay}{\lambda} % Standardized residual / error term \newcommand{\StdNormal}{\epsilon} % Leverage parameter in apARCH \newcommand{\APARCHleverage}{\zeta} % Power parameter in apARCH \newcommand{\APARCHpower}{\delta} % Degrees of freedom (Student-t) \newcommand{\DOF}{\nu} % GJR-GARCH leverage parameter \newcommand{\GJRleverage}{\gamma} % GARCH-X external regressor coefficient \newcommand{\RegressorCoeff}{\xi} % Mean (first moment) \newcommand{\Mean}{\mu} % Lag order in volatility models \newcommand{\Lag}{L} % Dummy variable (indicator in regressions) \newcommand{\DummyVar}{D} % --- Portfolio ----------------------------------------------------------- % Portfolio weight (scalar) \newcommand{\weight}{w} % Portfolio weight vector \newcommand{\weights}{\bm{w}} % Covariance matrix \newcommand{\CovMatrix}{\bm{\Sigma}} % Correlation matrix \newcommand{\CorrMatrix}{\bm{C}} % Correlation coefficient \newcommand{\correlation}{\rho} % Number of assets in portfolio \newcommand{\NumberAssets}{K} % Portfolio value \newcommand{\PortfolioValue}{\vartheta} % Asset beta (CAPM) \newcommand{\AssetBeta}{\beta} % Ledoit-Wolf shrinkage intensity \newcommand{\ShrinkageIntensity}{\delta} % Sample covariance matrix \newcommand{\SampleCov}{\bm{S}} % --- Time Series --------------------------------------------------------- % Sample size \newcommand{\SampleSize}{T} % Generic count (number of lags, payments, etc.) \newcommand{\Count}{N} % Estimation window length \newcommand{\EstWindow}{W_E} % Testing window length \newcommand{\TestWindow}{W_T} % Stress window length \newcommand{\StressWindow}{W_S} % AR coefficient \newcommand{\ARcoeff}{\phi} % MA coefficient \newcommand{\MAcoeff}{\psi} % --- Probability --------------------------------------------------------- % Probability level (e.g. 0.01 for 1% VaR) \newcommand{\probability}{p} % Cumulative sorted scenario weight \newcommand{\CumScenarioWeight}{\Omega} % p-quantile of the P and L distribution \newcommand{\Quantile}{q} % Quantile function \newcommand{\QuantileFunction}{Q} % Probability density function \newcommand{\PDF}{f} % Cumulative distribution function \newcommand{\CDF}{F} % Standard normal CDF \newcommand{\NormalCDF}{\Phi} % Inverse standard normal (quantile function) \newcommand{\NormalQuantile}{\Phi^{-1}} % Standard normal density function \newcommand{\NormalPDF}{\phi} % Likelihood function \newcommand{\lik}{\mathcal{L}} % Log-likelihood \newcommand{\LogLikelihood}{\ell} % Parameter vector (MLE estimation) \newcommand{\ParamSet}{\theta} % Parameter space \newcommand{\ParamSpace}{\Theta} % Fisher information matrix \newcommand{\FisherInfo}{\mathcal{I}} % Statistical power (Type II error rate) \newcommand{\StatPower}{\beta} % Significance level (CI coverage complement) \newcommand{\SignifLevel}{\gamma} % --- Options ------------------------------------------------------------- % Call option label \newcommand{\CallOption}{\text{call}} % Put option label \newcommand{\PutOption}{\text{put}} % Strike price \newcommand{\Strike}{X} % Risk-free interest rate \newcommand{\RiskFree}{r_f} % Option maturity (time to expiration) \newcommand{\OptionMaturity}{\tau} % Option delta \newcommand{\OptionDelta}{\Delta} % Option gamma \newcommand{\OptionGamma}{\Gamma} % Option vega \newcommand{\Vega}{\mathcal{V}} % Holding period \newcommand{\HoldingPeriod}{H} % Simulation profit/loss (signed; negative is a loss) \newcommand{\ProfitLoss}{\Pi} % Futures price \newcommand{\Futures}{F} % Hedge ratio \newcommand{\HedgeRatio}{h} % Units of basic asset held \newcommand{\StockHolding}{x^b} % Units of options held \newcommand{\OptionHolding}{x^o} % --- Interest Rates ------------------------------------------------------ % Bond convexity \newcommand{\Convexity}{C} % Interest rate / yield \newcommand{\Yields}{r} % Key rate change, basis points (scalar tenor component); used as \KeyRateChange_k or \KeyRateChange_{t,k}. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \Yields is defined earlier in generated output (same interest_rates category, TOML order), so expansion order is safe; verify this still holds if either entry is reordered. \newcommand{\KeyRateChange}{\Delta \Yields^{\mathrm{bp}}} % Key rate changes, basis points (vector); used bare or as \KeyRateChanges_t. EXCEPTION to the no-macro-to-macro-indirection rule, decided 2026-08-09: composed from \Yields on purpose, so a future change to the rate glyph propagates here without a follow-up edit. \bm{\Yields} depends on \bm/\boldsymbol correctly expanding a macro argument rather than a bare letter --- confirm this renders correctly the first time either consumer compiles it. \newcommand{\KeyRateChanges}{\Delta \bm{\Yields}^{\mathrm{bp}}} % Dollar value of a basis point \newcommand{\DV}{\text{DV01}} % Mean reversion speed (interest rate models) \newcommand{\MeanReversion}{\varkappa} % Modified duration \newcommand{\ModDur}{D} % Macaulay duration \newcommand{\MacDur}{D_{\text{Mac}}} % Effective duration \newcommand{\EffDur}{D_{\text{eff}}} % DV01 vector \newcommand{\DVvec}{\bm{d}} % Long-run mean rate (Vasicek/CIR/Hull-White) \newcommand{\LongRunRate}{\theta} % Hull-White time-varying drift \newcommand{\HullWhiteDrift}{\varphi} % Cash flow (coupon/principal payment) \newcommand{\CashFlow}{c} % --- Extreme Value ------------------------------------------------------- % Tail index (EVT, Pareto-type tails) \newcommand{\TailIndex}{\iota} % Shape parameter (xi = 1/iota) \newcommand{\ShapeParam}{\varsigma} % Threshold value (EVT) \newcommand{\Threshold}{u} % Extremal index (Leadbetter 1983) \newcommand{\ExtremalIndex}{\theta} % Multivariate extremal coefficient, theta in [1,d]. theta=1 complete tail dependence; theta=d asymptotic independence. Glyph collides with ExtremalIndex and ParamSet; disambiguated at source by macro name. \newcommand{\ExtremalCoef}{\theta} % GEV distribution function \newcommand{\GEV}{\mathcal{H}} % GPD distribution function \newcommand{\GPD}{\mathcal{G}} % GPD scale parameter \newcommand{\GPDscale}{\beta} % GPD scale parameter at a threshold u, distinct from the generic GPD scale \newcommand{\GPDscaleAtThreshold}{\beta_u} % Number of threshold exceedances (EVT) \newcommand{\TailCount}{C} % Sample maximum (block maxima, EVT) \newcommand{\SampleMax}{M} % Kurtosis value (realized, e.g. sample kurtosis) \newcommand{\KurtosisVal}{\kappa} % Pareto scaling constant (EVT) \newcommand{\ParetoConst}{\mathcal{A}} % Generic constant (EVT asymptotics) \newcommand{\RemainderConst}{\mathcal{C}} % Little-o asymptotic notation \newcommand{\LittleO}{o} % Moment order (EVT) \newcommand{\MomentOrder}{\mathscr{m}} % Doubled sample size (EVT block maxima) \newcommand{\DoubleSample}{D} % GEV normalizing location constant \newcommand{\GEVloc}{a} % GEV normalizing scale constant \newcommand{\GEVscale}{b} % Negative return (loss, EVT context) \newcommand{\NegativeReturn}{L} % --- Copulas ------------------------------------------------------------- % Copula function \newcommand{\Copula}{\mathcal{C}} % Copula density \newcommand{\CopulaDensity}{c} % Generator function (Archimedean copulas) \newcommand{\generator}{\varphi} % Copula dependence parameter \newcommand{\CopulaParam}{\theta} % Copula marginal parameter set \newcommand{\MarginalParamSet}{\eta} % Lower tail dependence coefficient \newcommand{\LowerTailDep}{\lambda_L} % Upper tail dependence coefficient \newcommand{\UpperTailDep}{\lambda_U} % Marginal CDF (second variable) \newcommand{\MarginalCDF}{G} % Joint density function \newcommand{\JointDensity}{h} % Marginal density function \newcommand{\MarginalDensity}{g} % Joint distribution function \newcommand{\JointCDF}{H} % Gaussian copula correlation parameter \newcommand{\CopulaCorr}{\rho} % --- Multivariate -------------------------------------------------------- % Diagonal matrix of conditional volatilities \newcommand{\DiagVolD}{\bm{D}} % Idiosyncratic variance matrix (diagonal, factor models) \newcommand{\IdioVarMatrix}{\bm{\Psi}} % Covariance matrix entry (two indices) \newcommand{\CovElement}{\sigma} % Auxiliary matrix in DCC dynamics \newcommand{\DCCauxQ}{\bm{Q}} % BEKK constant matrix \newcommand{\BEKKconst}{\bm{\Omega}} % BEKK ARCH parameter matrix \newcommand{\BEKKarch}{\bm{A}} % BEKK GARCH parameter matrix \newcommand{\BEKKgarch}{\bm{B}} % DCC rescaling diagonal matrix \newcommand{\DCCrescaleZ}{\bm{Z}} % DCC auxiliary matrix element \newcommand{\DCCelement}{q} % Factor loadings matrix (loadings/eigenvectors of the covariance matrix) \newcommand{\FactorLoadings}{\bm{\Lambda}} % Factor subscript label \newcommand{\factor}{\text{factor}} % PCA eigenvalue \newcommand{\Eigenvalue}{\lambda} % Number of factors in factor model \newcommand{\NumberFactors}{m} % Factor return vector \newcommand{\FactorReturn}{\bm{g}} % PCA factor score (scalar; not an eigenvector) \newcommand{\PCAFactor}{F} % BEKK cross-covariance coefficient \newcommand{\BEKKcross}{\delta} % Factor loading element \newcommand{\FactorLoading}{\Lambda} % DCC correlation persistence parameter \newcommand{\DCCxi}{\xi} % DCC news coefficient (weight on recent shocks) \newcommand{\DCCzeta}{\zeta} % --- Simulation ---------------------------------------------------------- % Number of simulation paths \newcommand{\NumberSims}{B} % Cholesky factor of covariance matrix \newcommand{\Cholesky}{\bm{L}} % Uniform random number \newcommand{\UniformDraw}{\tilde{u}} % --- Backtesting --------------------------------------------------------- % Violation ratio (observed/expected violations) \DeclareMathOperator{\ViolRatio}{VR} % Stressed Value at Risk \DeclareMathOperator{\StressedVaR}{SVaR} % QLIKE loss function for variance forecasts \DeclareMathOperator{\QLIKE}{QLIKE} % Mean squared error (forecast loss function) \DeclareMathOperator{\MSE}{MSE} % Mean absolute error (forecast loss function) \DeclareMathOperator{\MAE}{MAE} % Markov transition probability \newcommand{\TransProb}{\pi} % Exception indicator: 1 if y_t < -VaR_t \newcommand{\ExceptionInd}{\eta} % Exception count \newcommand{\ExceptionCount}{\upsilon} % Probability Integral Transform \newcommand{\ProbIntegralTransform}{\hat{u}} % Quantile score (pinball loss function) \newcommand{\QuantileScore}{\mathcal{S}} % Kolmogorov-Smirnov test statistic \newcommand{\KSstat}{D} % Test statistic (generic base letter) \newcommand{\TestStat}{J} % --- General ------------------------------------------------------------- % Regression intercept \newcommand{\RegressionIntercept}{a} % Regression slope \newcommand{\RegressionSlope}{\beta} % Risk factor \newcommand{\RiskFactor}{x} % Forecast horizon \newcommand{\ForecastHorizon}{h} % Block length (bootstrap) \newcommand{\BlockLength}{\ell} % Capital tau variant \newcommand{\DeliveryTime}{\Upsilon} % Today's calendar time in years (option pricing input; distinct from trading-date index t) \newcommand{\CalendarTime}{t^*} % Probability measure \newcommand{\ProbMeasure}{\mathbb{P}} % Risk-neutral measure \newcommand{\RiskNeutral}{\mathbb{Q}} % Pricing function (Black-Scholes, bond pricing, etc.) \newcommand{\PricingFn}{V} % Indicator function \newcommand{\Indicator}{\bm{1}} % Vector of ones \newcommand{\OnesVector}{\bm{1}} % Risk factor sensitivity (first-order P&L) \newcommand{\Sensitivity}{\delta} % Second-order sensitivity (convexity/gamma) \newcommand{\ConvexitySens}{\gamma} % Output floor percentage (Basel) \newcommand{\OutputFloor}{\alpha} % --- Subscript Labels ---------------------------------------------------- % Subscript label: annualized \newcommand{\Annual}{a} % Subscript label: implied \newcommand{\Implied}{I} % Subscript label: portfolio \newcommand{\Portfolio}{\pi} % --- Text Abbreviations -------------------------------------------- % S&P 500 index \newcommand{\SP}{\text{S\&P-500}} % Student-t distribution (text) \newcommand{\St}{\text{Student-t}} % Geopolitical Risk index (Caldara and Iacoviello) \newcommand{\GPR}{\text{GPR}} \]

19  Evaluating volatility models

Chapter 18 fitted four specifications and read their diagnostics. Neither exercise settles which model to run. A model can pass its residual tests and still generate paths that look nothing like the data, and it can sit closely on the estimation sample and forecast badly outside it.

This chapter asks both questions. Simulation shows what a fitted model implies, by drawing paths from it and setting them beside the returns it was fitted to. Out-of-sample evaluation scores its forecasts on days the estimation never touched. A specification can pass one and fail the other.

19.1 Data and libraries

The same libraries as the previous chapter, and the same S&P 500 returns.

library(lubridate)
library(zoo)
library(reshape2)
library(car)
library(tseries)
source("common/functions.r", chdir = TRUE)
library(tsgarch)
library(xts)
library(ggplot2)
import numpy as np
import pandas as pd
import sys
sys.path.insert(0, 'common')
from functions import ProcessRawData
from arch import arch_model
from plotnine import ggplot, aes, geom_line, labs, theme_minimal
using CSV, DataFrames, Dates, Statistics
using ARCHModels
using TidierPlots, CairoMakie
using Distributions
using StatsBase
using HypothesisTests
using Random
using Printf
include("common/functions.jl");
data = ProcessRawData()
dates = as.Date(as.character(data$sp500$date), format="%Y%m%d")
y = xts(data$sp500$y, order.by = dates)
y = na.omit(y)
y = y - mean(y)
y = y * 100
data = ProcessRawData()
y = data['sp500']['y'].values
y = y[~np.isnan(y)]
y = y - np.mean(y)
y = y * 100
data = ProcessRawData();
y = collect(skipmissing(data["sp500"].y));
y = y .- mean(y);
y = y .* 100;

19.2 Monte Carlo analysis of GARCH models

A fitted GARCH is a recipe for generating returns. Running that recipe forward and looking at what comes out is the most direct check there is on whether the model describes the data, and it catches things the residual tests do not.

19.2.1 Using simulate()

The code in this section and the next is shown but not run, so no output or figures appear below it. Each tab draws a 5,000 observation path from the fitted GARCH-t and plots it beside the S&P 500 returns, which makes it easy to see how much of the appearance of the data the model reproduces.

spec_sim = garch_modelspec(y = y, model = "garch", order = c(1, 1), distribution = "std")
spec_sim$parmatrix$value[match(Results$tGARCH11$parmatrix$parameter, spec_sim$parmatrix$parameter)] = Results$tGARCH11$parmatrix$value
s = simulate(spec_sim, nsim = 1, h = 5000)
sim_series = as.numeric(s$series[1, ])
sim_xts = xts(sim_series, order.by = seq(as.Date("2000-01-01"), by = "day", length.out = length(sim_series)))

par(mar = c(2, 3, 2, 0))
plot(y, type = 'l', bty = 'l', las = 1, main = "S&P 500")
plot(sim_xts, type = 'l', bty = 'l', las = 1, main = "Simulated GARCH-t S&P 500")
sim = Results['tGARCH11'].model.simulate(Results['tGARCH11'].params, nobs=5000)

df_obs = pd.DataFrame({'t': range(len(y)), 'y': y})
p = (ggplot(df_obs, aes(x='t', y='y'))
 + geom_line(size=0.5)
 + labs(title='S&P 500', x='', y='')
 + theme_minimal())
p.save("_figs/sim_obs_py.png", width=10, height=4, dpi=100)

df_sim = pd.DataFrame({'t': range(len(sim['data'])), 'y': sim['data']})
p = (ggplot(df_sim, aes(x='t', y='y'))
 + geom_line(size=0.5)
 + labs(title='Simulated GARCH-t S&P 500', x='', y='')
 + theme_minimal())
p.save("_figs/sim_tgarch_py.png", width=10, height=4, dpi=100)
using ARCHModels, DataFrames, TidierPlots
df_obs = DataFrame(t = 1:length(y), y = y)
p = ggplot(df_obs, @aes(x = t, y = y)) +
    geom_line() +
    labs(title = "S&P 500", x = "", y = "Returns")
ggsave("_figs/sim_obs_jl.png", p)

sim = simulate(Results_jl["tGARCH11"], 5000)
df_sim = DataFrame(t = 1:length(sim.data), y = sim.data)
p = ggplot(df_sim, @aes(x = t, y = y)) +
    geom_line() +
    labs(title = "Simulated GARCH-t S&P 500", x = "", y = "Returns")
ggsave("_figs/sim_tgarch_jl.png", p)

19.2.1.1 Simulation and estimation

Simulating from a fitted model and re-estimating on the simulated data is a check on the estimator. If the code is right, the coefficients recovered from each simulated path should scatter around the parameters that generated it. Watch how much they move from one path to the next, because that spread is the sampling uncertainty in the original estimates.

N = 5
for (n in 1:N) {
  spec_sim = garch_modelspec(y = y, model = "garch", order = c(1, 1), distribution = "norm")
  spec_sim$parmatrix$value[match(Results$GARCH11$parmatrix$parameter, spec_sim$parmatrix$parameter)] = Results$GARCH11$parmatrix$value
  s = simulate(spec_sim, nsim = 1, h = 5000)
  sim_series = as.numeric(s$series[1, ])
  sim_xts = xts(sim_series, order.by = seq(as.Date("2000-01-01"), by = "day", length.out = length(sim_series)))
  spec_re = garch_modelspec(y = sim_xts, model = "garch", order = c(1, 1), distribution = "norm")
  fit_sim = estimate(spec_re)
  print(coef(fit_sim))
}
N = 5
for n in range(N):
    sim = Results['GARCH11'].model.simulate(Results['GARCH11'].params, nobs=5000)
    model = arch_model(sim['data'], vol='GARCH', p=1, q=1, dist='normal', mean='Zero')
    fit = model.fit(disp='off')
    print(fit.params.values)
using ARCHModels
N = 5
for n in 1:N
    sim = simulate(Results_jl["GARCH11"], 5000)
    fit_sim = fit(GARCH{1,1}, sim.data; meanspec=NoIntercept)
    println(coef(fit_sim))
end

19.2.2 Manual simulation

Package functions are convenient, but writing the recursion out by hand matters. It shows exactly how the process works.

19.2.2.1 GARCH(1,1)

R, Python and Julia use different random number generators, so the simulated paths differ even with the same seed. All three implementations are correct. They simply draw different samples from the same GARCH process.

S = 1e3
omega = 0.02
alpha = 0.15
beta = 0.8

set.seed(42)
eps = rnorm(S)
y_sim = rep(NA, S)
sigma2 = omega / (1 - alpha - beta)

y_sim[1] = eps[1] * sqrt(sigma2)
for (i in 2:S) {
  sigma2 = omega + alpha * y_sim[i-1]^2 + beta * sigma2
  y_sim[i] = eps[i] * sqrt(sigma2)
}

df_sim = data.frame(t = 1:length(y_sim), y = y_sim)
ggplot(df_sim, aes(x = t, y = y)) +
  geom_line() +
  theme_minimal()

S = 1000
omega = 0.02
alpha = 0.15
beta = 0.8

np.random.seed(42)
eps = np.random.normal(size=S)
y_sim = np.zeros(S)
sigma2 = omega / (1 - alpha - beta)

y_sim[0] = eps[0] * np.sqrt(sigma2)
for i in range(1, S):
    sigma2 = omega + alpha * y_sim[i-1]**2 + beta * sigma2
    y_sim[i] = eps[i] * np.sqrt(sigma2)

df_sim = pd.DataFrame({'t': range(len(y_sim)), 'y': y_sim})
p = (ggplot(df_sim, aes(x='t', y='y'))
 + geom_line(size=0.5)
 + theme_minimal())
p.save("_figs/vol_sim_garch_py.png", width=10, height=3, dpi=100)

function simulate_garch(S, omega, alpha, beta, seed)
    Random.seed!(seed)
    eps = randn(S)
    y = zeros(S)
    sigma2 = omega / (1 - alpha - beta)

    y[1] = eps[1] * sqrt(sigma2)
    for i in 2:S
        sigma2 = omega + alpha * y[i-1]^2 + beta * sigma2
        y[i] = eps[i] * sqrt(sigma2)
    end
    return y
end

y_sim = simulate_garch(1000, 0.02, 0.15, 0.8, 42);
df_sim = DataFrame(t = 1:length(y_sim), y = y_sim);
p = ggplot(df_sim, @aes(x = t, y = y)) +
    geom_line(linewidth = 0.3) +
    labs(x = "", y = "") +
    theme_minimal();
ggsave("_figs/vol_sim_garch_jl.png", p);

19.2.2.2 GARCH-t

The same recursion with Student-t innovations. Only the draw changes — the variance equation is identical — and the standardisation by \(\sqrt{\DOF/(\DOF-2)}\) keeps the innovations at unit variance so \(\GARCHconst\), \(\ARCHcoeff\) and \(\GARCHcoeff\) mean what they meant before.

df = 6
omega = 0.02
alpha = 0.15
beta = 0.75
S = 1e4

set.seed(42)
eps = rt(S, df = df) / sqrt(df / (df - 2))
y_sim = rep(NA, S)
sigma2 = omega / (1 - alpha - beta)

y_sim[1] = eps[1] * sqrt(sigma2)
for (i in 2:S) {
  sigma2 = omega + alpha * y_sim[i-1]^2 + beta * sigma2
  y_sim[i] = eps[i] * sqrt(sigma2)
}

df_sim = data.frame(t = 1:length(y_sim), y = y_sim)
ggplot(df_sim, aes(x = t, y = y)) +
  geom_line() +
  theme_minimal()

df = 6
omega = 0.02
alpha = 0.15
beta = 0.75
S = 10000

np.random.seed(42)
eps = np.random.standard_t(df, size=S) / np.sqrt(df / (df - 2))
y_sim = np.zeros(S)
sigma2 = omega / (1 - alpha - beta)

y_sim[0] = eps[0] * np.sqrt(sigma2)
for i in range(1, S):
    sigma2 = omega + alpha * y_sim[i-1]**2 + beta * sigma2
    y_sim[i] = eps[i] * np.sqrt(sigma2)

df_sim = pd.DataFrame({'t': range(len(y_sim)), 'y': y_sim})
p = (ggplot(df_sim, aes(x='t', y='y'))
 + geom_line(size=0.5)
 + theme_minimal())
p.save("_figs/vol_sim_tgarch_py.png", width=10, height=3, dpi=100)

function simulate_tgarch(S, omega, alpha, beta, df, seed)
    Random.seed!(seed)
    eps = rand(TDist(df), S) ./ sqrt(df / (df - 2))
    y = zeros(S)
    sigma2 = omega / (1 - alpha - beta)

    y[1] = eps[1] * sqrt(sigma2)
    for i in 2:S
        sigma2 = omega + alpha * y[i-1]^2 + beta * sigma2
        y[i] = eps[i] * sqrt(sigma2)
    end
    return y
end

y_sim = simulate_tgarch(10000, 0.02, 0.15, 0.75, 6, 42);
df_sim = DataFrame(t = 1:length(y_sim), y = y_sim);
p = ggplot(df_sim, @aes(x = t, y = y)) +
    geom_line(linewidth = 0.3) +
    labs(x = "", y = "") +
    theme_minimal();
ggsave("_figs/vol_sim_tgarch_jl.png", p);

19.3 Out-of-sample forecast evaluation

Everything so far judges a model by how snugly it sits on the sample used to estimate it. Likelihood, AIC and BIC all work that way. But fitting the past closely and predicting the future well are different skills, and it is the second one we are buying. So the comparison that settles which specification to run should be made on days the estimation never touched.

The scheme is the one the backtesting chapter uses — estimate on a rolling window, record the one-step-ahead variance forecast, roll forward, repeat. What is different here is the scoring, and it runs into an immediate problem — volatility is not observed, so there is nothing to compare the forecast against.

Something observable has to stand in for it. The squared return is the natural candidate, and it is unbiased, but the substitution is a poor one day by day. Write \(\CompoundReturns_t^2 = \Vol_t^2 \StdNormal_t^2\). The thing we want is multiplied by a random factor whose mean is one and whose variance, under normality, is two. One day’s squared return is therefore a wildly dispersed reading of that day’s variance.

Intraday data supports a much sharper proxy, since summing squared five-minute returns averages the noise away, and that is the route to take when such data is to hand. It is not here, so we use squared returns and rely on the evaluation period being long enough for the dispersion to average out across days. That is enough to rank two models. It would not be enough to say anything about one particular Tuesday.

Two loss functions are standard. Writing \(\hat{\Vol}_t^2\) for the forecast and \(\tilde{\Vol}_t^2\) for the proxy,

\[L^{\mathrm{MSE}}_t = \left(\hat{\Vol}_t^2 - \tilde{\Vol}_t^2\right)^2, \qquad L^{\mathrm{QLIKE}}_t = \log \hat{\Vol}_t^2 + \frac{\tilde{\Vol}_t^2}{\hat{\Vol}_t^2},\]

each averaged over the evaluation period, lower being better. They are not interchangeable. MSE works on the difference of two variances, so it is symmetric and, being squared, is driven by whichever days happen to be largest.

QLIKE is asymmetric, and the reason is exact. Subtract \(\log \tilde{\Vol}_t^2\), which the forecast cannot influence and which therefore changes no ranking. What is left depends on the forecast only through the ratio \(\rho_t = \tilde{\Vol}_t^2 / \hat{\Vol}_t^2\) of actual to forecast:

\[L^{\mathrm{QLIKE}}_t - \log \tilde{\Vol}_t^2 = \rho_t - \log \rho_t .\]

That function bottoms out at \(\rho_t = 1\) and climbs away from it unevenly. Forecasting half the variance that arrives, \(\rho_t = 2\), costs about 0.31. Forecasting twice what arrives, \(\rho_t = 1/2\), costs about 0.19. So the penalty attaches to getting the ratio wrong, in either direction, but it bites harder when the forecast is too low — which is the useful asymmetry for risk work, where the expensive error is being caught short. The penalty does not depend on whether \(\hat{\Vol}_t^2\) is large or small in absolute terms, only on how far the ratio sits from one.

Both losses also have a property that many reasonable-looking alternatives lack. Because the proxy is noisy, a badly chosen loss can end up rewarding models that track the noise instead of the variance, and can rank two models differently from how it would rank them against the true variance. MSE and QLIKE are immune to that. Their expected ranking is the same whether the proxy is noisy or exact (Patton 2011). Scoring on absolute rather than squared errors, or on the volatility scale rather than the variance scale, generally is not.

WE_oos = 1000
n_oos = 250
start_oos = length(y) - n_oos

fc = matrix(NA_real_, nrow = n_oos, ncol = 2,
            dimnames = list(NULL, c("EWMA", "GARCH")))
actual = numeric(n_oos)

for (i in 1:n_oos) {
    win_xts = y[(start_oos + i - WE_oos):(start_oos + i - 1)]  # tsgarch needs xts
    win = as.numeric(win_xts)

    # EWMA: recursion through the window, one-step-ahead forecast
    s2 = var(win)
    for (j in 2:length(win)) s2 = 0.94 * s2 + 0.06 * win[j - 1]^2
    fc[i, "EWMA"] = 0.94 * s2 + 0.06 * win[length(win)]^2

    # GARCH(1,1), refit each step
    spec = garch_modelspec(win_xts, model = "garch", constant = FALSE,
                           order = c(1, 1), distribution = "norm")
    gfit = estimate(spec)
    fc[i, "GARCH"] = as.numeric(predict(gfit, h = 1)$sigma)^2

    actual[i] = as.numeric(y[start_oos + i])^2
}

loss = data.frame(
    Model = colnames(fc),
    MSE   = apply(fc, 2, function(f) mean((f - actual)^2)),
    QLIKE = apply(fc, 2, function(f) mean(log(f) + actual / f))
)
print(loss, row.names = FALSE, digits = 5)
 Model    MSE  QLIKE
  EWMA 36.159 1.2870
 GARCH 35.822 1.1825
WE_oos = 1000
n_oos = 250
start_oos = len(y) - n_oos

fc = {'EWMA': np.empty(n_oos), 'GARCH': np.empty(n_oos)}
actual = np.empty(n_oos)

for i in range(n_oos):
    window = y[start_oos + i - WE_oos:start_oos + i]

    s2 = np.var(window, ddof=1)
    for j in range(1, len(window)):
        s2 = 0.94 * s2 + 0.06 * window[j - 1]**2
    fc['EWMA'][i] = 0.94 * s2 + 0.06 * window[-1]**2

    res = arch_model(window, mean='Zero', vol='GARCH', p=1, q=1, dist='normal').fit(disp='off')
    fc['GARCH'][i] = res.forecast(horizon=1, reindex=False).variance.values[-1, 0]

    actual[i] = y[start_oos + i]**2

print(f"{'Model':<8} {'MSE':>12} {'QLIKE':>12}")
for m, f in fc.items():
    print(f"{m:<8} {np.mean((f - actual)**2):>12.5f} {np.mean(np.log(f) + actual / f):>12.5f}")
Model             MSE        QLIKE
EWMA         36.15949      1.28702
GARCH        35.89600      1.18478
WE_oos = 1000
n_oos = 250
start_oos = length(y) - n_oos

fc = Dict("EWMA" => zeros(n_oos), "GARCH" => zeros(n_oos))
actual = zeros(n_oos)

for i in 1:n_oos
    window = y[(start_oos + i - WE_oos):(start_oos + i - 1)]

    s2 = var(window)
    for j in 2:length(window)
        s2 = 0.94 * s2 + 0.06 * window[j-1]^2
    end
    fc["EWMA"][i] = 0.94 * s2 + 0.06 * window[end]^2

    gfit = fit(GARCH{1,1}, window; meanspec = NoIntercept)
    fc["GARCH"][i] = predict(gfit, :variance)

    actual[i] = y[start_oos + i]^2
end

@printf("%-8s %12s %12s\n", "Model", "MSE", "QLIKE")
for m in ["EWMA", "GARCH"]
    f = fc[m]
    @printf("%-8s %12.5f %12.5f\n", m, mean((f .- actual).^2), mean(log.(f) .+ actual ./ f))
end
Model             MSE        QLIKE
EWMA         36.15949      1.28702
GARCH        35.82430      1.18245

One model coming out ahead on average is suggestive, not conclusive. Both were scored on the same days, so the gap between their averages is itself a random quantity, and we should ask whether it is distinguishable from zero. The daily loss differences are not independent — volatility persists, so a stretch of days where one model does better tends to come in a run — which the standard error has to accommodate. That is the Diebold-Mariano test of Section 27.10.4, used here on variance forecasts instead of on VaR.

The exercise has limits. Winning on QLIKE means a model tracked the variance proxy well. It does not mean the model will behave when we start reading quantiles off it. Chapter 26 judges these same forecasts by how often and in what pattern they are breached, which is the question a risk committee and a regulator ask, and the two verdicts can disagree. A specification can score well here and still bunch its violations together.

This also gives a principled way to choose the EWMA decay factor rather than accepting 0.94 by convention. Run the same loop over a grid of \(\EWMAdecay\) and take the value that minimises out-of-sample QLIKE.

19.4 Exercises

  1. Forecasting evaluation: Generate one-step-ahead volatility forecasts for the last 100 observations and compare forecast accuracy across different model specifications.
Patton, Andrew J. 2011. “Volatility Forecast Comparison Using Imperfect Volatility Proxies.” Journal of Econometrics 160 (1): 246–56.