Split View: 시계열 변동성 예측 실전 가이드: GARCH 모델 패밀리와 Python 구현
시계열 변동성 예측 실전 가이드: GARCH 모델 패밀리와 Python 구현
- 들어가며
- 변동성의 정의와 Stylized Facts
- ARCH 모델 기초
- GARCH(1,1) 심화
- GJR-GARCH와 레버리지 효과
- EGARCH 모델
- Python 실전 구현
- 모델 진단과 선택
- VaR, CVaR 계산
- 백테스팅과 검증
- 트러블슈팅
- 운영 체크리스트
- 실패 사례와 교훈
- 고급 주제: 변동성 예측의 다음 단계
- 참고자료

들어가며
금융 시장에서 수익률을 예측하는 것은 극히 어렵다. 그러나 변동성(volatility)을 예측하는 것은 상대적으로 가능하다. 이 차이가 리스크 관리의 출발점이다. 내일 주가가 오를지 내릴지는 모르지만, 내일의 가격 변동 폭이 어느 정도일지는 오늘의 시장 상태로부터 상당 부분 추론할 수 있다.
GARCH(Generalized Autoregressive Conditional Heteroskedasticity) 모델 패밀리는 이 변동성 예측의 핵심 도구다. 1982년 Robert Engle이 ARCH 모델을 제안한 이후, Tim Bollerslev의 GARCH(1986), Nelson의 EGARCH(1991), Glosten-Jagannathan-Runkle의 GJR-GARCH(1993) 등으로 진화하면서, 금융 변동성 모델링의 표준 프레임워크로 자리잡았다. Engle은 ARCH 모델로 2003년 노벨 경제학상을 수상했다.
이 글은 변동성 모델의 수학적 원리를 설명하되, 수식을 나열하는 데 그치지 않는다. Python arch 패키지를 활용한 실전 구현, 모델 선택과 진단, VaR(Value at Risk) 계산, 백테스팅까지 실무에서 필요한 전체 워크플로우를 다룬다. 수학적 배경이 약한 개발자도 코드를 따라가며 실제 데이터에 적용할 수 있도록 구성했다.
변동성의 정의와 Stylized Facts
변동성이란 무엇인가
변동성은 자산 수익률의 분산 또는 표준편차를 의미한다. 실무에서는 크게 세 가지로 구분한다.
- 역사적 변동성(Historical Volatility): 과거 수익률 데이터에서 계산한 표준편차. 가장 단순하지만 후행적이다.
- 내재 변동성(Implied Volatility): 옵션 가격으로부터 역산한 시장의 기대 변동성. VIX 지수가 대표적이다.
- 조건부 변동성(Conditional Volatility): 과거 정보를 조건으로 한 미래 변동성의 기댓값. GARCH 모델이 추정하는 대상이 바로 이것이다.
연율화 변동성은 일간 변동성에 거래일 수의 제곱근을 곱하여 계산한다. 한국 시장 기준 연간 거래일 약 250일을 적용하면, 일간 표준편차 1.2%는 연율화 변동성 약 19%에 해당한다.
금융 수익률의 Stylized Facts
실제 금융 데이터는 정규분포와 동일 분산을 가정하는 단순 통계 모형으로는 설명이 안 되는 특성들을 가진다. 이를 Stylized Facts라 부르며, GARCH 모델은 이러한 특성을 포착하기 위해 설계되었다.
| Stylized Fact | 설명 | GARCH 관련성 |
|---|---|---|
| 변동성 군집(Volatility Clustering) | 큰 변동 뒤에 큰 변동, 작은 변동 뒤에 작은 변동이 이어지는 경향 | GARCH의 핵심 가정이자 모델링 대상 |
| 두꺼운 꼬리(Fat Tails) | 수익률 분포의 꼬리가 정규분포보다 두꺼움 (첨도 3 초과) | Student-t, GED 등 비정규 분포 적용으로 포착 |
| 레버리지 효과(Leverage Effect) | 하락 충격이 상승 충격보다 변동성을 더 크게 증가시킴 | EGARCH, GJR-GARCH로 비대칭성 모델링 |
| 평균 회귀(Mean Reversion) | 변동성이 장기 평균 수준으로 되돌아가는 경향 | GARCH 모수의 정상성 조건으로 반영 |
| 장기 기억(Long Memory) | 변동성의 자기상관이 매우 천천히 감소 | FIGARCH, IGARCH 등 분수 적분 모델로 포착 |
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from scipy import stats
# KOSPI 200 ETF (KODEX 200) 또는 S&P 500 데이터 수집
spy = yf.download('SPY', start='2020-01-01', end='2026-03-01', auto_adjust=True)
returns = spy['Close'].pct_change().dropna() * 100 # 백분율 수익률
# Stylized Facts 확인
print(f"평균: {returns.mean():.4f}%")
print(f"표준편차: {returns.std():.4f}%")
print(f"왜도(Skewness): {returns.skew():.4f}")
print(f"첨도(Kurtosis): {returns.kurtosis():.4f}") # 정규분포 = 0 (excess kurtosis)
# Jarque-Bera 정규성 검정
jb_stat, jb_pval = stats.jarque_bera(returns)
print(f"Jarque-Bera 통계량: {jb_stat:.2f}, p-value: {jb_pval:.6f}")
# Ljung-Box 검정: 수익률 자체 vs 수익률 제곱
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_returns = acorr_ljungbox(returns, lags=10, return_df=True)
lb_squared = acorr_ljungbox(returns**2, lags=10, return_df=True)
print(f"\n수익률 Ljung-Box p-value (lag=10): {lb_returns['lb_pvalue'].iloc[-1]:.4f}")
print(f"수익률^2 Ljung-Box p-value (lag=10): {lb_squared['lb_pvalue'].iloc[-1]:.6f}")
# 수익률 제곱의 p-value가 매우 작으면 변동성 군집 존재
위 코드를 실행하면 일반적으로 첨도가 3을 크게 초과하고(fat tails), 수익률 제곱의 Ljung-Box 검정이 유의미하게 나온다(변동성 군집). 이것이 GARCH 모델을 적용해야 하는 통계적 근거다.
ARCH 모델 기초
Engle의 ARCH(q) 모델
ARCH(AutoRegressive Conditional Heteroskedasticity) 모델은 조건부 분산이 과거 오차항의 제곱에 의존한다고 가정한다.
수익률 과정을 다음과 같이 정의한다.
r_t = mu + epsilon_t
epsilon_t = sigma_t * z_t, z_t ~ N(0,1)
여기서 조건부 분산 sigma_t^2는 다음과 같다.
sigma_t^2 = omega + alpha_1 * epsilon_{t-1}^2 + ... + alpha_q * epsilon_{t-q}^2
omega > 0: 장기 분산의 기저 수준alpha_i >= 0: 과거 충격의 영향 계수q: ARCH 차수 (과거 몇 시점의 충격을 반영할 것인가)
ARCH(1) 모델의 경우, 어제 큰 충격(양수든 음수든)이 발생하면 오늘의 조건부 분산이 커진다. 이것이 변동성 군집을 설명하는 메커니즘이다.
ARCH 모델의 한계
실무에서 ARCH 모델을 직접 쓰는 경우는 드물다. 변동성 군집을 제대로 포착하려면 q가 매우 커야 하는데, 이는 추정해야 할 모수가 많아진다는 뜻이다. ARCH(20) 이상을 설정해야 적합도가 나오는 경우가 흔하며, 이는 과적합 위험과 추정 불안정성을 초래한다. 이 한계를 극복한 것이 GARCH다.
GARCH(1,1) 심화
모델 구조
Bollerslev(1986)가 제안한 GARCH(p,q)는 조건부 분산에 과거 분산 자체를 추가한다.
sigma_t^2 = omega + alpha * epsilon_{t-1}^2 + beta * sigma_{t-1}^2
GARCH(1,1)의 세 가지 모수가 각각 의미하는 바는 다음과 같다.
omega: 장기 분산의 기여분. 값이 크면 분산의 하한이 높다.alpha: 뉴스 충격 계수. 어제 시장에서 발생한 충격이 오늘 변동성에 미치는 영향의 크기.beta: 분산 지속 계수. 어제의 변동성이 오늘로 얼마나 이어지는가.
정상성 조건과 모수 해석
GARCH(1,1)이 정상성(stationarity)을 가지려면 alpha + beta < 1이어야 한다. 이 값이 1에 가까울수록 변동성 충격이 오래 지속된다.
무조건 분산(장기 평균 분산)은 다음과 같이 계산한다.
sigma_long^2 = omega / (1 - alpha - beta)
실무에서 주식 수익률에 GARCH(1,1)을 적합하면 일반적으로 다음과 같은 결과를 얻는다.
| 모수 | 전형적 범위 | 해석 |
|---|---|---|
omega | 0.01 ~ 0.05 | 장기 분산의 기저 수준 |
alpha | 0.05 ~ 0.15 | 뉴스 충격이 변동성에 빠르게 반영되는 정도 |
beta | 0.80 ~ 0.95 | 변동성의 관성(persistence) |
alpha + beta | 0.95 ~ 0.99 | 1에 가까울수록 충격의 효과가 오래 지속 |
alpha가 크고 beta가 작으면 시장이 새 정보에 빠르게 반응하고 빠르게 잊는다. alpha가 작고 beta가 크면 변동성 수준이 한번 바뀌면 오래 유지된다.
Python 구현: GARCH(1,1) 적합
from arch import arch_model
import yfinance as yf
import pandas as pd
import numpy as np
# 데이터 준비
spy = yf.download('SPY', start='2018-01-01', end='2026-03-01', auto_adjust=True)
returns = spy['Close'].pct_change().dropna() * 100 # 백분율 수익률
# GARCH(1,1) with Student-t 분포
garch11 = arch_model(
returns,
vol='Garch',
p=1, # GARCH 차수 (과거 분산)
q=1, # ARCH 차수 (과거 충격)
mean='Constant',
dist='t' # Student-t 분포 (fat tails 포착)
)
result = garch11.fit(disp='off')
print(result.summary())
# 모수 추출
omega = result.params['omega']
alpha = result.params['alpha[1]']
beta = result.params['beta[1]']
persistence = alpha + beta
print(f"\nomega: {omega:.6f}")
print(f"alpha: {alpha:.4f}")
print(f"beta: {beta:.4f}")
print(f"persistence (alpha+beta): {persistence:.4f}")
# 장기 무조건 분산과 연율화 변동성
long_run_var = omega / (1 - alpha - beta)
annual_vol = np.sqrt(long_run_var * 252) / 100 # 소수점 비율로 변환
print(f"장기 연율화 변동성: {annual_vol:.2%}")
# 조건부 변동성 시계열
cond_vol = result.conditional_volatility
print(f"\n조건부 변동성 - 최근 5일:")
print(cond_vol.tail())
GJR-GARCH와 레버리지 효과
레버리지 효과란
주식 시장에서 동일한 크기의 충격이라도 하락 충격이 상승 충격보다 변동성을 더 크게 증가시킨다. Black(1976)이 처음 관찰한 이 현상을 레버리지 효과라 부른다. 주가가 하락하면 부채/자본 비율(leverage ratio)이 상승하여 기업의 위험이 증가한다는 것이 경제학적 설명이다.
일반 GARCH(1,1)은 충격의 부호와 무관하게 epsilon^2만 반영하므로 이 비대칭성을 포착하지 못한다.
GJR-GARCH 모델
Glosten, Jagannathan, Runkle(1993)이 제안한 GJR-GARCH는 지시 함수(indicator function)를 도입하여 비대칭성을 모델링한다.
sigma_t^2 = omega + (alpha + gamma * I_{t-1}) * epsilon_{t-1}^2 + beta * sigma_{t-1}^2
여기서 I_{t-1}은 epsilon_{t-1} < 0이면 1, 아니면 0인 지시 함수다.
- 양의 충격(상승): 분산에
alpha만큼 기여 - 음의 충격(하락): 분산에
alpha + gamma만큼 기여 gamma > 0이면 레버리지 효과가 존재
# GJR-GARCH(1,1,1) with Student-t
gjr = arch_model(
returns,
vol='Garch',
p=1, o=1, q=1, # o=1이 GJR의 비대칭 항
mean='Constant',
dist='t'
)
gjr_result = gjr.fit(disp='off')
print(gjr_result.summary())
gamma = gjr_result.params['gamma[1]']
alpha_gjr = gjr_result.params['alpha[1]']
print(f"\nalpha: {alpha_gjr:.4f}")
print(f"gamma: {gamma:.4f}")
print(f"양의 충격 영향: {alpha_gjr:.4f}")
print(f"음의 충격 영향: {alpha_gjr + gamma:.4f}")
print(f"비대칭 비율: {(alpha_gjr + gamma) / alpha_gjr:.2f}x")
gamma가 양수이고 통계적으로 유의미하면 레버리지 효과가 확인된 것이다. 실증 연구에서 주식 시장의 gamma는 대체로 0.05~0.15 범위에 위치한다.
EGARCH 모델
Nelson(1991)의 EGARCH
EGARCH(Exponential GARCH)는 로그 변동성을 모델링하여 두 가지 이점을 제공한다.
ln(sigma_t^2) = omega + alpha * (|z_{t-1}| - E|z_{t-1}|) + gamma * z_{t-1} + beta * ln(sigma_{t-1}^2)
여기서 z_{t-1} = epsilon_{t-1} / sigma_{t-1}은 표준화 잔차다.
EGARCH의 장점은 다음과 같다.
- 모수 제약 불필요: 로그를 취하므로 sigma_t^2는 자동으로 양수. alpha, beta에 비음수 제약을 걸 필요가 없다.
- 비대칭 효과의 직접 모델링: gamma 항이 z의 부호를 직접 반영. gamma가 음수이면 음의 충격이 변동성을 더 키운다.
- 승법적 구조: 로그 공간에서의 선형 모델이므로 충격 효과가 기존 변동성 수준에 비례한다.
# EGARCH(1,1) with Skewed Student-t
egarch = arch_model(
returns,
vol='EGARCH',
p=1, q=1,
mean='Constant',
dist='skewt' # 비대칭 Student-t 분포
)
egarch_result = egarch.fit(disp='off')
print(egarch_result.summary())
# EGARCH에서의 비대칭 파라미터 확인
# gamma < 0 이면 음의 충격이 변동성을 더 높임
gamma_egarch = egarch_result.params['gamma[1]']
print(f"\ngamma (비대칭): {gamma_egarch:.4f}")
if gamma_egarch < 0:
print("레버리지 효과 확인: 하락 충격이 변동성을 더 크게 증가시킴")
GARCH 변형 모델 비교
| 모델 | 비대칭 효과 | 모수 제약 | 장기 기억 | 실무 사용도 | 적합 상황 |
|---|---|---|---|---|---|
| GARCH(1,1) | 없음 | omega, alpha, beta 비음수 | 없음 | 매우 높음 | 기본 벤치마크, 대부분의 자산 |
| GJR-GARCH | 지시 함수 기반 | omega, alpha, beta, gamma 비음수 | 없음 | 높음 | 주식 시장 (레버리지 효과) |
| EGARCH | 연속적 비대칭 | 제약 없음 | 없음 | 높음 | 옵션 가격결정, 외환 시장 |
| TGARCH | 절대값 기반 | 비음수 제약 | 없음 | 중간 | GARCH(1,1) 대안 |
| IGARCH | 없음 | alpha+beta=1 | 단위근 | 중간 | 고빈도 데이터 |
| FIGARCH | 없음 | 추가 d 파라미터 | 분수 적분 | 낮음 | 장기 변동성 의존성 연구 |
Python 실전 구현
환경 설정
# 핵심 패키지 설치
pip install arch yfinance pandas numpy scipy matplotlib statsmodels
# 선택 패키지
pip install seaborn quantstats
변동성 모델링 Python 라이브러리 비교
| 라이브러리 | GARCH 지원 | 비대칭 모델 | 분포 선택 | 예측 | 장점 | 단점 |
|---|---|---|---|---|---|---|
| arch | GARCH, EGARCH, HARCH | GJR, EGARCH, APARCH | Normal, t, Skewt, GED | 다단계 예측 지원 | 가장 포괄적인 GARCH 구현 | API 학습 곡선 |
| statsmodels | 기본 GARCH | 제한적 | Normal | 기본 수준 | 통합 통계 패키지 | GARCH 기능 제한적 |
| rugarch (R) | 매우 광범위 | 전체 지원 | 10+ 분포 | 고급 예측 | 학술 표준 | R 환경 필요 |
| rmgarch (R) | DCC-GARCH | 다변량 전체 | 다양 | 상관관계 예측 | 다변량 GARCH | R 환경 필요 |
Python 환경에서는 arch 패키지가 사실상 표준이다. Kevin Sheppard가 개발하고 유지 관리하며, 학술 연구와 실무 양쪽에서 검증되었다.
전체 워크플로우: 데이터 수집부터 예측까지
import numpy as np
import pandas as pd
import yfinance as yf
from arch import arch_model
from arch.unitroot import ADF, KPSS
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
# --------------------------------------------------
# 1단계: 데이터 수집 및 전처리
# --------------------------------------------------
def prepare_returns(ticker: str, start: str, end: str) -> pd.Series:
"""수익률 데이터를 수집하고 전처리한다."""
data = yf.download(ticker, start=start, end=end, auto_adjust=True)
prices = data['Close']
log_returns = np.log(prices / prices.shift(1)).dropna() * 100
return log_returns
returns = prepare_returns('SPY', '2018-01-01', '2026-03-01')
# --------------------------------------------------
# 2단계: 사전 검정
# --------------------------------------------------
def pre_tests(returns: pd.Series) -> dict:
"""GARCH 모델 적용 전 필수 검정을 수행한다."""
results = {}
# ADF 검정: 수익률이 정상 과정인지 확인
adf = ADF(returns, lags=5)
results['ADF_stat'] = adf.stat
results['ADF_pvalue'] = adf.pvalue
results['is_stationary'] = adf.pvalue < 0.05
# ARCH 효과 검정 (Engle의 LM 검정)
from statsmodels.stats.diagnostic import het_arch
lm_stat, lm_pvalue, _, _ = het_arch(returns, nlags=10)
results['ARCH_LM_stat'] = lm_stat
results['ARCH_LM_pvalue'] = lm_pvalue
results['has_arch_effect'] = lm_pvalue < 0.05
return results
tests = pre_tests(returns)
for key, val in tests.items():
print(f"{key}: {val}")
# ADF p-value < 0.05 (정상) + ARCH LM p-value < 0.05 (ARCH 효과 존재)
# 두 조건 모두 충족해야 GARCH 적합이 의미 있음
# --------------------------------------------------
# 3단계: 복수 모델 적합
# --------------------------------------------------
def fit_models(returns: pd.Series) -> dict:
"""여러 GARCH 변형을 적합하고 결과를 반환한다."""
models = {}
# 1) GARCH(1,1) - Student-t
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
models['GARCH(1,1)-t'] = am.fit(disp='off')
# 2) GJR-GARCH(1,1,1) - Student-t
am = arch_model(returns, vol='Garch', p=1, o=1, q=1,
mean='Constant', dist='t')
models['GJR-GARCH-t'] = am.fit(disp='off')
# 3) EGARCH(1,1) - Skewed Student-t
am = arch_model(returns, vol='EGARCH', p=1, q=1,
mean='Constant', dist='skewt')
models['EGARCH-skewt'] = am.fit(disp='off')
# 4) GARCH(1,1) - Normal (비교용)
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='normal')
models['GARCH(1,1)-N'] = am.fit(disp='off')
return models
models = fit_models(returns)
# --------------------------------------------------
# 4단계: 모델 비교 (정보 기준)
# --------------------------------------------------
comparison = pd.DataFrame({
name: {
'AIC': res.aic,
'BIC': res.bic,
'Log-Likelihood': res.loglikelihood,
'Params': res.num_params
}
for name, res in models.items()
}).T
comparison = comparison.sort_values('BIC')
print("\n모델 비교 (BIC 기준 정렬):")
print(comparison.to_string())
모델 진단과 선택
잔차 진단
적합된 GARCH 모델이 데이터를 잘 설명하는지 확인하려면, 표준화 잔차(standardized residuals)가 iid이고 선택한 분포를 따르는지 검정해야 한다.
def diagnose_model(result, model_name: str = ""):
"""GARCH 모델의 적합도를 진단한다."""
std_resid = result.std_resid
print(f"=== {model_name} 진단 ===")
# 1. 표준화 잔차의 기술 통계
print(f"평균: {std_resid.mean():.4f} (기대값: 0)")
print(f"표준편차: {std_resid.std():.4f} (기대값: 1)")
print(f"왜도: {std_resid.skew():.4f}")
print(f"첨도: {std_resid.kurtosis():.4f}")
# 2. Ljung-Box 검정: 잔차 자기상관
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_resid = acorr_ljungbox(std_resid, lags=10, return_df=True)
lb_sq = acorr_ljungbox(std_resid**2, lags=10, return_df=True)
print(f"\nLjung-Box (잔차, lag=10) p-value: "
f"{lb_resid['lb_pvalue'].iloc[-1]:.4f}")
print(f"Ljung-Box (잔차^2, lag=10) p-value: "
f"{lb_sq['lb_pvalue'].iloc[-1]:.4f}")
# p-value > 0.05 이면 자기상관 없음 = 모델이 구조를 잘 포착함
# 3. ARCH-LM 검정: 잔여 ARCH 효과
from statsmodels.stats.diagnostic import het_arch
lm_stat, lm_pval, _, _ = het_arch(std_resid, nlags=10)
print(f"ARCH-LM (잔여 효과) p-value: {lm_pval:.4f}")
# p-value > 0.05 이면 잔여 ARCH 효과 없음 = 변동성 구조 충분히 포착
# 4. 정규성 vs t-분포 적합도
jb_stat, jb_pval = stats.jarque_bera(std_resid)
print(f"Jarque-Bera p-value: {jb_pval:.6f}")
print()
return {
'lb_resid_pval': lb_resid['lb_pvalue'].iloc[-1],
'lb_sq_pval': lb_sq['lb_pvalue'].iloc[-1],
'arch_lm_pval': lm_pval,
'jb_pval': jb_pval
}
# 최적 모델 진단
best_model_name = comparison.index[0] # BIC 최소 모델
best_result = models[best_model_name]
diag = diagnose_model(best_result, best_model_name)
모델 선택 기준 정리
| 기준 | 사용 시점 | 선호 방향 | 주의사항 |
|---|---|---|---|
| AIC | 예측 성능 중심 | 낮을수록 좋음 | 과적합 경향, 모수 페널티 약함 |
| BIC | 모델 간소성 중심 | 낮을수록 좋음 | 보수적, 단순 모델 선호 |
| Log-Likelihood | 모델 적합도 절대 수준 | 높을수록 좋음 | 단독 사용 불가 (모수 수 미반영) |
| Ljung-Box (잔차) | 평균 방정식 적합도 | p > 0.05 | 잔차에 남은 자기상관 없어야 함 |
| Ljung-Box (잔차^2) | 분산 방정식 적합도 | p > 0.05 | 잔여 ARCH 효과 없어야 함 |
| ARCH-LM | 분산 방정식 완전성 | p > 0.05 | Ljung-Box와 교차 확인 권장 |
| Out-of-Sample RMSE | 실전 예측력 | 낮을수록 좋음 | 가장 신뢰도 높은 비교 기준 |
실무적 권고: BIC로 후보를 2~3개 좁히고, out-of-sample 성능(rolling window 예측)으로 최종 결정한다. AIC와 BIC가 다른 모델을 지목할 경우, 예측이 목적이면 AIC, 해석이 목적이면 BIC를 따른다.
VaR, CVaR 계산
GARCH 기반 VaR
VaR(Value at Risk)는 특정 신뢰 수준에서 일정 기간 동안 발생할 수 있는 최대 손실 금액이다. GARCH 모델을 사용하면 시간 변동 VaR를 계산할 수 있다.
def calculate_garch_var(
returns: pd.Series,
model_result,
confidence_levels: list = [0.01, 0.05],
investment: float = 1_000_000
) -> pd.DataFrame:
"""
GARCH 모델 기반 VaR와 CVaR(ES)를 계산한다.
Parameters
----------
returns : pd.Series
수익률 시계열 (백분율)
model_result : arch result
적합된 GARCH 모델 결과
confidence_levels : list
VaR 신뢰 수준 (0.01 = 99%, 0.05 = 95%)
investment : float
투자 금액 (원 단위)
Returns
-------
pd.DataFrame
날짜별 VaR, CVaR 값
"""
cond_vol = model_result.conditional_volatility
mu = model_result.params.get('mu', 0)
# 모델에서 추정된 분포 파라미터 사용
dist = model_result.model.distribution
params = model_result.params
records = []
for alpha in confidence_levels:
# 분포의 분위수 (quantile)
# Student-t의 경우 자유도를 반영
if 'nu' in params.index:
nu = params['nu']
q = stats.t.ppf(alpha, df=nu)
else:
q = stats.norm.ppf(alpha)
# VaR = -(mu + sigma * quantile)
var_pct = -(mu + cond_vol * q) / 100
var_amount = var_pct * investment
# CVaR (Conditional VaR / Expected Shortfall)
# E[X | X < VaR] 계산
if 'nu' in params.index:
nu = params['nu']
# Student-t의 CVaR 공식
pdf_q = stats.t.pdf(q, df=nu)
cvar_factor = -(
(nu + q**2) / (nu - 1)
) * pdf_q / alpha
else:
pdf_q = stats.norm.pdf(q)
cvar_factor = -pdf_q / alpha
cvar_pct = -(mu + cond_vol * cvar_factor) / 100
cvar_amount = cvar_pct * investment
for date in cond_vol.index:
records.append({
'date': date,
'alpha': alpha,
'confidence': f"{(1-alpha)*100:.0f}%",
'VaR_pct': var_pct.loc[date],
'VaR_amount': var_amount.loc[date],
'CVaR_pct': cvar_pct.loc[date],
'CVaR_amount': cvar_amount.loc[date],
})
return pd.DataFrame(records)
# VaR 계산 실행
var_df = calculate_garch_var(returns, best_result)
# 최근 결과 확인
recent = var_df[var_df['alpha'] == 0.01].tail(5)
print("최근 5일 99% VaR (투자금 100만원 기준):")
print(recent[['date', 'confidence', 'VaR_pct', 'VaR_amount',
'CVaR_pct', 'CVaR_amount']].to_string(index=False))
VaR 해석 주의사항
99% 1일 VaR가 2.5%라는 것은 "내일 수익률이 -2.5% 이하로 떨어질 확률이 1%"라는 뜻이다. 100만 원 투자 기준 약 2.5만 원 이상의 손실이 발생할 확률이 1%이다. 그러나 VaR가 2.5%라고 해서 최대 손실이 2.5%라는 뜻이 아니다. VaR를 초과하는 경우의 평균 손실인 CVaR(Expected Shortfall)이 실제 테일 리스크를 더 정확하게 반영한다.
백테스팅과 검증
VaR 백테스팅: Kupiec 검정
VaR 모델의 정확성을 검증하는 가장 기본적인 방법은 실제 VaR 초과 빈도가 이론적 빈도와 일치하는지 검정하는 것이다. Kupiec(1995)의 POF(Proportion of Failures) 검정을 사용한다.
def backtest_var(
returns: pd.Series,
model_type: str = 'Garch',
p: int = 1,
o: int = 0,
q: int = 1,
dist: str = 't',
window: int = 1000,
alpha: float = 0.01
) -> dict:
"""
Rolling window 방식의 VaR 백테스팅을 수행한다.
Parameters
----------
returns : pd.Series
수익률 시계열 (백분율)
window : int
추정 윈도우 크기 (거래일)
alpha : float
VaR 신뢰 수준
Returns
-------
dict
백테스팅 결과 (초과 횟수, 비율, Kupiec 검정 등)
"""
violations = []
var_series = []
actual_series = []
n_forecasts = len(returns) - window
print(f"백테스팅 시작: {n_forecasts}일 예측")
for i in range(window, len(returns)):
train = returns.iloc[i-window:i]
actual = returns.iloc[i]
try:
am = arch_model(train, vol=model_type,
p=p, o=o, q=q,
mean='Constant', dist=dist)
res = am.fit(disp='off', show_warning=False)
# 1일 앞 예측
forecast = res.forecast(horizon=1)
mean_f = forecast.mean.iloc[-1, 0]
var_f = forecast.variance.iloc[-1, 0]
vol_f = np.sqrt(var_f)
# VaR 계산
if 'nu' in res.params.index:
nu = res.params['nu']
quantile = stats.t.ppf(alpha, df=nu)
else:
quantile = stats.norm.ppf(alpha)
var_value = -(mean_f + vol_f * quantile)
# 초과 여부 판단
is_violation = actual < -var_value
violations.append(is_violation)
var_series.append(var_value)
actual_series.append(actual)
except Exception:
violations.append(False)
var_series.append(np.nan)
actual_series.append(actual)
violations = np.array(violations)
n_violations = violations.sum()
violation_rate = n_violations / len(violations)
expected_rate = alpha
# Kupiec POF 검정 (우도비 검정)
n = len(violations)
x = n_violations
if x == 0 or x == n:
kupiec_stat = np.nan
kupiec_pval = np.nan
else:
lr = -2 * (
np.log((1 - alpha)**(n - x) * alpha**x)
- np.log((1 - x/n)**(n - x) * (x/n)**x)
)
kupiec_pval = 1 - stats.chi2.cdf(lr, df=1)
kupiec_stat = lr
result = {
'n_forecasts': n,
'n_violations': int(n_violations),
'violation_rate': violation_rate,
'expected_rate': expected_rate,
'kupiec_stat': kupiec_stat,
'kupiec_pval': kupiec_pval,
'model_accepted': kupiec_pval > 0.05 if not np.isnan(kupiec_pval) else None,
}
print(f"\n백테스팅 결과:")
print(f" 예상 초과율: {expected_rate:.2%}")
print(f" 실제 초과율: {violation_rate:.2%}")
print(f" 초과 횟수: {n_violations}/{n}")
print(f" Kupiec LR 통계량: {kupiec_stat:.4f}")
print(f" Kupiec p-value: {kupiec_pval:.4f}")
print(f" 모델 수용: {'Yes' if result['model_accepted'] else 'No'}")
return result
# 백테스팅 실행 (시간이 오래 걸릴 수 있음)
bt_result = backtest_var(
returns,
model_type='Garch',
p=1, o=1, q=1, # GJR-GARCH
dist='t',
window=1000,
alpha=0.01
)
Kupiec 검정의 p-value가 0.05 이상이면 모델의 VaR 예측이 통계적으로 정확하다고 판단한다. p-value가 0.05 미만이면 VaR 초과 빈도가 이론적 수준과 유의미하게 다르다는 뜻이므로 모델을 재검토해야 한다.
백테스팅 해석 가이드
| 상황 | 의미 | 대응 |
|---|---|---|
| 실제 초과율이 기대 초과율보다 높음 | VaR를 과소 추정 (위험 과소평가) | 더 보수적인 분포(t 자유도 낮추기) 또는 비대칭 모델 적용 |
| 실제 초과율이 기대 초과율보다 낮음 | VaR를 과대 추정 (자본 낭비) | 정규분포 또는 자유도 높은 t-분포 시도 |
| 초과가 군집되어 발생 | 조건부 모델이 변동성 체제 전환을 포착하지 못함 | 마르코프 체제 전환 모델 또는 윈도우 축소 |
| Kupiec 통과, Christoffersen 실패 | 전체 빈도는 맞지만 시간적 독립성 위반 | 모델 평균 방정식 또는 분산 방정식 재설정 |
트러블슈팅
자주 발생하는 문제와 해결 방법
문제 1: 최적화 수렴 실패 (Convergence Warning)
ConvergenceWarning: Maximum number of iterations has been exceeded.
원인은 대부분 초기값 설정 불량이거나 데이터 스케일 문제다.
해결 방법:
# 1. 수익률 스케일 확인 (0.01 단위가 아닌 백분율로)
returns_pct = returns * 100 # 소수점 -> 백분율
# 2. 다른 최적화 방법 시도
result = model.fit(
disp='off',
options={'maxiter': 500},
starting_values=np.array([0.01, 0.05, 0.90]) # omega, alpha, beta
)
문제 2: 분산이 음수로 추정됨
GARCH(1,1)에서는 모수 제약으로 인해 드물지만, 비표준 모델에서 발생할 수 있다. EGARCH로 전환하면 로그 분산을 모델링하므로 이 문제가 자동으로 해결된다.
문제 3: alpha + beta가 1 이상
비정상(non-stationary) GARCH는 장기 무조건 분산이 정의되지 않는다. 원인은 구조적 변동(structural break)이 데이터에 포함된 경우가 대부분이다.
해결 방법:
# 추정 기간 축소 (구조적 변동 이후 데이터만 사용)
# 예: COVID-19 이후 데이터만 사용
returns_post_covid = returns['2020-07-01':]
am = arch_model(returns_post_covid, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
result = am.fit(disp='off')
persistence = result.params['alpha[1]'] + result.params['beta[1]']
print(f"Persistence: {persistence:.4f}")
문제 4: 예측 변동성이 일정한 값으로 수렴
GARCH 다기간 예측은 장기 무조건 분산으로 빠르게 수렴하는 것이 정상 동작이다. 수렴 속도는 alpha + beta에 의해 결정된다. 장기 예측이 필요하면 GARCH보다 지수 가중 이동 평균(EWMA)이나 내재 변동성 기반 접근을 고려한다.
문제 5: Student-t 자유도 추정이 불안정
# 자유도를 고정하여 추정
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
# 자유도 고정 (예: nu=5)
constraints = {'nu': 5.0}
result = am.fit(disp='off')
# 또는 GED 분포를 대안으로 사용
am_ged = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='ged')
result_ged = am_ged.fit(disp='off')
운영 체크리스트
모델 구축 단계
- 수익률 데이터가 정상 과정인지 확인 (ADF 검정 p-value 0.05 미만)
- ARCH 효과가 존재하는지 확인 (Engle LM 검정 p-value 0.05 미만)
- 수익률 분포의 첨도와 왜도 확인 (정규분포 적합성 판단)
- 분포 선택: 첨도가 높으면 Student-t, 왜도도 있으면 Skewed-t
- 모델 후보 2~3개 적합 (GARCH, GJR-GARCH, EGARCH)
- 정보 기준(AIC/BIC)으로 후보 정렬
- 최적 모델의 잔차 진단 수행 (Ljung-Box, ARCH-LM)
예측 및 적용 단계
- Out-of-sample 예측력 검증 (rolling window)
- VaR 백테스팅 수행 (Kupiec 검정 통과 여부)
- 예측 변동성의 연율화 단위 변환 확인
- VaR 금액 계산 시 포지션 크기와 보유 기간 반영
- CVaR(Expected Shortfall)도 함께 보고
운영 및 모니터링 단계
- 모델 재추정 주기 결정 (주간/월간)
- 데이터 파이프라인의 결측치, 이상치 처리 로직 확인
- 극단적 시장 상황에서의 모델 행동 시뮬레이션
- 구조적 변동(structural break) 감지 로직 구현
- VaR 초과 발생 시 알림 체계 구축
- 분기별 모델 성능 리뷰 보고서 생성
실패 사례와 교훈
사례 1: 정규분포 가정으로 인한 VaR 과소 추정
한 리스크 관리 팀이 GARCH(1,1)-Normal로 99% VaR를 계산했다. 백테스팅 결과, 1%여야 할 VaR 초과율이 3.2%로 나왔다. 원인은 정규분포의 꼬리가 실제 수익률 분포보다 얇기 때문이었다.
교훈: 금융 수익률에 GARCH를 적합할 때 정규분포를 기본으로 쓰지 말 것. Student-t 분포를 기본값으로 하고, BIC로 정규분포와 비교하여 선택한다. 실증적으로 거의 항상 Student-t가 우세하다.
# 나쁜 예: 정규분포 가정
bad_model = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='normal')
# 좋은 예: Student-t 분포 기본
good_model = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
사례 2: 구조적 변동을 무시한 장기 데이터 사용
2008년 금융위기부터 2024년까지의 16년 데이터로 GARCH를 적합했더니 alpha + beta가 0.999에 달했다. 이는 IGARCH(분산에 단위근이 있는 모델)에 가까운 결과로, 변동성 예측이 사실상 "현재 수준이 영원히 지속된다"가 되어 무의미했다.
교훈: 구조적 변동(금융위기, 팬데믹 등)이 포함된 지나치게 긴 데이터는 모수 추정을 왜곡한다. 3~5년 윈도우를 기본으로 하되, 구조적 변동 이후 데이터만 사용하는 것이 실무적으로 안전하다. Bai-Perron 또는 CUSUM 검정으로 구조적 변동 시점을 감지할 수 있다.
사례 3: 변동성 예측과 수익률 예측의 혼동
퀀트 트레이더가 GARCH의 조건부 분산이 높아질 때 매도, 낮아질 때 매수하는 전략을 운용했다. 결과는 시장 대비 지속적인 언더퍼포먼스였다.
교훈: GARCH는 변동성(리스크)을 예측하는 모델이지, 수익률(방향)을 예측하는 모델이 아니다. 높은 변동성은 큰 상승의 가능성도 동시에 의미한다. GARCH 출력을 포지션 사이징(변동성이 높을 때 포지션 축소)이나 리스크 관리에 사용하는 것이 올바른 적용 방법이다.
사례 4: 일중(Intraday) 데이터에 일간 GARCH 적용
5분봉 데이터에 표준 GARCH(1,1)을 적용했더니 일중 패턴(장 시작 후 변동성 급등, 점심 시간 하락, 장 마감 전 상승)이 잔차에 고스란히 남았다. Ljung-Box 검정이 모두 기각되어 모델이 무의미했다.
교훈: 일중 데이터에는 일중 주기성(intraday periodicity)을 먼저 제거해야 한다. Andersen과 Bollerslev(1997)의 FFF(Flexible Fourier Form) 필터로 일중 패턴을 제거한 후 GARCH를 적용하거나, 실현 변동성(Realized Volatility)을 직접 모델링하는 HAR-RV 모델을 사용한다.
사례 5: 다변량 포트폴리오에 단변량 GARCH 각각 적용
5개 자산 포트폴리오의 리스크를 측정할 때, 각 자산에 개별 GARCH를 적합하고 고정 상관관계로 포트폴리오 분산을 계산했다. 2020년 COVID 폭락 시 실제 손실이 VaR의 4배를 초과했다.
교훈: 위기 시 자산 간 상관관계는 급격히 상승한다. 단변량 GARCH의 합으로는 이 동적 상관관계를 포착할 수 없다. DCC-GARCH(Dynamic Conditional Correlation)나 CCC-GARCH를 사용하여 상관관계의 시간 변동성을 함께 모델링해야 한다. Python에서는 arch 패키지에 아직 다변량 모델 지원이 제한적이므로, R의 rmgarch 패키지를 병행하거나 직접 구현해야 한다.
고급 주제: 변동성 예측의 다음 단계
실현 변동성과 HAR-RV 모델
GARCH가 수익률의 모수적 구조를 가정하는 반면, 실현 변동성(Realized Volatility)은 일중 고빈도 데이터로부터 비모수적으로 변동성을 직접 측정한다. Corsi(2009)의 HAR-RV 모델은 일간, 주간, 월간 실현 변동성의 자기회귀 구조를 결합하여 뛰어난 예측력을 보인다.
머신러닝과의 결합
최근 연구에서는 GARCH의 조건부 분산을 LSTM, Transformer 등 딥러닝 모델의 피처로 활용하는 하이브리드 접근이 주목받고 있다. GARCH가 선형 구조에서 포착하는 정보와 신경망이 비선형 패턴에서 포착하는 정보를 결합하면, 단독 사용보다 예측 성능이 향상될 수 있다. 다만 금융 데이터의 낮은 신호 대 잡음비(SNR) 때문에 과적합 위험이 높으므로 엄격한 교차 검증이 필수다.
체제 전환 GARCH (Markov-Switching GARCH)
변동성이 고변동성/저변동성 두 가지(또는 그 이상) 체제 사이를 전환한다고 가정하는 모델이다. 2008년 금융위기, 2020년 COVID와 같은 극단적 시장 국면 전환을 포착하는 데 유리하다. 그러나 모수 추정이 복잡하고 체제 수 선택에 임의성이 있다는 단점이 있다.
참고자료
arch 패키지 공식 문서 - Python GARCH 모델링의 표준 라이브러리. API 레퍼런스와 예제 코드가 충실하다. https://arch.readthedocs.io/en/latest/
Machine Learning Mastery - ARCH and GARCH Models - ARCH/GARCH의 개념과 Python 구현을 단계별로 설명하는 입문 가이드. https://machinelearningmastery.com/develop-arch-and-garch-models-for-time-series-forecasting-in-python/
QuantInsti - GARCH and GJR-GARCH Volatility Forecasting - GJR-GARCH를 활용한 변동성 예측과 트레이딩 적용을 다룬 실무 가이드. https://blog.quantinsti.com/garch-gjr-garch-volatility-forecasting-python/
Forecastegy - Volatility Forecasting in Python - 다양한 변동성 예측 방법론의 비교와 Python 구현. https://forecastegy.com/posts/volatility-forecasting-in-python/
DataCamp - GARCH Models in Python - GARCH 모델의 체계적인 학습 과정. 개념부터 실전 적용까지 커리큘럼이 잘 구성되어 있다. https://www.datacamp.com/courses/garch-models-in-python
Bollerslev, T. (1986) - "Generalized Autoregressive Conditional Heteroskedasticity." Journal of Econometrics, 31(3), 307-327. GARCH 모델의 원 논문.
Engle, R.F. (1982) - "Autoregressive Conditional Heteroscedasticity with Estimates of the Variance of United Kingdom Inflation." Econometrica, 50(4), 987-1007. ARCH 모델의 원 논문이자 노벨상 수상 연구.
Hansen, P.R. and Lunde, A. (2005) - "A Forecast Comparison of Volatility Models: Does Anything Beat a GARCH(1,1)?" Journal of Applied Econometrics. 수백 개의 변동성 모델을 비교한 결과, GARCH(1,1)이 놀라울 만큼 강건하다는 결론을 내린 연구.
Time Series Volatility Forecasting Practical Guide: GARCH Model Family and Python Implementation
- Introduction
- Definition of Volatility and Stylized Facts
- ARCH Model Basics
- GARCH(1,1) In-Depth
- GJR-GARCH and the Leverage Effect
- EGARCH Model
- Python Practical Implementation
- Model Diagnostics and Selection
- VaR and CVaR Calculation
- Backtesting and Validation
- Troubleshooting
- Operations Checklist
- Failure Cases and Lessons
- Advanced Topics: Next Steps in Volatility Forecasting
- References
- Quiz

Introduction
Predicting returns in financial markets is extremely difficult. However, predicting volatility is relatively feasible. This difference is the starting point of risk management. We may not know whether tomorrow's stock price will go up or down, but we can reasonably infer the magnitude of tomorrow's price movement from today's market conditions.
The GARCH (Generalized Autoregressive Conditional Heteroskedasticity) model family is the core tool for volatility forecasting. Since Robert Engle proposed the ARCH model in 1982, it has evolved through Tim Bollerslev's GARCH (1986), Nelson's EGARCH (1991), and Glosten-Jagannathan-Runkle's GJR-GARCH (1993), becoming the standard framework for financial volatility modeling. Engle received the 2003 Nobel Prize in Economics for the ARCH model.
This article explains the mathematical principles of volatility models without stopping at listing formulas. It covers the entire practical workflow needed in practice: implementation using the Python arch package, model selection and diagnostics, VaR (Value at Risk) calculation, and backtesting. It is structured so that developers with limited mathematical background can follow along and apply to real data.
Definition of Volatility and Stylized Facts
What Is Volatility?
Volatility refers to the variance or standard deviation of asset returns. In practice, it is broadly divided into three categories.
- Historical Volatility: Standard deviation calculated from past return data. The simplest but backward-looking.
- Implied Volatility: Expected market volatility reverse-engineered from option prices. The VIX index is representative.
- Conditional Volatility: Expected value of future volatility conditioned on past information. This is exactly what GARCH models estimate.
Annualized volatility is calculated by multiplying daily volatility by the square root of the number of trading days. Applying approximately 250 annual trading days for the Korean market, a daily standard deviation of 1.2% corresponds to approximately 19% annualized volatility.
Stylized Facts of Financial Returns
Real financial data has characteristics that cannot be explained by simple statistical models assuming normal distribution and constant variance. These are called Stylized Facts, and GARCH models are designed to capture these characteristics.
| Stylized Fact | Description | GARCH Relevance |
|---|---|---|
| Volatility Clustering | Large changes tend to be followed by large changes, small by small | Core assumption and modeling target of GARCH |
| Fat Tails | Return distribution tails are thicker than normal (kurtosis over 3) | Captured by Student-t, GED, etc. non-normal distributions |
| Leverage Effect | Negative shocks increase volatility more than positive shocks | Modeled by EGARCH, GJR-GARCH for asymmetry |
| Mean Reversion | Volatility tends to revert to a long-run average level | Reflected by stationarity conditions of GARCH parameters |
| Long Memory | Autocorrelation of volatility decays very slowly | Captured by FIGARCH, IGARCH fractional integration models |
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
from scipy import stats
# Collect KOSPI 200 ETF (KODEX 200) or S&P 500 data
spy = yf.download('SPY', start='2020-01-01', end='2026-03-01', auto_adjust=True)
returns = spy['Close'].pct_change().dropna() * 100 # Percentage returns
# Verify Stylized Facts
print(f"Mean: {returns.mean():.4f}%")
print(f"Std Dev: {returns.std():.4f}%")
print(f"Skewness: {returns.skew():.4f}")
print(f"Kurtosis: {returns.kurtosis():.4f}") # Excess kurtosis (normal = 0)
# Jarque-Bera normality test
jb_stat, jb_pval = stats.jarque_bera(returns)
print(f"Jarque-Bera statistic: {jb_stat:.2f}, p-value: {jb_pval:.6f}")
# Ljung-Box test: returns vs squared returns
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_returns = acorr_ljungbox(returns, lags=10, return_df=True)
lb_squared = acorr_ljungbox(returns**2, lags=10, return_df=True)
print(f"\nReturns Ljung-Box p-value (lag=10): {lb_returns['lb_pvalue'].iloc[-1]:.4f}")
print(f"Returns^2 Ljung-Box p-value (lag=10): {lb_squared['lb_pvalue'].iloc[-1]:.6f}")
# Very small p-value for squared returns indicates volatility clustering
Running the code above typically shows kurtosis well above 3 (fat tails) and a significant Ljung-Box test for squared returns (volatility clustering). This provides the statistical justification for applying GARCH models.
ARCH Model Basics
Engle's ARCH(q) Model
The ARCH (AutoRegressive Conditional Heteroskedasticity) model assumes that conditional variance depends on past squared error terms.
The return process is defined as follows:
r_t = mu + epsilon_t
epsilon_t = sigma_t * z_t, z_t ~ N(0,1)
Where conditional variance sigma_t^2 is:
sigma_t^2 = omega + alpha_1 * epsilon_{t-1}^2 + ... + alpha_q * epsilon_{t-q}^2
omega > 0: Base level of long-run variancealpha_i >= 0: Impact coefficients of past shocksq: ARCH order (how many past time points of shocks to reflect)
For ARCH(1), if a large shock (positive or negative) occurred yesterday, today's conditional variance increases. This is the mechanism that explains volatility clustering.
Limitations of the ARCH Model
In practice, ARCH models are rarely used directly. To properly capture volatility clustering, q must be very large, meaning many parameters need to be estimated. It is common to need ARCH(20) or higher for a good fit, which introduces overfitting risk and estimation instability. GARCH overcomes this limitation.
GARCH(1,1) In-Depth
Model Structure
GARCH(p,q), proposed by Bollerslev (1986), adds past variance itself to the conditional variance equation:
sigma_t^2 = omega + alpha * epsilon_{t-1}^2 + beta * sigma_{t-1}^2
The three parameters of GARCH(1,1) each mean:
omega: Long-run variance contribution. Larger values mean a higher floor for variance.alpha: News shock coefficient. The magnitude of impact yesterday's market shock has on today's volatility.beta: Variance persistence coefficient. How much of yesterday's volatility carries over to today.
Stationarity Conditions and Parameter Interpretation
For GARCH(1,1) to be stationary, alpha + beta < 1 must hold. The closer this value is to 1, the longer volatility shocks persist.
The unconditional variance (long-run average variance) is calculated as:
sigma_long^2 = omega / (1 - alpha - beta)
When fitting GARCH(1,1) to equity returns in practice, you typically get:
| Parameter | Typical Range | Interpretation |
|---|---|---|
omega | 0.01 - 0.05 | Base level of long-run variance |
alpha | 0.05 - 0.15 | Speed of news shock reflection in volatility |
beta | 0.80 - 0.95 | Volatility persistence |
alpha + beta | 0.95 - 0.99 | Closer to 1 means shock effects last longer |
If alpha is large and beta is small, the market reacts quickly to new information and forgets quickly. If alpha is small and beta is large, once the volatility level changes, it persists for a long time.
Python Implementation: Fitting GARCH(1,1)
from arch import arch_model
import yfinance as yf
import pandas as pd
import numpy as np
# Data preparation
spy = yf.download('SPY', start='2018-01-01', end='2026-03-01', auto_adjust=True)
returns = spy['Close'].pct_change().dropna() * 100 # Percentage returns
# GARCH(1,1) with Student-t distribution
garch11 = arch_model(
returns,
vol='Garch',
p=1, # GARCH order (past variance)
q=1, # ARCH order (past shocks)
mean='Constant',
dist='t' # Student-t distribution (captures fat tails)
)
result = garch11.fit(disp='off')
print(result.summary())
# Extract parameters
omega = result.params['omega']
alpha = result.params['alpha[1]']
beta = result.params['beta[1]']
persistence = alpha + beta
print(f"\nomega: {omega:.6f}")
print(f"alpha: {alpha:.4f}")
print(f"beta: {beta:.4f}")
print(f"persistence (alpha+beta): {persistence:.4f}")
# Long-run unconditional variance and annualized volatility
long_run_var = omega / (1 - alpha - beta)
annual_vol = np.sqrt(long_run_var * 252) / 100 # Convert to decimal ratio
print(f"Long-run annualized volatility: {annual_vol:.2%}")
# Conditional volatility time series
cond_vol = result.conditional_volatility
print(f"\nConditional volatility - last 5 days:")
print(cond_vol.tail())
GJR-GARCH and the Leverage Effect
What Is the Leverage Effect?
In equity markets, even shocks of the same magnitude cause negative shocks to increase volatility more than positive shocks. This phenomenon, first observed by Black (1976), is called the leverage effect. The economic explanation is that when stock prices fall, the debt-to-equity ratio (leverage ratio) rises, increasing the firm's risk.
Standard GARCH(1,1) only reflects epsilon^2 regardless of the sign of the shock, so it cannot capture this asymmetry.
GJR-GARCH Model
GJR-GARCH, proposed by Glosten, Jagannathan, and Runkle (1993), introduces an indicator function to model asymmetry:
sigma_t^2 = omega + (alpha + gamma * I_{t-1}) * epsilon_{t-1}^2 + beta * sigma_{t-1}^2
Where I_{t-1} is 1 if epsilon_{t-1} < 0, otherwise 0.
- Positive shock (upturn): contributes
alphato variance - Negative shock (downturn): contributes
alpha + gammato variance gamma > 0indicates the leverage effect exists
# GJR-GARCH(1,1,1) with Student-t
gjr = arch_model(
returns,
vol='Garch',
p=1, o=1, q=1, # o=1 is GJR's asymmetric term
mean='Constant',
dist='t'
)
gjr_result = gjr.fit(disp='off')
print(gjr_result.summary())
gamma = gjr_result.params['gamma[1]']
alpha_gjr = gjr_result.params['alpha[1]']
print(f"\nalpha: {alpha_gjr:.4f}")
print(f"gamma: {gamma:.4f}")
print(f"Positive shock impact: {alpha_gjr:.4f}")
print(f"Negative shock impact: {alpha_gjr + gamma:.4f}")
print(f"Asymmetry ratio: {(alpha_gjr + gamma) / alpha_gjr:.2f}x")
If gamma is positive and statistically significant, the leverage effect is confirmed. In empirical studies, gamma for equity markets generally falls in the 0.05-0.15 range.
EGARCH Model
Nelson's (1991) EGARCH
EGARCH (Exponential GARCH) models log volatility, providing two advantages:
ln(sigma_t^2) = omega + alpha * (|z_{t-1}| - E|z_{t-1}|) + gamma * z_{t-1} + beta * ln(sigma_{t-1}^2)
Where z_{t-1} = epsilon_{t-1} / sigma_{t-1} is the standardized residual.
EGARCH advantages include:
- No parameter constraints needed: Taking the log ensures sigma_t^2 is automatically positive. No non-negativity constraints on alpha and beta are needed.
- Direct modeling of asymmetric effects: The gamma term directly reflects the sign of z. If gamma is negative, negative shocks increase volatility more.
- Multiplicative structure: Since it is a linear model in log space, shock effects are proportional to the existing volatility level.
# EGARCH(1,1) with Skewed Student-t
egarch = arch_model(
returns,
vol='EGARCH',
p=1, q=1,
mean='Constant',
dist='skewt' # Skewed Student-t distribution
)
egarch_result = egarch.fit(disp='off')
print(egarch_result.summary())
# Check asymmetry parameter in EGARCH
# gamma < 0 means negative shocks increase volatility more
gamma_egarch = egarch_result.params['gamma[1]']
print(f"\ngamma (asymmetry): {gamma_egarch:.4f}")
if gamma_egarch < 0:
print("Leverage effect confirmed: negative shocks increase volatility more")
GARCH Variant Model Comparison
| Model | Asymmetric Effect | Parameter Constraints | Long Memory | Practical Usage | Suitable Situation |
|---|---|---|---|---|---|
| GARCH(1,1) | None | omega, alpha, beta non-negative | None | Very high | Base benchmark, most assets |
| GJR-GARCH | Indicator function based | omega, alpha, beta, gamma non-negative | None | High | Equity markets (leverage effect) |
| EGARCH | Continuous asymmetry | No constraints | None | High | Option pricing, FX markets |
| TGARCH | Absolute value based | Non-negative constraints | None | Medium | GARCH(1,1) alternative |
| IGARCH | None | alpha+beta=1 | Unit root | Medium | High-frequency data |
| FIGARCH | None | Additional d parameter | Fractional integration | Low | Long-run volatility dependency research |
Python Practical Implementation
Environment Setup
# Core package installation
pip install arch yfinance pandas numpy scipy matplotlib statsmodels
# Optional packages
pip install seaborn quantstats
Volatility Modeling Python Library Comparison
| Library | GARCH Support | Asymmetric Models | Distribution Options | Forecasting | Strengths | Weaknesses |
|---|---|---|---|---|---|---|
| arch | GARCH, EGARCH, HARCH | GJR, EGARCH, APARCH | Normal, t, Skewt, GED | Multi-step supported | Most comprehensive GARCH implementation | API learning curve |
| statsmodels | Basic GARCH | Limited | Normal | Basic level | Integrated statistics package | GARCH features limited |
| rugarch (R) | Very extensive | Full support | 10+ distributions | Advanced forecasting | Academic standard | R environment required |
| rmgarch (R) | DCC-GARCH | Full multivariate | Various | Correlation forecasting | Multivariate GARCH | R environment required |
In the Python environment, the arch package is the de facto standard. Developed and maintained by Kevin Sheppard, it has been validated in both academic research and practice.
Full Workflow: From Data Collection to Forecasting
import numpy as np
import pandas as pd
import yfinance as yf
from arch import arch_model
from arch.unitroot import ADF, KPSS
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
# --------------------------------------------------
# Step 1: Data Collection and Preprocessing
# --------------------------------------------------
def prepare_returns(ticker: str, start: str, end: str) -> pd.Series:
"""Collect and preprocess return data."""
data = yf.download(ticker, start=start, end=end, auto_adjust=True)
prices = data['Close']
log_returns = np.log(prices / prices.shift(1)).dropna() * 100
return log_returns
returns = prepare_returns('SPY', '2018-01-01', '2026-03-01')
# --------------------------------------------------
# Step 2: Pre-tests
# --------------------------------------------------
def pre_tests(returns: pd.Series) -> dict:
"""Perform essential tests before GARCH model application."""
results = {}
# ADF test: check if returns are a stationary process
adf = ADF(returns, lags=5)
results['ADF_stat'] = adf.stat
results['ADF_pvalue'] = adf.pvalue
results['is_stationary'] = adf.pvalue < 0.05
# ARCH effect test (Engle's LM test)
from statsmodels.stats.diagnostic import het_arch
lm_stat, lm_pvalue, _, _ = het_arch(returns, nlags=10)
results['ARCH_LM_stat'] = lm_stat
results['ARCH_LM_pvalue'] = lm_pvalue
results['has_arch_effect'] = lm_pvalue < 0.05
return results
tests = pre_tests(returns)
for key, val in tests.items():
print(f"{key}: {val}")
# ADF p-value under 0.05 (stationary) + ARCH LM p-value under 0.05 (ARCH effect exists)
# Both conditions must be met for GARCH fitting to be meaningful
# --------------------------------------------------
# Step 3: Fit multiple models
# --------------------------------------------------
def fit_models(returns: pd.Series) -> dict:
"""Fit multiple GARCH variants and return results."""
models = {}
# 1) GARCH(1,1) - Student-t
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
models['GARCH(1,1)-t'] = am.fit(disp='off')
# 2) GJR-GARCH(1,1,1) - Student-t
am = arch_model(returns, vol='Garch', p=1, o=1, q=1,
mean='Constant', dist='t')
models['GJR-GARCH-t'] = am.fit(disp='off')
# 3) EGARCH(1,1) - Skewed Student-t
am = arch_model(returns, vol='EGARCH', p=1, q=1,
mean='Constant', dist='skewt')
models['EGARCH-skewt'] = am.fit(disp='off')
# 4) GARCH(1,1) - Normal (for comparison)
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='normal')
models['GARCH(1,1)-N'] = am.fit(disp='off')
return models
models = fit_models(returns)
# --------------------------------------------------
# Step 4: Model comparison (information criteria)
# --------------------------------------------------
comparison = pd.DataFrame({
name: {
'AIC': res.aic,
'BIC': res.bic,
'Log-Likelihood': res.loglikelihood,
'Params': res.num_params
}
for name, res in models.items()
}).T
comparison = comparison.sort_values('BIC')
print("\nModel comparison (sorted by BIC):")
print(comparison.to_string())
Model Diagnostics and Selection
Residual Diagnostics
To verify whether a fitted GARCH model adequately explains the data, you need to test whether standardized residuals are iid and follow the chosen distribution.
def diagnose_model(result, model_name: str = ""):
"""Diagnose GARCH model fit quality."""
std_resid = result.std_resid
print(f"=== {model_name} Diagnostics ===")
# 1. Descriptive statistics of standardized residuals
print(f"Mean: {std_resid.mean():.4f} (expected: 0)")
print(f"Std Dev: {std_resid.std():.4f} (expected: 1)")
print(f"Skewness: {std_resid.skew():.4f}")
print(f"Kurtosis: {std_resid.kurtosis():.4f}")
# 2. Ljung-Box test: residual autocorrelation
from statsmodels.stats.diagnostic import acorr_ljungbox
lb_resid = acorr_ljungbox(std_resid, lags=10, return_df=True)
lb_sq = acorr_ljungbox(std_resid**2, lags=10, return_df=True)
print(f"\nLjung-Box (residuals, lag=10) p-value: "
f"{lb_resid['lb_pvalue'].iloc[-1]:.4f}")
print(f"Ljung-Box (residuals^2, lag=10) p-value: "
f"{lb_sq['lb_pvalue'].iloc[-1]:.4f}")
# p-value > 0.05 means no autocorrelation = model captures structure well
# 3. ARCH-LM test: residual ARCH effects
from statsmodels.stats.diagnostic import het_arch
lm_stat, lm_pval, _, _ = het_arch(std_resid, nlags=10)
print(f"ARCH-LM (residual effect) p-value: {lm_pval:.4f}")
# p-value > 0.05 means no residual ARCH effect = variance structure sufficiently captured
# 4. Normality vs t-distribution fit
jb_stat, jb_pval = stats.jarque_bera(std_resid)
print(f"Jarque-Bera p-value: {jb_pval:.6f}")
print()
return {
'lb_resid_pval': lb_resid['lb_pvalue'].iloc[-1],
'lb_sq_pval': lb_sq['lb_pvalue'].iloc[-1],
'arch_lm_pval': lm_pval,
'jb_pval': jb_pval
}
# Diagnose best model
best_model_name = comparison.index[0] # BIC minimum model
best_result = models[best_model_name]
diag = diagnose_model(best_result, best_model_name)
Model Selection Criteria Summary
| Criterion | When to Use | Preferred Direction | Notes |
|---|---|---|---|
| AIC | Prediction performance focus | Lower is better | Prone to overfitting, weak parameter penalty |
| BIC | Model parsimony focus | Lower is better | Conservative, prefers simpler models |
| Log-Likelihood | Absolute fit level | Higher is better | Cannot use alone (doesn't reflect parameter count) |
| Ljung-Box (residuals) | Mean equation fit | p > 0.05 | No autocorrelation should remain in residuals |
| Ljung-Box (residuals^2) | Variance equation fit | p > 0.05 | No residual ARCH effect should remain |
| ARCH-LM | Variance equation completeness | p > 0.05 | Cross-check with Ljung-Box recommended |
| Out-of-Sample RMSE | Practical predictive power | Lower is better | Most reliable comparison criterion |
Practical recommendation: Narrow candidates to 2-3 using BIC, then make the final decision based on out-of-sample performance (rolling window forecasting). If AIC and BIC point to different models, follow AIC if prediction is the goal, BIC if interpretation is the goal.
VaR and CVaR Calculation
GARCH-Based VaR
VaR (Value at Risk) is the maximum loss amount that can occur at a given confidence level over a certain period. Using GARCH models enables time-varying VaR calculation.
def calculate_garch_var(
returns: pd.Series,
model_result,
confidence_levels: list = [0.01, 0.05],
investment: float = 1_000_000
) -> pd.DataFrame:
"""
Calculate VaR and CVaR(ES) based on a GARCH model.
"""
cond_vol = model_result.conditional_volatility
mu = model_result.params.get('mu', 0)
dist = model_result.model.distribution
params = model_result.params
records = []
for alpha in confidence_levels:
if 'nu' in params.index:
nu = params['nu']
q = stats.t.ppf(alpha, df=nu)
else:
q = stats.norm.ppf(alpha)
var_pct = -(mu + cond_vol * q) / 100
var_amount = var_pct * investment
if 'nu' in params.index:
nu = params['nu']
pdf_q = stats.t.pdf(q, df=nu)
cvar_factor = -(
(nu + q**2) / (nu - 1)
) * pdf_q / alpha
else:
pdf_q = stats.norm.pdf(q)
cvar_factor = -pdf_q / alpha
cvar_pct = -(mu + cond_vol * cvar_factor) / 100
cvar_amount = cvar_pct * investment
for date in cond_vol.index:
records.append({
'date': date,
'alpha': alpha,
'confidence': f"{(1-alpha)*100:.0f}%",
'VaR_pct': var_pct.loc[date],
'VaR_amount': var_amount.loc[date],
'CVaR_pct': cvar_pct.loc[date],
'CVaR_amount': cvar_amount.loc[date],
})
return pd.DataFrame(records)
var_df = calculate_garch_var(returns, best_result)
recent = var_df[var_df['alpha'] == 0.01].tail(5)
print("Last 5 days 99% VaR (based on 1M won investment):")
print(recent[['date', 'confidence', 'VaR_pct', 'VaR_amount',
'CVaR_pct', 'CVaR_amount']].to_string(index=False))
VaR Interpretation Notes
A 99% 1-day VaR of 2.5% means "there is a 1% probability that tomorrow's return will fall below -2.5%." On a 1 million won investment, there is a 1% probability of a loss exceeding approximately 25,000 won. However, VaR of 2.5% does not mean the maximum loss is 2.5%. CVaR (Expected Shortfall), the average loss in cases exceeding VaR, more accurately reflects actual tail risk.
Backtesting and Validation
VaR Backtesting: Kupiec Test
The most basic method for validating VaR model accuracy is testing whether the actual VaR breach frequency matches the theoretical frequency. The Kupiec (1995) POF (Proportion of Failures) test is used.
def backtest_var(
returns: pd.Series,
model_type: str = 'Garch',
p: int = 1,
o: int = 0,
q: int = 1,
dist: str = 't',
window: int = 1000,
alpha: float = 0.01
) -> dict:
"""
Perform rolling window VaR backtesting.
"""
violations = []
var_series = []
actual_series = []
n_forecasts = len(returns) - window
print(f"Starting backtesting: {n_forecasts} day forecasts")
for i in range(window, len(returns)):
train = returns.iloc[i-window:i]
actual = returns.iloc[i]
try:
am = arch_model(train, vol=model_type,
p=p, o=o, q=q,
mean='Constant', dist=dist)
res = am.fit(disp='off', show_warning=False)
forecast = res.forecast(horizon=1)
mean_f = forecast.mean.iloc[-1, 0]
var_f = forecast.variance.iloc[-1, 0]
vol_f = np.sqrt(var_f)
if 'nu' in res.params.index:
nu = res.params['nu']
quantile = stats.t.ppf(alpha, df=nu)
else:
quantile = stats.norm.ppf(alpha)
var_value = -(mean_f + vol_f * quantile)
is_violation = actual < -var_value
violations.append(is_violation)
var_series.append(var_value)
actual_series.append(actual)
except Exception:
violations.append(False)
var_series.append(np.nan)
actual_series.append(actual)
violations = np.array(violations)
n_violations = violations.sum()
violation_rate = n_violations / len(violations)
expected_rate = alpha
n = len(violations)
x = n_violations
if x == 0 or x == n:
kupiec_stat = np.nan
kupiec_pval = np.nan
else:
lr = -2 * (
np.log((1 - alpha)**(n - x) * alpha**x)
- np.log((1 - x/n)**(n - x) * (x/n)**x)
)
kupiec_pval = 1 - stats.chi2.cdf(lr, df=1)
kupiec_stat = lr
result = {
'n_forecasts': n,
'n_violations': int(n_violations),
'violation_rate': violation_rate,
'expected_rate': expected_rate,
'kupiec_stat': kupiec_stat,
'kupiec_pval': kupiec_pval,
'model_accepted': kupiec_pval > 0.05 if not np.isnan(kupiec_pval) else None,
}
print(f"\nBacktesting results:")
print(f" Expected breach rate: {expected_rate:.2%}")
print(f" Actual breach rate: {violation_rate:.2%}")
print(f" Breach count: {n_violations}/{n}")
print(f" Kupiec LR statistic: {kupiec_stat:.4f}")
print(f" Kupiec p-value: {kupiec_pval:.4f}")
print(f" Model accepted: {'Yes' if result['model_accepted'] else 'No'}")
return result
bt_result = backtest_var(
returns,
model_type='Garch',
p=1, o=1, q=1, # GJR-GARCH
dist='t',
window=1000,
alpha=0.01
)
If the Kupiec test p-value is 0.05 or above, the model's VaR prediction is judged to be statistically accurate. If the p-value is below 0.05, the VaR breach frequency significantly differs from the theoretical level, meaning the model needs to be reviewed.
Backtesting Interpretation Guide
| Situation | Meaning | Response |
|---|---|---|
| Actual breach rate higher than expected | VaR underestimation (risk underestimation) | More conservative distribution (lower t df) or asymmetric model |
| Actual breach rate lower than expected | VaR overestimation (capital waste) | Try normal distribution or higher df t-distribution |
| Breaches occur in clusters | Conditional model fails to capture volatility regime changes | Markov regime-switching model or window reduction |
| Kupiec passes, Christoffersen fails | Overall frequency correct but temporal independence violated | Reconfigure mean or variance equation |
Troubleshooting
Common Issues and Solutions
Issue 1: Optimization Convergence Failure
ConvergenceWarning: Maximum number of iterations has been exceeded.
The cause is usually poor initial values or data scale issues.
Solution:
# 1. Check return scale (should be percentage, not decimal)
returns_pct = returns * 100 # decimal -> percentage
# 2. Try different optimization method
result = model.fit(
disp='off',
options={'maxiter': 500},
starting_values=np.array([0.01, 0.05, 0.90]) # omega, alpha, beta
)
Issue 2: Negative Variance Estimation
Rare in GARCH(1,1) due to parameter constraints, but can occur in non-standard models. Switching to EGARCH automatically resolves this since it models log variance.
Issue 3: alpha + beta equals or exceeds 1
Non-stationary GARCH means the long-run unconditional variance is undefined. The cause is usually structural breaks included in the data.
Solution:
# Reduce estimation period (use only data after structural break)
# e.g.: Use only post-COVID data
returns_post_covid = returns['2020-07-01':]
am = arch_model(returns_post_covid, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
result = am.fit(disp='off')
persistence = result.params['alpha[1]'] + result.params['beta[1]']
print(f"Persistence: {persistence:.4f}")
Issue 4: Forecast Volatility Converges to a Constant
GARCH multi-step forecasts converging quickly to the long-run unconditional variance is normal behavior. Convergence speed is determined by alpha + beta. For long-horizon forecasts, consider EWMA or implied volatility-based approaches over GARCH.
Issue 5: Unstable Student-t Degrees of Freedom Estimation
# Fix degrees of freedom
am = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
# Fix df (e.g., nu=5)
constraints = {'nu': 5.0}
result = am.fit(disp='off')
# Or use GED distribution as alternative
am_ged = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='ged')
result_ged = am_ged.fit(disp='off')
Operations Checklist
Model Construction Phase
- Confirm return data is a stationary process (ADF test p-value under 0.05)
- Confirm ARCH effects exist (Engle LM test p-value under 0.05)
- Check kurtosis and skewness of return distribution (normality assessment)
- Distribution selection: Student-t if high kurtosis, Skewed-t if skewed
- Fit 2-3 model candidates (GARCH, GJR-GARCH, EGARCH)
- Sort candidates by information criteria (AIC/BIC)
- Perform residual diagnostics on optimal model (Ljung-Box, ARCH-LM)
Forecasting and Application Phase
- Out-of-sample predictive power validation (rolling window)
- VaR backtesting performed (Kupiec test pass/fail)
- Verify annualization unit conversion of forecast volatility
- Reflect position size and holding period in VaR amount calculation
- Report CVaR (Expected Shortfall) alongside
Operations and Monitoring Phase
- Determine model re-estimation frequency (weekly/monthly)
- Verify missing value and outlier handling logic in data pipeline
- Simulate model behavior under extreme market conditions
- Implement structural break detection logic
- Build alert system for VaR breach events
- Generate quarterly model performance review reports
Failure Cases and Lessons
Case 1: VaR Underestimation Due to Normal Distribution Assumption
A risk management team calculated 99% VaR using GARCH(1,1)-Normal. Backtesting showed the VaR breach rate at 3.2% instead of the expected 1%. The cause was that the normal distribution's tails are thinner than the actual return distribution.
Lesson: Do not use normal distribution as the default when fitting GARCH to financial returns. Use Student-t distribution as the default, and compare with normal distribution using BIC. Empirically, Student-t is almost always superior.
# Bad example: normal distribution assumption
bad_model = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='normal')
# Good example: Student-t distribution default
good_model = arch_model(returns, vol='Garch', p=1, q=1,
mean='Constant', dist='t')
Case 2: Long-Term Data Ignoring Structural Breaks
Fitting GARCH to 16 years of data from the 2008 financial crisis through 2024 resulted in alpha + beta of 0.999. This is close to IGARCH (variance with unit root), making the volatility forecast effectively "current level persists forever," rendering it meaningless.
Lesson: Excessively long data containing structural breaks (financial crises, pandemics, etc.) distorts parameter estimation. A 3-5 year window is the practical default, using only data after structural breaks. Bai-Perron or CUSUM tests can detect structural break points.
Case 3: Confusing Volatility Forecasting with Return Forecasting
A quant trader ran a strategy of selling when GARCH conditional variance was high and buying when low. The result was persistent underperformance vs the market.
Lesson: GARCH is a model for forecasting volatility (risk), not returns (direction). High volatility also means the possibility of large upside. The correct application is to use GARCH output for position sizing (reduce position when volatility is high) or risk management.
Case 4: Applying Daily GARCH to Intraday Data
Standard GARCH(1,1) was applied to 5-minute bar data, but intraday patterns (volatility spike after market open, dip at lunch, rise before close) remained entirely in the residuals. All Ljung-Box tests were rejected, making the model meaningless.
Lesson: Intraday periodicity must be removed first from intraday data. Use Andersen and Bollerslev's (1997) FFF (Flexible Fourier Form) filter to remove intraday patterns before applying GARCH, or use the HAR-RV model that directly models Realized Volatility.
Case 5: Applying Univariate GARCH Separately to a Multivariate Portfolio
When measuring risk for a 5-asset portfolio, individual GARCH was fitted to each asset and portfolio variance was calculated using fixed correlations. During the COVID crash of March 2020, actual losses exceeded VaR by 4x.
Lesson: During crises, correlations between assets spike sharply. The sum of univariate GARCH models cannot capture this dynamic correlation. DCC-GARCH (Dynamic Conditional Correlation) or CCC-GARCH should be used to model time-varying correlations together. In Python, the arch package still has limited multivariate model support, so R's rmgarch package must be used in parallel or implemented directly.
Advanced Topics: Next Steps in Volatility Forecasting
Realized Volatility and HAR-RV Model
While GARCH assumes a parametric structure for returns, Realized Volatility measures volatility non-parametrically from intraday high-frequency data. Corsi's (2009) HAR-RV model combines autoregressive structures of daily, weekly, and monthly realized volatility, demonstrating excellent predictive power.
Integration with Machine Learning
Recent research has focused on hybrid approaches that use GARCH conditional variance as features for deep learning models like LSTM and Transformer. Combining the information GARCH captures from linear structures with the non-linear patterns neural networks capture can improve predictive performance over standalone use. However, due to the low signal-to-noise ratio (SNR) in financial data, strict cross-validation is essential given the high risk of overfitting.
Regime-Switching GARCH (Markov-Switching GARCH)
This model assumes volatility switches between two (or more) regimes: high-volatility and low-volatility. It is advantageous for capturing extreme market regime transitions like the 2008 financial crisis and 2020 COVID. However, it has drawbacks of complex parameter estimation and arbitrariness in choosing the number of regimes.
References
arch package official documentation - The standard library for Python GARCH modeling. API reference and example code are thorough. https://arch.readthedocs.io/en/latest/
Machine Learning Mastery - ARCH and GARCH Models - An introductory guide explaining ARCH/GARCH concepts and Python implementation step by step. https://machinelearningmastery.com/develop-arch-and-garch-models-for-time-series-forecasting-in-python/
QuantInsti - GARCH and GJR-GARCH Volatility Forecasting - A practical guide on volatility forecasting and trading application using GJR-GARCH. https://blog.quantinsti.com/garch-gjr-garch-volatility-forecasting-python/
Forecastegy - Volatility Forecasting in Python - Comparison and Python implementation of various volatility forecasting methodologies. https://forecastegy.com/posts/volatility-forecasting-in-python/
DataCamp - GARCH Models in Python - A systematic learning course on GARCH models with well-structured curriculum from concepts to practical application. https://www.datacamp.com/courses/garch-models-in-python
Bollerslev, T. (1986) - "Generalized Autoregressive Conditional Heteroskedasticity." Journal of Econometrics, 31(3), 307-327. The original GARCH paper.
Engle, R.F. (1982) - "Autoregressive Conditional Heteroscedasticity with Estimates of the Variance of United Kingdom Inflation." Econometrica, 50(4), 987-1007. The original ARCH paper and Nobel Prize-winning research.
Hansen, P.R. and Lunde, A. (2005) - "A Forecast Comparison of Volatility Models: Does Anything Beat a GARCH(1,1)?" Journal of Applied Econometrics. A study comparing hundreds of volatility models concluding that GARCH(1,1) is surprisingly robust.
Quiz
Q1: What is the main topic covered in "Time Series Volatility Forecasting Practical Guide: GARCH Model Family and Python Implementation"?
Covers mathematical principles of volatility models from ARCH/GARCH to EGARCH, GJR-GARCH, and DCC-GARCH, Python arch package implementation, model selection and diagnostics, backtesting, and practical risk management applications.
Q2: What is Definition of Volatility and Stylized Facts?
What Is Volatility? Volatility refers to the variance or standard deviation of asset returns. In practice, it is broadly divided into three categories. Historical Volatility: Standard deviation calculated from past return data. The simplest but backward-looking.
Q3: Explain the core concept of ARCH Model Basics.
Engle's ARCH(q) Model The ARCH (AutoRegressive Conditional Heteroskedasticity) model assumes that conditional variance depends on past squared error terms.
Q4: What are the key aspects of GARCH(1,1) In-Depth?
Model Structure GARCH(p,q), proposed by Bollerslev (1986), adds past variance itself to the conditional variance equation: The three parameters of GARCH(1,1) each mean: omega: Long-run variance contribution. Larger values mean a higher floor for variance.
Q5: How does GJR-GARCH and the Leverage Effect work?
What Is the Leverage Effect? In equity markets, even shocks of the same magnitude cause negative shocks to increase volatility more than positive shocks. This phenomenon, first observed by Black (1976), is called the leverage effect.