Skip to content
Published on

정량적 리스크 관리 실전 가이드: VaR·CVaR·포트폴리오 리스크 메트릭 Python 구현

Authors
  • Name
    Twitter
정량적 리스크 관리

들어가며

리스크 관리의 중요성: 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 구현

import numpy as np
import pandas as pd
import yfinance as yf
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 SimulationParametricMonte 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 구현

import matplotlib.pyplot as plt
import matplotlib.dates as mdates

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)  # 프로덕션에서 이렇게 하면 안 됨

# 올바른 방식: 날짜 기반 시드 또는 시드 미사용
import hashlib
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, 스트레스 테스트, 시나리오 분석을 종합적으로 활용하고, 정기적인 백테스트로 모형을 검증하며, 극단적 상황에 대한 방어 전략을 미리 수립해야 한다. 이 글의 코드가 여러분의 리스크 관리 시스템 구축에 실질적인 도움이 되기를 바란다.


참고자료