Skip to content
Published on

포트폴리오 리스크 관리: Python으로 구현하는 VaR과 몬테카를로 시뮬레이션 실전 가이드

Authors
  • Name
    Twitter
Portfolio VaR Monte Carlo

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) 불일치: 미국 주식과 유럽 주식의 종가 시점이 다른데 이를 무시하면 상관관계가 왜곡된다.

복구 절차:

  1. VaR 급변 시 즉시 데이터 파이프라인 점검 (누락, 이상치, 중복 여부)
  2. 단일 자산에 대해 수동 계산과 코드 결과를 대조 검증
  3. 알려진 분포(예: 표준정규분포)에서 생성한 합성 데이터로 코드 단위 테스트
  4. 과거 특정 날짜의 VaR를 재현하여 시점별 일관성 확인
  5. 독립적인 제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에 대한 가장 포괄적인 단행본. 방법론, 백테스팅, 규제 적용까지 전 범위를 다룬다.