diff --git a/.agents/AGENT.md b/.agents/AGENT.md index e918b96..905baeb 100644 --- a/.agents/AGENT.md +++ b/.agents/AGENT.md @@ -49,8 +49,8 @@ description: 모든 작업에 자동 적용되는 에이전트 행동 규칙. ## Python Environment -- **경로**: `C:\ProgramData\miniforge3\envs\quant` -- **실행**: `C:\ProgramData\miniforge3\envs\quant\python.exe` +- **경로**: `C:\ProgramData\miniforge3\envs\lifetimePD` +- **실행**: `C:\ProgramData\miniforge3\envs\lifetimePD\python.exe` - 모든 Python 스크립트 실행 시 위 경로의 python을 사용합니다. ## PowerShell Notes diff --git a/docs/devlog/2026-03-26.md b/docs/devlog/2026-03-26.md new file mode 100644 index 0000000..094fcc7 --- /dev/null +++ b/docs/devlog/2026-03-26.md @@ -0,0 +1,5 @@ +# Devlog: 2026-03-26 + +| # | 시간 | 작업 | 커밋 | 상태 | +|---|------|------|------|------| +| 001 | 19:16~21:28 | Python 환경 세팅(lifetimePD) + 확률 가중평균 시나리오 가중치 실증 분석 스크립트 작성 | `pending` | 🔧 | diff --git a/docs/devlog/entries/20260326-001.md b/docs/devlog/entries/20260326-001.md new file mode 100644 index 0000000..d338cf0 --- /dev/null +++ b/docs/devlog/entries/20260326-001.md @@ -0,0 +1,43 @@ +# 확률 가중평균 시나리오 가중치 고도화 - 방법론 설계 및 실증 분석 + +- **시간**: 2026-03-26 19:16~21:28 +- **Commit**: `pending` +- **Vikunja**: 신규 태스크 + +## 결정 사항 + +### 1. 확률 가중치 산출 방법론 + +기존 하드코딩(20/50/30)을 실증 데이터 기반으로 교체하는 방법론을 설계했다. + +- **30/50/20 분할 채택**: Downside 30% / Baseline 50% / Upside 20% + - 신용 손실 함수의 비선형성(convexity) 반영 -> 불황에 더 큰 비중 + - 분위수 경계: 30th percentile, 80th percentile + +- **12개월 롤링 연환산**: 월간 데이터를 연간 베타와 같은 단위로 변환 + - 원유(WTI): log(price) + - 순상품교역조건지수: 12개월 수익률 + - KOSPI: 12개월 log수익률 + +- **Virtual Zt 구성**: 연간 회귀 beta를 월별 롤링 데이터에 적용 (300개 표본) + +- **정규분포 면적으로 실증 가중치 산출**: 경험적 분위수 경계를 정규분포에 투영 + +### 2. 검토 후 기각된 방법들 + +- 3차 가우시안 AND 조건: 확률이 3% 수준으로 너무 작음 +- PCA 제1주성분: 변수 상관이 약해(|rho|=0.06~0.18) 설명력 낮음 +- 변수별 독립 평균: 단순하여 근거 부족 + +### 3. 실증 분석 결과 (beta: -0.8037, -10.8856, 3.2878 / const: 2.8575) + +- Virtual Zt: mu=0.016, sigma=0.94 (min=-2.54 @ 2009/06, max=+2.21 @ 2005/12) +- 정규분포 면적 가중치: Downside 27.2% / Baseline 56.4% / Upside 16.5% +- Jarque-Bera p=0.085 -> 정규성 기각 못함 -> 정규분포 가정 유효 + +## 미완료 + +- tmp_analysis.py를 정식 모듈로 이동/통합 (scenarios/ 또는 projection/) +- 산출된 가중치(27/56/17)를 config.yaml에 반영하고 실제 Lifetime PD 재산출 +- 방법론을 docs/methodology.md에 추가 (신규 섹션) +- AGENT.md의 Python 환경이 lifetimePD로 변경됨 -> architecture.md도 동기화 필요 diff --git a/tmp_analysis.py b/tmp_analysis.py new file mode 100644 index 0000000..89a2180 --- /dev/null +++ b/tmp_analysis.py @@ -0,0 +1,316 @@ +""" +================================================================================ +확률 가중평균 시나리오 가중치 실증 분석 +================================================================================ + +[목적] +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, + )