Split View: 포트폴리오 리스크 관리: Python으로 구현하는 VaR과 몬테카를로 시뮬레이션 실전 가이드
포트폴리오 리스크 관리: Python으로 구현하는 VaR과 몬테카를로 시뮬레이션 실전 가이드
- 1. 들어가며: 리스크를 수치로 말하는 법
- 2. VaR 기본 개념과 세 가지 방법론
- 3. 파라메트릭 VaR 구현
- 4. 히스토리컬 시뮬레이션 VaR
- 5. 몬테카를로 시뮬레이션 VaR
- 6. CVaR(Expected Shortfall) 확장
- 7. 포트폴리오 최적화와 리스크 통합
- 8. VaR 방법론 비교표
- 9. 백테스팅과 모델 검증
- 10. 운영 시 주의사항
- 11. 실패 사례와 복구 절차
- 12. 체크리스트
- 13. 참고자료

1. 들어가며: 리스크를 수치로 말하는 법
"이 포트폴리오의 리스크가 얼마나 됩니까?" 이 질문에 "좀 위험합니다" 혹은 "변동성이 큽니다"라고 대답하면 실무에서는 의미가 없다. 리스크 매니저, 규제 기관, 투자자 모두 원하는 것은 구체적인 숫자다. "95% 신뢰수준에서 1일 최대 손실은 2억 3천만 원입니다." 이것이 Value at Risk(VaR)가 존재하는 이유다.
VaR는 1990년대 초 JP Morgan의 RiskMetrics 시스템에서 대중화된 이후, 바젤 규제 체계(Basel II/III/IV)의 핵심 지표로 자리잡았다. 그러나 2008년 금융위기에서 VaR의 한계가 적나라하게 드러나면서, 단순히 VaR 숫자 하나만 보는 것이 아니라 CVaR(Expected Shortfall), 스트레스 테스팅, 백테스팅을 함께 수행하는 통합 리스크 프레임워크가 필수가 되었다.
이 글에서는 VaR의 세 가지 주요 계산 방법론인 파라메트릭(분산-공분산), 히스토리컬 시뮬레이션, 몬테카를로 시뮬레이션 각각을 Python 코드로 구현하고, 백테스팅으로 모델을 검증하며, 프로덕션 환경에서 리스크 시스템을 운영할 때 반드시 알아야 할 주의사항과 실패 사례를 상세히 다룬다.
대상 독자는 Python 기본 문법과 pandas/numpy에 익숙한 개발자 또는 퀀트 주니어다. 금융 도메인 지식은 가능한 한 처음부터 설명하므로 배경지식이 없어도 따라올 수 있다.
2. VaR 기본 개념과 세 가지 방법론
VaR란 무엇인가
VaR(Value at Risk)는 주어진 신뢰수준(confidence level)과 보유 기간(holding period) 하에서, 포트폴리오가 겪을 수 있는 최대 예상 손실액을 의미한다. 수학적으로 표현하면 다음과 같다.
VaR_α = -inf{x : P(L > x) <= 1 - α}
여기서 alpha는 신뢰수준(보통 95% 또는 99%), L은 손실 분포, x는 손실 금액이다. 직관적으로 이야기하면 "95% VaR = 1억 원"이란 "향후 1일 동안 95% 확률로 손실이 1억 원을 넘지 않을 것"을 의미한다. 반대로 말하면 5%의 확률로 1억 원 이상 손실이 날 수 있다는 뜻이기도 하다.
VaR의 세 가지 주요 파라미터
- 신뢰수준(Confidence Level): 95%, 99%, 99.5% 등. 바젤 규제에서는 99%를 기본으로 사용한다.
- 보유 기간(Holding Period): 1일, 10일, 1개월 등. 트레이딩 북은 보통 1일, 투자 포트폴리오는 10일~1개월을 사용한다.
- 관측 기간(Observation Window): VaR 계산에 사용하는 과거 데이터 기간. 보통 250일(1년)~500일(2년)을 사용한다.
세 가지 계산 방법론 개요
VaR를 계산하는 방법은 크게 세 가지로 분류된다.
파라메트릭(Parametric/Variance-Covariance) 방법: 수익률이 정규분포를 따른다고 가정하고, 평균과 표준편차(또는 공분산 행렬)만으로 VaR를 계산한다. 빠르지만 팻테일(fat tail) 현상을 포착하지 못한다.
히스토리컬 시뮬레이션(Historical Simulation) 방법: 과거 수익률 데이터를 직접 사용하여 손실 분포를 구성한다. 분포 가정이 필요 없지만, 과거에 없었던 시나리오는 반영하지 못한다.
몬테카를로 시뮬레이션(Monte Carlo Simulation) 방법: 확률 모형(예: 기하 브라운 운동)에서 무작위 시나리오를 수만~수십만 개 생성하여 손실 분포를 추정한다. 가장 유연하지만 계산 비용이 높다.
3. 파라메트릭 VaR 구현
파라메트릭 VaR는 가장 단순하고 빠른 방법이다. 포트폴리오 수익률이 정규분포 N(mu, sigma^2)를 따른다고 가정하면, VaR는 다음과 같이 계산된다.
VaR = -(mu + z*alpha * sigma) _ Portfolio_Value
여기서 z_alpha는 표준정규분포의 분위수(예: 95% 신뢰수준이면 z = -1.645)다.
import numpy as np
import pandas as pd
from scipy import stats
import yfinance as yf
from datetime import datetime, timedelta
# 포트폴리오 종목과 비중 설정
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA']
weights = np.array([0.25, 0.20, 0.20, 0.20, 0.15])
portfolio_value = 1_000_000_000 # 10억 원
# 주가 데이터 수집 (2년치)
end_date = datetime(2026, 3, 1)
start_date = end_date - timedelta(days=730)
prices = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
# 일간 로그 수익률 계산
log_returns = np.log(prices / prices.shift(1)).dropna()
# 포트폴리오 수익률
portfolio_returns = log_returns.dot(weights)
# 파라메트릭 VaR 계산
confidence_levels = [0.90, 0.95, 0.99]
mean_return = portfolio_returns.mean()
std_return = portfolio_returns.std()
print("=" * 60)
print("파라메트릭 VaR (분산-공분산 방법)")
print("=" * 60)
print(f"일간 평균 수익률: {mean_return:.6f}")
print(f"일간 표준편차: {std_return:.6f}")
print(f"포트폴리오 가치: {portfolio_value:,.0f} 원")
print("-" * 60)
for cl in confidence_levels:
z_score = stats.norm.ppf(1 - cl)
var_pct = -(mean_return + z_score * std_return)
var_amount = var_pct * portfolio_value
print(f"{cl*100:.0f}% VaR: {var_pct:.4%} = {var_amount:,.0f} 원")
# 10일 VaR (바젤 규제 기준): sqrt(10) 스케일링
print("\n10일 VaR (sqrt(T) 스케일링):")
for cl in confidence_levels:
z_score = stats.norm.ppf(1 - cl)
var_1d = -(mean_return + z_score * std_return)
var_10d = var_1d * np.sqrt(10)
var_10d_amount = var_10d * portfolio_value
print(f"{cl*100:.0f}% 10일 VaR: {var_10d:.4%} = {var_10d_amount:,.0f} 원")
위 코드에서 몇 가지 핵심 포인트를 짚어보자. 첫째, 로그 수익률(log return)을 사용한다. 로그 수익률은 시간에 대해 가법적(additive)이어서 다기간 수익률을 합산할 수 있고, 정규분포 가정과도 더 잘 맞는다. 둘째, 10일 VaR로의 스케일링에 sqrt(10) 규칙을 적용한다. 이는 수익률이 서로 독립이고 동일 분포(i.i.d.)라는 가정 하에서만 정확하다. 실제 금융 시계열은 자기상관과 변동성 군집(volatility clustering) 현상이 있으므로 이 스케일링은 근사치일 뿐이다.
파라메트릭 VaR의 다중 자산 확장: 공분산 행렬 활용
단일 포트폴리오 수익률의 표준편차를 직접 계산하는 대신, 개별 자산의 공분산 행렬을 활용하면 리스크 기여도(risk contribution) 분석이 가능해진다.
# 공분산 행렬 기반 포트폴리오 VaR
cov_matrix = log_returns.cov()
# 포트폴리오 분산 = w^T * Sigma * w
portfolio_variance = weights @ cov_matrix.values @ weights
portfolio_std = np.sqrt(portfolio_variance)
# 개별 자산 VaR 기여도 (Component VaR)
marginal_var = cov_matrix.values @ weights / portfolio_std
component_var = weights * marginal_var
print("\n" + "=" * 60)
print("컴포넌트 VaR 분석 (99% 신뢰수준)")
print("=" * 60)
z_99 = stats.norm.ppf(0.99)
for i, ticker in enumerate(tickers):
cvar_pct = component_var[i] * z_99
cvar_amount = cvar_pct * portfolio_value
contribution_pct = component_var[i] / portfolio_std * 100
print(f"{ticker}: 비중 {weights[i]*100:.0f}% | "
f"리스크 기여도 {contribution_pct:.1f}% | "
f"Component VaR {cvar_amount:,.0f} 원")
# 분산 효과 확인
individual_vars = np.array([
weights[i] * np.sqrt(cov_matrix.values[i, i]) * z_99
for i in range(len(tickers))
])
undiversified_var = individual_vars.sum() * portfolio_value
diversified_var = portfolio_std * z_99 * portfolio_value
diversification_benefit = undiversified_var - diversified_var
print(f"\n비분산 VaR (합산): {undiversified_var:,.0f} 원")
print(f"분산 VaR (포트폴리오): {diversified_var:,.0f} 원")
print(f"분산 효과: {diversification_benefit:,.0f} 원 "
f"({diversification_benefit/undiversified_var*100:.1f}% 절감)")
이 분석에서 핵심은 분산 효과(diversification benefit)다. 개별 종목의 VaR를 단순 합산한 값보다 포트폴리오 전체의 VaR가 작은데, 이 차이가 분산 투자의 리스크 절감 효과다. 상관관계가 낮을수록 분산 효과가 커지며, 상관계수가 1에 가까워지면(예: 위기 시 상관관계 수렴) 분산 효과는 급격히 줄어든다.
4. 히스토리컬 시뮬레이션 VaR
히스토리컬 시뮬레이션은 분포 가정을 하지 않는다는 점에서 파라메트릭 방법보다 현실적이다. 과거 수익률 데이터를 직접 손실 분포로 사용하므로, 팻테일이나 비대칭성(skewness)이 자연스럽게 반영된다.
def historical_var(returns, confidence_level=0.95, portfolio_value=1_000_000_000):
"""
히스토리컬 시뮬레이션 방식으로 VaR를 계산한다.
Parameters
----------
returns : pd.Series
포트폴리오 일간 수익률 시계열
confidence_level : float
신뢰수준 (예: 0.95, 0.99)
portfolio_value : float
포트폴리오 총 가치 (원)
Returns
-------
dict
VaR 계산 결과
"""
sorted_returns = returns.sort_values()
n = len(sorted_returns)
# 하위 (1-confidence_level) 백분위수
percentile_index = int(np.floor((1 - confidence_level) * n))
var_pct = -sorted_returns.iloc[percentile_index]
# 보간법 적용 버전 (더 정확)
var_pct_interp = -np.percentile(returns, (1 - confidence_level) * 100)
return {
'var_pct': var_pct,
'var_pct_interp': var_pct_interp,
'var_amount': var_pct * portfolio_value,
'var_amount_interp': var_pct_interp * portfolio_value,
'n_observations': n,
'worst_loss': -sorted_returns.iloc[0],
'worst_loss_date': sorted_returns.index[0],
}
# 히스토리컬 VaR 계산
results = {}
for cl in [0.90, 0.95, 0.99]:
result = historical_var(portfolio_returns, cl)
results[cl] = result
print(f"\n{cl*100:.0f}% 히스토리컬 VaR:")
print(f" 백분위수 방식: {result['var_pct']:.4%} = {result['var_amount']:,.0f} 원")
print(f" 보간법 방식: {result['var_pct_interp']:.4%} = {result['var_amount_interp']:,.0f} 원")
print(f"\n관측 기간 내 최대 손실: {results[0.95]['worst_loss']:.4%} "
f"({results[0.95]['worst_loss_date'].strftime('%Y-%m-%d')})")
# 히스토리컬 VaR의 롤링 분석
rolling_window = 250 # 1년 윈도우
rolling_var_95 = portfolio_returns.rolling(rolling_window).apply(
lambda x: -np.percentile(x, 5), raw=True
)
print(f"\n롤링 VaR (250일 윈도우) 통계:")
print(f" 평균 95% VaR: {rolling_var_95.mean():.4%}")
print(f" 최대 95% VaR: {rolling_var_95.max():.4%} (가장 높은 리스크 시기)")
print(f" 최소 95% VaR: {rolling_var_95.min():.4%} (가장 낮은 리스크 시기)")
히스토리컬 시뮬레이션의 가장 큰 약점은 "유령 효과(ghost effect)"다. 관측 윈도우에 극단적 이벤트가 포함되면 VaR가 높게 유지되다가, 해당 이벤트가 윈도우에서 빠지는 날 VaR가 급격히 하락한다. 예를 들어 250일 윈도우를 사용할 때, 정확히 250일 전에 큰 급락이 있었다면 그 급락이 윈도우 밖으로 나가는 다음 날 VaR가 갑자기 줄어든다. 이는 실제 리스크가 변하지 않았음에도 측정값이 급변하는 아티팩트다.
이 문제의 해법으로는 지수가중이동평균(EWMA) 방식의 가중 히스토리컬 시뮬레이션이 있다. 최근 관측값에 더 높은 가중치를 부여하여 유령 효과를 완화한다.
5. 몬테카를로 시뮬레이션 VaR
몬테카를로 시뮬레이션은 VaR 계산에서 가장 유연하고 강력한 방법이다. 기본 아이디어는 확률 모형(보통 기하 브라운 운동, GBM)에서 수천~수만 개의 가격 경로를 무작위로 생성한 뒤, 각 경로에서의 포트폴리오 손익을 계산하여 손실 분포를 구성하는 것이다.
기하 브라운 운동(GBM) 기반 시뮬레이션
기하 브라운 운동에서 주가 S의 변화는 다음 확률 미분 방정식을 따른다.
dS = mu _ S _ dt + sigma _ S _ dW
여기서 mu는 드리프트(기대수익률), sigma는 변동성, dW는 위너 과정의 증분이다. 이를 이산화하면 다음과 같다.
S(t+dt) = S(t) _ exp((mu - sigma^2/2) _ dt + sigma _ sqrt(dt) _ Z)
여기서 Z는 표준정규분포를 따르는 난수다.
def monte_carlo_var(
returns_df,
weights,
portfolio_value,
n_simulations=50_000,
n_days=1,
confidence_level=0.95,
seed=42
):
"""
몬테카를로 시뮬레이션으로 포트폴리오 VaR를 계산한다.
기하 브라운 운동(GBM) 기반으로 상관관계가 반영된
다변량 정규분포에서 시나리오를 생성한다.
Parameters
----------
returns_df : pd.DataFrame
개별 자산의 일간 로그 수익률 DataFrame
weights : np.ndarray
자산 비중 벡터
portfolio_value : float
포트폴리오 총 가치 (원)
n_simulations : int
시뮬레이션 횟수
n_days : int
보유 기간 (일)
confidence_level : float
신뢰수준
seed : int
난수 시드 (재현성 보장)
Returns
-------
dict
VaR, CVaR, 시뮬레이션 결과 등
"""
np.random.seed(seed)
# 개별 자산의 평균 수익률과 공분산 행렬
mean_returns = returns_df.mean().values
cov_matrix = returns_df.cov().values
n_assets = len(weights)
# 촐레스키 분해로 상관관계 반영된 난수 생성
L = np.linalg.cholesky(cov_matrix)
# 시뮬레이션 수행
portfolio_sim_returns = np.zeros(n_simulations)
for sim in range(n_simulations):
cumulative_return = 0
for day in range(n_days):
# 독립 표준정규 난수 생성
Z = np.random.standard_normal(n_assets)
# 상관관계 반영
correlated_returns = mean_returns + L @ Z
# 포트폴리오 수익률
daily_port_return = weights @ correlated_returns
cumulative_return += daily_port_return
portfolio_sim_returns[sim] = cumulative_return
# 손익 분포
sim_pnl = portfolio_sim_returns * portfolio_value
# VaR 계산
var_pct = -np.percentile(portfolio_sim_returns, (1 - confidence_level) * 100)
var_amount = var_pct * portfolio_value
# CVaR (Expected Shortfall) 계산
var_threshold = np.percentile(portfolio_sim_returns, (1 - confidence_level) * 100)
tail_losses = portfolio_sim_returns[portfolio_sim_returns <= var_threshold]
cvar_pct = -tail_losses.mean()
cvar_amount = cvar_pct * portfolio_value
return {
'var_pct': var_pct,
'var_amount': var_amount,
'cvar_pct': cvar_pct,
'cvar_amount': cvar_amount,
'sim_returns': portfolio_sim_returns,
'sim_pnl': sim_pnl,
'n_simulations': n_simulations,
'n_days': n_days,
'confidence_level': confidence_level,
}
# 몬테카를로 VaR 실행
mc_results = {}
for cl in [0.90, 0.95, 0.99]:
result = monte_carlo_var(
log_returns, weights, portfolio_value,
n_simulations=100_000,
n_days=1,
confidence_level=cl,
seed=42
)
mc_results[cl] = result
print(f"\n{cl*100:.0f}% 몬테카를로 VaR (100,000 시뮬레이션):")
print(f" VaR: {result['var_pct']:.4%} = {result['var_amount']:,.0f} 원")
print(f" CVaR: {result['cvar_pct']:.4%} = {result['cvar_amount']:,.0f} 원")
# 시뮬레이션 수에 따른 VaR 수렴 확인
print("\n시뮬레이션 수에 따른 95% VaR 수렴:")
for n_sim in [1_000, 5_000, 10_000, 50_000, 100_000, 500_000]:
result = monte_carlo_var(
log_returns, weights, portfolio_value,
n_simulations=n_sim, confidence_level=0.95, seed=42
)
print(f" N={n_sim:>7,}: VaR = {result['var_pct']:.6%}")
시뮬레이션 수의 결정과 수렴성
몬테카를로 VaR에서 시뮬레이션 횟수(N)는 정확도에 직접적으로 영향을 준다. VaR 추정값의 표준오차는 대략 1/sqrt(N)에 비례하므로, 시뮬레이션 수를 4배로 늘리면 표준오차가 절반으로 줄어든다. 실무에서는 최소 10,000회, 프로덕션에서는 50,000~100,000회가 권장된다. 99% VaR처럼 극단적인 신뢰수준을 사용할 때는 테일 영역의 샘플이 적으므로 더 많은 시뮬레이션이 필요하다.
촐레스키 분해(Cholesky decomposition)는 공분산 행렬을 하삼각행렬 L로 분해하여 L * L^T = Sigma 관계를 만족시키는 방법이다. 독립적인 표준정규 난수 벡터 Z에 L을 곱하면, 원래 공분산 구조를 가진 상관된 난수 벡터를 얻을 수 있다. 공분산 행렬이 양정치(positive definite)가 아니면 촐레스키 분해가 실패하는데, 이는 데이터 기간이 자산 수보다 짧거나 선형 종속 관계가 있을 때 발생한다. 이 경우 고유값 분해(eigendecomposition)를 사용하거나, 가장 가까운 양정치 행렬로 근사하는 Higham 알고리즘을 적용해야 한다.
6. CVaR(Expected Shortfall) 확장
VaR의 가장 큰 한계는 "VaR를 넘어서는 손실이 얼마나 클 수 있는가"에 대해 아무 정보도 주지 않는다는 점이다. 95% VaR가 2%라고 해도, 나머지 5%에 해당하는 경우에 3% 손실이 날 수도 있고 30% 손실이 날 수도 있다. CVaR(Conditional VaR), 또는 Expected Shortfall(ES)은 VaR를 초과하는 손실의 평균으로 정의되어, 테일 리스크를 훨씬 더 잘 포착한다.
수학적 정의는 다음과 같다.
CVaR_α = E[L | L > VaR_α]
CVaR는 VaR와 달리 서브가법성(subadditivity)을 만족하는 일관된 리스크 측도(coherent risk measure)다. 서브가법성이란 두 포트폴리오를 합쳤을 때의 리스크가 개별 리스크의 합보다 작거나 같다는 성질이다. VaR는 이 성질을 만족하지 않아서, VaR 기준으로 최적화하면 분산 투자의 효과를 과소평가할 수 있다.
바젤 III 이후 규제 체계에서는 VaR 대신 Expected Shortfall을 시장 리스크의 기본 측도로 채택하고 있다(FRTB: Fundamental Review of the Trading Book).
def comprehensive_risk_metrics(returns, portfolio_value, confidence_level=0.95):
"""
포트폴리오의 종합 리스크 지표를 계산한다.
VaR, CVaR 외에도 최대 낙폭(MDD), 변동성, 왜도, 첨도 등을
함께 계산하여 리스크의 전체 그림을 제공한다.
"""
sorted_returns = returns.sort_values()
n = len(sorted_returns)
# VaR
var_pct = -np.percentile(returns, (1 - confidence_level) * 100)
# CVaR (Expected Shortfall)
var_threshold = np.percentile(returns, (1 - confidence_level) * 100)
tail_returns = returns[returns <= var_threshold]
cvar_pct = -tail_returns.mean()
# 변동성 (연율화)
daily_vol = returns.std()
annual_vol = daily_vol * np.sqrt(252)
# 왜도와 첨도
skewness = returns.skew()
kurtosis = returns.kurtosis() # 초과 첨도 (정규분포 = 0)
# 최대 낙폭 (Maximum Drawdown)
cumulative = (1 + returns).cumprod()
running_max = cumulative.cummax()
drawdown = (cumulative - running_max) / running_max
max_drawdown = drawdown.min()
# 정규분포 대비 VaR 비율 (테일 위험 지표)
parametric_var = -(returns.mean() + stats.norm.ppf(1 - confidence_level) * returns.std())
tail_ratio = var_pct / parametric_var if parametric_var > 0 else float('inf')
results = {
'VaR': var_pct,
'CVaR': cvar_pct,
'VaR (금액)': var_pct * portfolio_value,
'CVaR (금액)': cvar_pct * portfolio_value,
'CVaR/VaR 비율': cvar_pct / var_pct if var_pct > 0 else 0,
'일간 변동성': daily_vol,
'연간 변동성': annual_vol,
'왜도': skewness,
'초과 첨도': kurtosis,
'최대 낙폭': max_drawdown,
'테일 비율 (실제/파라메트릭)': tail_ratio,
}
print("=" * 60)
print(f"종합 리스크 분석 ({confidence_level*100:.0f}% 신뢰수준)")
print("=" * 60)
for key, value in results.items():
if '금액' in key:
print(f" {key}: {value:,.0f} 원")
elif '비율' in key or '변동성' in key or '낙폭' in key:
print(f" {key}: {value:.4%}")
else:
print(f" {key}: {value:.6f}")
# 해석 가이드
print("\n[해석 가이드]")
if kurtosis > 1:
print(f" - 초과 첨도 {kurtosis:.2f}: 정규분포보다 꼬리가 두꺼움 "
f"(팻테일). 파라메트릭 VaR가 리스크를 과소평가할 가능성 높음.")
if skewness < -0.5:
print(f" - 왜도 {skewness:.2f}: 음의 비대칭. "
f"큰 손실이 큰 이익보다 더 자주/크게 발생하는 경향.")
if tail_ratio > 1.2:
print(f" - 테일 비율 {tail_ratio:.2f}: 실제 테일 리스크가 "
f"정규분포 가정 대비 {(tail_ratio-1)*100:.0f}% 더 큼.")
return results
# 실행
risk_metrics = comprehensive_risk_metrics(portfolio_returns, portfolio_value, 0.95)
CVaR/VaR 비율은 꼬리 위험의 심각도를 나타내는 유용한 지표다. 정규분포 하에서 95% 신뢰수준의 CVaR/VaR 비율은 약 1.25다. 이 비율이 1.5를 넘으면 극단적인 꼬리 위험이 존재한다는 신호이므로, 스트레스 테스팅을 강화하고 포트폴리오 조정을 검토해야 한다.
7. 포트폴리오 최적화와 리스크 통합
VaR와 CVaR를 단순히 측정하는 것에서 나아가, 이를 포트폴리오 구성에 반영하면 리스크 기반 최적화가 가능하다. PyPortfolioOpt 라이브러리는 평균-분산 최적화(Markowitz), 최소 CVaR 최적화, 계층적 리스크 패리티(HRP) 등 다양한 방법을 제공한다.
from pypfopt import EfficientFrontier, risk_models, expected_returns
from pypfopt import HRPOpt
from pypfopt.efficient_frontier import EfficientCVaR
# 기대수익률과 공분산 행렬 추정
mu = expected_returns.mean_historical_return(prices)
S = risk_models.sample_cov(prices)
# 1. 평균-분산 최적화 (최소 변동성 포트폴리오)
ef_minvol = EfficientFrontier(mu, S)
ef_minvol.min_volatility()
w_minvol = ef_minvol.clean_weights()
perf_minvol = ef_minvol.portfolio_performance(verbose=False)
# 2. 최대 샤프 비율 포트폴리오
ef_sharpe = EfficientFrontier(mu, S)
ef_sharpe.max_sharpe(risk_free_rate=0.035)
w_sharpe = ef_sharpe.clean_weights()
perf_sharpe = ef_sharpe.portfolio_performance(verbose=False)
# 3. 계층적 리스크 패리티 (HRP)
hrp = HRPOpt(log_returns)
hrp.optimize()
w_hrp = hrp.clean_weights()
perf_hrp = hrp.portfolio_performance(verbose=False)
# 4. 최소 CVaR 포트폴리오
ef_cvar = EfficientCVaR(mu, log_returns)
ef_cvar.min_cvar()
w_cvar = ef_cvar.clean_weights()
print("=" * 70)
print("포트폴리오 최적화 결과 비교")
print("=" * 70)
print(f"{'전략':<20} {'기대수익률':>10} {'변동성':>10} {'샤프':>8}")
print("-" * 70)
print(f"{'최소 변동성':<20} {perf_minvol[0]:>10.2%} {perf_minvol[1]:>10.2%} "
f"{perf_minvol[2]:>8.3f}")
print(f"{'최대 샤프':<20} {perf_sharpe[0]:>10.2%} {perf_sharpe[1]:>10.2%} "
f"{perf_sharpe[2]:>8.3f}")
print(f"{'HRP':<20} {perf_hrp[0]:>10.2%} {perf_hrp[1]:>10.2%} "
f"{perf_hrp[2]:>8.3f}")
print("\n비중 비교:")
for ticker in tickers:
print(f" {ticker}: 최소변동 {w_minvol.get(ticker,0):.1%} | "
f"최대샤프 {w_sharpe.get(ticker,0):.1%} | "
f"HRP {w_hrp.get(ticker,0):.1%} | "
f"최소CVaR {w_cvar.get(ticker,0):.1%}")
최소 변동성 포트폴리오는 전체 리스크를 최소화하지만 기대수익률도 낮을 수 있다. 최대 샤프 포트폴리오는 위험 대비 수익이 가장 높지만 특정 종목에 비중이 집중되는 경향이 있다. HRP(Hierarchical Risk Parity)는 공분산 행렬의 역행렬을 구하지 않으므로 추정 오차에 덜 민감하고, 자산 수가 많거나 공분산 행렬이 불안정할 때 강건한 결과를 제공한다.
실무에서는 단일 최적화 방법에 의존하기보다, 여러 방법의 결과를 앙상블하거나 블랙-리터만 모델로 투자자의 뷰를 결합하는 것이 일반적이다.
8. VaR 방법론 비교표
세 가지 VaR 방법론의 특성을 체계적으로 비교하면 다음과 같다.
| 기준 | 파라메트릭 VaR | 히스토리컬 VaR | 몬테카를로 VaR |
|---|---|---|---|
| 분포 가정 | 정규분포 (또는 t-분포) | 없음 (비모수적) | 모형에 따라 다름 (GBM 등) |
| 계산 속도 | 매우 빠름 (밀리초) | 빠름 (밀리초~초) | 느림 (초~분) |
| 팻테일 반영 | 못함 (t-분포 사용 시 부분적) | 과거 팻테일 자동 반영 | 모형에 따라 반영 가능 |
| 비선형 상품 | 불가 (델타-감마 근사 필요) | 가능 (풀 리밸류에이션) | 가능 (풀 리밸류에이션) |
| 새로운 시나리오 | 생성 가능 | 불가 (과거 데이터에 제한) | 생성 가능 |
| 구현 난이도 | 낮음 | 중간 | 높음 |
| 데이터 요구량 | 평균, 분산, 공분산만 필요 | 충분한 과거 데이터 필요 | 모형 파라미터 + 난수 생성 |
| 규제 인정 | 바젤: 조건부 인정 | 바젤: 인정 | 바젤: 인정 |
| 주요 약점 | 정규분포 가정 위배 | 유령 효과, 미래 시나리오 부재 | 모형 리스크, 계산 비용 |
| 적합 사용처 | 빠른 일중 리스크 모니터링 | 규제 보고, 일반 포트폴리오 | 파생상품, 복합 포트폴리오 |
| 확장성 | 자산 수 증가에 제곱 비용 | 선형 비용 | 높은 고정 비용 |
방법론 선택 가이드라인
- 단순 주식/채권 포트폴리오: 히스토리컬 VaR로 시작하고, 파라메트릭 VaR를 벤치마크로 비교
- 옵션/파생상품 포함: 반드시 몬테카를로 VaR 사용 (비선형 페이오프 반영 필수)
- 실시간 리스크 모니터링: 파라메트릭 VaR (속도 우선)
- 규제 보고 목적: 히스토리컬 또는 몬테카를로 VaR + 반드시 백테스팅 수반
- 리스크 한도 설정: CVaR 기반 권장 (VaR만으로는 테일 리스크 미반영)
9. 백테스팅과 모델 검증
VaR 모델을 구축했다면 반드시 백테스팅(backtesting)으로 모델의 정확성을 검증해야 한다. 백테스팅이란 과거 데이터에서 VaR를 계산한 뒤, 실제 손실이 VaR를 초과한 횟수(VaR breach)가 이론적 기대와 일치하는지를 통계적으로 검정하는 절차다.
예를 들어 95% VaR를 250일 동안 백테스팅하면, 기대되는 초과 횟수는 약 12.5회(250 x 5%)다. 초과 횟수가 이보다 유의미하게 많으면 모델이 리스크를 과소평가하고 있는 것이고, 적으면 과대평가하고 있는 것이다.
Kupiec 검정 (POF Test)
Kupiec 검정은 초과 비율(proportion of failures)이 이론적 비율과 일치하는지를 우도비 검정(likelihood ratio test)으로 확인한다.
Christoffersen 검정 (Conditional Coverage Test)
Christoffersen 검정은 초과의 독립성(independence)까지 검정한다. VaR 초과가 연속으로 발생하면(clustering), 모델이 변동성 군집 현상을 제대로 반영하지 못하고 있다는 증거다.
def backtest_var(returns, var_series, confidence_level=0.95, method_name=""):
"""
VaR 백테스팅: Kupiec 검정 수행
Parameters
----------
returns : pd.Series
실제 포트폴리오 수익률
var_series : pd.Series
동일 기간의 VaR 추정값 (양수)
confidence_level : float
VaR 신뢰수준
method_name : str
방법론 이름 (출력용)
"""
# VaR 초과 여부 판정
breaches = returns < -var_series
n_breaches = breaches.sum()
n_total = len(returns)
breach_rate = n_breaches / n_total
expected_rate = 1 - confidence_level
expected_breaches = expected_rate * n_total
print(f"\n{'=' * 60}")
print(f"VaR 백테스팅 결과: {method_name}")
print(f"{'=' * 60}")
print(f" 관측 기간: {n_total} 거래일")
print(f" VaR 초과 횟수: {n_breaches} 회")
print(f" 기대 초과 횟수: {expected_breaches:.1f} 회")
print(f" 실제 초과 비율: {breach_rate:.2%}")
print(f" 기대 초과 비율: {expected_rate:.2%}")
# Kupiec 우도비 검정
p = expected_rate
p_hat = breach_rate if breach_rate > 0 else 1e-10
# LR 통계량 계산
if n_breaches == 0:
lr_stat = -2 * (n_total * np.log(1 - p) - n_total * np.log(1 - p_hat))
elif n_breaches == n_total:
lr_stat = -2 * (n_total * np.log(p) - n_total * np.log(p_hat))
else:
lr_stat = -2 * (
n_breaches * np.log(p) +
(n_total - n_breaches) * np.log(1 - p) -
n_breaches * np.log(p_hat) -
(n_total - n_breaches) * np.log(1 - p_hat)
)
# 카이제곱 분포 (자유도 1)와 비교
p_value = 1 - stats.chi2.cdf(lr_stat, df=1)
print(f"\n Kupiec LR 통계량: {lr_stat:.4f}")
print(f" p-value: {p_value:.4f}")
if p_value > 0.05:
verdict = "통과 (모델 적합)"
else:
verdict = "실패 (모델 부적합 - 재검토 필요)"
print(f" 판정 (5% 유의수준): {verdict}")
# 바젤 신호등 체계 (Traffic Light)
# 250일 기준: 초록(0~4), 노랑(5~9), 빨강(10+)
if n_total >= 250:
scale_factor = n_total / 250
scaled_breaches = n_breaches / scale_factor
if scaled_breaches <= 4:
zone = "초록 (Green Zone) - 양호"
elif scaled_breaches <= 9:
zone = "노랑 (Yellow Zone) - 주의 필요"
else:
zone = "빨강 (Red Zone) - 모델 수정 필수"
print(f" 바젤 신호등: {zone}")
# 연속 초과 분석 (Christoffersen 독립성 예비 분석)
if n_breaches > 0:
consecutive = 0
max_consecutive = 0
for b in breaches:
if b:
consecutive += 1
max_consecutive = max(max_consecutive, consecutive)
else:
consecutive = 0
print(f"\n 최대 연속 초과: {max_consecutive} 거래일")
if max_consecutive >= 3:
print(f" 경고: 연속 초과 {max_consecutive}회 발생. "
f"변동성 군집 현상 미반영 가능성.")
return {
'n_breaches': n_breaches,
'breach_rate': breach_rate,
'lr_stat': lr_stat,
'p_value': p_value,
'pass': p_value > 0.05,
}
# 롤링 VaR로 백테스팅 수행
window = 250
test_returns = portfolio_returns.iloc[window:]
# 파라메트릭 VaR 롤링 계산
rolling_mean = portfolio_returns.rolling(window).mean().iloc[window:]
rolling_std = portfolio_returns.rolling(window).std().iloc[window:]
z_95 = stats.norm.ppf(0.95)
parametric_var_series = -(rolling_mean - z_95 * rolling_std)
# 히스토리컬 VaR 롤링 계산
historical_var_series = portfolio_returns.rolling(window).apply(
lambda x: -np.percentile(x, 5), raw=True
).iloc[window:]
# 백테스팅 실행
bt_param = backtest_var(test_returns, parametric_var_series, 0.95, "파라메트릭 VaR")
bt_hist = backtest_var(test_returns, historical_var_series, 0.95, "히스토리컬 VaR")
백테스팅에서 모델이 실패하면 즉시 원인을 분석해야 한다. 일반적인 원인과 대응은 다음과 같다.
- 초과 횟수가 너무 많음 (리스크 과소평가): 관측 윈도우를 늘리거나, 스트레스 기간을 포함하도록 데이터를 확장. GARCH 모델로 시변 변동성을 반영. 또는 신뢰수준을 높여서 보수적으로 운영.
- 초과 횟수가 너무 적음 (리스크 과대평가): 자본 효율성이 낮아지므로, 모델 파라미터를 정교화하거나 변동성 추정 방법을 개선.
- 초과가 군집(clustering)으로 발생: GARCH/EGARCH 등 변동성 군집 모델을 VaR 프레임워크에 통합.
10. 운영 시 주의사항
데이터 품질 문제
VaR 모델의 정확성은 입력 데이터의 품질에 전적으로 의존한다. 실무에서 자주 마주하는 데이터 문제는 다음과 같다.
- 누락된 가격 데이터: 특정 거래일에 가격이 없으면 수익률 계산이 왜곡된다. 단순 전일 가격 대입(forward fill)은 변동성을 과소평가할 수 있다. 누락 비율이 5%를 넘으면 해당 종목의 VaR 신뢰성을 재검토해야 한다.
- 배당/액면분할 미조정: 수정주가(adjusted price)를 사용하지 않으면 배당락일이나 액면분할일에 인위적인 급락이 관측되어 VaR를 과대평가한다.
- 생존자 편향(Survivorship Bias): 상장폐지된 종목이 데이터에서 빠지면 리스크를 과소평가한다. 히스토리컬 VaR에서 특히 심각하다.
- 시차(Stale Prices): 유동성이 낮은 자산은 마지막 체결 가격이 현재 가치를 반영하지 못할 수 있다. 이로 인해 변동성과 상관관계가 모두 왜곡된다.
모델 한계와 가정 위배
| 가정 | 현실 | 영향 | 대응 |
|---|---|---|---|
| 수익률이 정규분포 | 팻테일, 음의 왜도 존재 | 파라메트릭 VaR 과소평가 | t-분포 사용 또는 히스토리컬/MC 병행 |
| 수익률 간 독립 (i.i.d.) | 변동성 군집 현상 | sqrt(T) 스케일링 부정확 | GARCH로 시변 변동성 모델링 |
| 상관관계 일정 | 위기 시 상관관계 수렴 | 분산 효과 과대평가 | 스트레스 상관관계, 코퓰라 모델 |
| 시장 유동성 충분 | 급락장에서 유동성 소멸 | 청산 불가로 손실 확대 | 유동성 조정 VaR (LVaR) |
| 과거가 미래를 대표 | 구조적 변화(regime change) | 모든 VaR 방법론에 영향 | 레짐 스위칭 모델, 스트레스 시나리오 |
규제 고려사항
- 바젤 III/IV: 시장 리스크 자본은 97.5% ES(Expected Shortfall) 기준으로 계산. 내부 모델을 사용하려면 독립적인 백테스팅 통과 필수.
- FRTB(Fundamental Review of the Trading Book): 기존 VaR 기반에서 ES 기반으로 전환. 스트레스 기간의 ES를 사용하도록 요구.
- 한국 금융감독원: 내부 모델 승인을 받은 금융기관은 자체 VaR 모델을 사용할 수 있으나, 정기적인 백테스팅 보고서 제출 의무.
프로덕션 운영 체크포인트
- 매일 VaR 계산 결과를 자동으로 기록하고, 전일 대비 급격한 변화(예: 30% 이상 변동)가 있으면 알림을 발생시킨다.
- 월 단위로 백테스팅을 수행하여 모델 정확성을 지속적으로 모니터링한다.
- 분기별로 모델 파라미터(관측 윈도우, 변동성 모델 등)를 리뷰하고, 시장 환경 변화에 맞게 조정한다.
- 스트레스 테스트 시나리오를 최소 연 1회 업데이트한다. 새로운 위기 사례(예: 코로나 팬데믹, SVB 사태)를 시나리오에 추가한다.
11. 실패 사례와 복구 절차
사례 1: 2008년 금융위기와 VaR의 한계
2008년 글로벌 금융위기는 VaR 기반 리스크 관리의 근본적 한계를 보여준 역사적 사건이다. 위기 이전 대부분의 투자은행은 95% 또는 99% VaR를 리스크 한도로 사용했는데, 실제 손실은 VaR를 수십 배 초과했다.
원인 분석:
- 가정 위배: 서브프라임 모기지 관련 구조화 상품(CDO 등)의 기초자산 상관관계가 위기 시 급격히 1에 수렴했다. VaR 모델은 평시의 낮은 상관관계에 기반했으므로 분산 효과를 크게 과대평가했다.
- 유동성 리스크 미반영: VaR는 자산을 정상적으로 청산할 수 있다고 가정하지만, 위기 시 시장 유동성이 완전히 소멸했다. 매도호가가 없는 시장에서는 이론적 VaR 자체가 무의미했다.
- 모형 리스크(Model Risk): CDO 가격 결정에 사용된 가우시안 코퓰라(Gaussian copula) 모델이 꼬리 의존성(tail dependence)을 과소평가했다.
교훈:
- VaR는 리스크의 상한선이 아니라 "정상적인 시장 환경에서의 손실 추정치"일 뿐이다.
- VaR 단독 사용은 위험하며, CVaR + 스트레스 테스팅 + 유동성 리스크 평가를 반드시 병행해야 한다.
- 상관관계 가정의 강건성을 스트레스 시나리오에서 반드시 점검해야 한다.
사례 2: 모델 구현 오류로 인한 리스크 과소평가
실무에서 VaR 모델의 실패는 이론적 한계보다 구현 버그에서 더 자주 발생한다. 다음은 실제 발생하기 쉬운 오류 패턴이다.
자주 발생하는 버그:
- 수익률 계산 방향 오류:
(P_t - P_{t-1}) / P_{t-1}대신(P_{t-1} - P_t) / P_t로 계산하면 부호와 크기가 모두 틀린다. - 연율화 실수: 일간 변동성에 sqrt(252) 대신 252를 곱하면 변동성이 약 16배 과대평가된다.
- 배열 인덱스 오류: VaR의 분위수 계산에서 오름차순/내림차순을 혼동하면, 최소 손실을 VaR로 보고하는 심각한 오류가 발생한다.
- 시간대(timezone) 불일치: 미국 주식과 유럽 주식의 종가 시점이 다른데 이를 무시하면 상관관계가 왜곡된다.
복구 절차:
- VaR 급변 시 즉시 데이터 파이프라인 점검 (누락, 이상치, 중복 여부)
- 단일 자산에 대해 수동 계산과 코드 결과를 대조 검증
- 알려진 분포(예: 표준정규분포)에서 생성한 합성 데이터로 코드 단위 테스트
- 과거 특정 날짜의 VaR를 재현하여 시점별 일관성 확인
- 독립적인 제2 구현체(또는 벤더 시스템)와의 교차 검증
사례 3: 변동성 레짐 전환 미탐지
2020년 3월 코로나 팬데믹 초기에는 VIX 지수가 80을 넘어서며, 2018~2019년 데이터로 학습한 VaR 모델이 하루 만에 무력화되었다. 관측 윈도우 250일(1년) 내에 이런 극단적 변동성이 포함되지 않았기 때문이다.
대응 방안:
- GARCH(1,1) 모델을 적용하여 직전 수일간의 변동성 급증을 즉시 VaR에 반영한다.
- 장기 윈도우(500일, 750일)를 병행 사용하여 2008년 등 과거 위기 데이터를 포함시킨다.
- VaR 모델에 레짐 스위칭(Markov regime-switching) 기능을 추가하여, 현재가 고변동성 레짐인지 저변동성 레짐인지를 자동 탐지한다.
- VIX 등 외부 변동성 지표를 모니터링하여 급등 시 리스크 한도를 선제적으로 조정한다.
12. 체크리스트
VaR 기반 리스크 관리 시스템을 구축할 때 다음 항목을 점검한다.
모델 구축 단계
- 수익률 계산 방법 확인 (로그 수익률 vs 산술 수익률, 용도에 맞게 선택)
- 데이터 품질 검증 (누락, 이상치, 배당/분할 조정 여부)
- 최소 2가지 이상의 VaR 방법론 병행 계산 (파라메트릭 + 히스토리컬, 또는 히스토리컬 + 몬테카를로)
- CVaR(Expected Shortfall) 함께 계산하여 테일 리스크 파악
- 관측 윈도우 길이의 민감도 분석 수행 (250일, 500일, 750일 비교)
- 공분산 행렬의 양정치 여부 확인 (촐레스키 분해 가능 여부 테스트)
백테스팅 단계
- Kupiec 검정(POF test) 통과 확인
- 초과(breach) 패턴의 군집성 분석 (Christoffersen 독립성 검정)
- 바젤 신호등 체계 기준 그린존 유지 여부 확인
- 최소 250 거래일 이상의 백테스팅 기간 확보
프로덕션 운영 단계
- 일간 VaR 자동 계산 및 기록 파이프라인 구축
- VaR 급변 시 자동 알림 메커니즘 설정 (전일 대비 임계치 초과 시)
- 월간 백테스팅 보고서 자동 생성
- 분기별 모델 파라미터 리뷰 일정 수립
- 연간 스트레스 테스트 시나리오 업데이트
- 코드 변경 시 단위 테스트 (합성 데이터 기반 VaR 재현 테스트)
- 제2 시스템 또는 벤더와의 정기 교차 검증
거버넌스 단계
- 리스크 한도(limit) 체계와 VaR 연동 방식 문서화
- VaR 초과 시 에스컬레이션 절차 수립
- 모델 변경 관리(model change management) 프로세스 확립
- 모델 리스크 인벤토리에 VaR 모델 등록 및 정기 검토
13. 참고자료
- Investopedia: Value at Risk (VaR) Explained - VaR 개념의 기초를 잡기에 적합한 입문 자료. 신뢰수준, 보유 기간 등 핵심 파라미터에 대한 직관적 설명을 제공한다.
- Risk Engineering: Value at Risk - VaR의 수학적 정의와 세 가지 방법론에 대한 학술적 수준의 자료. 한계점과 대안에 대한 논의도 포함되어 있다.
- Interactive Brokers: Risk Metrics in Python - VaR and CVaR Guide - Python으로 VaR과 CVaR를 구현하는 실전 가이드. yfinance를 활용한 데이터 수집부터 백테스팅까지 코드 예제를 제공한다.
- PyPortfolioOpt GitHub Repository - 평균-분산 최적화, HRP, 블랙-리터만 등 다양한 포트폴리오 최적화 알고리즘의 Python 구현체. 리스크 통합 최적화에 활용 가능하다.
- PyQuant News: Quickly Compute Value at Risk with Monte Carlo - 몬테카를로 시뮬레이션 기반 VaR 계산의 핵심만 간결하게 정리한 뉴스레터. 촐레스키 분해와 시뮬레이션 수렴에 대한 실용적 조언을 포함한다.
- Hull, J.C. (2022). Options, Futures, and Other Derivatives. 11th Edition. Pearson. - 금융공학의 표준 교과서로, VaR 챕터에서 세 가지 방법론의 수학적 기초를 엄밀하게 다룬다.
- Jorion, P. (2006). Value at Risk: The New Benchmark for Managing Financial Risk. 3rd Edition. McGraw-Hill. - VaR에 대한 가장 포괄적인 단행본. 방법론, 백테스팅, 규제 적용까지 전 범위를 다룬다.
Portfolio Risk Management: A Practical Guide to VaR and Monte Carlo Simulation with Python
- 1. Introduction: Speaking Risk in Numbers
- 2. VaR Fundamentals and Three Methodologies
- 3. Parametric VaR Implementation
- 4. Historical Simulation VaR
- 5. Monte Carlo Simulation VaR
- 6. CVaR (Expected Shortfall) Extension
- 7. Portfolio Optimization and Risk Integration
- 8. VaR Methodology Comparison Table
- 9. Backtesting and Model Validation
- 10. Operational Considerations
- 11. Failure Cases and Recovery Procedures
- 12. Checklist
- 13. References
- Quiz

1. Introduction: Speaking Risk in Numbers
"How much risk does this portfolio carry?" Answering "it's somewhat risky" or "it has high volatility" is meaningless in practice. Risk managers, regulators, and investors all want concrete numbers. "At a 95% confidence level, the maximum one-day loss is 230 million won." This is why Value at Risk (VaR) exists.
Since VaR was popularized in the early 1990s through JP Morgan's RiskMetrics system, it has become a core metric in the Basel regulatory framework (Basel II/III/IV). However, the 2008 financial crisis starkly exposed VaR's limitations, making it essential to perform integrated risk frameworks that combine CVaR (Expected Shortfall), stress testing, and backtesting rather than relying on a single VaR number.
This article implements each of the three major VaR calculation methodologies -- Parametric (Variance-Covariance), Historical Simulation, and Monte Carlo Simulation -- in Python code, validates models through backtesting, and covers in detail the precautions and failure cases you must know when operating risk systems in production environments.
The target audience is developers or junior quants familiar with Python basics and pandas/numpy. Financial domain knowledge is explained from scratch wherever possible, so you can follow along even without background knowledge.
2. VaR Fundamentals and Three Methodologies
What Is VaR?
VaR (Value at Risk) represents the maximum expected loss a portfolio can experience under a given confidence level and holding period. Mathematically expressed:
VaR_alpha = -inf{x : P(L > x) <= 1 - alpha}
Here, alpha is the confidence level (typically 95% or 99%), L is the loss distribution, and x is the loss amount. Intuitively, "95% VaR = 100 million won" means "there is a 95% probability that losses will not exceed 100 million won over the next day." Conversely, it also means there is a 5% probability of losses exceeding 100 million won.
Three Key VaR Parameters
- Confidence Level: 95%, 99%, 99.5%, etc. Basel regulations use 99% as the default.
- Holding Period: 1 day, 10 days, 1 month, etc. Trading books typically use 1 day, while investment portfolios use 10 days to 1 month.
- Observation Window: The historical data period used for VaR calculation. Typically 250 days (1 year) to 500 days (2 years).
Overview of Three Calculation Methodologies
VaR calculation methods are broadly classified into three categories.
Parametric (Variance-Covariance) Method: Assumes returns follow a normal distribution and calculates VaR using only the mean and standard deviation (or covariance matrix). Fast but fails to capture fat-tail phenomena.
Historical Simulation Method: Directly uses past return data to construct the loss distribution. No distribution assumption is needed, but it cannot reflect scenarios that have not occurred in the past.
Monte Carlo Simulation Method: Generates tens of thousands to hundreds of thousands of random scenarios from a stochastic model (e.g., Geometric Brownian Motion) to estimate the loss distribution. Most flexible but computationally expensive.
3. Parametric VaR Implementation
Parametric VaR is the simplest and fastest method. Assuming portfolio returns follow a normal distribution N(mu, sigma^2), VaR is calculated as follows:
VaR = -(mu + z*alpha * sigma) _ Portfolio_Value
Here, z_alpha is the quantile of the standard normal distribution (e.g., z = -1.645 for 95% confidence level).
import numpy as np
import pandas as pd
from scipy import stats
import yfinance as yf
from datetime import datetime, timedelta
# Portfolio tickers and weights
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'NVDA']
weights = np.array([0.25, 0.20, 0.20, 0.20, 0.15])
portfolio_value = 1_000_000_000 # 1 billion won
# Collect 2 years of price data
end_date = datetime(2026, 3, 1)
start_date = end_date - timedelta(days=730)
prices = yf.download(tickers, start=start_date, end=end_date)['Adj Close']
# Calculate daily log returns
log_returns = np.log(prices / prices.shift(1)).dropna()
# Portfolio returns
portfolio_returns = log_returns.dot(weights)
# Parametric VaR calculation
confidence_levels = [0.90, 0.95, 0.99]
mean_return = portfolio_returns.mean()
std_return = portfolio_returns.std()
print("=" * 60)
print("Parametric VaR (Variance-Covariance Method)")
print("=" * 60)
print(f"Daily mean return: {mean_return:.6f}")
print(f"Daily std deviation: {std_return:.6f}")
print(f"Portfolio value: {portfolio_value:,.0f} won")
print("-" * 60)
for cl in confidence_levels:
z_score = stats.norm.ppf(1 - cl)
var_pct = -(mean_return + z_score * std_return)
var_amount = var_pct * portfolio_value
print(f"{cl*100:.0f}% VaR: {var_pct:.4%} = {var_amount:,.0f} won")
# 10-day VaR (Basel standard): sqrt(10) scaling
print("\n10-day VaR (sqrt(T) scaling):")
for cl in confidence_levels:
z_score = stats.norm.ppf(1 - cl)
var_1d = -(mean_return + z_score * std_return)
var_10d = var_1d * np.sqrt(10)
var_10d_amount = var_10d * portfolio_value
print(f"{cl*100:.0f}% 10-day VaR: {var_10d:.4%} = {var_10d_amount:,.0f} won")
Let us highlight a few key points from the code above. First, we use log returns. Log returns are additive over time, allowing multi-period return aggregation, and better fit the normal distribution assumption. Second, the sqrt(10) rule is applied for scaling to 10-day VaR. This is only accurate under the assumption that returns are independent and identically distributed (i.i.d.). Since real financial time series exhibit autocorrelation and volatility clustering, this scaling is merely an approximation.
Multi-Asset Extension of Parametric VaR: Using the Covariance Matrix
Instead of directly computing the standard deviation of the single portfolio return, using the covariance matrix of individual assets enables risk contribution analysis.
# Covariance matrix-based portfolio VaR
cov_matrix = log_returns.cov()
# Portfolio variance = w^T * Sigma * w
portfolio_variance = weights @ cov_matrix.values @ weights
portfolio_std = np.sqrt(portfolio_variance)
# Marginal VaR contribution per asset (Component VaR)
marginal_var = cov_matrix.values @ weights / portfolio_std
component_var = weights * marginal_var
print("\n" + "=" * 60)
print("Component VaR Analysis (99% Confidence Level)")
print("=" * 60)
z_99 = stats.norm.ppf(0.99)
for i, ticker in enumerate(tickers):
cvar_pct = component_var[i] * z_99
cvar_amount = cvar_pct * portfolio_value
contribution_pct = component_var[i] / portfolio_std * 100
print(f"{ticker}: Weight {weights[i]*100:.0f}% | "
f"Risk contribution {contribution_pct:.1f}% | "
f"Component VaR {cvar_amount:,.0f} won")
# Verify diversification benefit
individual_vars = np.array([
weights[i] * np.sqrt(cov_matrix.values[i, i]) * z_99
for i in range(len(tickers))
])
undiversified_var = individual_vars.sum() * portfolio_value
diversified_var = portfolio_std * z_99 * portfolio_value
diversification_benefit = undiversified_var - diversified_var
print(f"\nUndiversified VaR (sum): {undiversified_var:,.0f} won")
print(f"Diversified VaR (portfolio): {diversified_var:,.0f} won")
print(f"Diversification benefit: {diversification_benefit:,.0f} won "
f"({diversification_benefit/undiversified_var*100:.1f}% reduction)")
The key insight from this analysis is the diversification benefit. The portfolio's overall VaR is smaller than the simple sum of individual stock VaRs, and this difference represents the risk reduction effect of diversification. The lower the correlations, the greater the diversification benefit, and as correlations approach 1 (e.g., correlation convergence during crises), diversification benefit shrinks rapidly.
4. Historical Simulation VaR
Historical simulation is more realistic than the parametric method in that it makes no distribution assumptions. Since past return data is directly used as the loss distribution, fat tails and skewness are naturally reflected.
def historical_var(returns, confidence_level=0.95, portfolio_value=1_000_000_000):
"""
Calculate VaR using Historical Simulation method.
Parameters
----------
returns : pd.Series
Portfolio daily returns time series
confidence_level : float
Confidence level (e.g., 0.95, 0.99)
portfolio_value : float
Total portfolio value (won)
Returns
-------
dict
VaR calculation results
"""
sorted_returns = returns.sort_values()
n = len(sorted_returns)
# Lower (1-confidence_level) percentile
percentile_index = int(np.floor((1 - confidence_level) * n))
var_pct = -sorted_returns.iloc[percentile_index]
# Interpolated version (more accurate)
var_pct_interp = -np.percentile(returns, (1 - confidence_level) * 100)
return {
'var_pct': var_pct,
'var_pct_interp': var_pct_interp,
'var_amount': var_pct * portfolio_value,
'var_amount_interp': var_pct_interp * portfolio_value,
'n_observations': n,
'worst_loss': -sorted_returns.iloc[0],
'worst_loss_date': sorted_returns.index[0],
}
# Calculate Historical VaR
results = {}
for cl in [0.90, 0.95, 0.99]:
result = historical_var(portfolio_returns, cl)
results[cl] = result
print(f"\n{cl*100:.0f}% Historical VaR:")
print(f" Percentile method: {result['var_pct']:.4%} = {result['var_amount']:,.0f} won")
print(f" Interpolation: {result['var_pct_interp']:.4%} = {result['var_amount_interp']:,.0f} won")
print(f"\nMaximum loss in observation period: {results[0.95]['worst_loss']:.4%} "
f"({results[0.95]['worst_loss_date'].strftime('%Y-%m-%d')})")
# Rolling analysis of Historical VaR
rolling_window = 250 # 1-year window
rolling_var_95 = portfolio_returns.rolling(rolling_window).apply(
lambda x: -np.percentile(x, 5), raw=True
)
print(f"\nRolling VaR (250-day window) statistics:")
print(f" Mean 95% VaR: {rolling_var_95.mean():.4%}")
print(f" Max 95% VaR: {rolling_var_95.max():.4%} (highest risk period)")
print(f" Min 95% VaR: {rolling_var_95.min():.4%} (lowest risk period)")
The biggest weakness of historical simulation is the "ghost effect." When an extreme event is included in the observation window, VaR stays elevated, then drops abruptly on the day that event falls out of the window. For example, when using a 250-day window, if there was a large crash exactly 250 days ago, VaR suddenly decreases the next day when that crash exits the window. This is an artifact where the measured value changes abruptly even though actual risk has not changed.
A solution to this problem is the exponentially weighted moving average (EWMA) based weighted historical simulation, which mitigates the ghost effect by assigning higher weights to more recent observations.
5. Monte Carlo Simulation VaR
Monte Carlo simulation is the most flexible and powerful method for VaR calculation. The basic idea is to randomly generate thousands to tens of thousands of price paths from a stochastic model (typically Geometric Brownian Motion, GBM), then calculate portfolio P&L from each path to construct the loss distribution.
Geometric Brownian Motion (GBM) Based Simulation
In Geometric Brownian Motion, the change in stock price S follows this stochastic differential equation:
dS = mu _ S _ dt + sigma _ S _ dW
Here, mu is the drift (expected return), sigma is volatility, and dW is the increment of the Wiener process. Discretized:
S(t+dt) = S(t) _ exp((mu - sigma^2/2) _ dt + sigma _ sqrt(dt) _ Z)
Where Z is a random number following the standard normal distribution.
def monte_carlo_var(
returns_df,
weights,
portfolio_value,
n_simulations=50_000,
n_days=1,
confidence_level=0.95,
seed=42
):
"""
Calculate portfolio VaR using Monte Carlo simulation.
Generates scenarios from a multivariate normal distribution
with correlation structure based on GBM.
Parameters
----------
returns_df : pd.DataFrame
Individual asset daily log returns DataFrame
weights : np.ndarray
Asset weight vector
portfolio_value : float
Total portfolio value (won)
n_simulations : int
Number of simulations
n_days : int
Holding period (days)
confidence_level : float
Confidence level
seed : int
Random seed (for reproducibility)
Returns
-------
dict
VaR, CVaR, simulation results, etc.
"""
np.random.seed(seed)
# Individual asset mean returns and covariance matrix
mean_returns = returns_df.mean().values
cov_matrix = returns_df.cov().values
n_assets = len(weights)
# Cholesky decomposition for correlated random number generation
L = np.linalg.cholesky(cov_matrix)
# Run simulation
portfolio_sim_returns = np.zeros(n_simulations)
for sim in range(n_simulations):
cumulative_return = 0
for day in range(n_days):
# Generate independent standard normal random numbers
Z = np.random.standard_normal(n_assets)
# Apply correlation
correlated_returns = mean_returns + L @ Z
# Portfolio return
daily_port_return = weights @ correlated_returns
cumulative_return += daily_port_return
portfolio_sim_returns[sim] = cumulative_return
# P&L distribution
sim_pnl = portfolio_sim_returns * portfolio_value
# VaR calculation
var_pct = -np.percentile(portfolio_sim_returns, (1 - confidence_level) * 100)
var_amount = var_pct * portfolio_value
# CVaR (Expected Shortfall) calculation
var_threshold = np.percentile(portfolio_sim_returns, (1 - confidence_level) * 100)
tail_losses = portfolio_sim_returns[portfolio_sim_returns <= var_threshold]
cvar_pct = -tail_losses.mean()
cvar_amount = cvar_pct * portfolio_value
return {
'var_pct': var_pct,
'var_amount': var_amount,
'cvar_pct': cvar_pct,
'cvar_amount': cvar_amount,
'sim_returns': portfolio_sim_returns,
'sim_pnl': sim_pnl,
'n_simulations': n_simulations,
'n_days': n_days,
'confidence_level': confidence_level,
}
# Run Monte Carlo VaR
mc_results = {}
for cl in [0.90, 0.95, 0.99]:
result = monte_carlo_var(
log_returns, weights, portfolio_value,
n_simulations=100_000,
n_days=1,
confidence_level=cl,
seed=42
)
mc_results[cl] = result
print(f"\n{cl*100:.0f}% Monte Carlo VaR (100,000 simulations):")
print(f" VaR: {result['var_pct']:.4%} = {result['var_amount']:,.0f} won")
print(f" CVaR: {result['cvar_pct']:.4%} = {result['cvar_amount']:,.0f} won")
# Check VaR convergence by number of simulations
print("\n95% VaR convergence by number of simulations:")
for n_sim in [1_000, 5_000, 10_000, 50_000, 100_000, 500_000]:
result = monte_carlo_var(
log_returns, weights, portfolio_value,
n_simulations=n_sim, confidence_level=0.95, seed=42
)
print(f" N={n_sim:>7,}: VaR = {result['var_pct']:.6%}")
Determining Simulation Count and Convergence
The number of simulations (N) in Monte Carlo VaR directly affects accuracy. The standard error of the VaR estimate is roughly proportional to 1/sqrt(N), so quadrupling the number of simulations halves the standard error. In practice, a minimum of 10,000 is recommended, and 50,000 to 100,000 for production. When using extreme confidence levels like 99% VaR, more simulations are needed since there are fewer samples in the tail region.
Cholesky decomposition decomposes the covariance matrix into a lower triangular matrix L such that L * L^T = Sigma. Multiplying an independent standard normal random vector Z by L yields a correlated random vector with the original covariance structure. If the covariance matrix is not positive definite, Cholesky decomposition fails, which occurs when the data period is shorter than the number of assets or when there are linear dependencies. In such cases, eigendecomposition should be used, or the Higham algorithm should be applied to approximate the nearest positive definite matrix.
6. CVaR (Expected Shortfall) Extension
The biggest limitation of VaR is that it provides no information about "how large losses beyond VaR could be." Even if 95% VaR is 2%, losses in the remaining 5% could be 3% or 30%. CVaR (Conditional VaR), also known as Expected Shortfall (ES), is defined as the average of losses exceeding VaR and captures tail risk much more effectively.
The mathematical definition is:
CVaR_alpha = E[L | L > VaR_alpha]
Unlike VaR, CVaR is a coherent risk measure that satisfies subadditivity. Subadditivity means that the risk of combining two portfolios is less than or equal to the sum of their individual risks. VaR does not satisfy this property, so optimizing based on VaR may underestimate the benefits of diversification.
Since Basel III, the regulatory framework has adopted Expected Shortfall as the primary market risk measure instead of VaR (FRTB: Fundamental Review of the Trading Book).
def comprehensive_risk_metrics(returns, portfolio_value, confidence_level=0.95):
"""
Calculate comprehensive risk metrics for a portfolio.
Computes VaR, CVaR, plus maximum drawdown (MDD), volatility,
skewness, kurtosis, etc. to provide a complete risk picture.
"""
sorted_returns = returns.sort_values()
n = len(sorted_returns)
# VaR
var_pct = -np.percentile(returns, (1 - confidence_level) * 100)
# CVaR (Expected Shortfall)
var_threshold = np.percentile(returns, (1 - confidence_level) * 100)
tail_returns = returns[returns <= var_threshold]
cvar_pct = -tail_returns.mean()
# Volatility (annualized)
daily_vol = returns.std()
annual_vol = daily_vol * np.sqrt(252)
# Skewness and Kurtosis
skewness = returns.skew()
kurtosis = returns.kurtosis() # Excess kurtosis (normal distribution = 0)
# Maximum Drawdown
cumulative = (1 + returns).cumprod()
running_max = cumulative.cummax()
drawdown = (cumulative - running_max) / running_max
max_drawdown = drawdown.min()
# VaR ratio vs normal distribution (tail risk indicator)
parametric_var = -(returns.mean() + stats.norm.ppf(1 - confidence_level) * returns.std())
tail_ratio = var_pct / parametric_var if parametric_var > 0 else float('inf')
results = {
'VaR': var_pct,
'CVaR': cvar_pct,
'VaR (amount)': var_pct * portfolio_value,
'CVaR (amount)': cvar_pct * portfolio_value,
'CVaR/VaR ratio': cvar_pct / var_pct if var_pct > 0 else 0,
'Daily volatility': daily_vol,
'Annual volatility': annual_vol,
'Skewness': skewness,
'Excess kurtosis': kurtosis,
'Maximum drawdown': max_drawdown,
'Tail ratio (actual/parametric)': tail_ratio,
}
print("=" * 60)
print(f"Comprehensive Risk Analysis ({confidence_level*100:.0f}% Confidence Level)")
print("=" * 60)
for key, value in results.items():
if 'amount' in key:
print(f" {key}: {value:,.0f} won")
elif 'ratio' in key or 'volatility' in key or 'drawdown' in key:
print(f" {key}: {value:.4%}")
else:
print(f" {key}: {value:.6f}")
# Interpretation guide
print("\n[Interpretation Guide]")
if kurtosis > 1:
print(f" - Excess kurtosis {kurtosis:.2f}: Tails are heavier than normal "
f"distribution (fat tails). Parametric VaR likely underestimates risk.")
if skewness < -0.5:
print(f" - Skewness {skewness:.2f}: Negative asymmetry. "
f"Large losses tend to occur more frequently/severely than large gains.")
if tail_ratio > 1.2:
print(f" - Tail ratio {tail_ratio:.2f}: Actual tail risk is "
f"{(tail_ratio-1)*100:.0f}% greater than the normal distribution assumption.")
return results
# Execute
risk_metrics = comprehensive_risk_metrics(portfolio_returns, portfolio_value, 0.95)
The CVaR/VaR ratio is a useful indicator of tail risk severity. Under a normal distribution at a 95% confidence level, the CVaR/VaR ratio is approximately 1.25. If this ratio exceeds 1.5, it signals the presence of extreme tail risk, and you should strengthen stress testing and consider portfolio adjustments.
7. Portfolio Optimization and Risk Integration
Moving beyond simply measuring VaR and CVaR, incorporating them into portfolio construction enables risk-based optimization. The PyPortfolioOpt library provides various methods including mean-variance optimization (Markowitz), minimum CVaR optimization, and Hierarchical Risk Parity (HRP).
from pypfopt import EfficientFrontier, risk_models, expected_returns
from pypfopt import HRPOpt
from pypfopt.efficient_frontier import EfficientCVaR
# Estimate expected returns and covariance matrix
mu = expected_returns.mean_historical_return(prices)
S = risk_models.sample_cov(prices)
# 1. Mean-variance optimization (minimum volatility portfolio)
ef_minvol = EfficientFrontier(mu, S)
ef_minvol.min_volatility()
w_minvol = ef_minvol.clean_weights()
perf_minvol = ef_minvol.portfolio_performance(verbose=False)
# 2. Maximum Sharpe ratio portfolio
ef_sharpe = EfficientFrontier(mu, S)
ef_sharpe.max_sharpe(risk_free_rate=0.035)
w_sharpe = ef_sharpe.clean_weights()
perf_sharpe = ef_sharpe.portfolio_performance(verbose=False)
# 3. Hierarchical Risk Parity (HRP)
hrp = HRPOpt(log_returns)
hrp.optimize()
w_hrp = hrp.clean_weights()
perf_hrp = hrp.portfolio_performance(verbose=False)
# 4. Minimum CVaR portfolio
ef_cvar = EfficientCVaR(mu, log_returns)
ef_cvar.min_cvar()
w_cvar = ef_cvar.clean_weights()
print("=" * 70)
print("Portfolio Optimization Results Comparison")
print("=" * 70)
print(f"{'Strategy':<20} {'Exp Return':>10} {'Volatility':>10} {'Sharpe':>8}")
print("-" * 70)
print(f"{'Min Volatility':<20} {perf_minvol[0]:>10.2%} {perf_minvol[1]:>10.2%} "
f"{perf_minvol[2]:>8.3f}")
print(f"{'Max Sharpe':<20} {perf_sharpe[0]:>10.2%} {perf_sharpe[1]:>10.2%} "
f"{perf_sharpe[2]:>8.3f}")
print(f"{'HRP':<20} {perf_hrp[0]:>10.2%} {perf_hrp[1]:>10.2%} "
f"{perf_hrp[2]:>8.3f}")
print("\nWeight comparison:")
for ticker in tickers:
print(f" {ticker}: MinVol {w_minvol.get(ticker,0):.1%} | "
f"MaxSharpe {w_sharpe.get(ticker,0):.1%} | "
f"HRP {w_hrp.get(ticker,0):.1%} | "
f"MinCVaR {w_cvar.get(ticker,0):.1%}")
The minimum volatility portfolio minimizes overall risk but may also have lower expected returns. The maximum Sharpe portfolio has the best risk-adjusted return but tends to concentrate in specific stocks. HRP (Hierarchical Risk Parity) does not require inversion of the covariance matrix, making it less sensitive to estimation errors and providing robust results when the number of assets is large or the covariance matrix is unstable.
In practice, rather than relying on a single optimization method, it is common to ensemble results from multiple methods or combine investor views using the Black-Litterman model.
8. VaR Methodology Comparison Table
A systematic comparison of the three VaR methodologies is as follows:
| Criterion | Parametric VaR | Historical VaR | Monte Carlo VaR |
|---|---|---|---|
| Distribution assumption | Normal (or t-distribution) | None (non-parametric) | Depends on model (GBM, etc.) |
| Computation speed | Very fast (milliseconds) | Fast (milliseconds to seconds) | Slow (seconds to minutes) |
| Fat tail capture | Cannot (partial with t-dist) | Automatically reflects past fat tails | Possible depending on model |
| Non-linear instruments | Cannot (delta-gamma approx needed) | Possible (full revaluation) | Possible (full revaluation) |
| New scenarios | Can generate | Cannot (limited to past data) | Can generate |
| Implementation difficulty | Low | Medium | High |
| Data requirements | Only mean, variance, covariance | Sufficient historical data needed | Model parameters + random generation |
| Regulatory acceptance | Basel: conditional | Basel: accepted | Basel: accepted |
| Main weakness | Normal distribution violation | Ghost effect, no future scenarios | Model risk, computational cost |
| Suitable use case | Fast intraday risk monitoring | Regulatory reporting, general portfolios | Derivatives, complex portfolios |
| Scalability | Quadratic cost with asset count | Linear cost | High fixed cost |
Methodology Selection Guidelines
- Simple stock/bond portfolio: Start with Historical VaR and compare Parametric VaR as a benchmark
- Including options/derivatives: Monte Carlo VaR is mandatory (must reflect non-linear payoffs)
- Real-time risk monitoring: Parametric VaR (speed priority)
- Regulatory reporting purposes: Historical or Monte Carlo VaR + backtesting is mandatory
- Risk limit setting: CVaR-based is recommended (VaR alone does not reflect tail risk)
9. Backtesting and Model Validation
Once you have built a VaR model, you must validate model accuracy through backtesting. Backtesting is the procedure of calculating VaR on historical data, then statistically testing whether the number of times actual losses exceed VaR (VaR breaches) matches theoretical expectations.
For example, backtesting 95% VaR over 250 days yields an expected number of breaches of approximately 12.5 (250 x 5%). If the number of breaches is significantly more than this, the model is underestimating risk; if fewer, it is overestimating.
Kupiec Test (POF Test)
The Kupiec test uses a likelihood ratio test to check whether the proportion of failures matches the theoretical ratio.
Christoffersen Test (Conditional Coverage Test)
The Christoffersen test also tests independence of breaches. If VaR breaches occur consecutively (clustering), it indicates the model is not properly reflecting volatility clustering.
def backtest_var(returns, var_series, confidence_level=0.95, method_name=""):
"""
VaR Backtesting: Kupiec Test
Parameters
----------
returns : pd.Series
Actual portfolio returns
var_series : pd.Series
VaR estimates for the same period (positive values)
confidence_level : float
VaR confidence level
method_name : str
Method name (for output)
"""
# Determine VaR breaches
breaches = returns < -var_series
n_breaches = breaches.sum()
n_total = len(returns)
breach_rate = n_breaches / n_total
expected_rate = 1 - confidence_level
expected_breaches = expected_rate * n_total
print(f"\n{'=' * 60}")
print(f"VaR Backtesting Results: {method_name}")
print(f"{'=' * 60}")
print(f" Observation period: {n_total} trading days")
print(f" VaR breaches: {n_breaches}")
print(f" Expected breaches: {expected_breaches:.1f}")
print(f" Actual breach rate: {breach_rate:.2%}")
print(f" Expected breach rate: {expected_rate:.2%}")
# Kupiec likelihood ratio test
p = expected_rate
p_hat = breach_rate if breach_rate > 0 else 1e-10
# LR statistic calculation
if n_breaches == 0:
lr_stat = -2 * (n_total * np.log(1 - p) - n_total * np.log(1 - p_hat))
elif n_breaches == n_total:
lr_stat = -2 * (n_total * np.log(p) - n_total * np.log(p_hat))
else:
lr_stat = -2 * (
n_breaches * np.log(p) +
(n_total - n_breaches) * np.log(1 - p) -
n_breaches * np.log(p_hat) -
(n_total - n_breaches) * np.log(1 - p_hat)
)
# Compare with chi-squared distribution (1 degree of freedom)
p_value = 1 - stats.chi2.cdf(lr_stat, df=1)
print(f"\n Kupiec LR statistic: {lr_stat:.4f}")
print(f" p-value: {p_value:.4f}")
if p_value > 0.05:
verdict = "PASS (model adequate)"
else:
verdict = "FAIL (model inadequate - review needed)"
print(f" Verdict (5% significance): {verdict}")
# Basel Traffic Light System
# Based on 250 days: Green (0-4), Yellow (5-9), Red (10+)
if n_total >= 250:
scale_factor = n_total / 250
scaled_breaches = n_breaches / scale_factor
if scaled_breaches <= 4:
zone = "Green Zone - Good"
elif scaled_breaches <= 9:
zone = "Yellow Zone - Caution needed"
else:
zone = "Red Zone - Model revision required"
print(f" Basel Traffic Light: {zone}")
# Consecutive breach analysis (Christoffersen independence preliminary analysis)
if n_breaches > 0:
consecutive = 0
max_consecutive = 0
for b in breaches:
if b:
consecutive += 1
max_consecutive = max(max_consecutive, consecutive)
else:
consecutive = 0
print(f"\n Max consecutive breaches: {max_consecutive} trading days")
if max_consecutive >= 3:
print(f" Warning: {max_consecutive} consecutive breaches. "
f"Possible failure to capture volatility clustering.")
return {
'n_breaches': n_breaches,
'breach_rate': breach_rate,
'lr_stat': lr_stat,
'p_value': p_value,
'pass': p_value > 0.05,
}
# Perform backtesting with rolling VaR
window = 250
test_returns = portfolio_returns.iloc[window:]
# Rolling parametric VaR calculation
rolling_mean = portfolio_returns.rolling(window).mean().iloc[window:]
rolling_std = portfolio_returns.rolling(window).std().iloc[window:]
z_95 = stats.norm.ppf(0.95)
parametric_var_series = -(rolling_mean - z_95 * rolling_std)
# Rolling historical VaR calculation
historical_var_series = portfolio_returns.rolling(window).apply(
lambda x: -np.percentile(x, 5), raw=True
).iloc[window:]
# Run backtesting
bt_param = backtest_var(test_returns, parametric_var_series, 0.95, "Parametric VaR")
bt_hist = backtest_var(test_returns, historical_var_series, 0.95, "Historical VaR")
When backtesting reveals model failure, you must immediately analyze the cause. Common causes and responses are as follows:
- Too many breaches (risk underestimation): Extend the observation window or expand data to include stress periods. Apply GARCH models to reflect time-varying volatility. Or operate conservatively by raising the confidence level.
- Too few breaches (risk overestimation): Capital efficiency decreases, so refine model parameters or improve volatility estimation methods.
- Breaches occurring in clusters: Integrate volatility clustering models such as GARCH/EGARCH into the VaR framework.
10. Operational Considerations
Data Quality Issues
VaR model accuracy is entirely dependent on input data quality. Common data issues encountered in practice include:
- Missing price data: If prices are missing on certain trading days, return calculations become distorted. Simple forward fill understates volatility. If the missing ratio exceeds 5%, the VaR reliability for that stock should be reassessed.
- Unadjusted dividends/splits: Not using adjusted prices causes artificial crashes on ex-dividend or split dates, overestimating VaR.
- Survivorship bias: If delisted stocks are excluded from the data, risk is underestimated. This is particularly serious in historical VaR.
- Stale prices: For illiquid assets, the last traded price may not reflect current value, distorting both volatility and correlations.
Model Limitations and Assumption Violations
| Assumption | Reality | Impact | Response |
|---|---|---|---|
| Returns are normally distributed | Fat tails, negative skewness | Parametric VaR underestimates | Use t-distribution or combine Historical/MC |
| Returns are independent (i.i.d.) | Volatility clustering | sqrt(T) scaling inaccurate | GARCH for time-varying volatility |
| Constant correlations | Correlation convergence in crises | Overestimation of diversification | Stress correlations, copula models |
| Sufficient market liquidity | Liquidity evaporates in crashes | Losses amplified by inability to liquidate | Liquidity-adjusted VaR (LVaR) |
| Past represents future | Structural changes (regime change) | Affects all VaR methods | Regime-switching models, stress scenarios |
Regulatory Considerations
- Basel III/IV: Market risk capital calculated based on 97.5% ES (Expected Shortfall). Independent backtesting must pass to use internal models.
- FRTB (Fundamental Review of the Trading Book): Transition from VaR-based to ES-based. Requires use of ES from stress periods.
- Korea FSS: Financial institutions with approved internal models can use their own VaR models but must submit regular backtesting reports.
Production Operations Checkpoint
- Automatically record daily VaR calculation results and trigger alerts when there are sharp changes compared to the previous day (e.g., over 30% change).
- Perform backtesting on a monthly basis to continuously monitor model accuracy.
- Review model parameters (observation window, volatility model, etc.) quarterly and adjust to market conditions.
- Update stress test scenarios at least annually. Add new crisis cases (e.g., COVID pandemic, SVB incident) to scenarios.
11. Failure Cases and Recovery Procedures
Case 1: The 2008 Financial Crisis and VaR's Limitations
The 2008 global financial crisis was a historic event that demonstrated the fundamental limitations of VaR-based risk management. Before the crisis, most investment banks used 95% or 99% VaR as risk limits, but actual losses exceeded VaR by orders of magnitude.
Root Cause Analysis:
- Assumption violations: Correlations of underlying assets in subprime mortgage-related structured products (CDOs, etc.) rapidly converged to 1 during the crisis. VaR models were based on peacetime low correlations and thus grossly overestimated diversification benefits.
- Liquidity risk not reflected: VaR assumes assets can be liquidated normally, but market liquidity completely evaporated during the crisis. Theoretical VaR was meaningless in a market with no bid prices.
- Model risk: The Gaussian copula model used for CDO pricing underestimated tail dependence.
Lessons:
- VaR is not an upper bound on risk, but merely "a loss estimate under normal market conditions."
- Using VaR alone is dangerous; CVaR + stress testing + liquidity risk assessment must always be performed together.
- The robustness of correlation assumptions must be tested under stress scenarios.
Case 2: Risk Underestimation Due to Implementation Errors
In practice, VaR model failures occur more frequently from implementation bugs than theoretical limitations. The following are error patterns that easily occur in real-world implementations.
Common bugs:
- Return calculation direction error: Computing
(P_{t-1} - P_t) / P_tinstead of(P_t - P_{t-1}) / P_{t-1}produces errors in both sign and magnitude. - Annualization mistake: Multiplying daily volatility by 252 instead of sqrt(252) overestimates volatility by approximately 16 times.
- Array index error: Confusing ascending/descending order in VaR percentile calculations can cause the minimum loss to be reported as VaR -- a serious error.
- Timezone mismatch: Ignoring different closing time points for US and European stocks distorts correlations.
Recovery procedure:
- Immediately inspect the data pipeline when VaR changes abruptly (missing data, outliers, duplicates)
- Cross-verify manual calculations against code results for a single asset
- Unit test code with synthetic data generated from known distributions (e.g., standard normal)
- Reproduce VaR for specific past dates to verify temporal consistency
- Cross-validate with an independent second implementation (or vendor system)
Case 3: Failure to Detect Volatility Regime Transition
In early March 2020 during the COVID pandemic onset, the VIX index exceeded 80, rendering VaR models trained on 2018-2019 data powerless overnight. Such extreme volatility was not included in the 250-day (1-year) observation window.
Response measures:
- Apply GARCH(1,1) model to immediately reflect volatility spikes from recent days into VaR.
- Use longer windows (500 days, 750 days) in parallel to include past crisis data such as 2008.
- Add regime-switching (Markov regime-switching) capability to the VaR model to automatically detect whether the current state is a high-volatility or low-volatility regime.
- Monitor external volatility indicators like VIX and proactively adjust risk limits when they spike.
12. Checklist
Check the following items when building a VaR-based risk management system.
Model Construction Phase
- Confirm return calculation method (log returns vs arithmetic returns, choose appropriately for the use case)
- Validate data quality (missing values, outliers, dividend/split adjustment)
- Calculate at least 2 VaR methodologies in parallel (Parametric + Historical, or Historical + Monte Carlo)
- Calculate CVaR (Expected Shortfall) alongside to understand tail risk
- Perform sensitivity analysis on observation window length (compare 250, 500, 750 days)
- Verify covariance matrix is positive definite (test Cholesky decomposition feasibility)
Backtesting Phase
- Confirm Kupiec test (POF test) passes
- Analyze breach pattern clustering (Christoffersen independence test)
- Verify Green Zone maintenance under Basel Traffic Light system
- Ensure at least 250 trading days backtesting period
Production Operations Phase
- Build daily VaR automated calculation and recording pipeline
- Set up automatic alert mechanism for sudden VaR changes (threshold exceedance vs previous day)
- Auto-generate monthly backtesting reports
- Establish quarterly model parameter review schedule
- Annual stress test scenario updates
- Unit tests on code changes (synthetic data-based VaR reproduction tests)
- Regular cross-validation with a second system or vendor
Governance Phase
- Document risk limit framework and VaR linkage methodology
- Establish escalation procedures for VaR exceedances
- Establish model change management process
- Register VaR model in model risk inventory and conduct periodic reviews
13. References
- Investopedia: Value at Risk (VaR) Explained - Suitable introductory material for understanding VaR fundamentals. Provides intuitive explanations of key parameters such as confidence level and holding period.
- Risk Engineering: Value at Risk - Academic-level material on the mathematical definition and three methodologies of VaR. Includes discussion of limitations and alternatives.
- Interactive Brokers: Risk Metrics in Python - VaR and CVaR Guide - Practical guide for implementing VaR and CVaR in Python. Provides code examples from data collection using yfinance to backtesting.
- PyPortfolioOpt GitHub Repository - Python implementations of various portfolio optimization algorithms including mean-variance, HRP, and Black-Litterman. Can be used for risk-integrated optimization.
- PyQuant News: Quickly Compute Value at Risk with Monte Carlo - A concise newsletter summarizing the essentials of Monte Carlo simulation-based VaR calculation. Includes practical advice on Cholesky decomposition and simulation convergence.
- Hull, J.C. (2022). Options, Futures, and Other Derivatives. 11th Edition. Pearson. - The standard financial engineering textbook, rigorously covering the mathematical foundations of three methodologies in the VaR chapter.
- Jorion, P. (2006). Value at Risk: The New Benchmark for Managing Financial Risk. 3rd Edition. McGraw-Hill. - The most comprehensive monograph on VaR, covering the full range from methodology to backtesting to regulatory application.
Quiz
Q1: What is the main topic covered in "Portfolio Risk Management: A Practical Guide to VaR and
Monte Carlo Simulation with Python"?
A comprehensive guide covering three VaR calculation methodologies (Parametric, Historical, Monte Carlo), Python implementation, backtesting, CVaR extension, and building production risk management systems.
Q2: What is VaR Fundamentals and Three Methodologies?
What Is VaR? VaR (Value at Risk) represents the maximum expected loss a portfolio can experience
under a given confidence level and holding period.
Q3: Explain the core concept of Parametric VaR Implementation.
Parametric VaR is the simplest and fastest method. Assuming portfolio returns follow a normal
distribution N(mu, sigma^2), VaR is calculated as follows: VaR = -(mu + zalpha sigma) _
Portfolio_Value Here, z_alpha is the quantile of the standard normal distribution (e.g., z =
-1....
Q4: What are the key aspects of Historical Simulation VaR?
Historical simulation is more realistic than the parametric method in that it makes no
distribution assumptions. Since past return data is directly used as the loss distribution, fat
tails and skewness are naturally reflected.
Q5: How does Monte Carlo Simulation VaR work?
Monte Carlo simulation is the most flexible and powerful method for VaR calculation. The basic
idea is to randomly generate thousands to tens of thousands of price paths from a stochastic model
(typically Geometric Brownian Motion, GBM), then calculate portfolio P&L from each pat...