""" ================================================================================ 확률 가중평균 시나리오 가중치 실증 분석 ================================================================================ [목적] 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, )