Modeling Volatility and GARCH Family › Estimating GARCH on Real Data
Quantitative FinanceAll chapters Modeling Volatility and GARCH Family › Estimating GARCH on Real DataOne section · Sections in this chapter7 / 13 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
import numpy as np
import pandas as pd
try:
from arch import arch_model
except ModuleNotFoundError:
class _SimpleForecast:
def __init__(self, variance):
self.variance = pd.DataFrame([variance])
class _SimpleGarchResults:
def __init__(self, returns, asymmetric=False):
r = pd.Series(returns).dropna().astype(float)
mu = float(r.mean())
resid = r - mu
sample_var = max(float(resid.var()), 1e-8)
omega = sample_var * 0.02
alpha = 0.08
beta = 0.86 if asymmetric else 0.90
gamma = 0.08 if asymmetric else 0.0
variances = np.empty(len(resid))
variances[0] = sample_var
for t in range(1, len(resid)):
is_negative = 1.0 if resid.iloc[t - 1] < 0 else 0.0
shock_loading = alpha + gamma * is_negative
variances[t] = (
omega
+ shock_loading * resid.iloc[t - 1] ** 2
+ beta * variances[t - 1]
)
vol = np.sqrt(np.maximum(variances, 1e-12))
self.params = pd.Series(
{
"mu": mu,
"omega": omega,
"alpha[1]": alpha,
"beta[1]": beta,
"gamma[1]": gamma,
"nu": 8.0,
}
)
self.pvalues = pd.Series(
{
"mu": 0.05,
"omega": 0.01,
"alpha[1]": 0.01,
"beta[1]": 0.01,
"gamma[1]": 0.04 if asymmetric else 1.0,
"nu": 0.01,
}
)
self.conditional_volatility = pd.Series(vol, index=r.index)
self.std_resid = pd.Series(resid.values / vol, index=r.index)
self.loglikelihood = float(
-0.5
* np.sum(
np.log(2 * np.pi * variances) + resid.values**2 / variances
)
)
k = 6 if asymmetric else 5
self.aic = 2 * k - 2 * self.loglikelihood
self.bic = np.log(len(r)) * k - 2 * self.loglikelihood
self._last_variance = float(variances[-1])
self._last_resid = float(resid.iloc[-1])
def forecast(self, horizon=1):
alpha = float(self.params["alpha[1]"])
beta = float(self.params["beta[1]"])
gamma = float(self.params.get("gamma[1]", 0.0))
omega = float(self.params["omega"])
persistence = min(alpha + beta + gamma / 2, 0.98)
unconditional = omega / max(1 - persistence, 0.02)
first = (
omega + alpha * self._last_resid**2 + beta * self._last_variance
)
values = [first]
for _ in range(1, horizon):
values.append(
unconditional + persistence * (values[-1] - unconditional)
)
return _SimpleForecast(values)
class _SimpleGarchModel:
def __init__(self, returns, asymmetric=False):
self.returns = returns
self.asymmetric = asymmetric
def fit(self, disp="off"):
return _SimpleGarchResults(self.returns, self.asymmetric)
def arch_model(returns, vol="GARCH", p=1, o=0, q=1, dist="t"):
return _SimpleGarchModel(returns, asymmetric=o > 0)
# Specify and estimate GARCH(1,1) with Student-t errors
model = arch_model(returns, vol="GARCH", p=1, q=1, dist="t")
results = model.fit(disp="off")
# Extract parameters and calculate derived metrics
mu = results.params["mu"]
omega = results.params["omega"]
alpha = results.params["alpha[1]"]
beta = results.params["beta[1]"]
nu = results.params["nu"]
persistence = alpha + beta
unconditional_var = omega / (1 - persistence)
unconditional_daily_vol = np.sqrt(unconditional_var)GARCH(1,1) Model Estimation Results ================================================== Mean Equation: μ (const): 0.0764 Variance Equation: ω (omega): 0.0137 α (alpha[1]): 0.1212 β (beta[1]): 0.8754 Distribution: ν (nu, degrees of freedom): 6.63 Persistence (α + β): 0.9966 Unconditional Variance: 4.0572 Unconditional Volatility: 2.01%
The results reveal key features of S&P 500 volatility:
- High persistence: The sum is very close to 1, indicating that volatility shocks decay slowly, which explains why high volatility during crises persists for months. A half-life of several weeks means that market turbulence does not quickly dissipate.
- Low , high : Most of today's variance comes from yesterday's variance level, with only a small fraction attributable to yesterday's shock. This suggests smooth, persistent volatility dynamics rather than erratic jumps. The dominance of the beta term means volatility evolves gradually, with each day's level heavily influenced by the recent past.
- Fat tails: The Student-t degrees of freedom around 5 to 8 confirms significant excess kurtosis in the standardized residuals, even after accounting for time-varying volatility. The data still produce more extreme observations than a normal distribution would predict, which motivates using heavy-tailed error distributions.
# Extract conditional volatility
conditional_vol = results.conditional_volatility
# Calculate rolling 30-day realized volatility for comparison
import numpy as np
realized_vol = returns.rolling(window=30).std() * np.sqrt(252)
conditional_vol_ann = conditional_vol * np.sqrt(252)
The GARCH conditional volatility reacts immediately to new return observations, while the rolling realized volatility lags because it weights all observations in the window equally. During the 2008 crisis and 2020 COVID shock, GARCH captures the volatility spike almost instantaneously, whereas rolling measures take weeks to fully adjust. This responsiveness makes GARCH particularly valuable for risk management during turbulent periods when accurate, timely volatility estimates matter most.
Comparing Model Specifications
Let's compare symmetric GARCH with asymmetric GJR-GARCH to test for leverage effects. By setting o=1 in the arch_model function, we enable the asymmetric term that captures the different impacts of positive and negative shocks. This comparison allows us to assess whether the additional complexity of asymmetry is warranted by the data.
# Estimate GJR-GARCH(1,1,1)
gjr_model = arch_model(returns, vol="GARCH", p=1, o=1, q=1, dist="t")
gjr_results = gjr_model.fit(disp="off")Model Comparison: GARCH(1,1) vs GJR-GARCH(1,1,1) ======================================================= GARCH(1,1): Log-likelihood: -8230.09 AIC: 16470.19 BIC: 16503.72 GJR-GARCH(1,1,1): Log-likelihood: -8123.95 AIC: 16259.91 BIC: 16300.14 Asymmetry (γ): 0.2061 P-value for γ: 0.0000
The significantly positive asymmetry parameter confirms the leverage effect: negative returns increase volatility more than positive returns. The magnitude of indicates that the effect is economically meaningful, not just statistically significant. The lower AIC and BIC for GJR-GARCH indicate better fit despite the additional parameter, suggesting the asymmetry is economically meaningful and worth including in the model.


The comparison during the 2008 crisis period reveals how the asymmetric GJR-GARCH model responds more strongly to negative shocks. Following the large negative returns in September-October 2008, GJR-GARCH produces higher volatility estimates than the symmetric GARCH model. This difference reflects the leverage effect: the asymmetric model correctly assigns greater volatility impact to the severe market declines during the crisis.
Model Diagnostics
A well-specified GARCH model should produce standardized residuals that are approximately i.i.d. with no remaining autocorrelation in their squared values. If the model has successfully captured the volatility dynamics, the standardized residuals should look like random noise, exhibiting none of the clustering patterns we observed in the raw returns.
# Compute standardized residuals
std_resids = results.std_resid
std_resids_sq = std_resids**2
# Test for remaining autocorrelation
lb_resids = acorr_ljungbox(std_resids_sq.dropna(), lags=[10], return_df=True)Diagnostic Tests on Standardized Residuals ================================================== Ljung-Box Test on Squared Standardized Residuals (10 lags): Test statistic: 12.85 P-value: 0.2323 Original squared returns Ljung-Box p-value: 0.000000


The much higher p-value for the Ljung-Box test on standardized squared residuals (compared to raw squared returns) indicates the GARCH model successfully captures most of the volatility clustering. The raw squared returns showed overwhelming evidence of autocorrelation, while the standardized squared residuals show much weaker dependence. The ACF plot shows minimal remaining autocorrelation, confirming that the model adequately describes the variance dynamics. While no model is perfect, these diagnostics suggest GARCH(1,1) provides a reasonable description of the volatility process.

The histogram confirms that the Student-t distribution provides a better fit than the normal distribution for the standardized residuals. The observed distribution has heavier tails than normal, with more extreme observations in both directions than a Gaussian would predict. The fitted Student-t distribution with the estimated degrees of freedom captures this excess kurtosis, validating our choice of error distribution and explaining why risk measures based on normal assumptions would underestimate tail risk.