Week 6: Univariate Volatility Models

Version 5.0 - August 2026

Author
Affiliation

Jon Danielsson

London School of Economics

6 Week 6

Volatility is central to financial risk, but it cannot be observed directly. It has to be estimated. Financial returns also show volatility clustering. Large changes tend to be followed by large changes, and quiet periods by quiet periods.

This week uses a GARCH(1,1) model to estimate changing daily volatility. We fit the same model with normal and Student-t innovations, interpret its main parameters and inspect its conditional-volatility series. The emphasis is on what the model says, not on writing optimisation code.

NoteBefore you start

Open the main course folder and check that Returns.csv, created in Week 2, is present. You will use ideas about distributions and fat tails from Week 3. This week also needs the arch library installed in the Anaconda Python selected in Positron.

TipBy the end of this session

You should be able to fit a GARCH(1,1), explain omega, alpha[1], beta[1] and persistence, plot conditional volatility, and explain why a Student-t version can be preferable to a normal version.

6.1 The plan for this week

  1. Look for volatility clustering
  2. Fit one normal GARCH(1,1)
  3. Interpret its parameters and conditional volatility
  4. Fit the same model with Student-t innovations
  5. Compare the two models

6.2 Loading data and libraries

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy import stats
import statsmodels.api as sm
from statsmodels.graphics.tsaplots import plot_acf
from statsmodels.stats.diagnostic import acorr_ljungbox
from arch import arch_model

Returns = pd.read_csv("Returns.csv", index_col="date", parse_dates=True)
y = Returns["JPM"].dropna()
NoteCheck your result

y should be a dated series of decimal daily returns. A return of 1% is stored as 0.01, not 1. Run y.describe() if you want to inspect its scale.

y.describe()
count    6470.000000
mean        0.000393
std         0.023261
min        -0.232280
25%        -0.008600
50%         0.000388
75%         0.009663
max         0.223919
Name: JPM, dtype: float64
CautionUnits and signs used this week
  • Returns are decimals.
  • Conditional volatility is a daily standard deviation in decimal units.
  • Variance is volatility squared.
  • Volatility is non-negative. A negative return is not a negative volatility.

6.3 Seeing volatility clustering

The return plot alternates between quiet and turbulent periods. Squared returns make the clustering easier to see because both large gains and large losses become large positive values.

fig, axes = plt.subplots(2, 1, sharex=True, figsize=(8, 6))
axes[0].plot(y.index, y, linewidth=0.7)
axes[0].set_title("JPM daily returns")
axes[0].set_ylabel("Return")
axes[1].plot(y.index, y**2, linewidth=0.7, color="tab:orange")
axes[1].set_title("Squared returns")
axes[1].set_ylabel("Return squared")
axes[1].set_xlabel("Date")
fig.tight_layout()
plt.show()

Autocorrelation in squared returns is another sign that current volatility contains information about future volatility.

fig, ax = plt.subplots()
plot_acf(y**2, lags=20, ax=ax, title="Autocorrelation of squared returns")
plt.show()

6.4 A function for fitting GARCH

arch estimates more reliably when decimal returns are multiplied by 100. The function below does that, checks that the optimiser reports success, and converts conditional volatility and omega back to decimal-return units. It also sets the conditional mean to zero with mean="Zero", which is a modelling choice rather than a technical necessity. The average daily return is small beside the daily volatility, and setting it to zero keeps the attention on the variance equation. Run the block unchanged. The reason for the scaling is in Numerical details and other models.

def fit_garch(returns, dist="normal"):
    scaled_returns = 100 * returns
    model = arch_model(
        scaled_returns,
        mean="Zero",
        vol="GARCH",
        p=1,
        q=1,
        dist=dist,
        rescale=False,
    )
    result = model.fit(disp="off")
    if result.convergence_flag != 0:
        raise RuntimeError("The GARCH optimiser did not converge")

    parameters = result.params.copy()
    parameters["omega"] = parameters["omega"] / 100**2
    sigma = result.conditional_volatility / 100
    standardised_residuals = result.resid / result.conditional_volatility
    return {
        "result": result,
        "parameters": parameters,
        "sigma": sigma,
        "z": standardised_residuals,
    }

The dictionary it returns holds two scales. parameters and sigma are in decimal-return units, converted back after the fit. result is the object arch produced, so result.params is still on the percentage scale the optimiser worked on. Report from parameters, and use result for the likelihood-based quantities such as AIC and BIC.

6.5 A normal GARCH(1,1)

The model is

\[ y_t = \sigma_t \epsilon_t, \qquad \epsilon_t \sim N(0,1), \]

\[ \sigma_t^2 = \omega + \alpha y_{t-1}^2 + \beta \sigma_{t-1}^2. \]

The first equation states that the return is its conditional volatility multiplied by a draw with mean zero and variance one. The second states how that volatility evolves. The Student-t model below changes only the first equation, replacing the normal draw with a Student-t standardised to variance one, and leaves the second untouched.

The equations and Python output use different notation for the same quantities:

Formula Python output Meaning
\(\omega\) omega Baseline contribution to variance
\(\alpha\) alpha[1] Response to the latest squared return
\(\beta\) beta[1] Carry-over from the previous variance
\(\alpha+\beta\) sum of the two Persistence of a volatility shock
\(\sigma_t\) garch_normal["sigma"] Estimated daily conditional volatility
\(\epsilon_t\) garch_normal["z"] Standardised residual
garch_normal = fit_garch(y, dist="normal")
garch_normal["parameters"]
omega       0.000004
alpha[1]    0.082582
beta[1]     0.909955
Name: params, dtype: float64
alpha = garch_normal["parameters"]["alpha[1]"]
beta = garch_normal["parameters"]["beta[1]"]
persistence = alpha + beta
print("alpha:", round(alpha, 4))
print("beta:", round(beta, 4))
print("persistence:", round(persistence, 4))
alpha: 0.0826
beta: 0.91
persistence: 0.9925

Persistence close to one means that a volatility shock fades slowly. It does not mean that volatility is always high. It means that when volatility changes, the effect lasts.

fig, ax = plt.subplots()
ax.plot(y.index, garch_normal["sigma"])
ax.set_title("JPM conditional volatility: normal GARCH(1,1)")
ax.set_xlabel("Date")
ax.set_ylabel("Daily conditional volatility")
plt.show()

NoteCheck your result

The volatility line should always be positive and should rise in turbulent periods. alpha and beta should be non-negative, and persistence should be below one for this fit.

assert np.isfinite(garch_normal["sigma"]).all()
assert (garch_normal["sigma"] > 0).all()
assert alpha >= 0 and beta >= 0
assert persistence < 1

6.6 Normal versus Student-t

A normal distribution often assigns too little probability to extreme returns. The Student-t version keeps the same GARCH variance equation but allows fatter tails.

garch_t = fit_garch(y, dist="t")
garch_t["parameters"]
omega       0.000003
alpha[1]    0.083420
beta[1]     0.912406
nu          5.399890
Name: params, dtype: float64

The Student-t model adds nu, its degrees-of-freedom parameter. Smaller values mean fatter tails. The AIC and BIC compare fit while penalising the Student-t model for its extra parameter, and smaller values are preferred.

comparison = pd.DataFrame(
    {
        "AIC": [garch_normal["result"].aic, garch_t["result"].aic],
        "BIC": [garch_normal["result"].bic, garch_t["result"].bic],
    },
    index=["Normal GARCH", "Student-t GARCH"],
)
comparison.round(2)
AIC BIC
Normal GARCH 25320.76 25341.09
Student-t GARCH 24827.78 24854.88
nu = garch_t["parameters"]["nu"]
t_persistence = (
    garch_t["parameters"]["alpha[1]"]
    + garch_t["parameters"]["beta[1]"]
)
print("Student-t degrees of freedom:", round(nu, 2))
print("Student-t persistence:", round(t_persistence, 4))
Student-t degrees of freedom: 5.4
Student-t persistence: 0.9958
NoteCheck your result

Identify which row has the lower AIC and BIC. Then explain the result in words: does allowing fat tails improve the model enough to justify one extra parameter?

6.7 Residual diagnostics

If the model has captured the changing volatility, its standardised residuals should no longer show strong clusters. Their squares should have little autocorrelation.

z = garch_t["z"]

fig, axes = plt.subplots(1, 2, figsize=(9, 4))
plot_acf(z, lags=20, ax=axes[0], title="Residual autocorrelation")
plot_acf(z**2, lags=20, ax=axes[1], title="Squared-residual autocorrelation")
fig.tight_layout()
plt.show()

The Ljung–Box test from Week 3 turns that reading into a number:

acorr_ljungbox(z ** 2, lags=[10], return_df=True)
lb_stat lb_pvalue
10 11.420496 0.325709

A large p-value means no evidence of remaining autocorrelation in the squared standardised residuals, which is what a well-specified volatility model should leave behind.

sm.qqplot(z, dist=stats.t, distargs=(nu,), scale=np.sqrt((nu - 2) / nu), line="q")
plt.gca().set_title("GARCH-t standardised residuals")
plt.show()

arch standardises its Student-t to variance one, while stats.t with nu degrees of freedom has variance \(\nu/(\nu-2)\). The scale argument rescales the reference distribution so that the plot compares the residuals against the distribution that was actually fitted.

6.8 Numerical details and other models

fit_garch() fitted percentage returns because the optimiser handles parameters of similar numerical size more reliably. Multiplying returns by 100 multiplies variance parameters such as omega by \(100^2\) and volatility by 100. The function reverses both conversions. alpha, beta and nu are dimensionless. All model comparisons above use the same percentage scale, so their AIC and BIC are directly comparable. Comparing likelihoods fitted on different scales would additionally require a change-of-variables Jacobian.

arch also supports ARCH and asymmetric APARCH models. This block is an additional comparison, not part of the main analysis.

advanced_models = {
    "ARCH(1)": arch_model(
        100 * y, mean="Zero", vol="ARCH", p=1, rescale=False
    ).fit(disp="off"),
    "APARCH-t": arch_model(
        100 * y,
        mean="Zero",
        vol="APARCH",
        p=1,
        o=1,
        q=1,
        dist="t",
        rescale=False,
    ).fit(disp="off"),
    "APARCH-skewt": arch_model(
        100 * y,
        mean="Zero",
        vol="APARCH",
        p=1,
        o=1,
        q=1,
        dist="skewt",
        rescale=False,
    ).fit(disp="off"),
}

for name, result in advanced_models.items():
    if result.convergence_flag != 0:
        raise RuntimeError(f"The {name} optimiser did not converge")

advanced_comparison = pd.DataFrame(
    {
        "AIC": [result.aic for result in advanced_models.values()],
        "BIC": [result.bic for result in advanced_models.values()],
    },
    index=advanced_models.keys(),
)
advanced_comparison.round(2)
AIC BIC
ARCH(1) 27530.82 27544.37
APARCH-t 24682.13 24722.78
APARCH-skewt 24679.92 24727.34

APARCH can represent different reactions to positive and negative shocks, and the skewed-t can represent asymmetry in the return distribution. These extra parameters can improve in-sample fit but also make estimation and explanation harder. The normal distribution is reached only as nu tends to infinity, so a normal-versus-Student-t likelihood-ratio test has a boundary complication; that is why the core comparison uses AIC and BIC.

6.9 Recap

  • Squared-return clustering motivates a time-varying volatility model.
  • A GARCH(1,1) combines a baseline variance, the latest squared return and the previous conditional variance.
  • alpha + beta measures volatility persistence.
  • Conditional volatility is a positive daily standard deviation, not a return.
  • A Student-t model can account for more extreme returns than a normal model.
  • AIC and BIC compare fit while penalising extra parameters.

6.10 Exercises

6.10.1 Apply the method

  1. Replace JPM with AAPL, fit the normal and Student-t GARCH models, and compare their AIC and BIC.
  2. Report alpha, beta and alpha + beta for AAPL. Explain persistence in one sentence without using the phrase “the number is high”.
  3. Plot AAPL conditional volatility and identify one visibly turbulent period.

6.10.2 Compare results

  1. Compare the autocorrelation of squared raw returns with the autocorrelation of squared standardised residuals. What has the model removed?
  2. Repeat the analysis for another security and compare its persistence with JPM’s.

6.10.3 Extend the analysis

  1. Compare GARCH(1,1) with APARCH-t using AIC and BIC. State what extra feature APARCH is intended to capture.
  2. Investigate how fitting decimals rather than percentages affects optimiser convergence and parameter units. Do not compare unadjusted likelihoods across the two data scales.