317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""
|
|
================================================================================
|
|
확률 가중평균 시나리오 가중치 실증 분석
|
|
================================================================================
|
|
|
|
[목적]
|
|
IFRS 9 ECL 산출 시 복수 시나리오(호황/중립/불황)의 확률 가중치를 실증 데이터
|
|
기반으로 도출한다. 기존 하드코딩(20/50/30)을 대체할 객관적 가중치를 산출하고,
|
|
그 적정성을 통계적으로 검증한다.
|
|
|
|
[방법론 개요]
|
|
|
|
1. 시나리오 비율 선정: Downside 30% / Baseline 50% / Upside 20%
|
|
|
|
(1) 30/50/20 비율의 근거
|
|
- IFRS 9은 ECL을 "편향 없는(unbiased) 확률가중 금액"으로 측정하도록
|
|
요구하며(5.5.17), 복수 시나리오의 확률가중을 통해 산출한다.
|
|
- IFRS 9은 특정 시나리오 개수나 가중치 비율을 강제하지 않으므로,
|
|
각 기관이 자체 근거를 문서화하여 결정해야 한다.
|
|
- 30/50/20 분할은 다음 논리에 기반한다:
|
|
a) Downside에 Upside보다 더 큰 비중(30% > 20%)을 배분하는 것은
|
|
신용 손실 함수의 비선형성(convexity)을 반영한다.
|
|
-> PD는 경기 악화 시 급증하고 호황 시에는 완만히 감소한다.
|
|
-> 따라서 Downside 시나리오의 ECL 기여분이 Upside보다 크므로,
|
|
대칭 가중(25/50/25)보다 불황 쪽에 더 큰 비중을 두는 것이
|
|
보수적 추정(prudent estimation) 원칙에 부합한다.
|
|
b) Baseline이 50%로 가장 큰 비중을 차지하는 것은,
|
|
"가장 가능성 높은 시나리오"에 절반의 확률을 배정하는 것으로
|
|
합리적인 중심 시나리오 설정이다.
|
|
c) 불황(30%)과 호황(20%)의 합이 50%로, 양방향 테일을 균형있게
|
|
반영하되 하방 리스크에 더 무게를 둔다.
|
|
|
|
(2) 분위수에서의 의미
|
|
- 30/50/20은 연속 확률분포상의 영역 분할으로 해석할 수 있다:
|
|
Downside: 0 ~ 30th percentile (하위 30%)
|
|
Baseline: 30th ~ 80th percentile (중간 50%)
|
|
Upside: 80th ~ 100th percentile (상위 20%)
|
|
- 30th percentile과 80th percentile이 경계가 된다.
|
|
|
|
2. 가상 Zt(Virtual Zt) 구성
|
|
|
|
(1) 월간 거시경제변수 3종의 12개월 롤링 연환산:
|
|
- 원유(WTI): log(price) -- 수준(level) 변수
|
|
- 순상품교역조건지수: 12개월 수익률 (T(t)/T(t-12) - 1)
|
|
- KOSPI 평균: 12개월 log 수익률 (ln(T(t)/T(t-12)))
|
|
|
|
(2) 12개월 롤링으로 변환하는 이유:
|
|
- AR(1) 거시연계 모형의 beta 계수는 연간 데이터에서 추정되었으므로,
|
|
동일한 단위(연간 수익률)로 변환해야 beta를 적용할 수 있다.
|
|
- 월간 데이터를 그대로 사용하면 주기와 스케일이 맞지 않아
|
|
beta 적용이 불가능하다.
|
|
|
|
(3) beta 계수 적용:
|
|
- 연간 Zt ~ 거시변수 회귀에서 추정된 beta를 사용한다.
|
|
- Virtual_Zt(t) = const + b1 * X1(t) + b2 * X2(t) + b3 * X3(t)
|
|
- 이렇게 하면 월별로 하나의 Virtual Zt값이 생성된다.
|
|
- 300개월의 Virtual Zt -> 시나리오 비중 분석에 충분한 표본.
|
|
|
|
3. 정규분포 기반 확률 가중치 산출
|
|
|
|
(1) 경험적 분위수로 경계값 산출:
|
|
- q30 = Virtual Zt의 30th percentile 값
|
|
- q80 = Virtual Zt의 80th percentile 값
|
|
- 이 경계값은 30/50/20 분할에서 유래한다.
|
|
|
|
(2) Virtual Zt에 정규분포 N(mu, sigma)를 피팅:
|
|
- 정규분포 가정 하에, 경험적 분위수 경계의 "이론적" 면적을 산출한다.
|
|
- 데이터가 완벽한 정규분포이면 면적은 정확히 30/50/20이 된다.
|
|
- 실제 데이터의 비대칭성(skewness)이나 꼬리 두께(kurtosis)로 인해
|
|
경험적 분위수의 위치가 정규분포상의 이론적 위치와 다르면,
|
|
면적이 30/50/20에서 이탈한다.
|
|
- 이 이탈(gap)이 "실증 데이터 기반 시나리오 가중치"이다.
|
|
|
|
(3) 정규분포 가정의 적정성:
|
|
- Jarque-Bera 정규성 검정으로 가정의 유효성을 확인한다.
|
|
- 기각되지 않으면(p > 0.05) 정규분포 기반 면적을 가중치로 사용한다.
|
|
- 기각되면 경험적 빈도(= 정의상 30/50/20)를 유지하거나,
|
|
비모수적 방법(KDE 등)을 검토한다.
|
|
|
|
4. 결과 해석
|
|
|
|
- 정규분포 면적이 30/50/20에서 이탈한 정도가 곧
|
|
"실측 데이터에 기반한 보정된 시나리오 가중치"이다.
|
|
- 예: 정규분포 면적이 27/56/17이면, 실제 데이터의 분포 특성상
|
|
하드코딩 30/50/20보다 중립 비중이 높고 테일 비중이 낮다는 의미이다.
|
|
- 이 가중치는 분기/연간으로 갱신 가능하며,
|
|
감사(audit) 시 "과거 N개월 거시데이터의 분포에서 도출"이라는
|
|
정량적 근거를 제시할 수 있다.
|
|
|
|
================================================================================
|
|
"""
|
|
|
|
import sys, io
|
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
|
|
|
import numpy as np
|
|
from scipy.stats import norm, jarque_bera
|
|
|
|
|
|
def analyze_scenario_weights(
|
|
data_lists,
|
|
betas,
|
|
const,
|
|
var_names=None,
|
|
pct_down=30,
|
|
pct_up=80,
|
|
):
|
|
"""
|
|
확률 가중평균 시나리오 가중치 실증 분석
|
|
|
|
Parameters
|
|
----------
|
|
data_lists : list of array-like
|
|
3개 거시변수의 변환된 시계열 데이터 (동일 길이).
|
|
예: [oil_log, tot_ann_ret, kospi_ann_logret]
|
|
각 리스트는 12개월 롤링 연환산 등 이미 변환 완료된 상태여야 한다.
|
|
|
|
betas : list of float
|
|
3개 거시변수의 회귀 계수.
|
|
예: [-0.8037, -10.8856, 3.2878]
|
|
|
|
const : float
|
|
회귀 상수항.
|
|
예: 2.8575
|
|
|
|
var_names : list of str, optional
|
|
변수 이름 (출력용). 기본값: ["X1", "X2", "X3"]
|
|
|
|
pct_down : float
|
|
Downside/Baseline 경계 분위수 (기본: 30)
|
|
|
|
pct_up : float
|
|
Baseline/Upside 경계 분위수 (기본: 80)
|
|
|
|
Returns
|
|
-------
|
|
dict
|
|
분석 결과를 담은 딕셔너리:
|
|
- 'virtual_zt': Virtual Zt 배열
|
|
- 'weights_empirical': 경험적 가중치 {'down': ..., 'base': ..., 'up': ...}
|
|
- 'weights_normal': 정규분포 면적 기반 가중치
|
|
- 'scenario_zt': 시나리오별 평균 Zt
|
|
- 'normality_pvalue': Jarque-Bera p-value
|
|
- 'q_boundaries': (q_low, q_high) 분위수 경계값
|
|
"""
|
|
if var_names is None:
|
|
var_names = ["X1", "X2", "X3"]
|
|
|
|
# ========================================================================
|
|
# 1. Virtual Zt 생성
|
|
# - beta 계수를 각 변수에 적용하여 월별 가상 Zt를 산출
|
|
# ========================================================================
|
|
X = np.column_stack(data_lists)
|
|
beta_arr = np.array(betas)
|
|
Z_v = const + X @ beta_arr
|
|
|
|
n = len(Z_v)
|
|
print(f'=== Virtual Zt ({n} observations) ===')
|
|
print(f'mu = {Z_v.mean():.4f}')
|
|
print(f'sigma = {Z_v.std():.4f}')
|
|
print(f'min = {Z_v.min():.4f}')
|
|
print(f'max = {Z_v.max():.4f}')
|
|
print()
|
|
|
|
# Sanity check: top/bottom 5
|
|
sorted_idx = np.argsort(Z_v)
|
|
print('Bottom 5 (recession):')
|
|
for i in sorted_idx[:5]:
|
|
parts = ' '.join(f'{var_names[j]}={X[i,j]:.4f}' for j in range(3))
|
|
print(f' [{i:3d}] Zt={Z_v[i]:+.4f} {parts}')
|
|
print('Top 5 (boom):')
|
|
for i in sorted_idx[-5:][::-1]:
|
|
parts = ' '.join(f'{var_names[j]}={X[i,j]:.4f}' for j in range(3))
|
|
print(f' [{i:3d}] Zt={Z_v[i]:+.4f} {parts}')
|
|
print()
|
|
|
|
# ========================================================================
|
|
# 2. 분위수 경계 설정
|
|
# ========================================================================
|
|
q_low = np.percentile(Z_v, pct_down)
|
|
q_high = np.percentile(Z_v, pct_up)
|
|
|
|
pct_base = pct_up - pct_down
|
|
pct_up_width = 100 - pct_up
|
|
|
|
print(f'=== Percentile boundaries ({pct_down}/{pct_base}/{pct_up_width}) ===')
|
|
print(f'q{pct_down} = {q_low:.4f} (downside/baseline)')
|
|
print(f'q{pct_up} = {q_high:.4f} (baseline/upside)')
|
|
print()
|
|
|
|
# ========================================================================
|
|
# 3. 경험적 빈도 (정의상 pct_down / pct_base / pct_up_width)
|
|
# ========================================================================
|
|
w_d = np.mean(Z_v < q_low)
|
|
w_m = np.mean((Z_v >= q_low) & (Z_v <= q_high))
|
|
w_u = np.mean(Z_v > q_high)
|
|
|
|
print(f'=== Empirical frequency (sanity check) ===')
|
|
print(f'Downside: {w_d*100:.1f}%')
|
|
print(f'Baseline: {w_m*100:.1f}%')
|
|
print(f'Upside: {w_u*100:.1f}%')
|
|
print()
|
|
|
|
# ========================================================================
|
|
# 4. 정규분포 피팅 -> 면적 산출 (실증 가중치)
|
|
# ========================================================================
|
|
mu_z = Z_v.mean()
|
|
sig_z = Z_v.std()
|
|
p_d = norm.cdf(q_low, mu_z, sig_z)
|
|
p_u = 1 - norm.cdf(q_high, mu_z, sig_z)
|
|
p_m = 1 - p_d - p_u
|
|
|
|
print(f'=== Normal N({mu_z:.4f}, {sig_z:.4f}) area ===')
|
|
print(f'Downside (Z < {q_low:.4f}): {p_d*100:.2f}%')
|
|
print(f'Baseline ({q_low:.4f} ~ {q_high:.4f}): {p_m*100:.2f}%')
|
|
print(f'Upside (Z > {q_high:.4f}): {p_u*100:.2f}%')
|
|
print()
|
|
|
|
print(f'=== KEY RESULT ===')
|
|
print(f'Downside: empirical {pct_down:.0f}% vs normal {p_d*100:.2f}%')
|
|
print(f'Baseline: empirical {pct_base:.0f}% vs normal {p_m*100:.2f}%')
|
|
print(f'Upside: empirical {pct_up_width:.0f}% vs normal {p_u*100:.2f}%')
|
|
print()
|
|
|
|
# ========================================================================
|
|
# 5. 시나리오별 대표 Zt
|
|
# ========================================================================
|
|
z_down = Z_v[Z_v < q_low].mean()
|
|
z_base = Z_v[(Z_v >= q_low) & (Z_v <= q_high)].mean()
|
|
z_up = Z_v[Z_v > q_high].mean()
|
|
|
|
print(f'=== Scenario Zt levels ===')
|
|
print(f'Downside avg Zt = {z_down:.4f}')
|
|
print(f'Baseline avg Zt = {z_base:.4f}')
|
|
print(f'Upside avg Zt = {z_up:.4f}')
|
|
print()
|
|
|
|
# ========================================================================
|
|
# 6. 정규성 검정 (Jarque-Bera)
|
|
# ========================================================================
|
|
jb_stat, jb_p = jarque_bera(Z_v)
|
|
print(f'=== Normality test (Jarque-Bera) ===')
|
|
print(f'JB stat = {jb_stat:.2f}, p-value = {jb_p:.4f}')
|
|
if jb_p < 0.05:
|
|
print('-> REJECT normality (p < 0.05) -> empirical quantile preferred')
|
|
else:
|
|
print('-> Cannot reject normality -> normal assumption OK')
|
|
print()
|
|
|
|
return {
|
|
'virtual_zt': Z_v,
|
|
'weights_empirical': {
|
|
'down': w_d, 'base': w_m, 'up': w_u
|
|
},
|
|
'weights_normal': {
|
|
'down': p_d, 'base': p_m, 'up': p_u
|
|
},
|
|
'scenario_zt': {
|
|
'down': z_down, 'base': z_base, 'up': z_up
|
|
},
|
|
'normality_pvalue': jb_p,
|
|
'q_boundaries': (q_low, q_high),
|
|
}
|
|
|
|
|
|
# ============================================================================
|
|
# 사용 예시
|
|
# ============================================================================
|
|
if __name__ == '__main__':
|
|
import pandas as pd
|
|
import warnings
|
|
warnings.filterwarnings('ignore')
|
|
|
|
# --- 데이터 로딩 ---
|
|
df = pd.read_excel(
|
|
r'C:\Users\Certes\Downloads\ECOS데이터조회(월기준)_20260317_3개_수정본.xlsx',
|
|
sheet_name='데이터'
|
|
)
|
|
cols = df.columns.tolist()
|
|
item_col = cols[2]
|
|
month_cols = [c for c in cols if '/' in str(c)]
|
|
|
|
data = {}
|
|
for _, row in df.iterrows():
|
|
name = str(row[item_col]).strip()
|
|
vals = pd.Series(row[month_cols].values.astype(float), index=month_cols)
|
|
data[name] = vals
|
|
|
|
keys = list(data.keys())
|
|
oil = data[[k for k in keys if 'WTI' in k][0]]
|
|
tot = data[[k for k in keys if 'KOSPI' not in k and 'WTI' not in k][0]]
|
|
kospi = data[[k for k in keys if 'KOSPI' in k][0]]
|
|
|
|
# --- 12개월 롤링 연환산 변환 ---
|
|
oil_log = np.log(oil)
|
|
tot_ann_ret = tot / tot.shift(12) - 1
|
|
kospi_ann_logret = np.log(kospi / kospi.shift(12))
|
|
|
|
valid = oil_log.notna() & tot_ann_ret.notna() & kospi_ann_logret.notna()
|
|
months = np.array(month_cols)[valid.values]
|
|
print(f'Period: {months[0]} ~ {months[-1]} ({len(months)} months)')
|
|
print()
|
|
|
|
# --- 분석 실행 ---
|
|
result = analyze_scenario_weights(
|
|
data_lists=[
|
|
oil_log[valid].values,
|
|
tot_ann_ret[valid].values,
|
|
kospi_ann_logret[valid].values,
|
|
],
|
|
betas=[-0.8037, -10.8856, 3.2878],
|
|
const=2.8575,
|
|
var_names=['oil_log', 'tot_ret', 'kospi_logret'],
|
|
pct_down=30,
|
|
pct_up=80,
|
|
)
|