feat(scenarios): scenario weight empirical analysis script + env update lifetimePD

This commit is contained in:
2026-03-26 21:31:04 +09:00
parent d1ddf06e5d
commit acb232fff4
4 changed files with 366 additions and 2 deletions

View File

@@ -49,8 +49,8 @@ description: 모든 작업에 자동 적용되는 에이전트 행동 규칙.
## Python Environment ## Python Environment
- **경로**: `C:\ProgramData\miniforge3\envs\quant` - **경로**: `C:\ProgramData\miniforge3\envs\lifetimePD`
- **실행**: `C:\ProgramData\miniforge3\envs\quant\python.exe` - **실행**: `C:\ProgramData\miniforge3\envs\lifetimePD\python.exe`
- 모든 Python 스크립트 실행 시 위 경로의 python을 사용합니다. - 모든 Python 스크립트 실행 시 위 경로의 python을 사용합니다.
## PowerShell Notes ## PowerShell Notes

View File

@@ -0,0 +1,5 @@
# Devlog: 2026-03-26
| # | 시간 | 작업 | 커밋 | 상태 |
|---|------|------|------|------|
| 001 | 19:16~21:28 | Python 환경 세팅(lifetimePD) + 확률 가중평균 시나리오 가중치 실증 분석 스크립트 작성 | `pending` | 🔧 |

View File

@@ -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도 동기화 필요

316
tmp_analysis.py Normal file
View File

@@ -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,
)