Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

들어가며

리스크 관리의 중요성: 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는 "신뢰수준을...

작성 글자: 0원문 글자: 22,671작성 단락: 0/640