들어가며
리스크 관리의 중요성: 2008 금융위기의 교훈
2008년 글로벌 금융위기는 리스크 관리 실패의 교과서적 사례다. 당시 대부분의 금융기관은 VaR(Value at Risk)를 핵심 리스크 지표로 사용했지만, VaR는 "신뢰수준을 넘어서는 손실이 얼마나 클 수 있는지"를 전혀 알려주지 못했다. 99% 신뢰수준 VaR가 10억 원이라 해서 나머지 1% 시나리오에서의 손실이 11억 원일 수도 있고, 1000억 원일 수도 있다. 이것이 바로 VaR의 치명적 한계인 **꼬리 리스크(tail risk) 미포착** 문제다.
금융위기 이후 바젤 위원회(BCBS)는 리스크 측정의 패러다임 전환을 주도했다. Basel III의 Fundamental Review of the Trading Book(FRTB)에서는 기존 VaR 대신 **Expected Shortfall(ES, CVaR)**을 시장 리스크 자본 산출의 기본 지표로 채택했다. 이는 단순히 지표 하나를 교체한 것이 아니라, 꼬리 리스크를 체계적으로 관리하겠다는 규제 철학의 전환이다.
VaR에서 CVaR로의 패러다임 전환
VaR는 직관적이고 소통하기 쉬운 지표지만, 수학적으로 **부분가법성(sub-additivity)**을 만족하지 못한다. 이는 포트폴리오 A와 B를 합칠 때 합산 VaR가 개별 VaR의 합보다 커질 수 있다는 의미로, 분산 투자의 효과를 제대로 반영하지 못하는 근본적 결함이다.
CVaR(Conditional VaR, Expected Shortfall)은 VaR 임계점을 초과하는 손실의 평균값을 측정하므로, 부분가법성을 포함한 **coherent risk measure**의 네 가지 공리를 모두 충족한다. Basel III/IV 규제 프레임워크에서는 97.5% 신뢰수준의 ES를 기본 지표로 사용하며, 이는 기존 99% VaR와 유사한 수준의 보수성을 제공하면서도 꼬리 리스크 정보를 함께 전달한다.
Basel III/IV 규제 프레임워크 개요
Basel III의 FRTB는 2023년부터 단계적으로 시행되었으며, 주요 변경사항은 다음과 같다.
- **VaR에서 ES로 전환**: 시장 리스크 자본 산출 시 97.5% ES 사용
- **스트레스 기간 데이터 적용**: 최근 관측 기간뿐 아니라 스트레스 기간의 데이터를 반드시 포함
- **유동성 수평선(Liquidity Horizon) 차등 적용**: 자산 유동성에 따라 10일~120일까지 다른 보유 기간 적용
- **내부모형 승인 기준 강화**: 데스크 단위 백테스트 및 P&L 귀인(Attribution) 테스트 필수
이 글에서는 VaR의 3가지 산출 방법론을 Python으로 구현하고, CVaR 계산 및 포트폴리오 최적화, 리스크 모니터링 대시보드까지 실무에서 바로 사용 가능한 코드를 제공한다.
VaR(Value at Risk) 이론
VaR의 정의: 신뢰수준과 보유기간
VaR는 주어진 **신뢰수준**(alpha)과 **보유 기간**(t) 하에서 포트폴리오의 최대 예상 손실을 의미한다. 수학적으로 표현하면 다음과 같다.
VaR_alpha = -inf { x : P(L > x) <= 1 - alpha }
여기서 alpha는 신뢰수준(95% 또는 99%), L은 손실 확률변수, x는 손실 금액이다. 예를 들어 "95% 1일 VaR = 5억 원"이란 "95% 확률로 내일 하루 동안 손실이 5억 원을 넘지 않을 것"이라는 의미다.
3가지 산출 방법론 개요
VaR를 산출하는 방법론은 크게 세 가지로 나뉜다.
1. **Historical Simulation**: 과거 수익률 분포의 백분위수를 직접 사용
2. **Parametric (Variance-Covariance)**: 정규분포 가정 하에 평균과 공분산 행렬로 산출
3. **Monte Carlo Simulation**: 확률 모형에서 시나리오를 대량 생성하여 분포 추정
VaR의 한계
VaR는 실무에서 널리 쓰이지만 이론적으로 중요한 한계가 존재한다.
- **꼬리 리스크 미포착**: 신뢰수준을 넘어서는 손실 규모에 대한 정보 없음
- **부분가법성 위반**: 분산 투자가 오히려 VaR를 증가시킬 수 있음
- **분포 가정 민감성**: Parametric VaR는 정규분포 가정이 깨지면 부정확
- **정적 지표**: 시장 급변 시 과거 데이터 기반 VaR가 미래를 대표하지 못함
Historical Simulation VaR
과거 수익률 분포 기반 접근
Historical Simulation은 가장 직관적인 VaR 산출법이다. 과거 N일간의 수익률 데이터를 수집하고, 이를 오름차순 정렬한 뒤 해당 백분위수 값을 VaR로 사용한다. 분포에 대한 가정이 필요 없으므로 팻테일이나 비대칭 분포도 자연스럽게 반영된다.
**장점**: 분포 가정 불필요, 비선형 상품에도 적용 가능, 구현이 단순함
**단점**: 과거에 발생하지 않은 사건은 반영 불가, 관측 기간 선택에 민감, 최근 시장 상황 반영이 느림
Historical VaR Python 구현
from typing import Tuple
def fetch_portfolio_data(
tickers: list[str],
start: str = "2023-01-01",
end: str = "2026-03-01"
) -> pd.DataFrame:
"""야후 파이낸스에서 조정 종가 데이터를 다운로드한다."""
data = yf.download(tickers, start=start, end=end, auto_adjust=True)
prices = data["Close"][tickers]
return prices
def calculate_portfolio_returns(
prices: pd.DataFrame,
weights: np.ndarray
) -> pd.Series:
"""포트폴리오 일별 수익률을 계산한다."""
daily_returns = prices.pct_change().dropna()
portfolio_returns = daily_returns.dot(weights)
return portfolio_returns
def historical_var(
returns: pd.Series,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000
) -> Tuple[float, float]:
"""
Historical Simulation VaR를 계산한다.
Parameters
----------
returns : 포트폴리오 일별 수익률
confidence_level : 신뢰수준 (0.95 = 95%)
portfolio_value : 포트폴리오 총 가치 (원)
Returns
-------
var_pct : VaR (수익률 기준)
var_amount : VaR (금액 기준)
"""
손실 분포의 백분위수 계산 (왼쪽 꼬리)
var_pct = np.percentile(returns, (1 - confidence_level) * 100)
var_amount = abs(var_pct) * portfolio_value
return var_pct, var_amount
실행 예시
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN"]
weights = np.array([0.25, 0.25, 0.25, 0.25])
prices = fetch_portfolio_data(tickers)
port_returns = calculate_portfolio_returns(prices, weights)
var_95_pct, var_95_amt = historical_var(port_returns, 0.95)
var_99_pct, var_99_amt = historical_var(port_returns, 0.99)
print(f"=== Historical Simulation VaR ===")
print(f"95% 1-Day VaR: {var_95_pct:.4%} (약 {var_95_amt:,.0f}원)")
print(f"99% 1-Day VaR: {var_99_pct:.4%} (약 {var_99_amt:,.0f}원)")
print(f"관측 기간: {len(port_returns)}일")
print(f"일별 수익률 평균: {port_returns.mean():.4%}")
print(f"일별 수익률 표준편차: {port_returns.std():.4%}")
Parametric (Variance-Covariance) VaR
정규분포 가정과 공분산 행렬
Parametric VaR는 포트폴리오 수익률이 정규분포를 따른다고 가정한다. 이 가정 하에서 VaR는 다음과 같이 닫힌 형태(closed-form)로 계산된다.
VaR_alpha = -(mu + z_alpha * sigma)
여기서 mu는 포트폴리오 기대 수익률, sigma는 포트폴리오 표준편차, z_alpha는 정규분포의 alpha 분위수(95%이면 -1.645, 99%이면 -2.326)이다.
**장점**: 계산이 매우 빠름, 수천 개 자산 포트폴리오에도 실시간 산출 가능
**단점**: 정규분포 가정은 현실에서 자주 위배됨(팻테일, 비대칭성), 비선형 포지션(옵션) 처리 곤란
Parametric VaR Python 구현
from scipy import stats
def parametric_var(
returns: pd.Series,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000
) -> dict:
"""
Parametric (Variance-Covariance) VaR를 계산한다.
정규분포 가정 하에서 닫힌 형태 공식을 사용한다.
"""
mu = returns.mean()
sigma = returns.std()
정규분포 z-score
z_score = stats.norm.ppf(1 - confidence_level)
VaR 계산 (음수 수익률 = 손실)
var_pct = -(mu + z_score * sigma)
var_amount = var_pct * portfolio_value
return {
"var_pct": var_pct,
"var_amount": var_amount,
"mu": mu,
"sigma": sigma,
"z_score": z_score,
}
def multi_asset_parametric_var(
prices: pd.DataFrame,
weights: np.ndarray,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000
) -> dict:
"""
다중 자산 포트폴리오의 Parametric VaR를 공분산 행렬로 계산한다.
"""
daily_returns = prices.pct_change().dropna()
공분산 행렬 (연율화하지 않고 일별 기준)
cov_matrix = daily_returns.cov()
포트폴리오 분산: w^T * Sigma * w
port_variance = np.dot(weights.T, np.dot(cov_matrix.values, weights))
port_sigma = np.sqrt(port_variance)
port_mu = daily_returns.mean().dot(weights)
z_score = stats.norm.ppf(1 - confidence_level)
var_pct = -(port_mu + z_score * port_sigma)
var_amount = var_pct * portfolio_value
return {
"var_pct": var_pct,
"var_amount": var_amount,
"port_sigma": port_sigma,
"port_mu": port_mu,
"cov_matrix": cov_matrix,
}
실행 예시
result = multi_asset_parametric_var(prices, weights, 0.95)
print(f"=== Parametric VaR ===")
print(f"포트폴리오 일별 변동성: {result['port_sigma']:.4%}")
print(f"95% 1-Day VaR: {result['var_pct']:.4%} (약 {result['var_amount']:,.0f}원)")
print(f"\n공분산 행렬:")
print(result["cov_matrix"].round(6))
Monte Carlo Simulation VaR
Cholesky 분해를 활용한 상관 시뮬레이션
Monte Carlo VaR는 확률 모형에서 대량의 시나리오를 생성하여 손실 분포를 추정한다. 다중 자산 포트폴리오에서는 자산 간 상관관계를 반영해야 하므로, **Cholesky 분해**를 통해 상관된 난수를 생성한다.
Cholesky 분해는 공분산 행렬 Sigma를 하삼각 행렬 L과 그 전치 행렬의 곱으로 분해한다. 즉, Sigma = L \* L^T이다. 독립적인 표준정규 난수 벡터 Z에 L을 곱하면 상관된 난수 벡터가 된다.
**장점**: 비선형 포트폴리오(옵션, 구조화상품)에 최적, 유연한 분포 가정 가능
**단점**: 계산 비용이 높음, 시뮬레이션 수에 따라 결과 변동, 모형 리스크 존재
Monte Carlo VaR Python 구현
def monte_carlo_var(
prices: pd.DataFrame,
weights: np.ndarray,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000,
n_simulations: int = 100_000,
n_days: int = 1,
random_seed: int = 42
) -> dict:
"""
Monte Carlo Simulation VaR를 계산한다.
Cholesky 분해로 자산 간 상관관계를 반영한 시뮬레이션을 수행한다.
Parameters
----------
prices : 자산별 가격 데이터
weights : 포트폴리오 비중
confidence_level : 신뢰수준
portfolio_value : 포트폴리오 총 가치
n_simulations : 시뮬레이션 횟수
n_days : 보유 기간 (일)
random_seed : 재현성을 위한 시드 값
Returns
-------
dict : VaR, CVaR, 시뮬레이션된 수익률 분포 등
"""
np.random.seed(random_seed)
daily_returns = prices.pct_change().dropna()
mean_returns = daily_returns.mean().values
cov_matrix = daily_returns.cov().values
Cholesky 분해
L = np.linalg.cholesky(cov_matrix)
상관된 난수 생성
n_assets = len(weights)
Z = np.random.standard_normal((n_simulations, n_assets))
correlated_returns = Z @ L.T
n일 보유 기간 시뮬레이션
if n_days > 1:
cumulative_returns = np.zeros(n_simulations)
for _ in range(n_days):
Z = np.random.standard_normal((n_simulations, n_assets))
daily_sim = mean_returns + Z @ L.T
port_daily = daily_sim @ weights
cumulative_returns += port_daily
portfolio_returns_sim = cumulative_returns
else:
simulated_returns = mean_returns + correlated_returns
portfolio_returns_sim = simulated_returns @ weights
VaR 및 CVaR 계산
var_pct = np.percentile(portfolio_returns_sim, (1 - confidence_level) * 100)
cvar_pct = portfolio_returns_sim[
portfolio_returns_sim <= var_pct
].mean()
var_amount = abs(var_pct) * portfolio_value
cvar_amount = abs(cvar_pct) * portfolio_value
return {
"var_pct": var_pct,
"var_amount": var_amount,
"cvar_pct": cvar_pct,
"cvar_amount": cvar_amount,
"simulated_returns": portfolio_returns_sim,
"n_simulations": n_simulations,
}
실행 예시
mc_result = monte_carlo_var(prices, weights, 0.95, n_simulations=100_000)
print(f"=== Monte Carlo VaR (100,000 시나리오) ===")
print(f"95% 1-Day VaR: {mc_result['var_pct']:.4%} ({mc_result['var_amount']:,.0f}원)")
print(f"95% 1-Day CVaR: {mc_result['cvar_pct']:.4%} ({mc_result['cvar_amount']:,.0f}원)")
VaR 3가지 방법론 비교
| 항목 | Historical Simulation | Parametric | Monte Carlo |
| -------------------- | --------------------------- | ----------------------- | --------------------------- |
| **분포 가정** | 없음 (비모수적) | 정규분포 가정 | 모형에 따라 유연 |
| **계산 속도** | 빠름 | 매우 빠름 | 느림 (시뮬레이션 수에 비례) |
| **팻테일 포착** | 과거 데이터에 존재하면 반영 | 미반영 | 분포 설정에 따라 가능 |
| **비선형 상품** | 전체 재가치 평가 필요 | 부적합 (델타-노멀 근사) | 최적 (풀 밸류에이션) |
| **구현 복잡도** | 낮음 | 낮음 | 높음 |
| **과거 미경험 사건** | 반영 불가 | 가정에 따라 가능 | 모형에 따라 가능 |
| **규제 적용** | Basel 표준 방법론 | 내부 모형 | 내부 모형 (고급) |
| **주요 한계** | 관측 기간 의존성 | 정규분포 위배 시 부정확 | 모형 리스크, 계산 비용 |
시나리오별 선택 가이드
- **단순 주식 포트폴리오, 빠른 산출 필요**: Parametric VaR 권장
- **분포 가정 없이 과거 기반 보수적 추정**: Historical Simulation VaR 권장
- **파생상품 포함, 비선형 포지션 다수**: Monte Carlo VaR 필수
- **규제 보고 목적**: Basel FRTB 기준에 따라 ES(Expected Shortfall) 산출 필수
CVaR (Expected Shortfall)
Conditional VaR의 정의와 수학적 표현
CVaR(Conditional Value at Risk)는 **Expected Shortfall(ES)**이라고도 불리며, VaR 임계점을 넘어서는 손실의 **조건부 기대값**을 의미한다. 수학적으로는 다음과 같다.
CVaR_alpha = -E[X | X <= -VaR_alpha]
= -(1 / (1 - alpha)) * integral_{-inf}^{-VaR_alpha} x * f(x) dx
직관적으로, 95% CVaR는 "최악의 5% 시나리오에서 평균적으로 얼마나 손실이 발생하는가"를 알려준다. VaR가 "문턱값"이라면, CVaR는 "문턱을 넘었을 때의 평균 깊이"다.
Coherent Risk Measure: 부분가법성 충족
Artzner et al.(1999)이 정의한 coherent risk measure는 다음 네 가지 공리를 만족해야 한다.
1. **단조성(Monotonicity)**: 모든 시나리오에서 X의 손실이 Y보다 크면, rho(X) >= rho(Y)
2. **양의 동차성(Positive Homogeneity)**: rho(lambda _ X) = lambda _ rho(X)
3. **이동 불변성(Translation Invariance)**: rho(X + c) = rho(X) - c
4. **부분가법성(Sub-additivity)**: rho(X + Y) \<= rho(X) + rho(Y)
VaR는 4번 부분가법성을 위반하지만, CVaR는 네 가지 공리를 모두 충족하는 coherent risk measure다. 이는 포트폴리오 분산 효과를 올바르게 반영할 수 있음을 의미한다.
Basel III가 ES를 채택한 이유
Basel III FRTB에서 VaR 대신 ES를 채택한 핵심 이유는 다음과 같다.
- **꼬리 리스크 정보 제공**: VaR는 임계점만, ES는 임계 초과 손실의 평균을 제공
- **coherent risk measure**: 부분가법성 충족으로 자본 합산이 일관적
- **97.5% ES 사용 근거**: 연속 분포에서 97.5% ES는 99% VaR와 유사한 보수성을 가지면서도 꼬리 정보를 포함
CVaR 산출 Python 구현
def calculate_cvar(
returns: pd.Series,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000
) -> dict:
"""
Historical Simulation 기반 CVaR(Expected Shortfall)을 계산한다.
CVaR = VaR 임계점 이하 수익률의 평균
"""
var_threshold = np.percentile(returns, (1 - confidence_level) * 100)
VaR 임계점 이하의 수익률만 필터링
tail_losses = returns[returns <= var_threshold]
cvar_pct = tail_losses.mean()
cvar_amount = abs(cvar_pct) * portfolio_value
return {
"var_pct": var_threshold,
"var_amount": abs(var_threshold) * portfolio_value,
"cvar_pct": cvar_pct,
"cvar_amount": cvar_amount,
"tail_count": len(tail_losses),
"total_observations": len(returns),
"tail_ratio": len(tail_losses) / len(returns),
}
다양한 신뢰수준에서 VaR와 CVaR 비교
print(f"{'신뢰수준':>10} {'VaR':>12} {'CVaR':>12} {'CVaR/VaR':>10}")
print("-" * 50)
for cl in [0.90, 0.95, 0.975, 0.99]:
result = calculate_cvar(port_returns, cl)
ratio = result["cvar_amount"] / result["var_amount"]
print(
f"{cl:>10.1%} "
f"{result['var_amount']:>12,.0f} "
f"{result['cvar_amount']:>12,.0f} "
f"{ratio:>10.2f}x"
)
포트폴리오 CVaR 최적화
scipy.optimize.minimize를 활용한 최적화
CVaR 최적화는 포트폴리오의 Expected Shortfall을 최소화하는 자산 배분을 찾는 문제다. 이를 통해 기존 평균-분산(Mean-Variance) 최적화보다 꼬리 리스크에 더 강건한 포트폴리오를 구성할 수 있다.
CVaR 최적화 Python 구현
from scipy.optimize import minimize
def portfolio_cvar(
weights: np.ndarray,
returns_matrix: np.ndarray,
confidence_level: float = 0.95
) -> float:
"""주어진 비중에서 포트폴리오의 CVaR를 계산한다."""
portfolio_returns = returns_matrix @ weights
var_threshold = np.percentile(
portfolio_returns, (1 - confidence_level) * 100
)
cvar = portfolio_returns[portfolio_returns <= var_threshold].mean()
return -cvar # 최소화 문제이므로 부호 반전 (손실을 양수로)
def optimize_min_cvar_portfolio(
prices: pd.DataFrame,
confidence_level: float = 0.95,
target_return: float = None,
min_weight: float = 0.0,
max_weight: float = 1.0
) -> dict:
"""
CVaR를 최소화하는 최적 포트폴리오 비중을 산출한다.
Parameters
----------
prices : 자산별 가격 데이터
confidence_level : CVaR 신뢰수준
target_return : 목표 기대수익률 (None이면 제약 없음)
min_weight : 최소 자산 비중
max_weight : 최대 자산 비중
"""
daily_returns = prices.pct_change().dropna()
returns_matrix = daily_returns.values
n_assets = returns_matrix.shape[1]
asset_names = prices.columns.tolist()
초기 비중: 동일 비중
initial_weights = np.ones(n_assets) / n_assets
제약조건
constraints = [
비중 합계 = 1
{"type": "eq", "fun": lambda w: np.sum(w) - 1.0},
]
목표 수익률 제약 (선택)
if target_return is not None:
mean_returns = daily_returns.mean().values
constraints.append({
"type": "ineq",
"fun": lambda w: w @ mean_returns - target_return
})
비중 범위 제약
bounds = [(min_weight, max_weight)] * n_assets
최적화 실행
result = minimize(
portfolio_cvar,
initial_weights,
args=(returns_matrix, confidence_level),
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 1000, "ftol": 1e-10}
)
optimal_weights = result.x
opt_port_returns = returns_matrix @ optimal_weights
최적 포트폴리오 메트릭 계산
opt_var = np.percentile(opt_port_returns, (1 - confidence_level) * 100)
opt_cvar = opt_port_returns[opt_port_returns <= opt_var].mean()
return {
"weights": dict(zip(asset_names, optimal_weights.round(4))),
"annual_return": float(np.mean(opt_port_returns) * 252),
"annual_volatility": float(np.std(opt_port_returns) * np.sqrt(252)),
"var_95": float(opt_var),
"cvar_95": float(opt_cvar),
"optimization_success": result.success,
"sharpe_ratio": float(
(np.mean(opt_port_returns) * 252)
/ (np.std(opt_port_returns) * np.sqrt(252))
),
}
실행 예시
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN", "BRK-B", "JNJ", "JPM", "V"]
prices = fetch_portfolio_data(tickers)
opt_result = optimize_min_cvar_portfolio(
prices,
confidence_level=0.95,
min_weight=0.05,
max_weight=0.30
)
print("=== CVaR 최소화 최적 포트폴리오 ===")
print(f"최적화 성공: {opt_result['optimization_success']}")
print(f"\n자산별 비중:")
for asset, weight in opt_result["weights"].items():
bar = "█" * int(weight * 50)
print(f" {asset:>6}: {weight:>7.2%} {bar}")
print(f"\n연환산 기대수익률: {opt_result['annual_return']:.2%}")
print(f"연환산 변동성: {opt_result['annual_volatility']:.2%}")
print(f"샤프 비율: {opt_result['sharpe_ratio']:.3f}")
print(f"95% 1-Day VaR: {opt_result['var_95']:.4%}")
print(f"95% 1-Day CVaR: {opt_result['cvar_95']:.4%}")
Efficient Frontier with CVaR
기존 Mean-Variance의 Efficient Frontier와 유사하게, CVaR 기준의 효율적 투자선을 구성할 수 있다. 다양한 목표 수익률에 대해 CVaR를 최소화하는 포트폴리오를 연결하면 된다.
def generate_cvar_efficient_frontier(
prices: pd.DataFrame,
n_points: int = 30,
confidence_level: float = 0.95
) -> pd.DataFrame:
"""CVaR 기준 Efficient Frontier를 생성한다."""
daily_returns = prices.pct_change().dropna()
mean_returns = daily_returns.mean()
목표 수익률 범위 설정
min_ret = mean_returns.min()
max_ret = mean_returns.max()
target_returns = np.linspace(min_ret * 0.5, max_ret * 1.2, n_points)
frontier_data = []
for target_ret in target_returns:
try:
result = optimize_min_cvar_portfolio(
prices,
confidence_level=confidence_level,
target_return=target_ret,
min_weight=0.0,
max_weight=0.40
)
if result["optimization_success"]:
frontier_data.append({
"target_return": target_ret * 252,
"annual_return": result["annual_return"],
"annual_volatility": result["annual_volatility"],
"cvar_95": abs(result["cvar_95"]),
"sharpe_ratio": result["sharpe_ratio"],
})
except Exception:
continue
return pd.DataFrame(frontier_data)
frontier_df = generate_cvar_efficient_frontier(prices)
print("=== CVaR Efficient Frontier (상위 10개 포인트) ===")
print(frontier_df.sort_values("sharpe_ratio", ascending=False).head(10).to_string())
리스크 모니터링 대시보드
일별 VaR/CVaR 추이 시각화
실무에서는 VaR와 CVaR를 일별로 산출하고, 실제 손익(P&L)과 비교하는 **롤링 리스크 모니터링**이 필수적이다. VaR를 초과하는 날(breach)의 빈도가 이론적 기대치와 일치하는지를 검증하는 것이 백테스트의 핵심이다.
리스크 대시보드 Python 구현
def build_risk_dashboard(
prices: pd.DataFrame,
weights: np.ndarray,
window: int = 252,
confidence_level: float = 0.95,
portfolio_value: float = 1_000_000_000
) -> pd.DataFrame:
"""
롤링 VaR/CVaR 대시보드 데이터를 생성한다.
"""
daily_returns = prices.pct_change().dropna()
port_returns = daily_returns.dot(weights)
dashboard = pd.DataFrame(index=port_returns.index[window:])
dashboard["actual_return"] = port_returns.iloc[window:].values
rolling_var = []
rolling_cvar = []
for i in range(window, len(port_returns)):
hist_window = port_returns.iloc[i - window:i]
var_val = np.percentile(
hist_window, (1 - confidence_level) * 100
)
cvar_val = hist_window[hist_window <= var_val].mean()
rolling_var.append(var_val)
rolling_cvar.append(cvar_val)
dashboard["var"] = rolling_var
dashboard["cvar"] = rolling_cvar
dashboard["breach"] = dashboard["actual_return"] < dashboard["var"]
return dashboard
def plot_risk_dashboard(dashboard: pd.DataFrame) -> None:
"""리스크 대시보드를 시각화한다."""
fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)
1. 실제 수익률 vs VaR/CVaR
ax1 = axes[0]
ax1.plot(
dashboard.index, dashboard["actual_return"],
color="steelblue", alpha=0.6, linewidth=0.8,
label="Actual Return"
)
ax1.plot(
dashboard.index, dashboard["var"],
color="orange", linewidth=1.2, label="95% VaR"
)
ax1.plot(
dashboard.index, dashboard["cvar"],
color="red", linewidth=1.2, label="95% CVaR"
)
VaR breach 표시
breach_dates = dashboard[dashboard["breach"]].index
breach_returns = dashboard.loc[breach_dates, "actual_return"]
ax1.scatter(
breach_dates, breach_returns,
color="red", s=20, zorder=5, label="VaR Breach"
)
ax1.set_ylabel("Daily Return")
ax1.set_title("Risk Dashboard: Actual Returns vs VaR/CVaR")
ax1.legend(loc="lower left")
ax1.axhline(y=0, color="gray", linestyle="--", linewidth=0.5)
2. 롤링 VaR 추이
ax2 = axes[1]
ax2.fill_between(
dashboard.index,
dashboard["cvar"], dashboard["var"],
alpha=0.3, color="red", label="VaR-CVaR Gap"
)
ax2.plot(
dashboard.index, dashboard["var"],
color="orange", label="95% VaR"
)
ax2.plot(
dashboard.index, dashboard["cvar"],
color="red", label="95% CVaR"
)
ax2.set_ylabel("Risk Level")
ax2.set_title("Rolling VaR & CVaR Trend")
ax2.legend()
3. 누적 VaR Breach 비율
ax3 = axes[2]
cumulative_breach = dashboard["breach"].cumsum()
cumulative_count = range(1, len(dashboard) + 1)
breach_ratio = cumulative_breach / cumulative_count
ax3.plot(
dashboard.index, breach_ratio,
color="red", linewidth=1.2, label="Cumulative Breach Ratio"
)
ax3.axhline(
y=0.05, color="gray", linestyle="--",
label="Expected 5% (95% CL)"
)
ax3.set_ylabel("Breach Ratio")
ax3.set_title("Cumulative VaR Breach Ratio")
ax3.legend()
ax3.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
plt.tight_layout()
plt.savefig("risk_dashboard.png", dpi=150, bbox_inches="tight")
plt.show()
실행
tickers = ["AAPL", "MSFT", "GOOGL", "AMZN"]
weights = np.array([0.25, 0.25, 0.25, 0.25])
prices = fetch_portfolio_data(tickers)
dashboard = build_risk_dashboard(prices, weights, window=252)
plot_risk_dashboard(dashboard)
서머리 통계
total_days = len(dashboard)
breach_count = dashboard["breach"].sum()
breach_pct = breach_count / total_days
print(f"\n=== 리스크 대시보드 요약 ===")
print(f"분석 기간: {dashboard.index[0].strftime('%Y-%m-%d')} ~ "
f"{dashboard.index[-1].strftime('%Y-%m-%d')}")
print(f"총 거래일: {total_days}일")
print(f"VaR Breach 횟수: {breach_count}회")
print(f"Breach 비율: {breach_pct:.2%} (이론적 기대치: 5.00%)")
한도 초과 알림 시스템
실무에서는 VaR breach가 발생하거나 CVaR가 임계값을 넘을 때 즉시 알림을 보내야 한다. 아래는 간단한 알림 로직이다.
from dataclasses import dataclass
from datetime import datetime
@dataclass
class RiskAlert:
alert_type: str
severity: str # "WARNING", "CRITICAL"
metric_name: str
current_value: float
threshold: float
timestamp: datetime
message: str
def check_risk_limits(
dashboard: pd.DataFrame,
var_limit: float = -0.03,
cvar_limit: float = -0.05,
max_consecutive_breaches: int = 3
) -> list[RiskAlert]:
"""리스크 한도 초과 여부를 점검하고 알림을 생성한다."""
alerts = []
latest = dashboard.iloc[-1]
now = datetime.now()
1. VaR 한도 초과 점검
if latest["var"] < var_limit:
alerts.append(RiskAlert(
alert_type="VAR_LIMIT_BREACH",
severity="WARNING",
metric_name="95% VaR",
current_value=latest["var"],
threshold=var_limit,
timestamp=now,
message=f"VaR가 한도를 초과했습니다: "
f"현재 {latest['var']:.4%} < 한도 {var_limit:.4%}"
))
2. CVaR 한도 초과 점검
if latest["cvar"] < cvar_limit:
alerts.append(RiskAlert(
alert_type="CVAR_LIMIT_BREACH",
severity="CRITICAL",
metric_name="95% CVaR",
current_value=latest["cvar"],
threshold=cvar_limit,
timestamp=now,
message=f"CVaR가 한도를 초과했습니다: "
f"현재 {latest['cvar']:.4%} < 한도 {cvar_limit:.4%}"
))
3. 연속 breach 점검
recent_breaches = dashboard["breach"].tail(max_consecutive_breaches)
if recent_breaches.all():
alerts.append(RiskAlert(
alert_type="CONSECUTIVE_BREACHES",
severity="CRITICAL",
metric_name="VaR Breach Streak",
current_value=float(max_consecutive_breaches),
threshold=float(max_consecutive_breaches),
timestamp=now,
message=f"VaR breach가 {max_consecutive_breaches}일 "
f"연속 발생했습니다. 즉시 점검이 필요합니다."
))
return alerts
실패 사례와 교훈
사례 1: LTCM - VaR 한도만 의존하다 꼬리 리스크 발생
1998년 Long-Term Capital Management(LTCM)의 붕괴는 VaR 의존의 대표적 실패 사례다. LTCM은 노벨 경제학상 수상자 2명을 포함한 최고의 퀀트 팀을 보유했지만, 다음과 같은 문제가 있었다.
- 정규분포 가정에 기반한 VaR 모형을 사용하여 극단적 시나리오를 과소평가
- 러시아 디폴트로 인한 "비정상적" 상관관계 급등을 모형이 반영하지 못함
- 높은 레버리지(25:1)로 인해 작은 모형 오류도 치명적 결과 초래
- 최종적으로 36억 달러 규모의 구제금융 필요
**교훈**: VaR는 "정상적" 시장 상황에서의 리스크 지표일 뿐이다. 스트레스 테스트, CVaR, 시나리오 분석을 반드시 병행해야 한다.
사례 2: Monte Carlo 시뮬레이션 시드 고정 실수
한 퀀트 팀에서 Monte Carlo VaR를 산출할 때 random seed를 고정한 채로 프로덕션에 배포했다. 매일 동일한 시나리오가 생성되면서 VaR 숫자가 시장 변화와 무관하게 거의 변동하지 않았고, 위험 신호를 포착하지 못했다.
- **잘못된 코드**: `np.random.seed(42)`를 모든 일별 산출에 동일하게 적용
- **올바른 접근**: 시드는 백테스트/검증 시에만 고정하고, 프로덕션에서는 제거하거나 날짜 기반으로 변경
잘못된 방식: 매일 같은 시나리오 생성
np.random.seed(42) # 프로덕션에서 이렇게 하면 안 됨
올바른 방식: 날짜 기반 시드 또는 시드 미사용
date_str = datetime.now().strftime("%Y%m%d")
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % (2**32)
rng = np.random.default_rng(seed)
교훈과 방어 전략
1. **단일 지표 의존 금지**: VaR, CVaR, 스트레스 테스트를 함께 모니터링
2. **모형 검증 필수**: Kupiec POF Test, Christoffersen Test 등으로 정기 백테스트
3. **시뮬레이션 재현성 vs 다양성**: 검증 시에만 시드 고정, 프로덕션에서는 충분한 시나리오 다양성 확보
4. **극단적 시나리오 대비**: 과거 위기 기간 데이터를 별도로 스트레스 테스트에 활용
운영 시 주의사항
데이터 품질과 수익률 계산 방법
수익률 계산에는 크게 두 가지 방식이 있다.
- **산술 수익률(Arithmetic Return)**: `(P_t - P_{t-1}) / P_{t-1}` -- 단순하지만 복리 효과 미반영
- **로그 수익률(Log Return)**: `ln(P_t / P_{t-1})` -- 시간 가법성 보유, 정규분포 가정과 호환
VaR 산출 시 어떤 수익률을 사용하는지에 따라 결과가 달라질 수 있으므로, 조직 내 표준을 정하고 일관되게 적용해야 한다. 또한 배당 조정, 분할(split) 처리, 결측치 보간 방법도 사전에 정의해야 한다.
백테스트 검증: Kupiec POF Test
Kupiec의 Proportion of Failures(POF) Test는 VaR 모형의 breach 빈도가 이론적 기대치와 통계적으로 유의하게 다른지를 검증한다.
def kupiec_pof_test(
actual_returns: pd.Series,
var_series: pd.Series,
confidence_level: float = 0.95
) -> dict:
"""
Kupiec POF(Proportion of Failures) Test를 수행한다.
귀무가설: 실제 breach 비율 = 이론적 기대 비율 (1 - confidence_level)
"""
n = len(actual_returns)
breaches = (actual_returns < var_series).sum()
p_expected = 1 - confidence_level
p_observed = breaches / n
우도비(Likelihood Ratio) 통계량
if breaches == 0 or breaches == n:
lr_stat = 0.0
else:
lr_stat = -2 * (
np.log((1 - p_expected)**(n - breaches) * p_expected**breaches)
- np.log(
(1 - p_observed)**(n - breaches) * p_observed**breaches
)
)
카이제곱 분포 (자유도 1) 기반 p-value
p_value = 1 - stats.chi2.cdf(lr_stat, df=1)
return {
"total_observations": n,
"expected_breaches": int(n * p_expected),
"actual_breaches": int(breaches),
"expected_ratio": p_expected,
"observed_ratio": p_observed,
"lr_statistic": lr_stat,
"p_value": p_value,
"reject_h0_5pct": p_value < 0.05,
"model_status": "PASS" if p_value >= 0.05 else "FAIL",
}
백테스트 실행
backtest_result = kupiec_pof_test(
dashboard["actual_return"],
dashboard["var"],
confidence_level=0.95
)
print("=== Kupiec POF Test 결과 ===")
print(f"총 관측일: {backtest_result['total_observations']}")
print(f"기대 breach: {backtest_result['expected_breaches']}회 "
f"({backtest_result['expected_ratio']:.1%})")
print(f"실제 breach: {backtest_result['actual_breaches']}회 "
f"({backtest_result['observed_ratio']:.2%})")
print(f"LR 통계량: {backtest_result['lr_statistic']:.4f}")
print(f"p-value: {backtest_result['p_value']:.4f}")
print(f"모형 판정 (5% 유의수준): {backtest_result['model_status']}")
규제 보고와 내부 리스크 관리의 차이
| 구분 | 규제 보고 (Basel FRTB) | 내부 리스크 관리 |
| ------------- | ----------------------------------------- | --------------------------------- |
| **주요 지표** | 97.5% ES (Expected Shortfall) | VaR, CVaR, Stress VaR, Greeks |
| **보유 기간** | 유동성 수평선에 따라 10일~120일 | 1일 (트레이딩), 10일~1개월 (투자) |
| **관측 기간** | 최근 1년 + 스트레스 기간 | 조직 기준에 따라 유연 |
| **백테스트** | 데스크 단위 의무 (Kupiec + Traffic Light) | 포트폴리오/전략 단위 자체 기준 |
| **보고 주기** | 일별 의무 보고 | 실시간~일별 (내부 시스템) |
| **모형 변경** | 규제 승인 필요 (수개월 소요) | 내부 검증 후 즉시 반영 가능 |
마무리
이 글에서 다룬 내용을 정리하면 다음과 같다.
1. **VaR 3가지 방법론**: Historical, Parametric, Monte Carlo 각각의 이론과 Python 구현
2. **CVaR(Expected Shortfall)**: VaR의 한계를 보완하는 coherent risk measure로서의 이론적 우위
3. **포트폴리오 CVaR 최적화**: scipy.optimize를 활용한 꼬리 리스크 최소화 포트폴리오 구성
4. **리스크 모니터링**: 롤링 VaR/CVaR 대시보드, 한도 초과 알림, 백테스트 검증
5. **실패 사례와 교훈**: LTCM 사례, 시뮬레이션 시드 문제 등 실무 함정
리스크 관리에서 가장 중요한 원칙은 **"어떤 단일 지표도 완벽하지 않다"**는 것이다. VaR, CVaR, 스트레스 테스트, 시나리오 분석을 종합적으로 활용하고, 정기적인 백테스트로 모형을 검증하며, 극단적 상황에 대한 방어 전략을 미리 수립해야 한다. 이 글의 코드가 여러분의 리스크 관리 시스템 구축에 실질적인 도움이 되기를 바란다.
참고자료
- [Risk Metrics in Python: VaR and CVaR Guide | IBKR](https://www.interactivebrokers.com/campus/ibkr-quant-news/risk-metrics-in-python-var-and-cvar-guide/)
- [scipy.optimize.minimize | SciPy Docs](https://docs.scipy.org/doc/scipy/reference/generated/scipy.optimize.minimize.html)
- [BIS Market Risk Framework](https://www.bis.org/bcbs/publ/d457_inbrief.pdf)
- [Riskfolio-Lib Documentation](https://riskfolio-lib.readthedocs.io/en/latest/)
- [PyPortfolioOpt Documentation](https://pyportfolioopt.readthedocs.io/en/latest/MeanVariance.html)
- [From Basel III to Basel IV | ScienceDirect](https://www.sciencedirect.com/science/article/abs/pii/S1057521923001618)
- [Monte Carlo VaR | The Quant Journey](https://medium.com/the-quant-journey/monte-carlo-methods-for-risk-management-var-estimation-in-python-1f42d4b0d574)
현재 단락 (1/640)
2008년 글로벌 금융위기는 리스크 관리 실패의 교과서적 사례다. 당시 대부분의 금융기관은 VaR(Value at Risk)를 핵심 리스크 지표로 사용했지만, VaR는 "신뢰수준을...