Interactive exploration of EWMA and GARCH models for time-varying conditional volatility
Financial return volatility is not constant: it clusters in time and mean-reverts to a long-run level. This page explores the two workhorse models for tracking conditional volatility: the Exponentially Weighted Moving Average (EWMA/RiskMetrics) and the Generalized Autoregressive Conditional Heteroskedasticity (GARCH, Bollerslev 1986) family. Both models decompose daily returns as
where \(\sigma_{t+1}\) is the conditional standard deviation forecast and \(z_{t+1}\) is the standardized innovation. The goal is to model and forecast \(\sigma^2_{t+1}\)(see Hull 2023, secs. 8.6–8.10; Christoffersen 2012, chap. 4).
// ============================================================// SHARED UTILITIES// ============================================================// Seeded PRNG (Mulberry32)prng = {functionmulberry32(seed) {returnfunction() { seed |=0; seed = seed +0x6D2B79F5|0let t =Math.imul(seed ^ seed >>>15,1| seed) t = t +Math.imul(t ^ t >>>7,61| t) ^ treturn ((t ^ t >>>14) >>>0) /4294967296 } }functionboxMuller(rng) {const u1 =rng(), u2 =rng()returnMath.sqrt(-2*Math.log(u1)) *Math.cos(2*Math.PI* u2) }return { mulberry32, boxMuller }}
// Autocorrelation function (for lags 1..maxLag)computeACF = (series, maxLag) => {const n = series.lengthconst mean = series.reduce((a, b) => a + b,0) / nconst demeaned = series.map(x => x - mean)const var0 = demeaned.reduce((a, b) => a + b * b,0) / nconst result = []for (let k =1; k <= maxLag; k++) {let sum =0for (let t = k; t < n; t++) sum += demeaned[t] * demeaned[t - k] result.push({ lag: k,acf: sum / (n * var0) }) }return result}
// Ljung-Box statisticljungBox = (series, K) => {const acfVals =computeACF(series, K)const n = series.lengthlet stat =0for (const { lag, acf } of acfVals) { stat += ((n +2) / (n - lag)) * acf * acf }return n * stat}
// Descriptive statistics helperdescStats = (arr) => {const n = arr.lengthconst mean = arr.reduce((a, b) => a + b,0) / nconst m2 = arr.reduce((a, b) => a + (b - mean) **2,0) / nconst sd =Math.sqrt(m2)const z = arr.map(x => (x - mean) / sd)const skew = z.reduce((a, b) => a + b **3,0) / nconst kurt = z.reduce((a, b) => a + b **4,0) / nreturn { mean,variance: m2, sd, skew, kurt }}
// Format number to fixed decimalsfmt = (x, d) => x ===undefined||isNaN(x) ?"N/A": x.toFixed(d)
// Percentage formatter for plot axespctFmt = x => (x *100).toFixed(1) +"%"
1. EWMA vs GARCH(1,1)
The EWMA model updates variance as a weighted average of yesterday’s variance and squared return, with all weight on recent data and no mean reversion:
Both models are applied to the same simulated return series (generated from the GARCH process) so that the comparison is like-for-like.
Note
What happens when \(\alpha + \beta \geq 1\)?
The stationarity condition\(\alpha + \beta < 1\) ensures that the long-run variance \(V_L = \omega/(1-\alpha-\beta)\) is well-defined and positive, and that shocks to variance eventually decay. When this condition is violated:
\(\alpha + \beta = 1\) (the EWMA/RiskMetrics case): \(\omega = 0\) and the long-run variance is undefined. Variance follows a random walk: any shock persists forever in the forecast, and the model has no “anchor” to revert to. This is the EWMA model with \(\lambda = \beta\).
\(\alpha + \beta > 1\): The process is explosive. Shocks to variance are amplified over time rather than decaying, and the unconditional variance is infinite. The model is mean-averting rather than mean-reverting.
In practice, estimated GARCH models for financial data almost always yield \(\alpha + \beta\) slightly below 1 (typically 0.95 to 0.99), confirming that variance is highly persistent but ultimately stationary.
Tip
How to experiment
Adjust the GARCH parameters \(\alpha\) and \(\beta\) to control persistence and reactivity. Then compare with EWMA by varying \(\lambda\). After a volatility spike, watch how GARCH variance reverts to the long-run level (dashed line) while EWMA stays elevated. Check the ACF tabs to see which model better removes autocorrelation from squared returns. Try pushing \(\alpha + \beta\) close to or above 1 to see the stationarity constraint in action.
// Prepare time series data for plottingcmpPlotData = {const { ret, gVar, eVar } = cmpSimreturn ret.map((r, i) => ({t: i +1,ret: r,retSq: r * r,gVol:Math.sqrt(gVar[i]),eVol:Math.sqrt(eVar[i]),gVar: gVar[i],eVar: eVar[i],gStdSq: r * r / gVar[i],eStdSq: r * r / eVar[i] }))}
html`<p style="color:#666;font-size:0.85rem;">Both models track volatility similarly in the short run. After a spike, the GARCH variance reverts toward the long-run level while EWMA stays elevated. ${!cmpStationary ?'<strong style="color:#d62728;">Warning: α + β ≥ 1, GARCH is non-stationary (equivalent to EWMA).</strong>':''}</p>`
html`<p style="color:#666;font-size:0.85rem;">Autocorrelation of squared returns R²<sub>t</sub>. Positive autocorrelations at short lags are a signature of <strong>volatility clustering</strong>. The dashed lines show 95% Bartlett confidence bands (±${fmt(cmpBartlett,3)}).</p>`
where \(V_L = \omega/(1-\alpha-\beta)\) is the long-run variance and \(\alpha + \beta\) is the persistence measuring how long a volatility shock takes to decay.
Tip
How to experiment
Increase \(\alpha\) to make volatility more reactive to recent shocks. Increase \(\beta\) to make it more persistent. Watch the conditional volatility tab and ACF diagnostics. With very high persistence (\(\alpha + \beta > 0.99\)), the model behaves almost like EWMA.
viewof g11Alpha = Inputs.range([0.01,0.25], {label:"α (reaction to shocks)",step:0.01,value:0.10})
html`<p style="color:#666;font-size:0.85rem;">The GARCH model should remove autocorrelation from standardized squared returns. Values within the dashed 95% bands indicate adequate fit.</p>`
The long-run variance is \(V_L = \omega / (1 - \alpha_1 - \alpha_2 - \beta_1 - \beta_2)\). This model can equivalently be written as a component GARCH with interpretable short-run and long-run variance factors (see Christoffersen 2012, sec. 5.3 and appendix A).
Tip
How to experiment
Compare with GARCH(1,1) using similar total persistence (\(\alpha_1 + \alpha_2 + \beta_1 + \beta_2 \approx \alpha + \beta\)). GARCH(2,2) can capture slower ACF decay in squared returns. Watch for non-stationarity when the sum of all parameters approaches 1.
As \(k \to \infty\), the forecast converges to \(V_L\). Under EWMA (\(\alpha + \beta = 1\)), the forecast is flat: \(E_t[\sigma^2_{t+k}] = \sigma^2_{t+1}\) for all \(k\).
For multi-day VaR, we need the cumulative variance of the \(K\)-day return \(R_{t+1:t+K}\):
Start with current volatility above the long-run level and watch the GARCH forecast curve downward while EWMA stays flat. Then try setting current volatility below long-run to see upward reversion. Increase persistence toward 1 to see the two models converge.
html`<div style="display:flex;gap:18px;font-size:0.85rem;margin-top:-6px;flex-wrap:wrap;"> <span><svg width="24" height="10"><line x1="0" y1="5" x2="24" y2="5" stroke="#2f71d5" stroke-width="2"/></svg> GARCH term structure</span> <span><svg width="24" height="10"><line x1="0" y1="5" x2="24" y2="5" stroke="#999" stroke-width="1.5" stroke-dasharray="6 3"/></svg> Long-run annualized vol (${fmt(Math.sqrt(252* fcVL) *100,1)}%)</span></div><p style="color:#666;font-size:0.85rem;">The volatility term structure for option pricing (Hull's equation 8.15), with a = −ln(α+β) = ${fmt(-Math.log(fcPers),4)}. Current volatility ${fcV0 > fcVL ?"above":"below"} long-run implies a ${fcV0 > fcVL ?"downward":"upward"}-sloping term structure.</p>`
5. News impact function
The news impact function (NIF) shows how today’s standardized shock \(z_t\) affects tomorrow’s variance. Standard GARCH assumes a symmetric parabola, but the leverage effect (negative returns increase volatility more than positive returns) motivates asymmetric extensions (see Christoffersen 2012, sec. 5.1).
Select different models and adjust the leverage parameter \(\theta\). With NGARCH, a positive \(\theta\) shifts the minimum of the parabola to the right, so negative shocks (left of the minimum) produce larger variance increases. With GJR-GARCH, \(\theta > 0\) adds an extra kick for negative returns, creating a visible kink at \(z_t = 0\).
// Compute NIF datanifData = {const a = nifAlpha, b = nifBeta, th = nifTheta, s2 = nifSigma2const pts = []// Long-run variances for omega calculationconst omegaG = s2 * (1- a - b)const omegaN = s2 * (1- a * (1+ th * th) - b)const omegaGJR = s2 * (1- a -0.5* a * th - b) // approximate: E[I_t] = 0.5for (let z =-4; z <=4; z +=0.05) {const R = z *Math.sqrt(s2)const I = z <0?1:0const garch = omegaG + a * R * R + b * s2const ngarch = omegaN + a * s2 * (z - th) **2+ b * s2const gjr = omegaGJR + a * R * R + a * th * I * R * R + b * s2 pts.push({ z,GARCH:Math.sqrt(garch) *100,NGARCH:Math.sqrt(Math.max(0, ngarch)) *100,"GJR-GARCH":Math.sqrt(Math.max(0, gjr)) *100 }) }return pts}
html`<p style="color:#666;font-size:0.85rem;">The news impact function shows σ<sub>t+1</sub> as a function of the standardized shock z<sub>t</sub>, holding current volatility at ${nifSigma}%. GARCH is symmetric around z<sub>t</sub> = 0. NGARCH shifts the minimum to z<sub>t</sub> = θ = ${fmt(nifTheta,1)}. GJR-GARCH has a kink at z<sub>t</sub> = 0, with steeper slope for negative shocks.</p>`
{const a = nifAlpha, b = nifBeta, th = nifTheta, s2 = nifSigma2const omegaG = s2 * (1- a - b)const omegaN = s2 * (1- a * (1+ th * th) - b)const omegaGJR = s2 * (1- a -0.5* a * th - b)const shocks = [-3,-2,-1,1,2,3]const rows = shocks.map(z => {const R = z *Math.sqrt(s2)const I = z <0?1:0const gV =Math.sqrt(omegaG + a * R * R + b * s2) *100const nV =Math.sqrt(Math.max(0, omegaN + a * s2 * (z - th) **2+ b * s2)) *100const gjrV =Math.sqrt(Math.max(0, omegaGJR + a * R * R + a * th * I * R * R + b * s2)) *100returnhtml`<tr> <td style="text-align:center;padding:4px 8px;${z <0?'color:#d62728;font-weight:500;':''}">${z >0?'+':''}${z}σ</td> <td style="text-align:right;padding:4px 8px;">${fmt(gV,3)}%</td> <td style="text-align:right;padding:4px 8px;">${fmt(nV,3)}%</td> <td style="text-align:right;padding:4px 8px;">${fmt(gjrV,3)}%</td> </tr>` })returnhtml`<table style="font-size:0.9rem;border-collapse:collapse;width:100%;"> <thead> <tr style="border-bottom:2px solid #333;"> <th style="text-align:center;padding:4px 8px;">Shock z<sub>t</sub></th> <th style="text-align:right;padding:4px 8px;">GARCH σ<sub>t+1</sub></th> <th style="text-align:right;padding:4px 8px;">NGARCH σ<sub>t+1</sub></th> <th style="text-align:right;padding:4px 8px;">GJR σ<sub>t+1</sub></th> </tr> </thead> <tbody>${rows}</tbody> <tfoot> <tr style="border-top:2px solid #333;"> <td colspan="4" style="padding:4px 8px;font-size:0.82rem;color:#666;"> Current σ<sub>t</sub> = ${nifSigma}%. α = ${nifAlpha}, β = ${nifBeta}, θ = ${fmt(nifTheta,1)}. In GARCH, the response to ±kσ is identical. In NGARCH and GJR, negative shocks produce higher volatility than positive shocks of equal magnitude. </td> </tr> </tfoot> </table>`}