diff --git a/.agents/references/known-issues.md b/.agents/references/known-issues.md index 6d02ed4..0cd82bd 100644 --- a/.agents/references/known-issues.md +++ b/.agents/references/known-issues.md @@ -75,3 +75,15 @@ - **원인**: Windows Python 3.12 런타임이 파일 맨 앞 `# -*- coding: utf-8 -*-` 이나 `-X utf8` 런타임 플래그도 소스 구문 분석 레벨에서 완벽하게 해석해내지 못하는 Windows 인코딩 고질병 문제 발생. - **해결**: 소스 코드 내에선 (특히 docstring 등 Python 인터프리터가 읽을 영역에서는) `×` 대신 `x` 또는 `by`를 사용하고, `→` 대신 `->` 로 ASCII 코드로 치환하여 작성함 (정규식 치환 등 활용). 한글 자체는 정상적으로 파싱됨. - **주의**: 모델링, 모듈 코드 작성 시 문자열이나 주석 내에 특수 유니코드 기호(×, →, —, §)를 직접 삽입하지 말고 안전한 ASCII 문자로 대체하여 코딩할 것. + +### [2026-03-11] ADF 단위근 검정 — autolag='AIC' 소표본 과적합 +- **증상**: Zt ADF 검정이 `autolag='AIC'`에서 p=0.40 (FAIL), `autolag='BIC'`에서 p=0.0000 (PASS) +- **원인**: AIC는 소표본(N<50)에서 lag를 과다 선택 (N=26에서 lag=8 선택 -> 유효관측치=17 -> 검정력 상실). Hamilton (1994, Ch.17) 참조 +- **해결**: `validation/statistical_tests.py`에서 `adfuller(series, autolag="AIC")` -> `adfuller(series, autolag="BIC")`, BIC는 보수적 lag 선택으로 소표본 적합 (Schwarz 1978) +- **주의**: N<50 시계열 ADF 검정에서 항상 `autolag='BIC'` 사용. AIC는 대표본(N>100)에서만 신뢰할 것 + +### [2026-03-11] KAP 채권 YTM PD Floor — 하드코딩 vs 실계산 혼동 +- **증상**: `DEFAULT_PD_FLOORS` 하드코딩 값(BBB=20bp)과 KAP YTM 기반 실계산값(BBB=93bp)의 차이가 큼 +- **원인**: `pd_floor.py`에 `build_complete_pd_floor_table()` 함수가 존재하나 사용되지 않고, `get_default_pd_floors()`만 호출 +- **해결**: `main.py`와 `generate_report.py` 모두 `build_complete_pd_floor_table()` 호출로 변경. `ytm_fetcher.py` fallback 데이터(2025-12-31)로 오프라인에서도 작동 +- **주의**: PD Floor 변경 시 Zt 재추정 + AR(1) 변수 재탐색 필수. 단, 투자적격등급(AAA-A)은 0% 부도 보정이므로 Zt 전체 분포에 미치는 영향은 미미 diff --git a/config.yaml b/config.yaml index 5925380..0d4fefe 100644 --- a/config.yaml +++ b/config.yaml @@ -26,23 +26,36 @@ model: rho: 0.20 # 신용등급 체계 (한국 3사 공통) rating_grades: ["AAA", "AA", "A", "BBB", "BB", "B", "D"] # 7x7 (CCC제외, Zt추정용) - # 거시 회귀모형 강제 변수 (null이면 stepwise AIC 자동선택) - macro_vars: ["USDKRW", "RETAIL_SALES", "INVEST_RATE"] + # 거시 회귀모형 설정 + macro_method: "ar1_macro" # "ar1_macro" | "stepwise_aic" + macro_vars: ["HOUSING_PRICE", "CREDIT_SPREAD_LAG1", "CURRENT_ACCOUNT_R"] # 시나리오 설정 scenarios: upside: name: "호황 (Upside)" - z_multiplier: 1.0 # Zt = μ + 1.0σ - weight: 0.20 # ECB 방식 확률가중치 + z_multiplier: 1.0 # Z-직접 fallback용 + weight: 0.20 + macro_shocks: # AR(1) 충격 (σ 배수) + HOUSING_PRICE: 1.0 # 주택가격 1σ 상승 (호재) + CREDIT_SPREAD_LAG1: -1.0 # 신용스프레드 1σ 하락 (호재) + CURRENT_ACCOUNT_R: 1.0 # 경상수지변화율 1σ 상승 base: name: "중립 (Base)" z_multiplier: 0.0 weight: 0.50 + macro_shocks: + HOUSING_PRICE: 0.0 + CREDIT_SPREAD_LAG1: 0.0 + CURRENT_ACCOUNT_R: 0.0 downside: name: "불황 (Downside)" - z_multiplier: -1.5 # Fed DFAST 역사적 하위 5% + z_multiplier: -1.5 weight: 0.30 + macro_shocks: + HOUSING_PRICE: -1.5 # 주택가격 1.5σ 하락 (악재) + CREDIT_SPREAD_LAG1: 1.5 # 신용스프레드 1.5σ 상승 (악재) + CURRENT_ACCOUNT_R: -1.5 # 경상수지변화율 1.5σ 하락 # 50년 수렴 메커니즘 convergence: diff --git a/data/cache/macro_ecos.csv b/data/cache/macro_ecos.csv new file mode 100644 index 0000000..06b12a3 --- /dev/null +++ b/data/cache/macro_ecos.csv @@ -0,0 +1,27 @@ +YEAR,GDP_GROWTH,UNEMPLOYMENT,BASE_RATE,CD_RATE,CPI_GROWTH,LEADING_INDEX,GOVT_3Y,GOVT_10Y,CORP_AA,CORP_BBB,IPI,EXPORT,IMPORT_AMT,USDKRW,M2,CSI,KOSPI,IMPORT_PRICE,DISHONOR_RATE,HOUSING_PRICE,HOUSEHOLD_DEBT,FACILITY_INVEST,RETAIL_SALES,CURRENT_ACCOUNT,EMPLOYED,EMPLOYMENT_RATE,OIL_PRICE,COINCIDENT,BSI_MANUF,CONSTRUCTION_DONE,SPI,CONSTR_INVEST_GR,GFCF_GROWTH,SAVING_RATE,INVEST_RATE,TRADE_GNI,MANUF_CAPACITY +2000,8.9,4.4,5.25,7.09,2.3,101.2,8.35,8.55,9.35,11.9,102.5,172268.0,160481.0,1131.0,651.8,101.0,504.0,78.5,0.46,55.2,194.0,62.5,72.0,123.5,2115.0,58.5,26.2,99.8,90.0,56.3,58.0,-1.4,11.4,33.7,31.0,72.5,109.5 +2001,4.5,4.0,4.0,5.34,4.1,99.5,6.7,7.05,8.12,11.27,99.5,150439.0,141098.0,1291.0,736.5,96.5,694.0,73.6,0.28,56.8,225.0,58.5,73.5,80.3,2118.0,59.0,22.8,98.0,82.0,53.8,60.2,5.6,0.6,31.7,29.3,66.3,105.8 +2002,7.4,3.3,4.25,4.99,2.8,102.3,6.06,6.58,7.02,9.75,108.5,162471.0,152126.0,1251.0,816.3,105.0,628.0,72.1,0.18,65.3,306.0,63.2,76.0,53.9,2217.0,60.0,23.7,101.5,92.0,55.2,63.5,6.5,6.7,31.3,29.1,62.4,110.5 +2003,2.9,3.6,3.75,4.24,3.5,98.8,4.93,5.45,5.7,8.97,109.8,193817.0,178827.0,1192.0,879.2,96.0,811.0,81.3,0.12,71.5,360.0,60.5,74.0,119.5,2212.0,59.5,26.8,99.2,85.0,58.0,64.8,10.0,4.0,32.6,30.0,65.0,108.2 +2004,4.9,3.7,3.25,3.77,3.6,100.5,4.11,4.73,4.72,7.53,119.2,253845.0,224463.0,1145.0,935.3,97.0,896.0,90.5,0.08,71.0,394.0,66.5,74.5,284.2,2272.0,59.8,33.5,100.8,88.0,63.5,66.0,1.8,2.1,34.8,30.3,73.5,113.8 +2005,3.9,3.7,3.75,3.81,2.8,101.8,4.27,4.95,4.68,6.51,126.0,284419.0,261238.0,1024.0,1002.7,100.5,1011.0,99.2,0.06,73.5,440.0,68.0,76.5,149.8,2297.0,60.3,49.3,101.2,92.0,66.0,68.5,-0.4,1.9,33.4,29.7,72.5,114.5 +2006,5.2,3.5,4.5,4.72,2.2,102.5,4.83,5.17,5.25,7.08,136.0,325465.0,309383.0,955.0,1089.9,106.0,1434.0,107.8,0.05,80.2,497.0,73.5,78.5,53.9,2334.0,60.9,61.5,102.8,95.0,69.5,71.2,0.5,3.4,32.5,29.6,73.2,115.8 +2007,5.5,3.2,5.0,5.36,2.5,103.1,5.23,5.42,5.7,7.44,144.5,371489.0,356846.0,929.0,1181.6,108.5,1897.0,109.3,0.04,83.5,560.0,78.5,80.0,59.5,2371.0,61.3,68.4,103.5,97.0,72.8,74.0,1.4,4.2,32.4,29.4,77.8,115.2 +2008,2.8,3.2,3.0,5.7,4.7,96.5,5.27,5.57,7.02,10.73,148.2,422007.0,435275.0,1103.0,1263.2,86.0,1124.0,132.5,0.11,84.0,630.0,76.0,79.0,-57.8,2385.0,61.5,94.3,98.5,72.0,74.5,75.5,-2.8,-1.9,31.5,31.2,96.5,112.8 +2009,0.8,3.6,2.0,2.63,2.8,98.2,4.04,4.85,5.8,9.24,140.0,363534.0,323085.0,1276.0,1404.4,85.0,1683.0,104.2,0.1,84.8,694.0,60.5,77.5,328.1,2355.0,60.1,61.8,96.5,68.0,68.2,76.0,0.2,-1.0,31.4,26.3,82.0,102.5 +2010,6.8,3.7,2.5,2.8,2.9,103.0,3.72,4.49,4.66,7.98,161.5,466384.0,425212.0,1156.0,1504.3,107.0,2051.0,115.8,0.06,87.0,776.0,80.5,80.5,282.1,2397.0,60.4,78.1,103.0,95.0,72.0,78.5,-1.4,5.8,33.5,29.5,87.9,113.0 +2011,3.7,3.4,3.25,3.55,4.0,101.2,3.62,4.05,4.41,7.75,168.0,555214.0,524413.0,1108.0,1586.5,100.0,1826.0,130.2,0.05,89.5,857.0,82.0,82.0,184.1,2424.0,60.7,106.0,102.5,90.0,73.5,80.0,-4.9,0.8,34.0,29.4,96.7,112.5 +2012,2.4,3.2,2.75,3.13,2.2,100.3,3.13,3.35,3.76,6.56,168.2,547870.0,519584.0,1127.0,1673.5,100.5,1997.0,123.5,0.04,89.0,934.0,79.0,83.5,508.4,2468.0,61.3,109.1,100.5,85.0,72.0,82.5,-3.2,-0.5,33.8,28.4,96.8,110.2 +2013,3.2,3.1,2.5,2.72,1.3,100.8,2.79,3.28,3.19,5.87,168.8,559632.0,515586.0,1095.0,1756.2,103.0,2011.0,115.0,0.04,88.8,980.0,77.5,85.0,812.1,2503.0,61.6,105.5,101.0,88.0,71.5,84.0,5.4,3.3,34.0,28.7,93.2,108.0 +2014,3.2,3.5,2.0,2.36,1.3,101.0,2.56,2.92,2.99,5.22,168.5,572665.0,525515.0,1053.0,1871.0,104.0,1916.0,105.6,0.04,90.2,1050.0,81.0,86.5,843.5,2546.0,62.4,96.7,101.5,90.0,73.8,86.0,1.1,3.1,34.5,29.0,87.6,108.8 +2015,2.8,3.6,1.5,1.72,0.7,100.5,1.8,2.25,2.18,4.61,168.0,526757.0,436499.0,1131.0,2010.0,103.5,1961.0,79.5,0.03,95.0,1145.0,84.5,88.0,1059.4,2567.0,62.6,51.2,101.0,86.0,77.5,88.5,9.1,5.1,36.0,28.8,79.8,107.2 +2016,2.9,3.7,1.25,1.48,1.0,99.8,1.44,1.8,1.88,4.6,168.5,495426.0,406193.0,1161.0,2151.1,100.0,2026.0,78.0,0.03,97.5,1250.0,82.0,89.5,992.4,2597.0,63.0,41.3,100.2,85.0,89.5,90.0,10.3,5.6,36.4,29.2,74.5,106.0 +2017,3.2,3.7,1.5,1.52,1.9,101.5,1.8,2.33,2.28,4.83,174.2,573694.0,478478.0,1131.0,2347.2,105.0,2467.0,90.5,0.02,100.0,1364.0,92.0,92.0,752.6,2620.0,63.2,53.1,101.8,92.0,90.0,92.5,7.3,9.8,36.6,31.1,77.3,107.5 +2018,2.9,3.8,1.75,1.85,1.5,100.8,2.1,2.56,2.67,5.41,178.0,604860.0,535202.0,1100.0,2508.9,102.0,2041.0,100.0,0.03,102.0,1497.0,94.5,94.0,774.7,2633.0,63.1,69.5,101.5,88.0,85.5,94.5,-4.6,-2.4,35.9,30.3,77.3,107.0 +2019,2.2,3.8,1.25,1.63,0.4,99.3,1.5,1.74,1.93,4.52,175.5,542233.0,503343.0,1166.0,2694.0,97.0,2198.0,92.5,0.03,104.5,1573.0,89.0,96.5,597.0,2660.0,63.5,63.4,100.0,82.0,82.0,97.0,-3.1,-2.1,34.6,30.5,72.1,102.8 +2020,-0.7,4.0,0.5,0.76,0.5,97.0,0.98,1.52,2.03,5.25,170.0,512498.0,467633.0,1180.0,3070.2,90.0,2873.0,85.0,0.02,110.0,1723.0,100.0,100.0,752.8,2630.0,62.5,42.3,97.5,76.0,79.0,100.0,-0.1,2.6,36.3,31.3,65.8,100.0 +2021,4.3,3.7,1.0,1.09,2.5,102.8,1.43,2.12,2.26,5.64,183.0,644400.0,615093.0,1144.0,3415.8,106.0,2978.0,110.5,0.01,122.0,1853.0,108.5,105.0,883.0,2672.0,63.8,69.3,103.0,96.0,77.5,104.5,-1.5,3.1,35.8,31.6,74.5,105.2 +2022,2.6,2.9,3.25,3.77,5.1,99.2,3.14,3.6,4.25,8.18,186.5,683585.0,731370.0,1292.0,3561.0,95.0,2237.0,140.2,0.02,128.0,1903.0,105.0,107.5,258.3,2726.0,64.5,97.0,100.5,85.0,76.0,108.0,-3.5,-0.7,34.5,31.8,85.2,104.5 +2023,1.4,2.7,3.5,3.75,3.6,98.8,3.55,3.78,4.4,8.4,183.0,632744.0,642756.0,1305.0,3680.0,96.5,2655.0,120.0,0.03,118.0,1920.0,102.0,106.0,355.2,2750.0,65.0,82.5,99.2,80.0,72.0,109.5,-0.5,1.5,34.0,30.8,80.5,101.0 +2024,2.2,2.8,3.0,3.3,2.3,99.5,3.2,3.42,3.9,7.5,185.0,660000.0,650000.0,1350.0,3800.0,98.0,2400.0,115.0,0.03,115.0,1950.0,103.5,105.5,380.0,2760.0,65.2,80.0,99.5,82.0,68.0,110.0,-3.3,0.8,33.5,30.0,82.0,101.5 +2025,1.8,3.0,2.75,3.0,1.8,99.8,2.8,3.1,3.5,6.8,184.0,650000.0,640000.0,1380.0,3900.0,99.0,2500.0,110.0,0.03,112.0,1980.0,104.0,106.0,350.0,2770.0,65.5,75.0,100.0,84.0,65.0,111.0,-2.0,1.0,33.0,29.5,81.0,101.0 diff --git a/data/pd_floor.py b/data/pd_floor.py index 7337e0f..eae7aa8 100644 --- a/data/pd_floor.py +++ b/data/pd_floor.py @@ -103,6 +103,108 @@ def compute_pd_floors( return pd_floors +# ============================================================ +# 기본 PD 플로어 (시장 데이터 없이 사용 가능) +# ============================================================ +# 근거: +# AAA = 5bp : Basel III CRE30.4 규제 플로어 (2023 개정, 기업 IRB) +# AA = 5bp : Basel III 최저선 + S&P 장기평균 2bp + Moody's 5bp 중간값 +# A = 7bp : S&P 장기평균 5bp + Moody's 9bp 중간값 +# BBB = 20bp: S&P 15bp + Moody's 26bp 중간값, 한국 BBB 관측 27bp와 정합 +# BB = 60bp: S&P 56~63bp 범위, 관측치 사용 (floor 불필요) +# B = 300bp: S&P 293~334bp 범위, 관측치 사용 (floor 불필요) +# +# 참고문헌: +# [1] Basel Committee, CRE30.4: "PD shall not be less than 0.05%" +# [2] S&P Global, "2023 Annual Default and Transition Study" +# [3] Moody's, "Annual Default Study" (1920-2023) +# [4] 금융감독원, 신용평가공시 (한국기업평가 1998-2025) +DEFAULT_PD_FLOORS = { + "AAA": 0.0005, # 5bp — Basel III CRE30.4 + "AA": 0.0005, # 5bp — Basel III 최저선 + "A": 0.0007, # 7bp — S&P/Moody's 중간값 + "BBB": 0.0020, # 20bp — S&P/Moody's 중간값 + "BB": 0.0060, # 60bp — 관측치 수준 (floor 미적용) + "B": 0.0300, # 300bp — 관측치 수준 (floor 미적용) +} + +# 7×7 행렬용 (CCC 제외) +GRADES_7 = ["AAA", "AA", "A", "BBB", "BB", "B"] + + +def get_default_pd_floors() -> Dict[str, float]: + """기본 PD 플로어 반환 (Basel III + S&P/Moody's 근거)""" + return DEFAULT_PD_FLOORS.copy() + + +def apply_pd_floor_to_matrices( + matrices: Dict[int, 'np.ndarray'], + pd_floors: Optional[Dict[str, float]] = None, + grades: Optional[List[str]] = None +) -> Dict[int, 'np.ndarray']: + """ + 전이행렬의 D열(부도 전이확률)에 PD 플로어 적용 + + 로직: + 1. 각 등급의 TM[i, D] < floor[i] 이면 + 2. TM[i, D] = floor[i] 로 상향 + 3. 초과분(delta)을 TM[i, i] (대각선)에서 차감 + 4. 행 합 = 1.0 유지 + + 이론적 근거: + - 한국 투자적격등급(AAA~A) 부도 관측치 = 0% + - 0%는 "위험 없음"이 아니라 "관측 불가능한 확률" + - Basel III CRE30.4: 기업 PD ≥ 5bp (0.05%) + - S&P/Moody's 글로벌 장기평균으로 보정 + + Parameters + ---------- + matrices : Dict[int, np.ndarray] + 연도별 전이행렬 (N×N, 마지막 열 = D) + pd_floors : Dict[str, float], optional + 등급별 최소 PD (기본: DEFAULT_PD_FLOORS) + grades : List[str], optional + 등급 레이블 (기본: AAA~B for 7×7) + + Returns + ------- + Dict[int, np.ndarray] + PD 플로어 적용된 전이행렬 (새 복사본) + """ + if pd_floors is None: + pd_floors = DEFAULT_PD_FLOORS + if grades is None: + grades = GRADES_7 + + calibrated = {} + for year, tm in matrices.items(): + tm_new = tm.copy() + n = tm_new.shape[0] + d_col = n - 1 # 마지막 열 = D + + for i in range(n - 1): # D행은 제외 (흡수상태) + grade = grades[i] if i < len(grades) else None + if grade and grade in pd_floors: + floor = pd_floors[grade] + observed_pd = tm_new[i, d_col] + + if observed_pd < floor: + delta = floor - observed_pd + tm_new[i, d_col] = floor + # 대각선(유지확률)에서 차감 + tm_new[i, i] = max(tm_new[i, i] - delta, 0.0) + + # 행 합 재정규화 (안전장치) + row_sum = tm_new[i].sum() + if row_sum > 0: + tm_new[i] /= row_sum + + calibrated[year] = tm_new + + logger.info(f"PD 플로어 적용 완료: {len(calibrated)}개 연도") + return calibrated + + def extrapolate_speculative_grades( pd_floors: Dict[str, float], grades_to_extrapolate: List[str] = ["BB", "B"] diff --git a/docs/devlog/2026-03-11.md b/docs/devlog/2026-03-11.md index 35224e5..300a461 100644 --- a/docs/devlog/2026-03-11.md +++ b/docs/devlog/2026-03-11.md @@ -9,3 +9,6 @@ | 5 | 08:00 | 파이프라인 전단계 검증 엑셀 생성 (10시트) | `0e1e0e5` | ✅ | | 6 | 15:50 | 시장 YTM PD 플로어 + WR보정 + 7x7 전이행렬 파싱 | `b8514c1` | ✅ | | 7 | 16:20 | 7x7 Zt추정 + CCC보간(8x8) + main.py 연결 + 50Y PD검증(8/8통과) + 문서/Wiki | `2b94cc8`~`8a0d6e7` | ✅ | +| 8 | 21:57 | KAP 채권 YTM 기반 PD Floor 실계산 적용 (build_complete_pd_floor_table) | - | ✅ | +| 9 | 22:30 | 확장 변수 탐색 226개 (log/diff/return/lag2), 192만 조합. ADF 수정(AIC->BIC) | - | ✅ | +| 10 | 23:47 | Model #2 선택 (주택가격+신용스프레드+경상수지변화율), 6개 검정 진단표 보고서 | - | ✅ | diff --git a/docs/methodology.md b/docs/methodology.md index 23b86c3..7547713 100644 --- a/docs/methodology.md +++ b/docs/methodology.md @@ -278,149 +278,250 @@ PD_PIT(Z) = Φ( (Φ⁻¹(PD_TTC) - √ρ × Z) / √(1-ρ) ) [Basel: Z>0=불 --- -### 2.5 거시연계 회귀모형: Zt ~ 거시변수 +### 2.5 AR(1) + Macro 신용사이클 모형 -**왜 거시변수와 연결하는가?** +#### 2.5.1 모형의 목적 -Zt는 "신용사이클"이라는 추상적 개념입니다. 이를 관측 가능한 거시경제변수로 설명하면: -1. **해석 가능성**: Zt의 변동 원인을 이해할 수 있음 -2. **예측 가능성**: 거시 전망치(IMF WEO, KDI 등)를 입력하면 미래 Zt를 예측할 수 있음 -3. **시나리오 분석**: "만약 GDP가 -2%이고 실업률이 5%이면?"이라는 질문에 답할 수 있음 +Zt는 전이행렬에서 역산한 "신용사이클 인덱스"로, 그 자체로는 **미래를 예측할 수 없습니다.** +IFRS 9 Lifetime PD는 **미래 경기 전망(forward-looking)**을 반영해야 하므로, +관측된 Zt를 **거시경제변수와 연결**하여 미래 Zt 경로를 생성해야 합니다. -**변수 풀 (37개 ECOS 변수):** +이를 위해 **AR(1) + Macro 모형**을 사용합니다. 이 모형은: +1. 신용사이클의 **관성**(φ·Z(t-1))과 **거시경제 충격**(β·X(t))을 동시에 포착 +2. **미래 경로 생성**에서 거시변수가 **직접적으로** 기여 +3. **Mean-reversion**이 φ에 의해 자동으로 결정 (하드코딩 불필요) -BOK ECOS 100대 통계지표 및 주요 거시경제변수 37개를 후보 풀로 구성: -- 성장(GDP성장률), 고용(실업률, 고용률), 금리(기준금리, CD, 국고채3Y/10Y, 회사채AA/BBB) -- 물가(CPI, 수입물가, 생산자물가), 경기지수(선행/동행), 심리(CSI, BSI) -- 생산(광공업, 서비스업), 교역(수출/수입, 수출입대GNI비율) -- 환율(원/달러), 통화(M2), 부도(어음부도율/금액), 주식(KOSPI) -- 부동산(주택매매가격), 가계(가계부채) -- 투자(설비투자, 건설투자증감률, 총고정자본형성, 총저축률, 국내총투자율) -- 제조업(평균가동률) +#### 2.5.2 모형 정의 -**모형 구조 (3변수 강제 지정):** - -37개 변수에서 3변수 조합 7,770개를 전수 탐색(exhaustive search)하여, -**부호 일관성**을 만족하는 최적 조합을 선택: +**수학적 구조:** ``` -Z_t = β₀ + β₁·USDKRW_t + β₂·RETAIL_SALES_t + β₃·INVEST_RATE_t + ε_t +Z(t) = c + φ·Z(t-1) + β₁·X₁(t) + β₂·X₂(t) + β₃·X₃(t) + ε(t) ``` -| 변수 | 구분 | 계수 부호 | 경제적 근거 | +여기서: +- **Z(t)**: 연도 t의 신용사이클 인덱스 (Belkin convention: Z>0 = 호황) +- **Z(t-1)**: 전년도 신용사이클 → **자기회귀(AR) 항** +- **c**: 절편 (장기 균형 수준 조정) +- **φ**: 자기회귀 계수 — **사이클의 관성(persistence)** + - 0 < φ < 1: 정상(stationary) 과정, 자연 감쇠 + - φ가 1에 가까울수록 사이클이 오래 지속 + - **반감기** = ln(2) / |ln(φ)| 년 +- **β₁~β₃**: 거시변수 계수 — 경기 충격의 크기와 방향 +- **ε(t) ~ N(0, σ²_ε)**: 잔차 (모형이 설명하지 못하는 변동) + +**장기 균형**: + +거시 충격이 없고(X=X̄) 충분한 시간이 지나면: + +``` +Z∞ = (c + β·X̄) / (1 - φ) ≈ 0 (TTC 수준) +``` + +#### 2.5.3 변수 선택 + +**변수 풀**: BOK ECOS 100대 통계지표 포함 37개 거시변수 + +37개 변수에서 3변수 조합 7,770개를 **전수 탐색(exhaustive search)** 하여, +**부호 일관성(sign consistency)**을 만족하는 최적 조합을 선택합니다. + +**선택된 3변수:** + +| 변수 | 코드 | 계수 부호 | 경제적 근거 | |------|------|:---------:|------------| -| USDKRW | 환율 (원/달러) | − | 원화 약세 → 외국인 자본유출, 수입원가 상승 → 기업 부담↑ → Zt↓ | -| RETAIL_SALES | 소매판매액지수 | + | 내수 소비 활성화 → 기업 매출·수익성↑ → Zt↑ | -| INVEST_RATE | 국내총투자율 (%) | + | 투자 확대 → 경기 확장 → 부도 감소 → Zt↑ | +| 원/달러 환율 | USDKRW | − | 원화 약세 → 외국인 자본유출, 수입원가 상승 → 기업 부담↑ → Zt↓ | +| 소매판매액지수 | RETAIL_SALES | + | 내수 소비 활성화 → 기업 매출·수익성↑ → Zt↑ | +| 국내총투자율 | INVEST_RATE | + | 투자 확대 → 경기 확장 → 부도 감소 → Zt↑ | -- **R² = 0.43** (비표준화), **7/8 검증 통과** -- 3변수는 각각 **외부충격(환율)**, **내수(소비)**, **투자(자본형성)**을 대표 +**변수 설계 원칙:** +- 3변수는 각각 **외부충격(환율)**, **내수(소비)**, **투자(자본형성)**를 대표 +- **과적합 방지**: 관측치 수 / (변수 수 + AR항) ≈ 25 / 4 = 6.25 +- **다중공선성 회피**: 환율·소비·투자는 서로 다른 경기 차원을 포착 -**왜 3변수인가?** +#### 2.5.4 왜 AR(1) + Macro 인가? -- 26개 연간 관측치로는 과적합(overfitting) 방지를 위해 설명변수를 최소화해야 함 -- 경험칙: 관측치 수 / 변수 수 ≥ 8 (26/3 ≈ 8.7) -- 3변수는 환율·소비·투자라는 서로 다른 경기 측면을 포착 +**기존 OLS 대비 개선:** -**왜 OLS인가?** +| 항목 | OLS 모형 (기존) | AR(1)+Macro (개선) | +|------|---------------|-------------------| +| 미래 Zt 생성 | Zt 분포 통계(μ±kσ) | **φ·Z(t-1) + β·충격** | +| 거시변수 역할 | 사후 해석만 | **시나리오 충격 직접 투영** | +| Mean-reversion | 하드코딩 (λ=0.3) | **φ에서 자동 결정** | +| 사이클 관성 | 무시 | **φ로 포착 (불황 지속성)** | +| IFRS 9 호환 | 약함 | **명시적 forward-looking** | -- 26개 연간 관측치로는 VAR, VECM 등 복잡한 시계열 모형의 자유도가 부족 -- OLS는 소표본에서도 BLUE(Best Linear Unbiased Estimator) 조건 하에서 최적 -- 잔차 진단으로 OLS 가정 위반 여부를 검증 +**이론적 근거:** +- Moody's Analytics: Z-score → macro regression → scenario forecast +- Zanders Group: Vasicek Z → macro regression → PiT 전이행렬 +- EBA/ECB: Forward-looking macro overlay on Z-index +- 한국 FSS: 복수 시나리오 + 거시경제 전망 반영 의무 --- -### 2.6 통계적 검증 (엄밀한 관점) +### 2.6 시나리오 경로 생성 메커니즘 -#### (a) ADF (Augmented Dickey-Fuller) 검정 — Zt 정상성 +IFRS 9 (B5.5.42-44)는 **복수의 거시경제 시나리오를 확률 가중**하여 +ECL을 산출할 것을 요구합니다. 본 모형에서 시나리오 차이는 +**거시변수의 충격(shock) 크기와 방향**에 의해 결정됩니다. + +#### 2.6.1 시나리오 정의 + +각 시나리오는 기준시점(t₀) 대비 **거시변수의 이탈(σ 배수)**로 정의합니다: + +| 시나리오 | USDKRW 충격 | RETAIL 충격 | INVEST 충격 | 가중치 | +|---------|:----------:|:----------:|:----------:|:-----:| +| 호황 (Upside) | −1.0σ | +1.0σ | +1.0σ | 20% | +| 중립 (Base) | 0 | 0 | 0 | 50% | +| 불황 (Downside) | +1.5σ | −1.5σ | −1.5σ | 30% | + +**σ는 각 변수의 과거 표본 표준편차**입니다. +- σ(USDKRW) ≈ 120원 → Downside 충격 = +180원 (1,380 → 1,560원) +- σ(RETAIL) ≈ 8pt → Downside 충격 = −12pt (107 → 95) +- σ(INVEST) ≈ 1.5%p → Downside 충격 = −2.25%p (30% → 27.75%) + +**불황에 더 큰 충격(1.5σ > 1.0σ)을 적용하는 이유:** +1. 신용 손실 함수의 비선형성 — 불황의 PD 증가폭이 호황의 감소폭보다 큼 +2. ECB/Fed 감독 관행 — 보수적 추정(conservative estimation) 원칙 +3. IFRS 9 B5.5.42: 편향 없는 확률 가중은 테일 리스크를 반영해야 함 + +#### 2.6.2 Zt 경로 생성 알고리즘 ``` -H₀: Zt에 단위근이 존재 (비정상 시계열) -H₁: Zt는 정상 시계열 +입력: Z(t₀) = Zt의 마지막 관측값 + c, φ, β₁, β₂, β₃ = 적합된 AR(1) 파라미터 + ΔX = (ΔX₁, ΔX₂, ΔX₃) = 시나리오별 거시 충격 + σ_X = 각 변수의 표본 표준편차 ``` -Zt가 비정상이면 회귀분석의 t-통계량과 R²가 거짓 결과를 낼 수 있습니다 (허위 회귀). -**본 모형 결과: p = 0.0000 → 정상 시계열 확인 (Pass)** - -#### (b) Shapiro-Wilk 검정 — Zt 정규성 - -Belkin & Suchower (1998)는 Z ~ N(0,1)을 가정합니다. 추정된 Zt가 정규분포를 따르는지 확인합니다. -**본 모형 결과: p = 0.0017 → 비정규 (Fail)** - -이는 IMF 위기, GFC, COVID 등 극단적 사건으로 인한 비대칭 분포 때문입니다. Belkin 원논문에서도 이 한계를 인정하고 있으며, 실무적으로는 심각한 문제가 아닙니다. - -#### (c) Durbin-Watson / Ljung-Box — 잔차 자기상관 +**Step 1: 시나리오별 t=1 진입** ``` -H₀: 잔차에 자기상관이 없음 -DW ≈ 2이면 자기상관 없음 -Ljung-Box: p > 0.05이면 자기상관 없음 +X_shock(i) = ΔX(i) × σ_X(i) +Z(t₀+1) = c + φ·Z(t₀) + Σᵢ βᵢ·X_shock(i) ``` -잔차에 자기상관이 존재하면 OLS 표준오차가 과소추정되어 유의성 검정이 왜곡됩니다. -**본 모형 결과: DW = 2.235, LB p = 0.2743 → 자기상관 없음 (Pass)** +거시변수의 **충격 수준**이 Zt의 초기 분기를 결정합니다. +- Base: X_shock = 0 → Z(t₀+1)은 순수한 AR(1) 감쇠 +- Downside: X_shock 반영 → Z(t₀+1)이 음(−)의 방향으로 이동 +- Upside: X_shock 반영 → Z(t₀+1)이 양(+)의 방향으로 이동 -#### (d) Breusch-Pagan / ARCH-LM — 이분산 +**Step 2: t=2 이후 — 자기회귀 전파 (AR propagation)** ``` -H₀: 잔차의 분산이 일정 (등분산) +Z(t₀+k) = c + φ·Z(t₀+k-1) (k ≥ 2) ``` -이분산이 존재하면 OLS 추정량은 여전히 불편이지만, 효율적이지 않습니다. -**본 모형 결과: BP p = 0.3951, ARCH p = 0.7885 → 등분산 (Pass)** +t=2부터는 **거시 충격 없이**, φ에 의한 **자연 감쇠**만 적용됩니다. +- φ = 0.7이면: 반감기 ≈ 2.0년 → 충격이 약 4년 만에 10% 이하로 감쇠 +- φ = 0.5이면: 반감기 ≈ 1.0년 → 충격이 약 3년 만에 소멸 -#### (e) R² / F-test — 모형 설명력 +**Step 3: 장기 수렴 (TTC)** ``` -R² = 1 - (잔차변동/총변동) -F-test H₀: 모든 회귀계수 = 0 +lim_{k→∞} Z(t₀+k) = c / (1 − φ) ≈ 0 ``` -**본 모형 결과: R² = 0.889, F p = 0.0000 → 거시변수가 Zt 변동의 89%를 설명 (Pass)** +충분한 시간이 지나면 모든 시나리오가 **TTC(Z=0)로 자연 수렴**합니다. +이는 경기 사이클이 장기적으로 평균 회귀한다는 가정과 일치합니다. + +**핵심: 이 과정에서 거시변수의 미래 값을 예측(forecast)하지 않습니다.** +거시변수는 t=1에서의 **시나리오 진입 충격**만을 정의하며, +t=2 이후는 φ에 의한 내생적(endogenous) 감쇠가 Zt 경로를 결정합니다. + +#### 2.6.3 시각화 + +``` +Z(t) + ↑ + │ ╭── Upside (β·[−1σ,+1σ,+1σ] 충격) + │ ╱ + │ ╱ + │──╱──────── Base (충격 없음, φ 감쇠만) + │ │╲ + │ │ ╲ + │ │ ╲──── Downside (β·[+1.5σ,−1.5σ,−1.5σ] 충격) + │ │ ╲ +─┼──┼────╲───────────────────→ t + 0 t₀ t₀+1 t₀+2 ... t₀+10 ... t₀+50 + │←── 충격 ──→│←── φ 감쇠 ──────────→│← TTC (Z≈0) →│ +``` --- -### 2.7 시나리오 설계 (ECB/Fed 방식) +### 2.7 분기별 업데이트 (Quarterly Refresh) -**IFRS 9 요구사항 (B5.5.42-44):** +#### 2.7.1 연간 Full Calibration vs 분기 Light Update -ECL 산출 시 복수의 시나리오를 확률 가중하여 반영해야 합니다. "편향 없는(unbiased)" 추정을 위해 호황과 불황 양방향을 모두 고려해야 합니다. +| | 연간 (Full) | 분기 (Light) | +|--|-----------|------------| +| **시점** | 연초 (직전년도 데이터 확정 후) | Q2, Q3, Q4 | +| **전이행렬** | 3사 PDF → TTC 재산출 | 변경 없음 | +| **Zt** | 전 기간 WLS 재추정 | 변경 없음 | +| **AR(1) 파라미터** | c, φ, β 재적합 | **변경 없음** (연초 확정값 사용) | +| **거시변수** | 연간 관측값 | **최신 분기/월간 관측값** | +| **Z 경로** | 전체 재생성 | **Z(t₀) 시작점만 갱신** | +| **Lifetime PD** | 전체 재산출 | **갱신된 Z경로로 재산출** | -| 시나리오 | Zt 설정 | 가중치 | 학술적 근거 | -|---------|---------|--------|------------| -| 호황 | μ + 1.0σ | 20% | ECB: 상위 시나리오에 15-25% | -| 중립 | μ + 0σ | 50% | IMF WEO 기본 전망 | -| 불황 | μ - 1.5σ | 30% | Fed DFAST: 역사적 하위 5% | +#### 2.7.2 분기 업데이트 절차 -**가중치 비대칭의 이유:** +``` +[Step 1] ECOS에서 최신 거시 관측값 수집: + USDKRW(Q_current), RETAIL_SALES(Q_current), INVEST_RATE(Q_current) -불황 시나리오에 더 높은 가중치(30% > 20%)를 부여하는 것은: -1. 신용 손실 함수의 비선형성 — 불황의 영향이 호황의 이익보다 큼 -2. ECB/Fed의 감독 관행 — "보수적 추정" 원칙 -3. 역사적으로 불황의 빈도가 호황보다 약간 높음 +[Step 2] 현재 Z 수준 재계산 (연초 파라미터 사용): + Z_current = c + φ·Z_prev + β₁·USDKRW_Q + β₂·RETAIL_Q + β₃·INVEST_Q + +[Step 3] Z_current를 새로운 시작점으로 50년 Z-path 재생성 + +[Step 4] Vasicek 조건부 TM → 행렬곱 → Lifetime PD 재산출 + +[Step 5] ECL = Σ EAD(t) × PD(t) × LGD(t) × DF(t) 재산출 +``` + +이 방식의 장점: +- **거시변수가 Lifetime PD에 실시간으로 기여** — IFRS 9 forward-looking 충족 +- **모형 재추정 불필요** — 파라미터 안정성 유지 +- **감사 추적 가능** — 어떤 거시 관측값이 PD 변동을 야기했는지 추적 --- -### 2.8 50년 수렴 메커니즘 +### 2.8 IFRS 9 준수 매핑 + +| IFRS 9 요구사항 | 조항 | 본 모형 대응 | +|---------------|------|-----------| +| Forward-looking 정보 반영 | B5.5.4 | AR(1) macro 충격 → Zt → PiT PD | +| 복수 시나리오 확률 가중 | B5.5.42 | 3 시나리오 × 확률 (20/50/30%) | +| 편향 없는 확률가중 추정 | B5.5.43 | Up/Base/Down 양방향 반영 | +| 과도한 비용·노력 없이 이용 가능 | B5.5.51 | ECOS 공개 데이터 (API) 사용 | +| PiT PD 사용 | 5.5.17 | Vasicek Z-조건부 전이행렬 | +| Lifetime ECL (Stage 2) | 5.5.3 | 50년 누적/한계 PD term structure | +| 정기적 갱신 | B5.5.52 | 분기별 Quarterly Refresh | + +**IFRS 9 (2024년 5월 개정, 2026년 1월 발효) 참고:** +최신 개정은 금융자산 분류/측정, ESG 연계, 전자결제 제거에 관한 것이며, +ECL/PD 모형 방법론 자체에는 변경이 없습니다. +(IFRS 17은 보험계약 회계 기준으로, 본 프로젝트(대출/채권 신용위험)와 적용 범위가 다릅니다.) + +--- + +### 2.9 50년 수렴 메커니즘 **왜 수렴이 필요한가?** -거시경제 예측은 현실적으로 3-5년이 한계입니다 (IMF WEO는 5년 전망). 50년 예측에서는: -1. **1~5년 (PIT 구간)**: 거시 시나리오 기반 Zt 적용 — 가장 신뢰도 높은 구간 -2. **6~10년 (전환 구간)**: Mean-reversion으로 점진적 수렴 — 불확실성 증가에 대응 -3. **11~50년 (TTC 구간)**: Z = 0 (장기 평균) — 경기 사이클이 반복된다는 가정 +AR(1) 모형에서 0 < φ < 1이면 Z(t)는 자동으로 장기 균형으로 수렴합니다. +별도의 수렴 메커니즘이 불필요하며, 이것이 AR(1) 모형의 핵심 장점입니다. -**Mean-Reversion 공식:** +**수렴 속도:** -``` -Z_t^adj = Z_t^scenario × exp(-λ × (t - T_pit)) (t > T_pit) -``` - -- λ = 0.3: Mean-reversion 속도 — 5년 후 Z가 약 22%로 감소 -- T_pit = 5: PIT 적용 종료 시점 +| φ | 반감기 | 충격이 5% 이하로 감쇠 | 50년 시점 잔여 충격 | +|---|--------|-------------------|-----------------| +| 0.3 | 0.6년 | ~2.5년 | ≈ 0% | +| 0.5 | 1.0년 | ~4.3년 | ≈ 0% | +| 0.7 | 1.9년 | ~8.4년 | ≈ 0% | +| 0.9 | 6.6년 | ~28.4년 | ~0.5% | **학술적 근거:** -- Ornstein-Uhlenbeck 과정: 금리/스프레드 모형에서 널리 사용 +- Ornstein-Uhlenbeck 과정의 이산 시간 버전이 AR(1) - Basel III FRTB: 장기 리스크 파라미터의 평균회귀 가정 - IFRS 9 IG: 예측 불가능한 장기 구간에서는 역사적 평균 사용 권장 diff --git a/main.py b/main.py index 3a97d4e..750b054 100644 --- a/main.py +++ b/main.py @@ -103,18 +103,38 @@ def main(): tm_source = data_config.get("transition_source", "builtin") tm_dir = data_config.get("transition_dir", None) logger.info(f"전이행렬 로딩 중 (source={tm_source})...") - transition_matrices = load_transition_matrices(tm_source, data_dir=tm_dir) + transition_matrices_all = load_transition_matrices(tm_source, data_dir=tm_dir) + # 2000-2025 필터 + transition_matrices_raw = {y:m for y,m in transition_matrices_all.items() if 2000 <= y <= 2025} + + # PD 플로어 적용: KAP 채권 YTM 기반 시장내재 PD + from data.pd_floor import apply_pd_floor_to_matrices, build_complete_pd_floor_table + pd_floors_broad, _, pd_floors_full = build_complete_pd_floor_table() + transition_matrices = apply_pd_floor_to_matrices(transition_matrices_raw, pd_floors_broad) + ttc_matrix = compute_ttc_matrix(transition_matrices) default_rates = get_default_rates(transition_matrices) print(f"\n 전이행렬: {len(transition_matrices)}개 연도 ({min(transition_matrices.keys())}~{max(transition_matrices.keys())})" f" [source={tm_source}]") - print(display_matrix(ttc_matrix, "TTC 전이행렬 (장기 평균)")) + print(f" PD 플로어 (KAP 채권 YTM 기반): AAA={pd_floors_broad['AAA']*10000:.0f}bp, AA={pd_floors_broad['AA']*10000:.0f}bp, " + f"A={pd_floors_broad['A']*10000:.0f}bp, BBB={pd_floors_broad['BBB']*10000:.0f}bp") + print(display_matrix(ttc_matrix, "TTC 전이행렬 (KAP PD Floor 적용 후 장기 평균)")) # 거시경제변수 if args.no_api: logger.info("Fallback 거시경제 데이터 사용") macro_data = _fallback_macro_data() + # ECOS fallback 데이터도 병합 (37개 변수) + try: + from data.ecos_fetcher import load_macro_data as load_ecos_macro + ecos_data = load_ecos_macro() + if ecos_data is not None and not ecos_data.empty: + macro_data = pd.concat([macro_data, ecos_data], axis=1) + macro_data = macro_data.loc[:, ~macro_data.columns.duplicated()] + logger.info(f"ECOS fallback 병합 완료: {len(macro_data.columns)}개 변수") + except Exception as e: + logger.warning(f"ECOS fallback 병합 실패: {e}") else: macro_data = load_macro_data(args.config) @@ -167,10 +187,19 @@ def main(): model_input = macro_data forced_vars = config.get("model", {}).get("macro_vars", None) - macro_model = build_macro_zt_model(zt_dict, model_input, method="stepwise_aic", + macro_method = config.get("model", {}).get("macro_method", "ar1_macro") + macro_model = build_macro_zt_model(zt_dict, model_input, method=macro_method, forced_vars=forced_vars) print(f"\n 선택된 변수: {macro_model.selected_vars}") + if macro_model.is_ar1: + import math + phi = macro_model.ar1_phi + half_life = math.log(2) / abs(math.log(abs(phi))) if 0 < abs(phi) < 1 else float('inf') + print(f" [AR(1)+Macro] φ = {phi:.4f} (반감기 = {half_life:.1f}년)") + print(f" c = {macro_model.ar1_const:.4f}") + for var, beta in macro_model.ar1_beta.items(): + print(f" β({var}) = {beta:+.6f}") print(macro_model.summary()) diag = macro_model.diagnostics() diff --git a/models/macro_model.py b/models/macro_model.py index fdd53ca..adfe946 100644 --- a/models/macro_model.py +++ b/models/macro_model.py @@ -1,17 +1,16 @@ """ -거시경제 변수 ↔ Zt 연계 통계모형 +거시경제 변수 ↔ Zt 연계 AR(1) + Macro 모형 -Zt(신용사이클 인덱스)를 거시경제변수로 설명하는 회귀모형을 구축하고, -미래 거시 시나리오에 따른 Zt 전망을 생성합니다. +Zt(신용사이클 인덱스)를 거시경제변수와 자기회귀 항으로 설명하는 모형. -모형: - Z_t = β₀ + β₁·GDP_growth + β₂·Unemployment + β₃·Base_Rate - + β₄·CD_Rate + β₅·CPI_growth + β₆·Leading_Index + ε_t +AR(1) + Macro 모형: + Z(t) = c + phi*Z(t-1) + beta1*X1(t) + beta2*X2(t) + beta3*X3(t) + eps(t) 방법론 참고: -- IMF (2021). "IFRS 9 and CECL Compatible Estimation for Top-Down Solvency Stress Testing" -- ECB (2019). "Scenario Design for IFRS 9 Expected Credit Loss Estimation" -- Fed (2022). "Dodd-Frank Act Stress Test Methodology" +- Moody's Analytics: Z-score macro regression scenario forecast +- Zanders Group: Vasicek Z macro regression PiT transition matrix +- EBA/ECB: Forward-looking macro overlay on Z-index +- IFRS 9 B5.5.42-44 """ import numpy as np @@ -45,7 +44,15 @@ class MacroZtModel: self.model = None self.result = None self.selected_vars = None - self.scaler_params = {} # 정규화 파라미터 + self.scaler_params = {} + # AR(1) attributes + self.ar1_phi = None + self.ar1_const = None + self.ar1_beta = None + self.ar1_sigma_eps = None + self.ar1_macro_stats = {} + self.is_ar1 = False + self._ar1_var_names = None def fit( self, @@ -253,16 +260,23 @@ class MacroZtModel: try: X = self.result.model.exog vif_values = {} - var_names = ["const"] + self.selected_vars - for i in range(X.shape[1]): + if self.is_ar1 and self._ar1_var_names: + var_names = self._ar1_var_names + else: + var_names = ["const"] + self.selected_vars + for i in range(min(X.shape[1], len(var_names))): vif_values[var_names[i]] = variance_inflation_factor(X, i) diag["vif"] = vif_values except Exception: diag["vif"] = {} # 계수 요약 + if self.is_ar1 and self._ar1_var_names: + coef_names = self._ar1_var_names + else: + coef_names = ["const"] + self.selected_vars coef_df = pd.DataFrame({ - "변수": ["const"] + self.selected_vars, + "변수": coef_names, "계수": self.result.params, "표준오차": self.result.bse, "t값": self.result.tvalues, @@ -283,36 +297,201 @@ class MacroZtModel: if self.result is None: return np.array([]) return self.result.resid + + # ================================================================ + # AR(1) + Macro 모형 + # ================================================================ + + def fit_ar1( + self, + zt_series: pd.Series, + macro_data: pd.DataFrame, + forced_vars: Optional[List[str]] = None + ) -> "MacroZtModel": + """ + AR(1) + Macro 모형 적합 + + Z(t) = c + phi*Z(t-1) + beta1*X1(t) + beta2*X2(t) + beta3*X3(t) + eps(t) + + Z(t-1)을 설명변수에 포함하여 OLS로 추정합니다. + + Parameters + ---------- + zt_series : pd.Series + index=연도, values=Zt 추정값 + macro_data : pd.DataFrame + index=연도, columns=거시변수 + forced_vars : List[str], optional + 강제 지정 거시변수 + """ + self.is_ar1 = True + + # 인덱스 정렬 및 교집합 + common_years = sorted(set(zt_series.index) & set(macro_data.index)) + if len(common_years) < 5: + raise ValueError(f"공통 데이터 포인트가 부족합니다: {len(common_years)}개") + + # AR(1) 구성: Z(t)와 Z(t-1) 쌍 + zt_full = zt_series.loc[common_years].sort_index() + years = list(zt_full.index) + + # t-1이 필요하므로 첫 해 제외 + y_years = years[1:] + + y = zt_full.loc[y_years].values.astype(float) + z_lag = zt_full.loc[years[:-1]].values.astype(float) + + # 거시변수 + X_macro = macro_data.loc[y_years].copy() + X_macro = X_macro.ffill().bfill().dropna(axis=1) + + # 변수 선택 + if forced_vars: + available = [v for v in forced_vars if v in X_macro.columns] + if len(available) != len(forced_vars): + missing = set(forced_vars) - set(available) + logger.warning(f"AR(1) 강제 지정 변수 중 누락: {missing}") + self.selected_vars = available + else: + self.selected_vars = list(X_macro.columns) + + # 거시변수 표본 통계 저장 (시나리오 충격용) + for col in self.selected_vars: + col_data = macro_data[col].dropna() + self.ar1_macro_stats[col] = { + "mean": float(col_data.mean()), + "std": float(col_data.std()), + "last": float(col_data.iloc[-1]) if len(col_data) > 0 else 0.0 + } + + # 거시변수 표준화 (mean=0, std=1) + # → β가 "1σ 충격의 Z 영향"으로 직접 해석됨 + # → 절편 c가 장기 평균 Z 수준을 결정 + X_std = X_macro[self.selected_vars].copy() + for col in self.selected_vars: + mean = self.ar1_macro_stats[col]["mean"] + std = self.ar1_macro_stats[col]["std"] + if std > 0: + X_std[col] = (X_std[col] - mean) / std + else: + X_std[col] = 0.0 + + # 설계행렬: [const, Z(t-1), X1_std(t), X2_std(t), X3_std(t)] + X_design = np.column_stack([ + z_lag, + X_std.values + ]) + X_with_const = sm.add_constant(X_design) + + # OLS 적합 + self.model = sm.OLS(y, X_with_const) + self.result = self.model.fit() + + # AR(1) 파라미터 추출 + # params: [const, phi, beta1, beta2, ...] + params = self.result.params + self.ar1_const = params[0] + self.ar1_phi = params[1] + self.ar1_beta = {} + for i, col in enumerate(self.selected_vars): + self.ar1_beta[col] = params[2 + i] + self.ar1_sigma_eps = float(np.std(self.result.resid)) + + # 변수명 저장 (진단용) + self._ar1_var_names = ["const", "Z_lag1"] + self.selected_vars + + # 정상성 체크 + if abs(self.ar1_phi) > 0 and abs(self.ar1_phi) < 1: + half_life = np.log(2) / abs(np.log(abs(self.ar1_phi))) + else: + half_life = np.inf + + logger.info(f"AR(1)+Macro 적합 완료:") + logger.info(f" phi = {self.ar1_phi:.4f} (반감기 = {half_life:.1f}년)") + logger.info(f" c = {self.ar1_const:.4f}") + for col, beta in self.ar1_beta.items(): + logger.info(f" beta({col}) = {beta:+.6f}") + logger.info(f" R2 = {self.result.rsquared:.4f}, " + f"Adj.R2 = {self.result.rsquared_adj:.4f}") + + if abs(self.ar1_phi) >= 1.0: + logger.warning(f" phi={self.ar1_phi:.4f} >= 1.0 -> non-stationary!") + + return self + + def forecast_z_path( + self, + z_last: float, + macro_shocks: Dict[str, float], + horizon: int = 50 + ) -> np.ndarray: + """ + AR(1) 모형으로 미래 Z 경로 생성 + + t=1: Z = c + phi*Z(t0) + sum(beta_i * shock_i * sigma_i) + t>=2: Z = c + phi*Z(t-1) (거시 충격 없음, AR 감쇠만) + + Parameters + ---------- + z_last : float + 마지막 관측 Z(t0) + macro_shocks : Dict[str, float] + 변수별 충격 (sigma 배수) + 예: {"USDKRW": +1.5, "RETAIL_SALES": -1.5} + horizon : int + 예측 기간 (년) + + Returns + ------- + np.ndarray : [Z(t0+1), ..., Z(t0+horizon)] + """ + if not self.is_ar1: + raise ValueError("AR(1) 모형이 적합되지 않았습니다.") + + z_path = np.zeros(horizon) + z_prev = z_last + + for t in range(horizon): + z_next = self.ar1_const + self.ar1_phi * z_prev + + # t=0 (첫 해)만 거시 충격 적용 + # β는 표준화된 변수 기준이므로 shock_sigma가 곧 β의 배수 + if t == 0: + for var, shock_sigma in macro_shocks.items(): + if var in self.ar1_beta: + beta = self.ar1_beta[var] + z_next += beta * shock_sigma + + z_path[t] = z_next + z_prev = z_next + + return z_path def build_macro_zt_model( zt_dict: Dict[int, float], macro_df: pd.DataFrame, - method: str = "stepwise_aic", + method: str = "ar1_macro", forced_vars: Optional[List[str]] = None ) -> MacroZtModel: """ - 편의 함수: Zt 딕셔너리 + 거시 DataFrame → 회귀모형 구축 + 편의 함수: Zt + 거시 DataFrame -> 회귀모형 구축 Parameters ---------- - zt_dict : Dict[int, float] - {연도: Zt값} - macro_df : pd.DataFrame - index=연도, columns=거시변수 - method : str - 변수 선택 방법 - forced_vars : List[str], optional - 강제 지정 변수 (지정 시 method 무시) - - Returns - ------- - MacroZtModel : 적합된 모형 + zt_dict : {연도: Zt값} + macro_df : index=연도, columns=거시변수 + method : "ar1_macro" (기본) | "stepwise_aic" | "all" + forced_vars : 강제 지정 변수 """ zt_series = pd.Series(zt_dict, name="Zt") zt_series.index.name = "YEAR" model = MacroZtModel() - model.fit(zt_series, macro_df, method=method, forced_vars=forced_vars) + + if method == "ar1_macro": + model.fit_ar1(zt_series, macro_df, forced_vars=forced_vars) + else: + model.fit(zt_series, macro_df, method=method, forced_vars=forced_vars) return model diff --git a/reports/generate_report.py b/reports/generate_report.py new file mode 100644 index 0000000..d229541 --- /dev/null +++ b/reports/generate_report.py @@ -0,0 +1,692 @@ +""" +Lifetime PD 분석 보고서 생성기 (Excel) + +사용법: + python reports/generate_report.py + python reports/generate_report.py --config config.yaml --output results/report.xlsx + +다중 시트 구성: + 1. 요약 (Summary) — 모형 개요, 핵심 파라미터, 결론 + 2. 원시데이터_전이행렬 — 연도별 전이행렬, TTC 행렬 + 3. 원시데이터_거시변수 — ECOS 거시경제변수 시계열 + 4. Zt_추정 — Belkin & Suchower Zt 역산 결과 + 5. AR1_모형 — AR(1)+Macro 회귀 결과, 계수, 진단 + 6. 시나리오_Z경로 — 3 시나리오별 50년 Zt 경로 + 7. Lifetime_PD — 시나리오별 누적 PD term structure + 8. 가중평균_PD — 확률가중 최종 PD + 9. 검증결과 — 통계 검정 결과 +""" + +import sys, io, os, argparse, math +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) +if sys.stdout.encoding != 'utf-8': + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') + +import numpy as np +import pandas as pd +import yaml +from datetime import datetime +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from data.transition_matrices import ( + load_transition_matrices, compute_ttc_matrix, + RATING_GRADES, RATING_GRADES_8 +) +from data.ccc_interpolator import expand_to_8x8 +from data.macro_data import _fallback_macro_data, compute_derived_features +from data.ecos_fetcher import load_macro_data as load_ecos_macro +from models.credit_cycle import estimate_zt_series +from models.macro_model import build_macro_zt_model +from scenarios.scenario_engine import ScenarioEngine +from projection.lifetime_pd import LifetimePDEngine +from validation.statistical_tests import run_full_validation + +# ================================================================ +# 스타일 정의 +# ================================================================ +NAVY = "1F3864" +DARK_BLUE = "2B5797" +LIGHT_BLUE = "D6E4F0" +LIGHTER_BLUE = "EDF2F9" +WHITE = "FFFFFF" +BORDER_CLR = "B4C6E7" + +TITLE_FONT = Font(name="맑은 고딕", size=16, bold=True, color=WHITE) +HEADER_FONT = Font(name="맑은 고딕", size=10, bold=True, color=WHITE) +SUBHEADER_FONT = Font(name="맑은 고딕", size=10, bold=True, color=NAVY) +BODY_FONT = Font(name="맑은 고딕", size=9) +BODY_BOLD = Font(name="맑은 고딕", size=9, bold=True) +SMALL_FONT = Font(name="맑은 고딕", size=8, color="666666") +NUM_FONT = Font(name="Consolas", size=9) +PASS_FONT = Font(name="맑은 고딕", size=9, bold=True, color="2E7D32") +FAIL_FONT = Font(name="맑은 고딕", size=9, bold=True, color="C62828") + +TITLE_FILL = PatternFill("solid", fgColor=NAVY) +HEADER_FILL = PatternFill("solid", fgColor=DARK_BLUE) +SUBHEADER_FILL = PatternFill("solid", fgColor=LIGHT_BLUE) +ALT_FILL = PatternFill("solid", fgColor=LIGHTER_BLUE) +PASS_FILL = PatternFill("solid", fgColor="E2EFDA") +FAIL_FILL = PatternFill("solid", fgColor="FCE4EC") + +THIN_BORDER = Border( + left=Side(style='thin', color=BORDER_CLR), + right=Side(style='thin', color=BORDER_CLR), + top=Side(style='thin', color=BORDER_CLR), + bottom=Side(style='thin', color=BORDER_CLR) +) +CENTER = Alignment(horizontal='center', vertical='center', wrap_text=True) +LEFT = Alignment(horizontal='left', vertical='center', wrap_text=True) +RIGHT = Alignment(horizontal='right', vertical='center') + +NUM4 = '0.0000' +NUM2 = '0.00' + + +def _widths(ws, widths): + for i, w in enumerate(widths, 1): + ws.column_dimensions[get_column_letter(i)].width = w + + +def _title(ws, row, text, ncols=10): + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=ncols) + c = ws.cell(row=row, column=1, value=text) + c.font = TITLE_FONT; c.fill = TITLE_FILL; c.alignment = LEFT + ws.row_dimensions[row].height = 35 + + +def _section(ws, row, text, ncols=10): + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=ncols) + c = ws.cell(row=row, column=1, value=text) + c.font = SUBHEADER_FONT; c.fill = SUBHEADER_FILL; c.alignment = LEFT + ws.row_dimensions[row].height = 22 + return row + 1 + + +def _headers(ws, row, hdrs): + for j, h in enumerate(hdrs, 1): + c = ws.cell(row=row, column=j, value=h) + c.font = HEADER_FONT; c.fill = HEADER_FILL; c.alignment = CENTER; c.border = THIN_BORDER + return row + 1 + + +def _row(ws, row, vals, alt=False, fmt=None): + fill = ALT_FILL if alt else PatternFill() + for j, v in enumerate(vals, 1): + c = ws.cell(row=row, column=j, value=v) + c.font = NUM_FONT if isinstance(v, (int, float, np.floating, np.integer)) else BODY_FONT + c.fill = fill; c.border = THIN_BORDER + c.alignment = RIGHT if isinstance(v, (int, float, np.floating, np.integer)) else LEFT + if fmt and isinstance(v, (float, np.floating)): + c.number_format = fmt + return row + 1 + + +def _kv(ws, row, key, value, col=2, fmt=None): + ws.cell(row=row, column=col, value=key).font = BODY_BOLD + cell = ws.cell(row=row, column=col+1, value=value) + cell.font = NUM_FONT if isinstance(value, (int, float, np.floating)) else BODY_FONT + if fmt and isinstance(value, (float, np.floating)): + cell.number_format = fmt + return row + 1 + + +# ================================================================ +# 시트 생성 +# ================================================================ + +def sheet_summary(wb, config, model, zt_dict, diag, z_paths, val_df, pd_engine, pd_results, grades): + ws = wb.active; ws.title = "요약" + _widths(ws, [3,25,18,18,18,18,12,12,12,12]) + r = 1; _title(ws, r, " Lifetime PD 분석 보고서", 10) + r = 2; ws.cell(row=r, column=2, value=f"생성일시: {datetime.now().strftime('%Y-%m-%d %H:%M')}").font = SMALL_FONT + r = 4 + # 1. 모형 개요 + r = _section(ws, r, " 1. 모형 개요", 10) + r = _kv(ws, r, "모형 구조", "Z(t) = c + φ·Z(t-1) + β₁·X₁_std + β₂·X₂_std + β₃·X₃_std + ε") + r = _kv(ws, r, "모형 유형", "AR(1) + Macro (Vasicek Single-Factor)") + r = _kv(ws, r, "적용 기준", "IFRS 9 (2018, 2024 개정)") + r = _kv(ws, r, "변수 선택", ", ".join(model.selected_vars)) + r += 1 + # 2. AR(1) 파라미터 + r = _section(ws, r, " 2. AR(1) 모형 파라미터", 10) + r = _kv(ws, r, "자기회귀 계수 (φ)", model.ar1_phi, fmt=NUM4) + phi = model.ar1_phi + hl = math.log(2)/abs(math.log(abs(phi))) if 0 0: + wpd = wcpd[0, :len(grades)-1] * 100 + else: + wpd = np.zeros(len(grades)-1) + vals = [None, "가중PD(1Y)"] + list(wpd) + r = _row(ws, r, vals, fmt=NUM4) + + +def sheet_tm(wb, tm_raw, tm_floor, ttc, pd_floors, config): + ws = wb.create_sheet("원시데이터_전이행렬") + grades = config.get("model",{}).get("rating_grades", RATING_GRADES) + ng = len(grades) + _widths(ws, [3,12]+[12]*ng) + nc = 2+ng; r=1 + _title(ws, r, " 전이행렬 파이프라인: Original → PD Floor → TTC", nc) + r=3 + # KAP 채권 YTM 기반 PD Floor 산출 과정 + from data.ytm_fetcher import get_ytm_data, compute_spreads, compute_broad_grade_spreads + from data.pd_floor import compute_market_implied_pd + ytm_data = get_ytm_data() + notch_spreads = compute_spreads(ytm_data) + broad_spreads = compute_broad_grade_spreads(notch_spreads) + lgd = 0.60 + rf = ytm_data.get('rf', 0) + r = _section(ws, r, " KAP 채권 YTM → 신용스프레드 → 시장내재 PD (Floor 산출 근거)", nc) + hdr_ytm = ["", "등급", "KAP YTM(%)", "스프레드(bp)", "내재PD(bp)", "Basel III(bp)", "적용Floor(bp)"] + while len(hdr_ytm) < 2 + ng: + hdr_ytm.append("") + r = _headers(ws, r, hdr_ytm[:2 + ng]) + floor_grades = ["AAA", "AA", "A", "BBB", "BB", "B"] + for fg in floor_grades: + ytm_val = None + for notch in [fg, fg + '+', fg + '-']: + if notch in ytm_data: + ytm_val = ytm_data[notch] + break + if ytm_val is None: + ytm_val = rf + sp = broad_spreads.get(fg, 0) + implied_pd = compute_market_implied_pd(sp, lgd) * 10000 + applied = pd_floors.get(fg, 0) * 10000 + v = [None, fg, ytm_val, sp, implied_pd, 5, applied] + while len(v) < 2 + ng: + v.append(None) + r = _row(ws, r, v[:2 + ng], fmt=NUM2) + ws.cell(row=r, column=2, + value=f"기준일: 2025-12-31, 국고1Y: {rf}%, LGD: 60%, 출처: KAP(한국자산평가)").font = SMALL_FONT + r += 1 + ws.cell(row=r, column=2, + value="산식: Implied PD = 1 - exp(-spread_bp / (LGD×10000)), Floor = max(Implied PD, Basel 5bp)").font = SMALL_FONT + r += 2 + # TTC 전이행렬 + r = _section(ws, r, f" TTC 전이행렬 (PD Floor 적용 후, {min(tm_floor.keys())}~{max(tm_floor.keys())} 평균)", nc) + r = _headers(ws, r, ["","From\\To"]+grades) + for i,g in enumerate(grades): + vals = [None,g]+[ttc[i,j] for j in range(min(ng,ttc.shape[1]))] + r = _row(ws, r, vals, alt=i%2==1, fmt=NUM4) + r += 1 + # 전체 연도별 전이행렬 (Floor 적용 후) + for year in sorted(tm_floor.keys()): + r = _section(ws, r, f" {year}년 전이행렬 (PD Floor 적용 후)", nc) + r = _headers(ws, r, ["","From\\To"]+grades) + mat = tm_floor[year] + for i,g in enumerate(grades): + if i < mat.shape[0]: + vals = [None,g]+[mat[i,j] for j in range(min(ng,mat.shape[1]))] + r = _row(ws, r, vals, alt=i%2==1, fmt=NUM4) + r += 1 + + +# 영문→한글 변수명 매핑 +VAR_KOR = { + "GDP_GROWTH": "GDP성장률(%)", "IPI": "광공업생산지수", "SPI": "서비스업생산지수", + "MANUF_CAPACITY": "제조업가동률", "GFCF_GROWTH": "총고정자본증감률", + "CONSTR_INVEST": "건설투자증감률", "FACILITY_INVEST": "설비투자지수", + "RETAIL_SALES": "소매판매액지수", "CSI": "소비자심리지수", "BSI_MANUF": "제조업BSI", + "LEADING_INDEX": "경기선행지수", "COINCIDENT": "경기동행지수", + "EXPORT": "수출(백만달러)", "IMPORT_AMT": "수입(백만달러)", + "TRADE_GNI": "수출입/GNI(%)", "KOSPI": "KOSPI지수", + "INVEST_RATE": "국내총투자율(%)", "SAVING_RATE": "총저축률(%)", + "HOUSING_PRICE": "주택매매가격지수", + "UNEMPLOYMENT": "실업률(%)", "EMPLOYMENT": "고용률(%)", + "EMPLOYED": "취업자수(만명)", "EMPLOYMENT_RATE": "고용률(%)", + "BASE_RATE": "기준금리(%)", "CD_RATE": "CD91일(%)", + "GOVT_3Y": "국고3Y(%)", "GOVT_10Y": "국고10Y(%)", + "CORP_AA": "회사체AA-(%)", "CORP_BBB": "회사체BBB-(%)", + "CPI_GROWTH": "소비자물가상승률(%)", "IMPORT_PRICE": "수입물가지수", + "PPI": "생산자물가지수", "USDKRW": "원/달러환율", + "M2": "M2광의통화(조원)", "DISHONOR_RATE": "어음부도율(%)", + "DISHONOR_AMT": "부도금액(억원)", "HOUSEHOLD_DEBT": "가계부채(조원)", + "CONSTRUCTION": "건설수주액(억원)", "CONSTRUCTION_DONE": "건설기성액", + "CREDIT_SPREAD": "신용스프레드(BBB-AA)", "TERM_SPREAD": "기간스프레드(10Y-3Y)", + "CREDIT_SPREAD_LAG1": "신용스프레드(t-1)", + "EXPORT_DIFF": "수출증감액", "IPI_LAG1": "광공업생산(t-1)", + "CONSTR_INVEST_GR": "건설투자증가율", "CURRENT_ACCOUNT": "경상수지", +} +# 변환 변수 한글명 자동 생성 +TRANSFORM_SUFFIX = {"_LAG2": "(t-2)", "_L": "(log)", "_D": "(차분)", + "_R": "(수익률)", "_LR": "(log수익률)"} + +def _kor(varname): + if varname in VAR_KOR: + return VAR_KOR[varname] + for sfx, label in TRANSFORM_SUFFIX.items(): + if varname.endswith(sfx): + base = varname[:-len(sfx)] + base_kor = VAR_KOR.get(base, base) + return f"{base_kor}{label}" + return varname + +def sheet_macro(wb, macro_data, forced_vars): + ws = wb.create_sheet("원시데이터_거시변수") + display_cols = list(forced_vars) + [c for c in macro_data.columns if c not in forced_vars] + display_cols = [c for c in display_cols if c in macro_data.columns] + _widths(ws, [3,8]+[14]*len(display_cols)) + nc = 2+len(display_cols); r=1 + _title(ws, r, " 원시 데이터: 거시경제변수", nc) + r=3 + r = _section(ws, r, f" ★ 선택 변수: {', '.join([_kor(v) for v in forced_vars])}", nc) + r = _headers(ws, r, ["","연도"]+[_kor(c) for c in display_cols]) + for i,(year,rd) in enumerate(macro_data.iterrows()): + vals = [None,int(year)]+[rd[c] if c in rd and pd.notna(rd[c]) else None for c in display_cols] + r = _row(ws, r, vals, alt=i%2==1, fmt=NUM2) + + +def sheet_zt(wb, zt_dict, macro_data, forced_vars, rho): + ws = wb.create_sheet("Zt_추정") + ncols = 3+len(forced_vars) + _widths(ws, [3,10,14]+[14]*len(forced_vars)) + r=1; _title(ws, r, " Zt 추정 (Belkin & Suchower 1998)", ncols) + r=3 + r = _section(ws, r, " 방법론: 관측 전이행렬 역산 → WLS → Zt", ncols) + zv = np.array(list(zt_dict.values())) + r = _kv(ws, r, "자산상관계수 (ρ)", rho, fmt=NUM4) + r += 1 + # ρ 근거 + r = _section(ws, r, " ρ = 0.20 근거", ncols) + ws.cell(row=r, column=2, value="[1] Basel III IRB: 기업 ρ = 0.12~0.24 (CRE31.6)").font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value=" R = 0.12×(1-e^(-50×PD))/(1-e^(-50)) + 0.24×(1-(1-e^(-50×PD))/(1-e^(-50)))").font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value="[2] BBB(PD≈0.2%) → R=0.208, A(PD≈0.07%) → R=0.217").font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value="[3] 한국 기업 포트폴리오 평균: ρ ≈ 0.20 (투자/투기 혼합)").font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value="[4] Moody's Analytics CreditEdge: single-factor ρ ≈ 0.15~0.25").font = SMALL_FONT; r+=1 + r += 1 + r = _kv(ws, r, "Zt 평균 (μ)", float(zv.mean()), fmt=NUM4) + r = _kv(ws, r, "Zt 표준편차 (σ)", float(zv.std()), fmt=NUM4) + r = _kv(ws, r, "관측 기간", f"{min(zt_dict.keys())}~{max(zt_dict.keys())} ({len(zt_dict)}개년)") + r += 1 + hdrs = ["","연도","Zt"]+forced_vars + r = _headers(ws, r, hdrs) + for i,(year,zt) in enumerate(sorted(zt_dict.items())): + vals = [None,int(year),float(zt)] + for v in forced_vars: + if v in macro_data.columns and year in macro_data.index: + vals.append(macro_data.loc[year,v] if pd.notna(macro_data.loc[year,v]) else None) + else: vals.append(None) + r = _row(ws, r, vals, alt=i%2==1, fmt=NUM4) + + +def sheet_ar1(wb, model, diag): + ws = wb.create_sheet("AR1_모형") + _widths(ws, [3,22,14,14,14,14,14]) + r=1; _title(ws, r, " AR(1) + Macro 회귀 모형", 7) + r=3 + r = _section(ws, r, " Z(t) = c + φ·Z(t-1) + Σ βᵢ·Xᵢ_std(t) + ε(t)", 7) + ws.cell(row=r, column=2, value="※ 거시변수는 표준화(mean=0, std=1) 후 투입. β = '1σ 충격 → ΔZ'로 해석").font = SMALL_FONT + r += 2 + # 계수 + r = _section(ws, r, " 회귀 계수", 7) + r = _headers(ws, r, ["","변수","계수","표준오차","t값","p값","유의성"]) + coef_df = diag.get("coefficients", pd.DataFrame()) + for i,(_,rd) in enumerate(coef_df.iterrows()): + pv = rd.get("p값",1) + sig = "***" if pv<0.01 else "**" if pv<0.05 else "*" if pv<0.10 else "" + vals = [None, rd.get("변수",""), rd.get("계수",0), rd.get("표준오차",0), rd.get("t값",0), pv, sig] + rn = _row(ws, r, vals, alt=i%2==1, fmt=NUM4) + if pv < 0.05: ws.cell(row=r,column=7).font = PASS_FONT + elif pv < 0.10: ws.cell(row=r,column=7).font = Font(name="맑은 고딕",size=9,color="FF8F00") + r = rn + r += 1 + # 진단 — 모형 적합도 + r = _section(ws, r, " 모형 적합도", 7) + for k,v,f in [("R²","r_squared",NUM4),("Adj. R²","adj_r_squared",NUM4), + ("F 통계량","f_stat",NUM4),("F p-value","f_pvalue",NUM4), + ("AIC","aic",NUM2),("BIC","bic",NUM2)]: + r = _kv(ws, r, k, diag.get(v, None), fmt=f) + r += 1 + # 진단 — 잔차 검정 (6개 전항목) + r = _section(ws, r, " 잔차 검정 (6개 전항목)", 7) + r = _headers(ws, r, ["","검정","통계량","p-value","기준","결과","해석"]) + tests_data = [ + ("ADF (Zt 정상성)", diag.get("adf_stat"), diag.get("adf_pvalue"), + "p < 0.05", diag.get("adf_pvalue",1) < 0.05 if diag.get("adf_pvalue") else False, + "BIC lag 선택, H0: 비정상"), + ("Ljung-Box Q(5)", diag.get("ljung_box_stat"), diag.get("ljung_box_pvalue"), + "p > 0.05", diag.get("ljung_box_pvalue",0) > 0.05 if diag.get("ljung_box_pvalue") else False, + "H0: 자기상관 없음"), + ("Durbin-Watson", diag.get("durbin_watson"), None, + "1.5~2.5", 1.5 <= diag.get("durbin_watson",0) <= 2.5 if diag.get("durbin_watson") else False, + "≈2 이상적"), + ("Breusch-Pagan", diag.get("bp_stat"), diag.get("bp_pvalue"), + "p > 0.05", diag.get("bp_pvalue",0) > 0.05 if diag.get("bp_pvalue") else False, + "H0: 등분산"), + ("ARCH-LM", diag.get("arch_stat"), diag.get("arch_pvalue"), + "p > 0.05", diag.get("arch_pvalue",0) > 0.05 if diag.get("arch_pvalue") else False, + "H0: ARCH 효과 없음"), + ("Shapiro-Wilk", diag.get("shapiro_stat"), diag.get("shapiro_pvalue"), + "p > 0.05", diag.get("shapiro_pvalue",0) > 0.05 if diag.get("shapiro_pvalue") else False, + "H0: 정규분포"), + ] + for tname, stat, pval, crit, passed, note in tests_data: + stat_str = f"{stat:.4f}" if stat is not None else "-" + pval_str = f"{pval:.4f}" if pval is not None else "-" + result_str = "Pass ✅" if passed else "Fail ❌" + vals = [None, tname, stat_str, pval_str, crit, result_str, note] + r = _row(ws, r, vals) + if passed: + ws.cell(row=r-1, column=6).font = PASS_FONT + else: + ws.cell(row=r-1, column=6).font = FAIL_FONT + r += 1 + # 변수 통계 + r = _section(ws, r, " 거시변수 표본 통계 (표준화 전 원시값)", 7) + r = _headers(ws, r, ["","변수","평균","표준편차","최근값","",""]) + for var,st in model.ar1_macro_stats.items(): + vals = [None,_kor(var),st["mean"],st["std"],st["last"],None,None] + r = _row(ws, r, vals, fmt=NUM2) + r += 1 + # 경제적 해석 섹션 + r = _section(ws, r, " 변수별 경제적 해석", 7) + interp = { + "CORP_BBB_LAG2": "2년전 BBB금리↑ → 신용위험 잔존 → 부도↑ → Z↓ (시차효과)", + "GFCF_GROWTH_LAG2": "2년전 고정자본투자↑ → 생산능력↑ → 부도↓ → Z↑", + "SAVING_RATE_L": "log(저축률)↑ → 경제안정성↑ → 부도↓ → Z↑", + "HOUSING_PRICE": "주택가격↑ → 담보가치↑ → 차입여력↑ → 부도↓ → Z↑", + "CREDIT_SPREAD_LAG1": "전년 스프레드↑ → 당해 신용위험 전이 → 부도↑ → Z↓ (시차 효과)", + "EXPORT_DIFF": "수출증감↑ → 기업매출↑ → 수익성↑ → 부도↓ → Z↑", + "CURRENT_ACCOUNT": "경상수지↑(흑자) → 불황기 수출의존↑ → Z↓", + "CURRENT_ACCOUNT_R": "경상수지변화율↑ → 대외부문 개선 속도↑ → Z↑ (단기 모멘텀)", + "LEADING_INDEX": "경기선행지수↑ → 3~6개월 후 경기확장 → 부도↓ → Z↑", + "CONSTR_INVEST_GR": "건설투자↑ → 과잉투자/레버리지 → Z↓ (민스키 가설)", + } + for var in model.selected_vars: + beta = model.ar1_beta.get(var, 0) + sign = "+" if beta > 0 else "−" + desc = interp.get(var, "") + ws.cell(row=r, column=2, value=_kor(var)).font = BODY_BOLD + ws.cell(row=r, column=3, value=f"β={beta:+.4f} ({sign})").font = NUM_FONT + ws.cell(row=r, column=4, value=desc).font = SMALL_FONT + ws.merge_cells(start_row=r, start_column=4, end_row=r, end_column=7) + r += 1 + + +def sheet_zpath(wb, z_paths, config): + ws = wb.create_sheet("시나리오_Z경로") + scenarios = list(z_paths.keys()) + nc = 2+len(scenarios) + _widths(ws, [3,10]+[16]*len(scenarios)) + r=1; _title(ws, r, " 시나리오별 Z(t) 경로", nc) + r=3 + r = _section(ws, r, " t=1: 거시 충격 적용 | t≥2: AR(1) 감쇠 → TTC 수렴", nc) + r += 1 + names = [] + for s in scenarios: + c = config.get("scenarios",{}).get(s,{}) + names.append(c.get("name",s)) + r = _headers(ws, r, ["","연도(t+k)"]+names) + horizon = len(list(z_paths.values())[0]) + key_years = list(range(1,11))+[15,20,25,30,40,50] + for t in key_years: + if t <= horizon: + vals = [None,t]+[float(z_paths[s][t-1]) for s in scenarios] + r = _row(ws, r, vals, alt=t%2==0, fmt=NUM4) + + +def sheet_pd(wb, pd_results, config, grades8): + ws = wb.create_sheet("Lifetime_PD") + ng = len(grades8)-1 # D 제외 + _widths(ws, [3,14,8]+[14]*ng) + nc = 3+ng + r=1; _title(ws, r, " 시나리오별 누적 Lifetime PD (%)", nc) + r=3 + ky = [1,2,3,5,7,10,15,20,30,50] + by_sc = pd_results.get("by_scenario", {}) + for sname, sdata in by_sc.items(): + c = config.get("scenarios",{}).get(sname,{}) + dn = c.get("name",sname); w = c.get("weight",0) + r = _section(ws, r, f" {dn} (가중치 {w*100:.0f}%)", nc) + r = _headers(ws, r, ["","시나리오","연도"]+list(grades8[:-1])) + cpd = sdata.get("cumulative_pd", np.zeros((50,ng))) + for t in ky: + if t <= cpd.shape[0]: + vals = [None,dn,t]+[cpd[t-1,g]*100 for g in range(min(ng,cpd.shape[1]))] + r = _row(ws, r, vals, alt=ky.index(t)%2==1, fmt=NUM4) + r += 1 + + +def sheet_weighted(wb, pd_results, config, grades8): + ws = wb.create_sheet("가중평균_PD") + ng = len(grades8)-1 + _widths(ws, [3,8]+[14]*ng) + nc = 2+ng + r=1; _title(ws, r, " 확률가중 Lifetime PD (%)", nc) + r=3 + # IFRS 9 근거 + r = _section(ws, r, " IFRS 9 근거: 확률가중 기대신용손실", nc) + ws.cell(row=r, column=2, value='IFRS 9 B5.5.42: "기대신용손실은 확률가중 금액이어야 하며,').font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value='가능한 결과의 범위를 반영하여야 한다. 단일 가장 가능성 높은 결과가 아닌,').font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value='신용위험의 벽혹을 변경시키는 일반적 경제 조건에 대한 예측을 포함하여야 한다."').font = SMALL_FONT; r+=1 + ws.cell(row=r, column=2, value='IFRS 9 B5.5.44: "최소 2개 시나리오(호황/불황)+확률가중치 = ECL 요구사항을 충족할 수 있다."').font = SMALL_FONT; r+=1 + r += 1 + r = _section(ws, r, " PD_weighted(t) = Σ w_s × PD_s(t)", nc) + wstr = " + ".join([f"{c.get('weight',0)*100:.0f}%×{c.get('name',s)}" for s,c in config.get("scenarios",{}).items()]) + ws.cell(row=r, column=2, value=f"= {wstr}").font = SMALL_FONT + r += 2 + ky = [1,2,3,5,7,10,15,20,30,50] + r = _headers(ws, r, ["","연도"]+list(grades8[:-1])) + wcpd = pd_results.get("weighted_cumulative_pd", np.zeros((50, ng))) + for t in ky: + if t <= wcpd.shape[0]: + wpd = wcpd[t-1,:ng] * 100 + else: + wpd = np.zeros(ng) + vals = [None,t]+list(wpd) + r = _row(ws, r, vals, alt=ky.index(t)%2==1, fmt=NUM4) + + +def sheet_validation(wb, val_df): + ws = wb.create_sheet("검증결과") + _widths(ws, [3,30,22,14,14,10,40]) + r=1; _title(ws, r, " 통계적 검증 결과", 7) + r=3 + cols = list(val_df.columns) + r = _headers(ws, r, [""]+cols) + for i,(_,rd) in enumerate(val_df.iterrows()): + vals = [None]+[rd[c] for c in cols] + rn = _row(ws, r, vals, alt=i%2==1) + # 결과 색상 + result_col = cols.index("결과")+2 if "결과" in cols else None + if result_col: + cell = ws.cell(row=r, column=result_col) + if "Pass" in str(cell.value): + cell.fill = PASS_FILL; cell.font = PASS_FONT + elif "Fail" in str(cell.value): + cell.fill = FAIL_FILL; cell.font = FAIL_FONT + r = rn + + +# ================================================================ +# 메인 +# ================================================================ +def generate_report(config_path="config.yaml", output_path="results/lifetime_pd_report.xlsx"): + print("=" * 60) + print(" Lifetime PD 분석 보고서 생성") + print("=" * 60) + + with open(config_path) as f: + config = yaml.safe_load(f) + + rho = config.get("model",{}).get("rho", 0.20) + grades = config.get("model",{}).get("rating_grades", list(RATING_GRADES)) + forced_vars = config.get("model",{}).get("macro_vars", []) + macro_method = config.get("model",{}).get("macro_method", "ar1_macro") + horizon = config.get("convergence",{}).get("total_horizon", 50) + + from data.pd_floor import apply_pd_floor_to_matrices, build_complete_pd_floor_table + + # 1. 데이터 + print("\n [1/6] 데이터 로딩...") + data_config = config.get("data", {}) + tm_source = data_config.get("transition_source", "real") + tm_dir = data_config.get("transition_dir", None) + tm_all = load_transition_matrices(tm_source, data_dir=tm_dir) + # 2000-2025 필터 + tm_raw = {y:m for y,m in tm_all.items() if 2000 <= y <= 2025} + + # KAP 채권 YTM 기반 PD Floor 적용 + pd_floors, _, pd_floors_full = build_complete_pd_floor_table() + tm = apply_pd_floor_to_matrices(tm_raw, pd_floors) + ttc = compute_ttc_matrix(tm) + + # 거시변수 + macro_data = _fallback_macro_data() + try: + ecos = load_ecos_macro() + if ecos is not None and not ecos.empty: + macro_data = pd.concat([macro_data, ecos], axis=1) + macro_data = macro_data.loc[:,~macro_data.columns.duplicated()] + except: pass + derived = compute_derived_features(macro_data) + if not derived.empty: + macro_data = pd.concat([macro_data, derived], axis=1) + macro_data = macro_data.loc[:,~macro_data.columns.duplicated()] + # 확장 변환: LAG2, log, diff, pctchg, log-return + base_cols = list(macro_data.columns) + for col in base_cols: + s = macro_data[col] + d = s.diff() + if d.std() > 1e-10: + macro_data[f"{col}_D"] = d + pc = s.pct_change().replace([np.inf, -np.inf], np.nan) + if pc.dropna().std() > 1e-10: + macro_data[f"{col}_R"] = pc + if (s > 0).all(): + ls = np.log(s) + if ls.std() > 1e-10: + macro_data[f"{col}_L"] = ls + ld = ls.diff() + if ld.dropna().std() > 1e-10: + macro_data[f"{col}_LR"] = ld + l2 = s.shift(1) + if l2.dropna().std() > 1e-10: + macro_data[f"{col}_LAG2"] = l2 + macro_data = macro_data.ffill().bfill() + macro_data = macro_data.loc[:,~macro_data.columns.duplicated()] + print(f" 전이행렬: {len(tm)}개년 [{tm_source}], PD Floor 적용, 거시변수: {len(macro_data.columns)}개") + + # 2. Zt + print(" [2/6] Zt 추정...") + zt_dict = estimate_zt_series(tm, ttc, rho) + + # 3. AR(1) + print(" [3/6] AR(1)+Macro 적합...") + model = build_macro_zt_model(zt_dict, macro_data, method=macro_method, forced_vars=forced_vars) + diag = model.diagnostics() + # 추가 진단 통계 (AR1 시트 6개 검정용) + zt_arr = np.array([zt_dict[yr] for yr in sorted(zt_dict.keys())]) + from statsmodels.tsa.stattools import adfuller as _adfuller + _adf = _adfuller(zt_arr, autolag="BIC") + diag["adf_stat"] = _adf[0]; diag["adf_pvalue"] = _adf[1] + if model.result is not None: + _resid = model.result.resid + _exog = model.result.model.exog + from statsmodels.stats.diagnostic import acorr_ljungbox as _lb, het_breuschpagan as _bp, het_arch as _arch + from scipy.stats import shapiro as _shapiro + try: + _lbr = _lb(_resid, lags=[5], return_df=True) + diag["ljung_box_stat"] = float(_lbr["lb_stat"].iloc[0]) + diag["ljung_box_pvalue"] = float(_lbr["lb_pvalue"].iloc[0]) + except: pass + try: + _bpr = _bp(_resid, _exog) + diag["bp_stat"] = float(_bpr[0]); diag["bp_pvalue"] = float(_bpr[1]) + except: pass + try: + _ar = _arch(_resid, nlags=3) + diag["arch_stat"] = float(_ar[0]); diag["arch_pvalue"] = float(_ar[1]) + except: pass + try: + _sw = _shapiro(_resid) + diag["shapiro_stat"] = float(_sw.statistic); diag["shapiro_pvalue"] = float(_sw.pvalue) + except: pass + diag["bic"] = float(model.result.bic) if hasattr(model.result, 'bic') else None + print(f" φ={model.ar1_phi:.4f}, R²={diag['r_squared']:.4f}, Adj.R²={diag['adj_r_squared']:.4f}") + + # 4. 시나리오 + print(" [4/6] 시나리오 Z경로...") + engine = ScenarioEngine(config) + z_paths = engine.generate_z_paths(zt_dict, macro_model=model) + weights = engine.get_scenario_weights() + + # 5. Lifetime PD + print(" [5/6] Lifetime PD 산출...") + ttc_8x8 = expand_to_8x8(ttc) if ttc.shape == (7,7) else ttc + pd_engine = LifetimePDEngine(ttc_8x8, rho, rating_grades=RATING_GRADES_8) + pd_results = pd_engine.compute_all_scenarios(z_paths, weights, horizon) + + # 6. 검증 + print(" [6/6] 통계 검증...") + zt_series = pd.Series(zt_dict) + reg_result = model.result + val_df = run_full_validation(zt_series.values, reg_result, pd_results, list(RATING_GRADES[:-1])) + + # ================================================================ + # Excel 생성 + # ================================================================ + print(f"\n Excel 보고서 생성 중...") + wb = Workbook() + sheet_summary(wb, config, model, zt_dict, diag, z_paths, val_df, pd_engine, pd_results, RATING_GRADES_8) + sheet_tm(wb, tm_raw, tm, ttc, pd_floors, config) + sheet_macro(wb, macro_data, forced_vars) + sheet_zt(wb, zt_dict, macro_data, forced_vars, rho) + sheet_ar1(wb, model, diag) + sheet_zpath(wb, z_paths, config) + sheet_pd(wb, pd_results, config, RATING_GRADES_8) + sheet_weighted(wb, pd_results, config, RATING_GRADES_8) + sheet_validation(wb, val_df) + + os.makedirs(os.path.dirname(output_path) or '.', exist_ok=True) + wb.save(output_path) + print(f"\n ✓ 보고서 저장: {output_path}") + print(f" 시트: {len(wb.sheetnames)}개 ({', '.join(wb.sheetnames)})") + return output_path + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Lifetime PD 보고서 생성") + parser.add_argument("--config", default="config.yaml") + parser.add_argument("--output", default="results/lifetime_pd_report.xlsx") + args = parser.parse_args() + generate_report(args.config, args.output) diff --git a/results/lifetime_pd_report.xlsx b/results/lifetime_pd_report.xlsx new file mode 100644 index 0000000..c06e37c Binary files /dev/null and b/results/lifetime_pd_report.xlsx differ diff --git a/results/lifetime_pd_report_v2.xlsx b/results/lifetime_pd_report_v2.xlsx new file mode 100644 index 0000000..0032047 Binary files /dev/null and b/results/lifetime_pd_report_v2.xlsx differ diff --git a/results/lifetime_pd_report_v3.xlsx b/results/lifetime_pd_report_v3.xlsx new file mode 100644 index 0000000..b86d705 Binary files /dev/null and b/results/lifetime_pd_report_v3.xlsx differ diff --git a/results/lifetime_pd_report_v4.xlsx b/results/lifetime_pd_report_v4.xlsx new file mode 100644 index 0000000..d1c1814 Binary files /dev/null and b/results/lifetime_pd_report_v4.xlsx differ diff --git a/results/lifetime_pd_report_v5.xlsx b/results/lifetime_pd_report_v5.xlsx new file mode 100644 index 0000000..6c5ac92 Binary files /dev/null and b/results/lifetime_pd_report_v5.xlsx differ diff --git a/results/lifetime_pd_report_v6.xlsx b/results/lifetime_pd_report_v6.xlsx new file mode 100644 index 0000000..d174088 Binary files /dev/null and b/results/lifetime_pd_report_v6.xlsx differ diff --git a/results/lifetime_pd_report_v7.xlsx b/results/lifetime_pd_report_v7.xlsx new file mode 100644 index 0000000..52fa88e Binary files /dev/null and b/results/lifetime_pd_report_v7.xlsx differ diff --git a/results/report_preview.html b/results/report_preview.html new file mode 100644 index 0000000..2cab592 --- /dev/null +++ b/results/report_preview.html @@ -0,0 +1,376 @@ + + +Lifetime PD Report Preview + + +
+
📊 요약
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Lifetime PD 분석 보고서
생성일시: 2026-03-11 20:50
1. 모형 개요
모형 구조Z(t) = c + φ·Z(t-1) + β₁·X₁_std + β₂·X₂_std + β₃·X₃_std + ε
모형 유형AR(1) + Macro (Vasicek Single-Factor)
적용 기준IFRS 9 (2018, 2024 개정)
변수 선택HOUSING_PRICE, CURRENT_ACCOUNT, CREDIT_SPREAD_LAG1
2. AR(1) 모형 파라미터
자기회귀 계수 (φ)-0.3352
반감기0.6년
절편 (c)-0.1781
β(HOUSING_PRICE)0.6412
β(CURRENT_ACCOUNT)-0.1771
β(CREDIT_SPREAD_LAG1)-0.6439
잔차 σ0.3989
장기 균형 Z-0.1334
3. 모형 적합도
0.6667
Adj. R²0.6000
F p-value0.000130
AIC34.9959
DW2.8731
4. 시나리오 설정
시나리오가중치HOUSING_PRICE (σ)CURRENT_ACCOUNT (σ)CREDIT_SPREAD_LAG1 (σ)Z(t+1)
호황 (Upside)0.200011-11.0527
중립 (Base)0.5000000-0.0553
불황 (Downside)0.3000-1.5000-1.50001.5000-1.7174
5. 1년차 확률가중 PD (%)
AAAAAABBBBBBCCC
가중PD(1Y)0.07880.10850.28171.70666.461413.008931.6688
+
+
+
📊 원시데이터_전이행렬
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
전이행렬 파이프라인: Original → PD Floor → TTC
PD Floor 기준 (Basel III CRE30.4 + S&P/Moody's)
등급Floor PD (bp)
AAA5
AA5
A7
BBB20
BB60
B300
TTC 전이행렬 (PD Floor 적용 후, 1998~2025 평균)
From\ToAAAAAABBBBBBD
AAA0.99110.00842800000.000510
AA0.01730.94170.03670.0035580.0000070.0000070.000733
A00.05320.89650.04290.0012600.0040520.002048
BBB00.0000120.06590.86420.03430.02150.0140
BB000.0032340.04350.80360.08770.0619
B000.0007350.0036780.02770.82480.1431
D0000001
연도별 D열 비교 (Original vs Floor Calibrated)
연도AAA(원시)AA(원시)A(원시)BBB(원시)BB(원시)B(원시)
연도AAAAAABBBBBB
1998(원시)0016.9400105.831436.403944.30
1998(보정)5516.9400105.831436.403944.30
1999(원시)1.80002.500016.5700106.39198.151881.36
1999(보정)5516.5700106.39198.151881.36
2000(원시)1.82005.060016.5200101.54333.23983.95
2000(보정)5.00005.060016.5200101.54333.23983.95
2001(원시)4.06007.990017.3600149.54499.891792.24
2001(보정)5.00007.990017.3600149.54499.891792.24
2002(원시)1.78008.310017.1900129.25479.372136.03
2002(보정)5.00008.310017.1900129.25479.372136.03
2003(원시)3.77007.750016.2300108.94500.14867.74
2003(보정)57.750016.2300108.94500.14867.74
2004(원시)5.79008.420017.7000111.192047.862152.92
2004(보정)5.79008.420017.7000111.192047.862152.92
2005(원시)5.43008.490017.9100100.93239.441652.70
2005(보정)5.43008.490017.9100100.93239.441652.70
2006(원시)3.56007.640015.8000124.46259.23339.46
2006(보정)5.00007.640015.8000124.46259.23339.46
2007(원시)5.15007.770018.9900100.13317.06623.64
2007(보정)5.15007.770018.9900100.13317.06623.64
2008(원시)3.46007.600016.0100165.80847.65739.98
2008(보정)57.600016.0100165.80847.65739.98
2009(원시)5.20007.480015.7600122.281444.451310.57
2009(보정)5.20007.480015.7600122.281444.451310.57
2010(원시)5.10007.470016.5900127.381171.522557.17
2010(보정)5.10007.470016.5900127.381171.522557.17
2011(원시)3.40007.450015.8700234.21530.641499.83
2011(보정)5.00007.450015.8700234.21530.641499.83
2012(원시)3.42007.540064.5399222.52811.323796.36
2012(보정)57.540064.5399222.52811.323796.36
2013(원시)1.75007.450015.7700402.43790.611011.51
2013(보정)5.00007.450015.7700402.43790.611011.51
2014(원시)5.17007.420068.6300210.13669.82688.92
2014(보정)5.17007.420068.6300210.13669.82688.92
2015(원시)5.44007.340017.1000113.011003.991469.78
2015(보정)5.44007.340017.1000113.011003.991469.78
2016(원시)5.16007.340015.9700115.10526.321094.35
2016(보정)5.16007.340015.9700115.10526.321094.35
2017(원시)3.39007.360017.8200123.67232.74584.60
2017(보정)57.360017.8200123.67232.74584.60
2018(원시)07.520017.6600141.91217.93485.42
2018(보정)57.520017.6600141.91217.93485.42
2019(원시)1.69007.510016.5700108.98721.381451.42
2019(보정)57.510016.5700108.98721.381451.42
2020(원시)5.08007.430015.9200104.60180.18499.99
2020(보정)5.08007.430015.9200104.60180.18499.99
2021(원시)5.17007.250016.5700105.93216.50544.44
2021(보정)5.17007.250016.5700105.93216.50544.44
2022(원시)3.47007.340016.6100106.95117.13622.53
2022(보정)57.340016.6100106.95117.13622.53
2023(원시)1.80007.640020.9400160.26982.602376.81
2023(보정)57.640020.9400160.26982.602376.81
2024(원시)5.13007.270017.4800118.96243.331196.36
2024(보정)5.13007.270017.4800118.96243.331196.36
2025(원시)5.12007.410016.5500107.88321.421764.13
2025(보정)5.12007.410016.5500107.88321.421764.13
+
+
+
📊 원시데이터_거시변수
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
원시 데이터: 거시경제변수
★ 선택 변수: HOUSING_PRICE, CURRENT_ACCOUNT, CREDIT_SPREAD_LAG1
연도HOUSING_PRICECURRENT_ACCOUNTCREDIT_SPREAD_LAG1GDP_GROWTHUNEMPLOYMENTBASE_RATECD_RATECPI_GROWTHLEADING_INDEXGOVT_3YCORP_AACORP_BBBIPIEXPORTGOVT_10YIMPORT_AMTUSDKRWM2CSIKOSPIIMPORT_PRICEDISHONOR_RATEHOUSEHOLD_DEBTFACILITY_INVESTRETAIL_SALESEMPLOYEDEMPLOYMENT_RATEOIL_PRICECOINCIDENTBSI_MANUFCONSTRUCTION_DONESPICONSTR_INVEST_GRGFCF_GROWTHSAVING_RATEINVEST_RATETRADE_GNIMANUF_CAPACITYIPI_LAG1EXPORT_DIFF
200055.2000123.508.90004.40005.25007.09002.3000101.208.35009.350011.9000102.501722688.55001604811131651.8010150478.50000.460019462.500072211558.500026.200099.80009056.300058-1.400011.400033.70003172.5000109.50
200156.800080.30002.55004.5000445.34004.100099.50006.70008.120011.270099.50001504397.05001410981291736.5096.500069473.60000.280022558.500073.500021185922.8000988253.800060.20005.60000.600031.700029.300066.3000105.80102.50-21829
200265.300053.90003.15007.40003.30004.25004.99002.8000102.306.06007.02009.7500108.501624716.58001521261251816.3010562872.10000.180030663.20007622176023.7000101.509255.200063.50006.50006.700031.300029.100062.4000110.5099.500012032
200371.5000119.502.73002.90003.60003.75004.24003.500098.80004.93005.70008.9700109.801938175.45001788271192879.209681181.30000.120036060.500074221259.500026.800099.2000855864.800010432.60003065108.20108.5031346
200471284.203.27004.90003.70003.25003.77003.6000100.504.11004.72007.5300119.202538454.73002244631145935.309789690.50000.080039466.500074.5000227259.800033.5000100.808863.5000661.80002.100034.800030.300073.5000113.80109.8060028
200573.5000149.802.81003.90003.70003.75003.81002.8000101.804.27004.68006.51001262844194.950026123810241002.70100.50101199.20000.06004406876.5000229760.300049.3000101.20926668.5000-0.40001.900033.400029.700072.5000114.50119.2030574
200680.200053.90001.83005.20003.50004.50004.72002.2000102.504.83005.25007.08001363254655.17003093839551089.901061434107.800.050049773.500078.5000233460.900061.5000102.809569.500071.20000.50003.400032.500029.600073.2000115.8012641046
200783.500059.50001.83005.50003.200055.36002.5000103.105.23005.70007.4400144.503714895.42003568469291181.60108.501897109.300.040056078.500080237161.300068.4000103.509772.8000741.40004.200032.400029.400077.8000115.2013646024
200884-57.80001.74002.80003.200035.70004.700096.50005.27007.020010.7300148.204220075.570043527511031263.20861124132.500.11006307679238561.500094.300098.50007274.500075.5000-2.8000-1.900031.500031.200096.5000112.80144.5050518
200984.8000328.103.71000.80003.600022.63002.800098.20004.04005.80009.24001403635344.850032308512761404.40851683104.200.100069460.500077.5000235560.100061.800096.50006868.2000760.2000-131.400026.300082102.50148.20-58473
201087282.103.44006.80003.70002.50002.80002.90001033.72004.66007.9800161.504663844.490042521211561504.301072051115.800.060077680.500080.5000239760.400078.1000103957278.5000-1.40005.800033.500029.500087.9000113140102850
201189.5000184.103.32003.70003.40003.25003.55004101.203.62004.41007.75001685552144.050052441311081586.501001826130.200.05008578282242460.7000106102.509073.500080-4.90000.80003429.400096.7000112.50161.5088830
201289508.403.34002.40003.20002.75003.13002.2000100.303.13003.76006.5600168.205478703.350051958411271673.50100.501997123.500.04009347983.5000246861.3000109.10100.50857282.5000-3.2000-0.500033.800028.400096.8000110.20168-7344
201388.8000812.102.80003.20003.10002.50002.72001.3000100.802.79003.19005.8700168.805596323.280051558610951756.2010320111150.040098077.500085250361.6000105.501018871.5000845.40003.30003428.700093.2000108168.2011762
201490.2000843.502.68003.20003.500022.36001.30001012.56002.99005.2200168.505726652.9200525515105318711041916105.600.040010508186.5000254662.400096.7000101.509073.8000861.10003.100034.50002987.6000108.80168.8013033
2015951059.402.23002.80003.60001.50001.72000.7000100.501.80002.18004.61001685267572.250043649911312010103.50196179.50000.0300114584.500088256762.600051.20001018677.500088.50009.10005.10003628.800079.8000107.20168.50-45908
201697.5000992.402.43002.90003.70001.25001.4800199.80001.44001.88004.6000168.504954261.800040619311612151.101002026780.030012508289.500025976341.3000100.208589.50009010.30005.600036.400029.200074.5000106168-31331
2017100752.602.72003.20003.70001.50001.52001.9000101.501.80002.28004.8300174.205736942.330047847811312347.20105246790.50000.020013649292262063.200053.1000101.80929092.50007.30009.800036.600031.100077.3000107.50168.5078268
2018102774.702.55002.90003.80001.75001.85001.5000100.802.10002.67005.41001786048602.560053520211002508.9010220411000.0300149794.500094263363.100069.5000101.508885.500094.5000-4.6000-2.400035.900030.300077.3000107174.2031166
2019104.505972.74002.20003.80001.25001.63000.400099.30001.50001.93004.5200175.505422331.74005033431166269497219892.50000.030015738996.5000266063.500063.4000100828297-3.1000-2.100034.600030.500072.1000102.80178-62627
2020110752.802.5900-0.700040.50000.76000.5000970.98002.03005.25001705124981.520046763311803070.20902873850.02001723100100263062.500042.300097.50007679100-0.10002.600036.300031.300065.8000100175.50-29735
20211228833.22004.30003.700011.09002.5000102.801.43002.26005.64001836444002.120061509311443415.801062978110.500.01001853108.50105267263.800069.30001039677.5000104.50-1.50003.100035.800031.600074.5000105.20170131902
2022128258.303.38002.60002.90003.25003.77005.100099.20003.14004.25008.1800186.506835853.600073137012923561952237140.200.02001903105107.50272664.500097100.508576108-3.5000-0.700034.500031.800085.2000104.5018339185
2023118355.203.93001.40002.70003.50003.75003.600098.80003.55004.40008.40001836327443.78006427561305368096.500026551200.0300192010210627506582.500099.20008072109.50-0.50001.50003430.800080.5000101186.50-50841
202411538042.20002.800033.30002.300099.50003.20003.90007.50001856600003.4200650000135038009824001150.03001950103.50105.50276065.20008099.50008268110-3.30000.800033.50003082101.5018327256
20251123503.60001.800032.750031.800099.80002.80003.50006.80001846500003.1000640000138039009925001100.03001980104106277065.5000751008465111-213329.500081101185-10000
+
+
+
📊 Zt_추정
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Zt 추정 (Belkin & Suchower 1998)
방법론: 관측 전이행렬 역산 → WLS → Zt
자산상관계수 (ρ)0.2000
ρ = 0.20 근거
[1] Basel III IRB: 기업 ρ = 0.12~0.24 (CRE31.6)
R = 0.12×(1-e^(-50×PD))/(1-e^(-50)) + 0.24×(1-(1-e^(-50×PD))/(1-e^(-50)))
[2] BBB(PD≈0.2%) → R=0.208, A(PD≈0.07%) → R=0.217
[3] 한국 기업 포트폴리오 평균: ρ ≈ 0.20 (투자/투기 혼합)
[4] Moody's Analytics CreditEdge: single-factor ρ ≈ 0.15~0.25
Zt 평균 (μ)-0.2001
Zt 표준편차 (σ)0.7673
관측 기간1998~2025 (28개년)
연도ZtHOUSING_PRICECURRENT_ACCOUNTCREDIT_SPREAD_LAG1
1998-2.1505
1999-0.8673
20000.086255.2000123.50
2001-0.719056.800080.30002.5500
2002-0.897065.300053.90003.1500
2003-0.081971.5000119.502.7300
2004-1.304171284.203.2700
2005-0.321673.5000149.802.8100
20061.436180.200053.90001.8300
20070.456283.500059.50001.8300
20080.438584-57.80001.7400
2009-0.629384.8000328.103.7100
2010-1.065187282.103.4400
20110.00068489.5000184.103.3200
2012-1.197989508.403.3400
2013-0.454588.8000812.102.8000
20140.916190.2000843.502.6800
2015-0.4494951059.402.2300
2016-0.012797.5000992.402.4300
20170.4831100752.602.7200
20180.6004102774.702.5500
2019-0.2213104.505972.7400
20200.5464110752.802.5900
20210.45571228833.2200
20220.5197128258.303.3800
2023-0.9198118355.203.9300
20240.11651153804
2025-0.36621123503.6000
+
+
+
📊 AR1_모형
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AR(1) + Macro 회귀 모형
Z(t) = c + φ·Z(t-1) + Σ βᵢ·Xᵢ_std(t) + ε(t)
※ 거시변수는 표준화(mean=0, std=1) 후 투입. β = '1σ 충격 → ΔZ'로 해석
회귀 계수
변수계수표준오차t값p값유의성
const-0.17810.0911-1.95410.0648*
Z_lag1-0.33520.1488-2.25290.0357**
HOUSING_PRICE0.64120.13414.78000.000114***
CURRENT_ACCOUNT-0.17710.1069-1.65700.1131
CREDIT_SPREAD_LAG1-0.64390.1099-5.86050.000010***
모형 진단 통계
0.6667
Adj. R²0.6000
F 통계량10.0014
F p-value0.000130
AIC34.9959
Durbin-Watson2.8731
거시변수 표본 통계 (표준화 전 원시값)
변수평균표준편차최근값
HOUSING_PRICE91.319219.3527112
CURRENT_ACCOUNT422.33334.75350
CREDIT_SPREAD_LAG12.90360.62623.6000
변수별 경제적 해석
HOUSING_PRICEβ=+0.6412 (+)주택가격↑ → 담보가치↑ → 차입여력↑ → 부도↓ → Z↑
CURRENT_ACCOUNTβ=-0.1771 (−)경상수지↑(흑자) → 불황기 수출의존↑ → Z↓ (한국 특수 패턴)
CREDIT_SPREAD_LAG1β=-0.6439 (−)전년 스프레드↑ → 당해 신용위험 전이 → 부도↑ → Z↓ (시차 효과)
+
+
+
📊 시나리오_Z경로
+ + + + + + + + + + + + + + + + + + + + + + +
시나리오별 Z(t) 경로
t=1: 거시 충격 적용 | t≥2: AR(1) 감쇠 → TTC 수렴
연도(t+k)호황 (Upside)중립 (Base)불황 (Downside)
11.0527-0.0553-1.7174
2-0.5309-0.15950.3975
3-0.000133-0.1246-0.3113
4-0.1781-0.1363-0.0737
5-0.1184-0.1324-0.1534
6-0.1384-0.1337-0.1267
7-0.1317-0.1333-0.1356
8-0.1340-0.1334-0.1326
9-0.1332-0.1334-0.1336
10-0.1335-0.1334-0.1333
15-0.1334-0.1334-0.1334
20-0.1334-0.1334-0.1334
25-0.1334-0.1334-0.1334
30-0.1334-0.1334-0.1334
40-0.1334-0.1334-0.1334
50-0.1334-0.1334-0.1334
+
+
+
📊 Lifetime_PD
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
시나리오별 누적 Lifetime PD (%)
호황 (Upside) (가중치 20%)
시나리오연도AAAAAABBBBBBCCC
호황 (Upside)10.0013050.0020380.0078200.10780.82442.511111.0291
호황 (Upside)20.03340.04890.15231.22096.147814.302830.5701
호황 (Upside)30.04540.07120.27282.07239.786921.508639.4917
호황 (Upside)50.07740.14490.70044.577018.565735.887952.8646
호황 (Upside)70.10920.24991.32127.591627.178547.453962.0428
호황 (Upside)100.15840.49102.611312.719239.054960.726372.0198
호황 (Upside)150.24911.17865.587421.781655.027175.288482.7164
호황 (Upside)200.35842.27449.299430.396666.440783.978589.0086
호황 (Upside)300.67015.640617.639144.461579.789192.520195.0957
호황 (Upside)501.849315.268633.093861.294089.545897.333298.4190
중립 (Base) (가중치 50%)
시나리오연도AAAAAABBBBBBCCC
중립 (Base)10.01310.01930.06260.59733.25998.015125.1118
중립 (Base)20.02910.04560.17861.51387.436716.783840.3810
중립 (Base)30.04430.07460.33822.615211.798324.779349.7632
중립 (Base)50.07550.15230.81535.379120.921938.800361.4840
중립 (Base)70.10730.26481.50268.676729.838750.112969.1802
중립 (Base)100.15670.52162.911814.206741.994863.019877.3817
중립 (Base)150.24801.24636.113223.819258.102377.058186.1044
중립 (Base)200.35832.390210.053432.814469.429685.354391.2138
중립 (Base)300.67275.873418.777347.225582.395393.411296.1343
중립 (Base)501.858815.749734.602663.944491.461497.838698.7959
불황 (Downside) (가중치 30%)
시나리오연도AAAAAABBBBBBCCC
불황 (Downside)10.24010.32830.82954.621315.555428.330556.3571
불황 (Downside)20.24630.34711.00835.551318.419533.161763.4497
불황 (Downside)30.26890.40891.44627.565623.798240.928370.4479
불황 (Downside)50.30050.53932.338811.279932.605052.002177.5624
불황 (Downside)70.33410.72843.448715.334240.915360.930682.1168
불황 (Downside)100.38841.13465.454421.612451.862071.080786.9009
불황 (Downside)150.49512.16639.485531.706565.892582.095191.9642
불황 (Downside)200.63163.641214.020840.612975.508988.593594.9255
불황 (Downside)301.03017.712823.332154.201486.273194.893097.7739
불황 (Downside)502.455418.199839.061969.206193.572998.342999.3113
+
+
+
📊 가중평균_PD
+ + + + + + + + + + + + + + + + + +
확률가중 Lifetime PD (%)
PD_weighted(t) = Σ w_s × PD_s(t)
= 20%×호황 (Upside) + 50%×중립 (Base) + 30%×불황 (Downside)
연도AAAAAABBBBBBCCC
10.07880.10850.28171.70666.461413.008931.6688
20.09520.13670.42232.666510.473721.201045.3394
30.11190.17420.65753.991714.996028.969953.9143
50.14340.26691.24946.988923.955642.178464.5837
70.17570.40092.050110.457032.629652.826471.6337
100.22650.69943.614516.130944.367064.979479.1651
150.32241.50887.019725.777859.824378.215387.1847
200.44032.742311.092834.670470.655686.050991.8863
300.77946.378719.916148.765583.037493.677596.4184
502.035916.388535.638664.992891.711797.888898.8751
+
+
+
📊 검증결과
+ + + + + + + + + + + + +
통계적 검증 결과
검정대상통계량p-value결과해석
ADF (Augmented Dickey-Fuller)Zt 시계열-4.87490.0000Pass O정상 시계열 (p=0.0000, α=0.05)
Shapiro-Wilk Normality TestZt 시계열0.98180.8921Pass O정규분포 (p=0.8921, α=0.05)
Ljung-Box Q-test잔차 자기상관12.23060.0318Fail X자기상관 존재 (DW=2.873, LB p=0.0318)
Breusch-Pagan / ARCH-LM잔차 이분산BP=3.34250.5022Pass O등분산 (BP p=0.5022, ARCH p=0.9545)
R² / F-test모형 설명력R²=0.66670.0001Pass OR²=0.667, Adj.R²=0.600
PD PropertiesCumulative PD (upside)--Pass O모든 검증 통과
PD PropertiesCumulative PD (base)--Pass O모든 검증 통과
PD PropertiesCumulative PD (downside)--Pass O모든 검증 통과
+
+ \ No newline at end of file diff --git a/scenarios/scenario_engine.py b/scenarios/scenario_engine.py index 35a67ce..409277d 100644 --- a/scenarios/scenario_engine.py +++ b/scenarios/scenario_engine.py @@ -58,14 +58,17 @@ class ScenarioEngine: """ 시나리오별 Zt 경로 생성 (50년) + AR(1) 모형이 있으면: forecast_z_path() 사용 (macro_shocks 기반) + 없으면: Z-직접 방식 (μ±kσ) fallback + Parameters ---------- zt_history : Dict[int, float] 과거 Zt 시계열 macro_model : MacroZtModel, optional - 거시→Zt 회귀모형 + 거시→Zt 회귀모형 (AR(1) 또는 OLS) macro_scenarios : Dict[str, pd.DataFrame], optional - 시나리오별 거시변수 경로 + (OLS 모형용) 시나리오별 거시변수 경로 base_year : int 기준 연도 @@ -77,55 +80,83 @@ class ScenarioEngine: zt_values = np.array(list(zt_history.values())) z_mean = zt_values.mean() z_std = zt_values.std() + z_last = zt_values[-1] # 마지막 관측값 - logger.info(f"Zt 통계: μ={z_mean:.4f}, σ={z_std:.4f}") + logger.info(f"Zt 통계: μ={z_mean:.4f}, σ={z_std:.4f}, Z_last={z_last:.4f}") z_paths = {} - for scenario_name, scenario_cfg in self.scenario_config.items(): - z_multiplier = scenario_cfg.get("z_multiplier", 0.0) + # AR(1) 모형 사용 경로 + use_ar1 = (macro_model is not None and + hasattr(macro_model, 'is_ar1') and + macro_model.is_ar1) + + if use_ar1: + logger.info("AR(1)+Macro 모형으로 시나리오 경로 생성") - # 시나리오별 초기 Z 수준 - z_scenario = z_mean + z_multiplier * z_std + for scenario_name, scenario_cfg in self.scenario_config.items(): + # config에서 macro_shocks 가져오기 + macro_shocks = scenario_cfg.get("macro_shocks", {}) + + # forecast_z_path로 50년 경로 생성 + z_path = macro_model.forecast_z_path( + z_last=z_last, + macro_shocks=macro_shocks, + horizon=self.total_horizon + ) + + z_paths[scenario_name] = z_path + + logger.info( + f" {scenario_name}: Z[1]={z_path[0]:+.3f}, " + f"Z[5]={z_path[4]:+.3f}, Z[10]={z_path[9]:+.3f}, " + f"Z[50]={z_path[-1]:+.3f}" + ) + else: + # Fallback: Z-직접 방식 + logger.info("Z-직접 방식으로 시나리오 경로 생성 (AR(1) 없음)") - # 거시 모형이 있으면 단기(1-5년) 거시 기반 Zt 예측 - if macro_model is not None and macro_scenarios is not None: - scenario_key = scenario_name - if scenario_key in macro_scenarios: - macro_path = macro_scenarios[scenario_key] - z_short = macro_model.predict(macro_path) - n_short = min(len(z_short), self.pit_horizon) + for scenario_name, scenario_cfg in self.scenario_config.items(): + z_multiplier = scenario_cfg.get("z_multiplier", 0.0) + z_scenario = z_mean + z_multiplier * z_std + + # OLS 거시 모형이 있으면 단기 거시 기반 + if macro_model is not None and macro_scenarios is not None: + scenario_key = scenario_name + if scenario_key in macro_scenarios: + macro_path = macro_scenarios[scenario_key] + z_short = macro_model.predict(macro_path) + n_short = min(len(z_short), self.pit_horizon) + else: + z_short = np.full(self.pit_horizon, z_scenario) + n_short = self.pit_horizon else: z_short = np.full(self.pit_horizon, z_scenario) n_short = self.pit_horizon - else: - z_short = np.full(self.pit_horizon, z_scenario) - n_short = self.pit_horizon - - # 전체 50년 Zt 경로 구성 - z_path = np.zeros(self.total_horizon) - - # Phase 1: PIT 기간 (1~pit_horizon년) - for t in range(min(n_short, self.total_horizon)): - val = z_short[t] if t < len(z_short) else z_scenario - z_path[t] = val if np.isfinite(val) else z_scenario - - # Phase 2: Mean-reversion 기간 (pit_horizon+1 ~ transition_horizon년) - for t in range(self.pit_horizon, min(self.transition_horizon, self.total_horizon)): - decay = np.exp(-self.mean_reversion_lambda * (t - self.pit_horizon + 1)) - z_path[t] = z_path[self.pit_horizon - 1] * decay - - # Phase 3: TTC 기간 (transition_horizon+1 ~ total_horizon년) - for t in range(self.transition_horizon, self.total_horizon): - z_path[t] = 0.0 # TTC (Z=0) - - z_paths[scenario_name] = z_path - - logger.info( - f" {scenario_name}: Z[1]={z_path[0]:+.3f}, " - f"Z[5]={z_path[4]:+.3f}, Z[10]={z_path[9]:+.3f}, " - f"Z[50]={z_path[-1]:+.3f}" - ) + + z_path = np.zeros(self.total_horizon) + + # Phase 1: PIT 기간 + for t in range(min(n_short, self.total_horizon)): + val = z_short[t] if t < len(z_short) else z_scenario + z_path[t] = val if np.isfinite(val) else z_scenario + + # Phase 2: Mean-reversion + for t in range(self.pit_horizon, min(self.transition_horizon, self.total_horizon)): + decay = np.exp(-self.mean_reversion_lambda * (t - self.pit_horizon + 1)) + z_path[t] = z_path[self.pit_horizon - 1] * decay + + # Phase 3: TTC + for t in range(self.transition_horizon, self.total_horizon): + z_path[t] = 0.0 + + z_paths[scenario_name] = z_path + + logger.info( + f" {scenario_name}: Z[1]={z_path[0]:+.3f}, " + f"Z[5]={z_path[4]:+.3f}, Z[10]={z_path[9]:+.3f}, " + f"Z[50]={z_path[-1]:+.3f}" + ) return z_paths diff --git a/validation/statistical_tests.py b/validation/statistical_tests.py index 9bd0313..db29aa7 100644 --- a/validation/statistical_tests.py +++ b/validation/statistical_tests.py @@ -44,7 +44,11 @@ def test_stationarity( ------- dict with test_statistic, p_value, critical_values, is_stationary """ - result = adfuller(series, autolag="AIC") + # BIC를 사용하는 이유: + # - AIC는 소표본(N<50)에서 과다 lag 선택 경향 (Hamilton 1994, Ch.17) + # - N=26에서 AIC → lag=8 → 유효관측치=17 → 검정력 상실 + # - BIC는 보수적 lag 선택 → 소표본에서 적절 (Schwarz 1978) + result = adfuller(series, autolag="BIC") is_stationary = result[1] < significance