feat: Lifetime PD (50yr) - Belkin & Suchower + Vasicek model
- Belkin & Suchower (1998) credit cycle index (Zt) estimation via WLS - Vasicek single-factor conditional PD/TM model - Macro-Zt OLS regression with stepwise variable selection - 3-scenario (boom/neutral/recession) 50yr PD projection - Statistical validation suite (ADF, Ljung-Box, R2, ARCH) - BOK ECOS API integration with fallback data - Visualization module (7 chart types) - Detailed theoretical methodology docs/methodology.md
This commit is contained in:
54
.agent/.agents/AGENT.md
Normal file
54
.agent/.agents/AGENT.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
description: 모든 작업에 자동 적용되는 에이전트 행동 규칙. 새 대화 시작 시 반드시 이 파일을 먼저 읽습니다.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Agent Rules
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
당신은 이 프로젝트의 시니어 개발자입니다. 지시를 정확히 따르고, 추측보다 근거를 우선합니다.
|
||||||
|
|
||||||
|
## NEVER (절대 금지)
|
||||||
|
|
||||||
|
1. NEVER start coding without reading relevant reference documents in `.agents/references/`
|
||||||
|
2. NEVER guess when documentation exists — always check `.agents/references/` first
|
||||||
|
3. NEVER repeat a failed approach — check `.agents/references/known-issues.md` first
|
||||||
|
4. NEVER call APIs directly when helper scripts exist in `.agents/workflows/helpers/`
|
||||||
|
5. NEVER skip the pre-task checklist defined in `.agents/workflows/pre-task.md`
|
||||||
|
6. NEVER attempt the same failed approach more than 2 times
|
||||||
|
7. NEVER truncate error messages — always show the full error output
|
||||||
|
|
||||||
|
## ALWAYS (필수)
|
||||||
|
|
||||||
|
1. ALWAYS run `.agents/workflows/pre-task.md` before any implementation task
|
||||||
|
2. ALWAYS check `.agents/references/known-issues.md` before debugging
|
||||||
|
3. ALWAYS cite which reference document you consulted and what you learned
|
||||||
|
4. ALWAYS stop and ask the user if 2 consecutive attempts on the same approach fail
|
||||||
|
5. ALWAYS use existing helper scripts instead of raw API calls
|
||||||
|
6. ALWAYS read related existing code (minimum 3 files) before writing new code
|
||||||
|
|
||||||
|
## Failure Protocol
|
||||||
|
|
||||||
|
```
|
||||||
|
1st failure → Re-read reference docs → Try DIFFERENT approach
|
||||||
|
2nd failure (same issue) → STOP → Report diagnosis to user with:
|
||||||
|
- What was tried
|
||||||
|
- What failed
|
||||||
|
- Root cause hypothesis
|
||||||
|
- Suggested next steps
|
||||||
|
3rd attempt on same approach → FORBIDDEN
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Loading Order
|
||||||
|
|
||||||
|
1. `.agents/AGENT.md` (this file — behavior rules)
|
||||||
|
2. `.agents/references/known-issues.md` (past failure patterns)
|
||||||
|
3. `.agents/references/` (project-specific knowledge)
|
||||||
|
4. `.agents/workflows/services.md` (service credentials & protocols)
|
||||||
|
5. `.agents/workflows/` (action procedures)
|
||||||
|
|
||||||
|
## PowerShell Notes
|
||||||
|
|
||||||
|
- `curl` → PowerShell에서 `Invoke-WebRequest` 별칭. **반드시 `curl.exe`** 사용
|
||||||
|
- `npm` → 실행 정책 문제 시 `cmd /c npm` 사용
|
||||||
|
- JSON 처리 시 `.py` 스크립트 권장 (PowerShell 이스케이핑 이슈 방지)
|
||||||
163
.agent/.agents/GUIDE.md
Normal file
163
.agent/.agents/GUIDE.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# AI 에이전트 워크플로우 시스템 가이드
|
||||||
|
|
||||||
|
> 이 가이드는 AI 코딩 에이전트가 더 똑똑하게 동작하도록 설계된 범용 워크플로우 시스템의 사용법을 설명합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 왜 이 시스템이 필요한가?
|
||||||
|
|
||||||
|
AI 에이전트는 다음과 같은 문제를 자주 일으킵니다:
|
||||||
|
|
||||||
|
| 문제 | 원인 |
|
||||||
|
|------|------|
|
||||||
|
| 📋 워크플로우를 무시함 | 규칙이 강제가 아닌 권고 사항으로만 작성됨 |
|
||||||
|
| 🔄 같은 실수를 반복함 | 과거 실패 기록을 저장/참조하는 메커니즘 없음 |
|
||||||
|
| 📖 레퍼런스 문서를 안 읽음 | "읽어라"는 강제 지시가 없고, 어떤 문서를 확인할지 불명확 |
|
||||||
|
| 🎲 추측으로 시행착오 | 작업 전 체크리스트(Pre-flight Checklist) 부재 |
|
||||||
|
|
||||||
|
이 시스템은 **13회 웹 검색**, **80+ 소스 분석**, **7개 주요 AI 플랫폼**(Claude, GPT, Gemini, Cursor, Cline, Roo, Windsurf) 연구를 기반으로 설계되었습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 파일 구조 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/
|
||||||
|
├── AGENT.md ← 🧠 에이전트 헌법 (NEVER/ALWAYS 규칙)
|
||||||
|
├── GUIDE.md ← 📖 이 가이드
|
||||||
|
├── references/ ← 📚 프로젝트 지식 베이스
|
||||||
|
│ ├── architecture.md ← 아키텍처 설명
|
||||||
|
│ ├── tech-stack.md ← 기술 스택 & 버전
|
||||||
|
│ ├── conventions.md ← 코딩 컨벤션
|
||||||
|
│ └── known-issues.md ← 🔴 과거 실패 기록 (핵심!)
|
||||||
|
└── workflows/ ← ⚙️ 행동 절차
|
||||||
|
├── start.md ← 세션 시작 (룰 로딩 + devlog 복구)
|
||||||
|
├── end.md ← 세션 종료 (devlog + known-issues + Vikunja + Git)
|
||||||
|
├── pre-task.md ← 작업 전 필수 체크리스트
|
||||||
|
├── debug.md ← 디버깅 전용 절차
|
||||||
|
├── services.md ← 서비스 연동 정보 + AI 작업 프로토콜
|
||||||
|
├── check-gitea.md ← Gitea 현황 조회
|
||||||
|
├── check-vikunja.md ← Vikunja 태스크 조회
|
||||||
|
└── helpers/
|
||||||
|
├── vikunja_helper.py ← Vikunja API 안전 래퍼
|
||||||
|
└── wiki_helper.py ← Gitea Wiki 래퍼
|
||||||
|
```
|
||||||
|
|
||||||
|
**프로젝트 루트에 자동 생성되는 디렉토리:**
|
||||||
|
```
|
||||||
|
docs/devlog/ ← 📓 세션별 작업 기록
|
||||||
|
├── YYYY-MM-DD.md ← Index (매일 1줄씩 누적)
|
||||||
|
└── entries/
|
||||||
|
└── YYYYMMDD-NNN.md ← Entry (설계 결정/미완료 시만)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 각 파일의 역할
|
||||||
|
|
||||||
|
### 🧠 `AGENT.md` — 에이전트 헌법
|
||||||
|
|
||||||
|
에이전트가 **모든 대화에서 따라야 하는 글로벌 규칙**입니다.
|
||||||
|
|
||||||
|
**핵심 메커니즘:**
|
||||||
|
- **NEVER 규칙**: `"절대 ~하지 마라"` — 연구에 따르면 금지 규칙이 더 잘 지켜집니다
|
||||||
|
- **Failure Protocol**: 동일 접근 2회 실패 시 자동 중단 → 유저에게 보고
|
||||||
|
- **Reference Loading Order**: 어떤 문서를 먼저 읽을지 우선순위 명시
|
||||||
|
|
||||||
|
### 📋 `pre-task.md` — 사전 점검 체크리스트
|
||||||
|
|
||||||
|
모든 구현 작업 전에 실행하는 **4단계 체크리스트**:
|
||||||
|
1. 요구사항 정리
|
||||||
|
2. 레퍼런스 확인 (추측 금지)
|
||||||
|
3. 계획 수립
|
||||||
|
4. 유저 확인
|
||||||
|
|
||||||
|
### 🔴 `known-issues.md` — 과거 실패 기록
|
||||||
|
|
||||||
|
**가장 중요한 파일.** 에이전트가 같은 실수를 반복하는 근본 원인은 **실패를 기억하지 못하기 때문**입니다. 이 파일은:
|
||||||
|
- 세션 종료 시 에이전트가 자동으로 새 이슈를 추가
|
||||||
|
- 디버깅/구현 전에 에이전트가 반드시 확인
|
||||||
|
- 시간이 지날수록 **축적 학습** 효과
|
||||||
|
|
||||||
|
### 🔧 `debug.md` — 디버깅 전용 워크플로우
|
||||||
|
|
||||||
|
**추측 기반 디버깅을 금지**하는 5단계 절차:
|
||||||
|
1. 정보 수집 (에러 전문 확인)
|
||||||
|
2. known-issues 확인
|
||||||
|
3. 근본 원인 분석 (가설 → 검증)
|
||||||
|
4. 수정 및 검증
|
||||||
|
5. 기록 (known-issues에 추가)
|
||||||
|
|
||||||
|
### 📓 Devlog — 세션별 작업 기록 (start.md / end.md에서 관리)
|
||||||
|
|
||||||
|
known-issues가 **실패만** 기록한다면, devlog는 **전체 세션 이력**을 기록합니다:
|
||||||
|
- **Index** (`docs/devlog/YYYY-MM-DD.md`): 매 작업마다 1줄 (필수)
|
||||||
|
- **Entry** (`docs/devlog/entries/YYYYMMDD-NNN.md`): 설계 결정/미완료/삽질 시만 (선택)
|
||||||
|
- **start.md**에서 자동으로 오늘/어제 devlog를 읽어 맥락 복구
|
||||||
|
|
||||||
|
### ▶️ `start.md` / ⏹️ `end.md` — 세션 관리
|
||||||
|
|
||||||
|
- **start**: 에이전트 룰 로딩 + devlog 맥락 복구 + Git 상태 + Vikunja TODO
|
||||||
|
- **end**: known-issues 업데이트 + devlog 기록 + Vikunja 동기화 + Git commit/push
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 사용법
|
||||||
|
|
||||||
|
### 새 프로젝트에 적용하기
|
||||||
|
|
||||||
|
1. `.agents/` 디렉토리를 프로젝트에 복사
|
||||||
|
2. `references/` 파일들을 프로젝트에 맞게 채우기:
|
||||||
|
- `architecture.md` — 프로젝트 구조 설명
|
||||||
|
- `tech-stack.md` — 사용 기술 및 버전
|
||||||
|
- `conventions.md` — 코딩 스타일 규칙
|
||||||
|
3. 프로젝트별 워크플로우가 있다면 `workflows/`에 추가
|
||||||
|
|
||||||
|
### 프로젝트별 워크플로우와 함께 사용하기
|
||||||
|
|
||||||
|
이 범용 워크플로우와 프로젝트별 워크플로우(예: Vikunja 동기화, Gitea 연동)는 **함께 사용**합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/
|
||||||
|
├── AGENT.md ← 범용 (공통)
|
||||||
|
├── references/ ← 범용 + 프로젝트 특화
|
||||||
|
│ ├── known-issues.md ← 범용 (공통)
|
||||||
|
│ └── ... ← 프로젝트에 맞게 작성
|
||||||
|
└── workflows/
|
||||||
|
├── pre-task.md ← 범용 (공통)
|
||||||
|
├── debug.md ← 범용 (공통)
|
||||||
|
├── start.md ← 범용 기반 + 프로젝트 단계 추가
|
||||||
|
├── end.md ← 범용 기반 + 프로젝트 단계 추가
|
||||||
|
├── services.md ← ⭐ 프로젝트별
|
||||||
|
├── check-vikunja.md ← ⭐ 프로젝트별
|
||||||
|
├── check-gitea.md ← ⭐ 프로젝트별
|
||||||
|
└── helpers/
|
||||||
|
├── vikunja_helper.py ← ⭐ 프로젝트별
|
||||||
|
└── wiki_helper.py ← ⭐ 프로젝트별
|
||||||
|
```
|
||||||
|
|
||||||
|
### 다른 AI IDE에서도 사용하기
|
||||||
|
|
||||||
|
| 대상 플랫폼 | 방법 |
|
||||||
|
|------------|------|
|
||||||
|
| **Cursor** | `AGENT.md` → `.cursor/rules/agent.mdc` (alwaysApply) |
|
||||||
|
| **Claude Code** | `AGENT.md` → `CLAUDE.md`, references를 `@import` |
|
||||||
|
| **Windsurf** | `AGENT.md` → `.windsurfrules` 또는 `.windsurf/rules/agent.md` |
|
||||||
|
| **Cline/Roo** | 루트에 `AGENTS.md`로 복사 |
|
||||||
|
| **Gemini** | `AGENT.md` → `.gemini/GEMINI.md` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 연구 근거 요약
|
||||||
|
|
||||||
|
이 시스템의 각 설계 결정은 학술 연구와 실무 사례에 근거합니다:
|
||||||
|
|
||||||
|
| 설계 결정 | 근거 |
|
||||||
|
|----------|------|
|
||||||
|
| NEVER > ALWAYS (금지 규칙 우선) | Community 검증 — "NEVER use X" ≫ "always prefer Y" |
|
||||||
|
| 2회 실패 시 자동 중단 | Streak Breaker / Sentinel Check 연구 |
|
||||||
|
| 실패 기록 누적 | Reflexion Framework (텍스트 피드백 기반 자기 교정) |
|
||||||
|
| 사전 체크리스트 강제 | Claude Skills 체크리스트 + GPT Chain-of-Thought |
|
||||||
|
| Progressive Disclosure | Anthropic Context Engineering (2025) |
|
||||||
|
| 300줄 이하 규칙 | Claude `CLAUDE.md` 공식 권장 (토큰 효율성) |
|
||||||
|
| 코드 예시 > 설명 | GitHub Copilot Agents, AGENTS.md 공통 Best Practice |
|
||||||
35
.agent/.agents/references/architecture.md
Normal file
35
.agent/.agents/references/architecture.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
> 이 프로젝트의 아키텍처를 설명하는 문서입니다.
|
||||||
|
> AI 에이전트는 구현 전 이 문서를 반드시 확인합니다.
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
<!-- 프로젝트의 목적과 핵심 기능을 간략히 서술 -->
|
||||||
|
|
||||||
|
(프로젝트 설명을 여기에 작성하세요)
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── src/ # 소스 코드
|
||||||
|
├── tests/ # 테스트
|
||||||
|
├── docs/ # 문서
|
||||||
|
├── .agents/ # AI 에이전트 설정
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심 모듈
|
||||||
|
|
||||||
|
<!-- 각 모듈의 역할과 의존 관계를 설명 -->
|
||||||
|
|
||||||
|
| 모듈 | 역할 | 의존성 |
|
||||||
|
|------|------|--------|
|
||||||
|
| (모듈명) | (역할 설명) | (의존하는 모듈) |
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
<!-- 주요 데이터 흐름을 Mermaid 다이어그램이나 텍스트로 설명 -->
|
||||||
|
|
||||||
|
(데이터 흐름을 여기에 작성하세요)
|
||||||
45
.agent/.agents/references/conventions.md
Normal file
45
.agent/.agents/references/conventions.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
> AI 에이전트는 코드를 작성하기 전 이 컨벤션을 확인합니다.
|
||||||
|
|
||||||
|
## 네이밍
|
||||||
|
|
||||||
|
| 대상 | 규칙 | 예시 |
|
||||||
|
|------|------|------|
|
||||||
|
| 변수/함수 | camelCase | `getUserData()` |
|
||||||
|
| 클래스 | PascalCase | `UserService` |
|
||||||
|
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
|
||||||
|
| 파일명 | kebab-case | `user-service.js` |
|
||||||
|
| CSS 클래스 | kebab-case | `.nav-header` |
|
||||||
|
|
||||||
|
## 코드 스타일
|
||||||
|
|
||||||
|
- 들여쓰기: (2 spaces / 4 spaces / tab)
|
||||||
|
- 세미콜론: (사용 / 미사용)
|
||||||
|
- 따옴표: (single / double)
|
||||||
|
- 줄바꿈: LF (Unix style)
|
||||||
|
|
||||||
|
## 커밋 메시지
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
type: feat|fix|refactor|test|docs|chore|ci|infra
|
||||||
|
scope: (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
**예시:**
|
||||||
|
- `feat(server): add WebSocket reconnection logic`
|
||||||
|
- `fix(frontend): resolve button overlap on mobile`
|
||||||
|
- `docs: update API documentation`
|
||||||
|
|
||||||
|
## 주석
|
||||||
|
|
||||||
|
- 한국어/영어 혼용 가능
|
||||||
|
- TODO 주석: `// TODO: 설명` 형식
|
||||||
|
- 복잡한 로직에는 반드시 WHY(왜) 주석 추가
|
||||||
|
|
||||||
|
## 테스트
|
||||||
|
|
||||||
|
- 테스트 파일 위치: (예: `__tests__/` 또는 `*.test.js`)
|
||||||
|
- 테스트 네이밍: `should [expected behavior] when [condition]`
|
||||||
43
.agent/.agents/references/known-issues.md
Normal file
43
.agent/.agents/references/known-issues.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Known Issues & Lessons Learned
|
||||||
|
|
||||||
|
> **이 파일은 SSOT(Single Source of Truth)입니다.**
|
||||||
|
> 디버깅이나 구현 전에 **반드시** 이 파일을 확인하세요.
|
||||||
|
> 세션 종료 시 새로 발견된 이슈를 이 파일에 추가합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 포맷
|
||||||
|
|
||||||
|
각 항목은 아래 형식을 따릅니다:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [날짜] [키워드] — 한줄 요약
|
||||||
|
- **증상**: 무엇이 잘못되었는가
|
||||||
|
- **원인**: 근본 원인
|
||||||
|
- **해결**: 올바른 해결 방법
|
||||||
|
- **주의**: 재발 방지를 위한 교훈
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 공통 이슈
|
||||||
|
|
||||||
|
### [2026-03-08] PowerShell curl — Invoke-WebRequest 충돌
|
||||||
|
- **증상**: `curl` 명령이 예상과 다른 응답 형식을 반환
|
||||||
|
- **원인**: PowerShell에서 `curl`은 `Invoke-WebRequest`의 별칭
|
||||||
|
- **해결**: **`curl.exe`**를 명시적으로 사용
|
||||||
|
- **주의**: HTTP 관련 모든 명령에서 `curl.exe` 사용 필수
|
||||||
|
|
||||||
|
### [2026-03-08] PowerShell npm — 실행 정책 오류
|
||||||
|
- **증상**: `npm run` 명령이 `실행 정책` 관련 오류로 실패
|
||||||
|
- **원인**: PowerShell 스크립트 실행 정책이 제한적으로 설정됨
|
||||||
|
- **해결**: `cmd /c npm run dev` 형식으로 cmd를 통해 실행
|
||||||
|
- **주의**: npm 관련 명령은 항상 `cmd /c` 접두어 사용 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프로젝트별 이슈
|
||||||
|
|
||||||
|
> 아래에 프로젝트 특화 이슈를 추가하세요.
|
||||||
|
|
||||||
|
(아직 기록된 프로젝트별 이슈가 없습니다)
|
||||||
37
.agent/.agents/references/tech-stack.md
Normal file
37
.agent/.agents/references/tech-stack.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Tech Stack
|
||||||
|
|
||||||
|
> AI 에이전트는 구현 전 이 문서를 확인하여 올바른 기술/버전을 사용합니다.
|
||||||
|
|
||||||
|
## 언어 & 런타임
|
||||||
|
|
||||||
|
| 항목 | 버전 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| (예: Node.js) | (예: 20.x) | (설치 경로 등) |
|
||||||
|
| (예: Python) | (예: 3.12) | (가상환경 경로 등) |
|
||||||
|
|
||||||
|
## 프레임워크
|
||||||
|
|
||||||
|
| 항목 | 버전 | 용도 |
|
||||||
|
|------|------|------|
|
||||||
|
| (예: Express) | (예: 4.18) | (서버) |
|
||||||
|
| (예: React) | (예: 18.x) | (프론트엔드) |
|
||||||
|
|
||||||
|
## 패키지 관리
|
||||||
|
|
||||||
|
- 패키지 매니저: (npm / yarn / pnpm / pip 등)
|
||||||
|
- Lock 파일: (package-lock.json / yarn.lock 등)
|
||||||
|
|
||||||
|
## 개발 도구
|
||||||
|
|
||||||
|
| 도구 | 명령어 |
|
||||||
|
|------|--------|
|
||||||
|
| 개발 서버 | (예: `cmd /c npm run dev`) |
|
||||||
|
| 빌드 | (예: `cmd /c npm run build`) |
|
||||||
|
| 테스트 | (예: `cmd /c npm test`) |
|
||||||
|
| 린트 | (예: `cmd /c npm run lint`) |
|
||||||
|
|
||||||
|
## 환경 변수
|
||||||
|
|
||||||
|
| 변수명 | 용도 | 기본값 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| (예: PORT) | (서버 포트) | (3000) |
|
||||||
40
.agent/.agents/workflows/check-gitea.md
Normal file
40
.agent/.agents/workflows/check-gitea.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: Gitea API로 저장소 커밋/이슈/PR 현황을 조회하는 워크플로우
|
||||||
|
---
|
||||||
|
|
||||||
|
# Gitea 저장소 현황 조회
|
||||||
|
|
||||||
|
서비스 정보는 `.agents/workflows/services.md` 참조.
|
||||||
|
|
||||||
|
// turbo-all
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. 최근 커밋 조회 (최신 10개):
|
||||||
|
```powershell
|
||||||
|
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||||
|
$commits = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/LifetimePD/commits?limit=10&sha=main" -Headers $h
|
||||||
|
$commits | ForEach-Object { Write-Host "$($_.sha.Substring(0,7)) $($_.commit.message.Split("`n")[0])" }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 열린 이슈 조회:
|
||||||
|
```powershell
|
||||||
|
$h = @{Authorization="token 3a01b4b15a39921572e64c413353e870d4d2161b"}
|
||||||
|
$issues = Invoke-RestMethod -Uri "https://git.variet.net/api/v1/repos/Variet/LifetimePD/issues?state=open&type=issues" -Headers $h
|
||||||
|
$issues | ForEach-Object { Write-Host "#$($_.number) $($_.title)" }
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Wiki 페이지 목록:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\wiki_helper.py list
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Wiki 페이지 읽기:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\wiki_helper.py read "Architecture"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Wiki 페이지 업데이트:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\wiki_helper.py update "페이지-제목" /tmp/wiki_content.md
|
||||||
|
```
|
||||||
41
.agent/.agents/workflows/check-vikunja.md
Normal file
41
.agent/.agents/workflows/check-vikunja.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
description: Vikunja API로 프로젝트 태스크 현황을 조회하는 워크플로우
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vikunja 태스크 현황 조회
|
||||||
|
|
||||||
|
서비스 정보는 `.agents/workflows/services.md` 참조.
|
||||||
|
|
||||||
|
// turbo-all
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
1. 전체 목록:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py list
|
||||||
|
```
|
||||||
|
|
||||||
|
2. TODO만:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py list todo
|
||||||
|
```
|
||||||
|
|
||||||
|
3. DONE만:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py list done
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 태스크 완료 처리 (**⚠️ 반드시 이 방법 사용 — 직접 API 호출 금지**):
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. 새 태스크 생성:
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **절대로** `Invoke-RestMethod -Method Post -Body '{"done": true}'` 같은 직접 API 호출을 사용하지 마세요.
|
||||||
|
> Vikunja API는 POST 시 body에 포함되지 않은 필드를 빈값으로 덮어씁니다.
|
||||||
|
> `vikunja_helper.py`는 항상 GET → 기존 필드 보존 → POST 패턴을 사용합니다.
|
||||||
52
.agent/.agents/workflows/debug.md
Normal file
52
.agent/.agents/workflows/debug.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
description: 에러/버그 발생 시 체계적 디버깅 워크플로우 (에러, 안돼요, 왜 안돼, 버그, 디버그, 수정)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Debug Workflow
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 추측으로 코드를 수정하지 마세요. 반드시 이 순서를 따릅니다.
|
||||||
|
|
||||||
|
## 1단계: 정보 수집 (추측 금지)
|
||||||
|
|
||||||
|
- [ ] 에러 메시지 **전문** 확인 (절대 잘라내지 않기)
|
||||||
|
- [ ] 관련 로그 파일 확인
|
||||||
|
- [ ] 환경 정보 확인 (OS, Node/Python 버전, 의존성 버전 등)
|
||||||
|
- [ ] 에러가 발생하는 **정확한 입력/조건** 파악
|
||||||
|
|
||||||
|
## 2단계: Known Issues 확인
|
||||||
|
|
||||||
|
`.agents/references/known-issues.md`를 읽고 동일하거나 유사한 문제가 있는지 확인합니다.
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **known-issues 확인 없이 해결 시도를 시작하지 마세요.**
|
||||||
|
> 이미 해결된 문제를 다시 삽질하는 것은 시간 낭비입니다.
|
||||||
|
|
||||||
|
## 3단계: 근본 원인 분석
|
||||||
|
|
||||||
|
- [ ] 에러가 발생하는 **정확한 코드 위치** 확인
|
||||||
|
- [ ] 가설을 세우고, 가설을 검증할 수 있는 **최소한의 테스트** 수행
|
||||||
|
- [ ] 가설이 틀렸다면 **즉시 다른 가설로 전환**
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **동일한 접근을 2회 초과 시도하지 마세요.**
|
||||||
|
> 2회 실패 시 유저에게 보고하고 판단을 요청합니다.
|
||||||
|
> 보고 내용: 시도한 것 / 실패한 것 / 원인 가설 / 다음 제안
|
||||||
|
|
||||||
|
## 4단계: 수정 및 검증
|
||||||
|
|
||||||
|
- [ ] 수정 적용
|
||||||
|
- [ ] 동일 에러가 재현되지 않는지 확인
|
||||||
|
- [ ] 사이드 이펙트(다른 기능에 영향) 없는지 확인
|
||||||
|
|
||||||
|
## 5단계: 기록
|
||||||
|
|
||||||
|
- [ ] `known-issues.md`에 새 항목 추가 (아래 포맷 사용)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [날짜] [키워드] — 한줄 요약
|
||||||
|
- **증상**: 무엇이 잘못되었는가
|
||||||
|
- **원인**: 근본 원인
|
||||||
|
- **해결**: 올바른 해결 방법
|
||||||
|
- **주의**: 재발 방지를 위한 교훈
|
||||||
|
```
|
||||||
165
.agent/.agents/workflows/end.md
Normal file
165
.agent/.agents/workflows/end.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
---
|
||||||
|
description: 세션 종료 시 devlog 기록 + git commit + Vikunja 동기화 (끝, 마무리, 커밋해, 완료)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 세션 종료 프로토콜
|
||||||
|
|
||||||
|
작업 완료, "끝", "마무리", "커밋해" 등 요청 시 이 워크플로우를 실행합니다.
|
||||||
|
|
||||||
|
// turbo-all
|
||||||
|
|
||||||
|
## 0. 학습 기록 (실패/시행착오 저장)
|
||||||
|
|
||||||
|
이번 세션에서 발생한 실패, 시행착오, 새로 알게 된 사실을 정리합니다:
|
||||||
|
|
||||||
|
- [ ] `.agents/references/known-issues.md`에 추가할 항목이 있는지 확인
|
||||||
|
- [ ] 있다면 아래 포맷으로 추가:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### [날짜] [키워드] — 한줄 요약
|
||||||
|
- **증상**: ...
|
||||||
|
- **원인**: ...
|
||||||
|
- **해결**: ...
|
||||||
|
- **주의**: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Devlog 기록
|
||||||
|
|
||||||
|
### Index 업데이트 (필수 — 매 작업)
|
||||||
|
|
||||||
|
오늘 날짜의 index 파일에 완료된 작업 1줄을 추가합니다.
|
||||||
|
|
||||||
|
- **파일**: `docs/devlog/YYYY-MM-DD.md`
|
||||||
|
- **형식**:
|
||||||
|
```markdown
|
||||||
|
| NNN | HH:MM | 작업 설명 | `커밋해시` | ✅ 또는 🔧 |
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> - ✅ = 완료, 🔧 = 미완료 (다음 세션에서 이어받기)
|
||||||
|
> - 파일이 없으면 새로 생성 (테이블 헤더 포함)
|
||||||
|
|
||||||
|
### Entry 작성 (선택적 — 필요할 때만)
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Entry는 **git/Vikunja/wiki에 없는 정보**가 있을 때만 작성합니다.
|
||||||
|
|
||||||
|
**Entry 작성 기준:**
|
||||||
|
- ✅ 설계 결정이 있었을 때 (왜 A가 아닌 B를 선택했는지)
|
||||||
|
- ✅ 미완료 사항이 있을 때 (다음 세션이 이어받아야 할 맥락)
|
||||||
|
- ✅ 삽질/트러블슈팅이 있었을 때 (같은 실수 방지)
|
||||||
|
|
||||||
|
**Entry 불필요:**
|
||||||
|
- ❌ 단순 버그 픽스 (커밋 메시지로 충분)
|
||||||
|
- ❌ 문서 업데이트 (git diff로 충분)
|
||||||
|
- ❌ 이미 Vikunja 태스크에 상세 설명이 있는 경우
|
||||||
|
|
||||||
|
**Entry 파일**: `docs/devlog/entries/YYYYMMDD-NNN.md`
|
||||||
|
```markdown
|
||||||
|
# 작업 제목
|
||||||
|
|
||||||
|
- **시간**: YYYY-MM-DD HH:MM~HH:MM
|
||||||
|
- **Commit**: `해시`
|
||||||
|
- **Vikunja**: #태스크번호 → done/진행중
|
||||||
|
|
||||||
|
## 결정 사항
|
||||||
|
- 왜 이 방식을 선택했는지
|
||||||
|
|
||||||
|
## 미완료
|
||||||
|
- 남은 작업 (있을 경우)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Vikunja 동기화
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **반드시 `vikunja_helper.py` 사용.** 직접 API 호출 금지.
|
||||||
|
> Vikunja API는 POST 시 body에 없는 필드를 빈값으로 덮어씁니다.
|
||||||
|
|
||||||
|
### 2-1. 커밋 전수 검사
|
||||||
|
|
||||||
|
이번 세션의 **모든 커밋을 하나씩 검사**하고 Vikunja에 매핑합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git log --oneline -20
|
||||||
|
```
|
||||||
|
|
||||||
|
| 커밋 유형 | Vikunja 액션 |
|
||||||
|
|-----------|-------------|
|
||||||
|
| 기존 태스크 해당 작업 **완료** | `python .agents\workflows\helpers\vikunja_helper.py done {ID}` |
|
||||||
|
| 신규 작업 완료 (기존 태스크 없음) | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --done --labels Backend,Priority:High` |
|
||||||
|
| 작업 중 발견된 **미완료 TODO** | `python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:Mid` |
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 모든 커밋이 기존 또는 신규 태스크에 매핑되었는지 확인.
|
||||||
|
|
||||||
|
### 2-2. 완료 처리
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-3. 신규 태스크 생성
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py create "제목" "설명" --labels Backend,Priority:High
|
||||||
|
```
|
||||||
|
|
||||||
|
### 라벨 규칙
|
||||||
|
|
||||||
|
**영역 (필수 1개 이상):** `Backend` / `Frontend` / `Engine` / `Infra` / `Test`
|
||||||
|
**우선순위 (필수 1개):** `Priority:High` / `Priority:Mid` / `Priority:Low`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Wiki 동기화 (해당 시에만)
|
||||||
|
|
||||||
|
| 코드 변경 | 대상 Wiki |
|
||||||
|
|-----------|----------|
|
||||||
|
| 서버 변경 | Architecture |
|
||||||
|
| 프론트엔드 변경 | Architecture |
|
||||||
|
| 인프라 변경 | Architecture |
|
||||||
|
| 새 모듈/패키지 추가 | Architecture |
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\wiki_helper.py update "Architecture" /tmp/wiki_content.md
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Git Commit & Push
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add -A
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
```powershell
|
||||||
|
git commit -m "커밋 메시지"
|
||||||
|
```
|
||||||
|
```powershell
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
**커밋 메시지 컨벤션:**
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
type: feat|fix|refactor|test|docs|chore|ci|infra
|
||||||
|
scope: (선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 최종 체크리스트
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> 아래 항목 중 하나라도 누락되면 세션 종료를 완료할 수 없습니다.
|
||||||
|
|
||||||
|
- [ ] known-issues 업데이트됨 (새 이슈가 있었다면)
|
||||||
|
- [ ] devlog index 업데이트됨
|
||||||
|
- [ ] devlog entry 작성됨 (필요한 경우만)
|
||||||
|
- [ ] Vikunja 태스크 생성/완료 처리됨 (커밋 전수 검사 기반)
|
||||||
|
- [ ] Wiki 동기화됨 (아키텍처 변경이 있었다면)
|
||||||
|
- [ ] git push 완료
|
||||||
|
- [ ] 사용자에게 완료 보고
|
||||||
217
.agent/.agents/workflows/helpers/vikunja_helper.py
Normal file
217
.agent/.agents/workflows/helpers/vikunja_helper.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
"""Vikunja safe task updater — preserves existing fields when updating tasks.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python vikunja_helper.py done 75 # Mark task #75 as done
|
||||||
|
python vikunja_helper.py done 71 77 78 # Mark multiple tasks done
|
||||||
|
python vikunja_helper.py undone 75 # Mark task #75 as not done
|
||||||
|
python vikunja_helper.py comment 75 "text" # Add comment to task #75
|
||||||
|
python vikunja_helper.py desc 75 "text" # Set description (appends if exists)
|
||||||
|
python vikunja_helper.py create "title" "desc" --labels Backend,Priority:High
|
||||||
|
python vikunja_helper.py create "title" "desc" --done --labels Frontend,Priority:Mid
|
||||||
|
python vikunja_helper.py label 75 Backend Priority:High # Add labels to task
|
||||||
|
python vikunja_helper.py list # List all tasks
|
||||||
|
python vikunja_helper.py list todo # List TODO only
|
||||||
|
python vikunja_helper.py list done # List DONE only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Fix Windows console encoding (cp949 → utf-8)
|
||||||
|
if sys.stdout.encoding != "utf-8":
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ⚙️ CONFIGURATION — PROJECT_ID만 프로젝트별로 변경하세요
|
||||||
|
# ============================================================
|
||||||
|
API_BASE = "https://plan.variet.net/api/v1"
|
||||||
|
TOKEN = "tk_070f8e0b715e818bb7178c3815ed5389040eddca"
|
||||||
|
PROJECT_ID = 10 # ← 프로젝트별 변경 필요 (e.g. 9)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
HEADERS = {
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Label name → Vikunja label ID mapping
|
||||||
|
# Customize for your project's labels
|
||||||
|
LABEL_MAP = {
|
||||||
|
"Backend": 1, "Frontend": 2, "Engine": 3, "Infra": 4, "Test": 5,
|
||||||
|
"Priority:High": 6, "Priority:Mid": 7, "Priority:Low": 8,
|
||||||
|
"Agent": 17, "Tool": 18, "AI/LLM": 19,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api_get(path: str):
|
||||||
|
req = urllib.request.Request(f"{API_BASE}{path}", headers=HEADERS)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def api_post(path: str, data: dict):
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="POST")
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def api_put(path: str, data: dict):
|
||||||
|
body = json.dumps(data).encode("utf-8")
|
||||||
|
req = urllib.request.Request(f"{API_BASE}{path}", data=body, headers=HEADERS, method="PUT")
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_task(task_id: int) -> dict:
|
||||||
|
return api_get(f"/tasks/{task_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def safe_update_task(task_id: int, updates: dict) -> dict:
|
||||||
|
task = get_task(task_id)
|
||||||
|
safe_body = {
|
||||||
|
"title": task.get("title", ""),
|
||||||
|
"description": task.get("description", ""),
|
||||||
|
"priority": task.get("priority", 0),
|
||||||
|
"done": task.get("done", False),
|
||||||
|
}
|
||||||
|
safe_body.update(updates)
|
||||||
|
return api_post(f"/tasks/{task_id}", safe_body)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_done(task_ids: list):
|
||||||
|
for tid in task_ids:
|
||||||
|
result = safe_update_task(tid, {"done": True})
|
||||||
|
title = result.get("title", "?")
|
||||||
|
print(f" ✅ #{tid} → done=True [{title}]")
|
||||||
|
|
||||||
|
|
||||||
|
def mark_undone(task_ids: list):
|
||||||
|
for tid in task_ids:
|
||||||
|
result = safe_update_task(tid, {"done": False})
|
||||||
|
title = result.get("title", "?")
|
||||||
|
print(f" ⬜ #{tid} → done=False [{title}]")
|
||||||
|
|
||||||
|
|
||||||
|
def add_comment(task_id: int, comment: str):
|
||||||
|
result = api_put(f"/tasks/{task_id}/comments", {"comment": comment})
|
||||||
|
print(f" 💬 #{task_id} comment added (id={result.get('id', '?')})")
|
||||||
|
|
||||||
|
|
||||||
|
def set_description(task_id: int, desc: str, append: bool = True):
|
||||||
|
task = get_task(task_id)
|
||||||
|
existing = task.get("description", "") or ""
|
||||||
|
if append and existing:
|
||||||
|
new_desc = existing.rstrip() + "\n\n" + desc
|
||||||
|
else:
|
||||||
|
new_desc = desc
|
||||||
|
result = safe_update_task(task_id, {"description": new_desc})
|
||||||
|
print(f" 📝 #{task_id} description updated [{result.get('title', '?')}]")
|
||||||
|
|
||||||
|
|
||||||
|
def list_tasks(filter_: str = "all"):
|
||||||
|
all_tasks = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
batch = api_get(f"/projects/{PROJECT_ID}/tasks?per_page=50&page={page}")
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
all_tasks.extend(batch)
|
||||||
|
if len(batch) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
if filter_ == "todo":
|
||||||
|
all_tasks = [t for t in all_tasks if not t["done"]]
|
||||||
|
elif filter_ == "done":
|
||||||
|
all_tasks = [t for t in all_tasks if t["done"]]
|
||||||
|
|
||||||
|
all_tasks.sort(key=lambda t: t["id"])
|
||||||
|
for t in all_tasks:
|
||||||
|
status = "✅" if t["done"] else "⬜"
|
||||||
|
desc = (t.get("description") or "")[:50].replace("\n", " ")
|
||||||
|
labels = ", ".join(l["title"] for l in (t.get("labels") or []))
|
||||||
|
print(f" {status} #{t['id']:3d} {t['title'][:40]:<40} [{labels}] {desc}")
|
||||||
|
print(f"\n Total: {len(all_tasks)} tasks")
|
||||||
|
|
||||||
|
|
||||||
|
def add_labels(task_id: int, label_names: list):
|
||||||
|
for name in label_names:
|
||||||
|
label_id = LABEL_MAP.get(name)
|
||||||
|
if not label_id:
|
||||||
|
print(f" ⚠️ Unknown label '{name}'. Valid: {', '.join(LABEL_MAP.keys())}")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
api_put(f"/tasks/{task_id}/labels", {"label_id": label_id})
|
||||||
|
print(f" 🏷️ #{task_id} + {name} (id={label_id})")
|
||||||
|
except Exception as e:
|
||||||
|
if "already" in str(e).lower() or "409" in str(e):
|
||||||
|
print(f" 🏷️ #{task_id} already has {name}")
|
||||||
|
else:
|
||||||
|
print(f" ⚠️ #{task_id} label {name} failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_task(title: str, description: str = "", done: bool = False, labels: list = None):
|
||||||
|
payload = {"title": title, "description": description}
|
||||||
|
result = api_put(f"/projects/{PROJECT_ID}/tasks", payload)
|
||||||
|
task_id = result["id"]
|
||||||
|
print(f" ✨ #{task_id} created: {result.get('title', '?')}")
|
||||||
|
|
||||||
|
if labels:
|
||||||
|
add_labels(task_id, labels)
|
||||||
|
|
||||||
|
if done:
|
||||||
|
result = safe_update_task(task_id, {"done": True})
|
||||||
|
print(f" ✅ #{task_id} → done=True")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
return
|
||||||
|
|
||||||
|
cmd = sys.argv[1].lower()
|
||||||
|
|
||||||
|
if cmd == "done":
|
||||||
|
ids = [int(x) for x in sys.argv[2:]]
|
||||||
|
mark_done(ids)
|
||||||
|
elif cmd == "undone":
|
||||||
|
ids = [int(x) for x in sys.argv[2:]]
|
||||||
|
mark_undone(ids)
|
||||||
|
elif cmd == "comment":
|
||||||
|
add_comment(int(sys.argv[2]), sys.argv[3])
|
||||||
|
elif cmd == "desc":
|
||||||
|
set_description(int(sys.argv[2]), sys.argv[3])
|
||||||
|
elif cmd == "list":
|
||||||
|
f = sys.argv[2] if len(sys.argv) > 2 else "all"
|
||||||
|
list_tasks(f)
|
||||||
|
elif cmd == "label":
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("Usage: vikunja_helper.py label TASK_ID Label1 Label2 ...")
|
||||||
|
return
|
||||||
|
add_labels(int(sys.argv[2]), sys.argv[3:])
|
||||||
|
elif cmd == "create":
|
||||||
|
title = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
|
desc = sys.argv[3] if len(sys.argv) > 3 and not sys.argv[3].startswith("--") else ""
|
||||||
|
is_done = "--done" in sys.argv
|
||||||
|
labels = None
|
||||||
|
for i, arg in enumerate(sys.argv):
|
||||||
|
if arg == "--labels" and i + 1 < len(sys.argv):
|
||||||
|
labels = sys.argv[i + 1].split(",")
|
||||||
|
break
|
||||||
|
if not title:
|
||||||
|
print("Error: title is required")
|
||||||
|
return
|
||||||
|
create_task(title, desc, done=is_done, labels=labels)
|
||||||
|
else:
|
||||||
|
print(f"Unknown command: {cmd}")
|
||||||
|
print(__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
100
.agent/.agents/workflows/helpers/wiki_helper.py
Normal file
100
.agent/.agents/workflows/helpers/wiki_helper.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Gitea Wiki helper: list, read, create, update wiki pages.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
wiki_helper.py list — list all pages
|
||||||
|
wiki_helper.py read <title> — read a page
|
||||||
|
wiki_helper.py create <title> <file> — create a page from file
|
||||||
|
wiki_helper.py update <title> <file> — update a page from file
|
||||||
|
"""
|
||||||
|
import sys, io, json, base64, urllib.request, urllib.error
|
||||||
|
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ⚙️ CONFIGURATION — GITEA_REPO만 프로젝트별로 변경하세요
|
||||||
|
# ============================================================
|
||||||
|
GITEA_BASE_URL = "https://git.variet.net"
|
||||||
|
GITEA_OWNER = "Variet"
|
||||||
|
GITEA_REPO = "LifetimePD" # ← 프로젝트별 변경 필요
|
||||||
|
GITEA_TOKEN = "3a01b4b15a39921572e64c413353e870d4d2161b"
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
BASE = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{GITEA_REPO}/wiki"
|
||||||
|
HEADERS = {"Authorization": f"token {GITEA_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def _req(method, path, data=None):
|
||||||
|
url = f"{BASE}{path}"
|
||||||
|
body = json.dumps(data).encode() if data else None
|
||||||
|
req = urllib.request.Request(url, data=body, headers=HEADERS, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
err = e.read().decode()
|
||||||
|
print(f" ⚠️ HTTP {e.code}: {err}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_sub_url(title):
|
||||||
|
pages = _req("GET", "/pages")
|
||||||
|
if pages:
|
||||||
|
for p in pages:
|
||||||
|
if p.get("title", "").lower() == title.lower():
|
||||||
|
return p.get("sub_url", title)
|
||||||
|
return title
|
||||||
|
|
||||||
|
def list_pages():
|
||||||
|
pages = _req("GET", "/pages")
|
||||||
|
if pages:
|
||||||
|
print(f"=== {len(pages)} Wiki Pages ===")
|
||||||
|
for p in pages:
|
||||||
|
print(f" {p.get('title', '?')}")
|
||||||
|
return pages
|
||||||
|
|
||||||
|
def read_page(title):
|
||||||
|
sub = _find_sub_url(title)
|
||||||
|
page = _req("GET", f"/page/{sub}")
|
||||||
|
if page and page.get("content_base64"):
|
||||||
|
content = base64.b64decode(page["content_base64"]).decode("utf-8")
|
||||||
|
return content
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_page(title, content):
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"content_base64": base64.b64encode(content.encode()).decode(),
|
||||||
|
}
|
||||||
|
result = _req("POST", "/new", data)
|
||||||
|
if result:
|
||||||
|
print(f" ✅ Created wiki page: {title}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_page(title, content):
|
||||||
|
sub = _find_sub_url(title)
|
||||||
|
data = {
|
||||||
|
"title": title,
|
||||||
|
"content_base64": base64.b64encode(content.encode()).decode(),
|
||||||
|
}
|
||||||
|
result = _req("PATCH", f"/page/{sub}", data)
|
||||||
|
if result:
|
||||||
|
print(f" ✅ Updated wiki page: {title}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cmd = sys.argv[1] if len(sys.argv) > 1 else "list"
|
||||||
|
|
||||||
|
if cmd == "list":
|
||||||
|
list_pages()
|
||||||
|
elif cmd == "read" and len(sys.argv) > 2:
|
||||||
|
content = read_page(sys.argv[2])
|
||||||
|
if content:
|
||||||
|
print(content[:5000])
|
||||||
|
else:
|
||||||
|
print(f" Page '{sys.argv[2]}' not found")
|
||||||
|
elif cmd == "create" and len(sys.argv) > 3:
|
||||||
|
with open(sys.argv[3], "r", encoding="utf-8") as f:
|
||||||
|
create_page(sys.argv[2], f.read())
|
||||||
|
elif cmd == "update" and len(sys.argv) > 3:
|
||||||
|
with open(sys.argv[3], "r", encoding="utf-8") as f:
|
||||||
|
update_page(sys.argv[2], f.read())
|
||||||
|
else:
|
||||||
|
print("Usage: wiki_helper.py list|read <title>|create <title> <file>|update <title> <file>")
|
||||||
39
.agent/.agents/workflows/pre-task.md
Normal file
39
.agent/.agents/workflows/pre-task.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
description: 모든 구현 작업 전 실행하는 사전 점검 체크리스트 (pre-task, 준비, 시작 전, 계획, 구현)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pre-Task Checklist
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 코딩을 시작하기 전에 반드시 이 체크리스트를 순서대로 완료하세요.
|
||||||
|
> 체크리스트를 건너뛸 경우 불필요한 시행착오가 발생합니다.
|
||||||
|
|
||||||
|
## 1단계: 요구사항 정리
|
||||||
|
|
||||||
|
- [ ] 유저 요청을 구체적 작업 항목으로 분해
|
||||||
|
- [ ] 변경 범위(scope)를 명확히 정의 (영향받는 파일/모듈)
|
||||||
|
- [ ] 성공 기준(acceptance criteria) 확인
|
||||||
|
|
||||||
|
## 2단계: 레퍼런스 확인 (추측 금지)
|
||||||
|
|
||||||
|
- [ ] `.agents/references/architecture.md` — 현재 아키텍처 확인
|
||||||
|
- [ ] `.agents/references/tech-stack.md` — 기술 스택 및 버전 확인
|
||||||
|
- [ ] `.agents/references/conventions.md` — 코딩 컨벤션 확인
|
||||||
|
- [ ] `.agents/references/known-issues.md` — 과거 실패 패턴 확인
|
||||||
|
- [ ] 관련 기존 코드 최소 3개 파일 읽기
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> 레퍼런스 문서가 존재하는 주제에 대해 추측하지 마세요.
|
||||||
|
> 문서가 없으면 유저에게 확인을 요청하세요.
|
||||||
|
|
||||||
|
## 3단계: 계획 수립
|
||||||
|
|
||||||
|
- [ ] 변경할 파일 목록 작성
|
||||||
|
- [ ] 의존성 순서 파악 (어떤 파일부터 수정해야 하는가?)
|
||||||
|
- [ ] 리스크 식별 (어디서 실패할 가능성이 높은가?)
|
||||||
|
- [ ] 테스트 방법 결정 (어떻게 검증할 것인가?)
|
||||||
|
|
||||||
|
## 4단계: 유저 확인
|
||||||
|
|
||||||
|
- [ ] 계획을 유저에게 보고하고 승인받기 (변경 파일 3개 이상인 경우)
|
||||||
|
- [ ] 작은 변경은 바로 실행하되, 변경 내용을 명확히 설명
|
||||||
128
.agent/.agents/workflows/services.md
Normal file
128
.agent/.agents/workflows/services.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
---
|
||||||
|
description: 프로젝트 연동 서비스 URL, API 키, 프로젝트 정보 참조
|
||||||
|
---
|
||||||
|
|
||||||
|
# 서비스 연동 정보
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> 이 파일에는 API 토큰이 포함되어 있습니다. 프라이빗 레포에서만 사용하세요.
|
||||||
|
|
||||||
|
## 로컬 환경
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **Node.js** | 시스템 설치 (`node`, `npm`) |
|
||||||
|
| **Python (helper)** | 시스템 설치 또는 conda 환경 |
|
||||||
|
| **Shell** | PowerShell (`curl` = `Invoke-WebRequest` 별칭이므로 반드시 **`curl.exe`** 사용) |
|
||||||
|
|
||||||
|
## Gitea (Git Repository)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **Base URL** | `https://git.variet.net` |
|
||||||
|
| **API Base** | `https://git.variet.net/api/v1` |
|
||||||
|
| **Repo** | `Variet/LifetimePD` |
|
||||||
|
| **Token** | `3a01b4b15a39921572e64c413353e870d4d2161b` |
|
||||||
|
| **Auth Header** | `-H "Authorization: token 3a01b4b15a39921572e64c413353e870d4d2161b"` |
|
||||||
|
|
||||||
|
## Vikunja (Task Management)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **Base URL** | `https://plan.variet.net` |
|
||||||
|
| **API Base** | `https://plan.variet.net/api/v1` |
|
||||||
|
| **Project ID** | `10` |
|
||||||
|
| **Token** | `tk_070f8e0b715e818bb7178c3815ed5389040eddca` |
|
||||||
|
| **Auth Header** | `-H "Authorization: Bearer tk_070f8e0b715e818bb7178c3815ed5389040eddca"` |
|
||||||
|
|
||||||
|
## Vikunja 태스크 조회
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> 태스크 목록은 항상 라이브 조회를 사용합니다. 하드코딩된 매핑은 유지하지 않습니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py list todo
|
||||||
|
```
|
||||||
|
|
||||||
|
## 기타 서비스
|
||||||
|
|
||||||
|
| 서비스 | URL | 용도 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| Uptime Kuma | `https://status.variet.net` | 서비스 모니터링 |
|
||||||
|
| Authentik | `https://auth.variet.net` | SSO 인증 |
|
||||||
|
|
||||||
|
## AI 작업 프로토콜
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 아래 규칙은 모든 작업에 자동 적용됩니다. 유저가 별도 지시하지 않아도 따릅니다.
|
||||||
|
|
||||||
|
### Vikunja = Single Source of Truth (SSOT)
|
||||||
|
|
||||||
|
- **Vikunja가 유일한 작업 현황 관리 도구**입니다.
|
||||||
|
- 로컬 `task.md`는 현재 대화 내 세부 체크리스트용으로만 사용합니다.
|
||||||
|
- 새 TODO 발견 시 → Vikunja에 태스크 생성 (로컬 파일에만 적는 것은 금지)
|
||||||
|
- 작업 완료 시 → Vikunja 태스크 완료 처리 (로컬 체크만 하는 것은 금지)
|
||||||
|
|
||||||
|
### Vikunja 태깅 규칙
|
||||||
|
|
||||||
|
태스크 생성 시 반드시 아래 라벨을 적절히 부여합니다:
|
||||||
|
|
||||||
|
**영역 라벨 (필수, 1개 이상):**
|
||||||
|
|
||||||
|
| ID | 라벨 | 적용 대상 |
|
||||||
|
|:--:|-------|-----------:|
|
||||||
|
| 1 | `Backend` | 서버, DB, API |
|
||||||
|
| 2 | `Frontend` | UI, 웹 프론트엔드 |
|
||||||
|
| 3 | `Engine` | 핵심 엔진/로직 |
|
||||||
|
| 4 | `Infra` | Docker, CI/CD, 모니터링 |
|
||||||
|
| 5 | `Test` | 테스트, E2E |
|
||||||
|
|
||||||
|
**우선순위 라벨 (필수, 1개):**
|
||||||
|
|
||||||
|
| ID | 라벨 | 기준 |
|
||||||
|
|:--:|-------|------:|
|
||||||
|
| 6 | `Priority:High` | 핵심 기능 미완성, 블로커 |
|
||||||
|
| 7 | `Priority:Mid` | 기능 개선, UX 향상, 리팩터링 |
|
||||||
|
| 8 | `Priority:Low` | nice-to-have, 문서, 코드 정리 |
|
||||||
|
|
||||||
|
**태스크 제목 규칙:**
|
||||||
|
- 한글 + 핵심 키워드 (예: `WebSocket 재연결 로직 구현`)
|
||||||
|
- 50자 이내
|
||||||
|
|
||||||
|
### 작업 시작 시
|
||||||
|
1. `git pull` 으로 최신 코드 동기화
|
||||||
|
2. Vikunja 태스크 조회 (`/check-vikunja`) → 관련 태스크 ID 확인
|
||||||
|
3. 관련 태스크가 있으면 Vikunja에서 진행중 표시
|
||||||
|
4. 관련 태스크가 없으면 Vikunja에 새 태스크 생성 (태깅 규칙 준수)
|
||||||
|
|
||||||
|
### 작업 중
|
||||||
|
5. 의미 있는 단위마다 자주 커밋 (대규모 변경을 한번에 커밋하지 않음)
|
||||||
|
6. 커밋 메시지 규칙:
|
||||||
|
- `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:` 접두사 사용
|
||||||
|
- 관련 Vikunja 태스크가 있으면 `#task-{ID}` 참조 포함
|
||||||
|
- 예: `feat(server): WebSocket 재연결 로직 #task-21`
|
||||||
|
|
||||||
|
### 작업 완료 시
|
||||||
|
7. 모든 변경사항 커밋 + `git push`
|
||||||
|
8. Vikunja 태스크 완료 처리 (**반드시 `vikunja_helper.py` 사용**):
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py done {TASK_ID}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **직접 `Invoke-RestMethod -Body '{"done": true}'` 사용 금지!**
|
||||||
|
> Vikunja API는 POST 시 body에 없는 필드를 빈값으로 덮어씁니다.
|
||||||
|
|
||||||
|
9. 작업 중 발견된 새 TODO → Vikunja에 태스크 생성
|
||||||
|
|
||||||
|
### 멀티 AI 협업 시 추가 규칙
|
||||||
|
- 작업 전 `git pull` 필수 (다른 AI가 push한 변경 반영)
|
||||||
|
- 같은 파일을 동시에 수정하지 않음
|
||||||
|
- 공유 인터페이스 수정 시 즉시 commit + push
|
||||||
|
- 충돌 발생 시 유저에게 확인 요청
|
||||||
|
|
||||||
|
## PowerShell 주의사항
|
||||||
|
|
||||||
|
- `curl` → PowerShell에서 `Invoke-WebRequest`의 별칭. **반드시 `curl.exe`** 사용
|
||||||
|
- `npm` → PowerShell에서 실행 정책 문제 시 `cmd /c npm` 사용
|
||||||
|
- JSON 파이프 파싱 시 PowerShell 이스케이핑 문제 → `.py` 스크립트 파일로 만들어 실행 권장
|
||||||
65
.agent/.agents/workflows/start.md
Normal file
65
.agent/.agents/workflows/start.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
description: 세션 시작 시 프로젝트 맥락을 빠르게 복구하는 워크플로우 (시작, continue, 이어서, 작업 시작)
|
||||||
|
---
|
||||||
|
|
||||||
|
# 세션 시작 프로토콜
|
||||||
|
|
||||||
|
새 대화 시작, "continue", "이어서", "작업 시작" 등 요청 시 이 워크플로우를 실행합니다.
|
||||||
|
|
||||||
|
// turbo-all
|
||||||
|
|
||||||
|
## 절차
|
||||||
|
|
||||||
|
### 0. 에이전트 룰 & 맥락 로딩 (자동)
|
||||||
|
|
||||||
|
`.agents/AGENT.md`를 읽고 에이전트 행동 규칙을 로딩합니다.
|
||||||
|
`.agents/references/known-issues.md`를 읽어 최근 이슈를 파악합니다.
|
||||||
|
|
||||||
|
### 1. Devlog 맥락 복구
|
||||||
|
|
||||||
|
오늘 + 어제 devlog index를 읽고 최근 작업 흐름을 파악합니다.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$today = Get-Date -Format "yyyy-MM-dd"
|
||||||
|
$yesterday = (Get-Date).AddDays(-1).ToString("yyyy-MM-dd")
|
||||||
|
if (Test-Path "docs\devlog\$today.md") {
|
||||||
|
Write-Host "=== Devlog: $today ==="
|
||||||
|
Get-Content "docs\devlog\$today.md"
|
||||||
|
} elseif (Test-Path "docs\devlog\$yesterday.md") {
|
||||||
|
Write-Host "=== Devlog: $yesterday (no entry for today yet) ==="
|
||||||
|
Get-Content "docs\devlog\$yesterday.md"
|
||||||
|
} else {
|
||||||
|
Write-Host "=== No recent devlog found ==="
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
미완료(🔧) 항목이 있으면 해당 entry 파일을 읽어 이어받기 맥락을 확보합니다:
|
||||||
|
- Entry 경로: `docs/devlog/entries/YYYYMMDD-NNN.md`
|
||||||
|
|
||||||
|
### 2. Git 상태 확인
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
```powershell
|
||||||
|
git log --oneline -5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Vikunja TODO 태스크
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python .agents\workflows\helpers\vikunja_helper.py list todo
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 종합 보고
|
||||||
|
|
||||||
|
결과를 종합하여 사용자에게 보고:
|
||||||
|
- 마지막 작업 맥락 + 미완료 항목 (devlog 🔧 기반)
|
||||||
|
- TODO 태스크 목록 (라벨 + 우선순위)
|
||||||
|
- 다음 작업 제안
|
||||||
|
|
||||||
|
**우선순위 판단 기준** (라벨만으로 판단 금지):
|
||||||
|
- P0: 최근 커밋에서 스키마/모델/인터페이스 변경 → 연쇄 영향 점검
|
||||||
|
- P1: 서버 기동/API 응답 장애
|
||||||
|
- P2: 기능 미완성/UX 개선
|
||||||
|
- P3: 정확도 향상, 신규 기능, CI/CD, 문서 정리
|
||||||
67
.agent/README.md
Normal file
67
.agent/README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Agent Guide — AI 에이전트 범용 워크플로우 시스템
|
||||||
|
|
||||||
|
> AI 코딩 에이전트가 더 똑똑하게 동작하도록 설계된 범용 워크플로우 템플릿.
|
||||||
|
> 새 프로젝트에서 `.agents/` 폴더를 통째로 복사하고, `{{PLACEHOLDER}}`를 교체하면 즉시 사용 가능합니다.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 이 레포를 클론하거나 .agents/ 폴더를 복사
|
||||||
|
git clone https://git.variet.net/Variet/agent_guide.git
|
||||||
|
cp -r agent_guide/.agents/ your-project/.agents/
|
||||||
|
|
||||||
|
# 2. 프로젝트별 값 2개만 교체
|
||||||
|
# - {{GITEA_REPO}} → services.md, check-gitea.md, wiki_helper.py
|
||||||
|
# - {{VIKUNJA_PROJECT_ID}} → services.md, vikunja_helper.py (PROJECT_ID)
|
||||||
|
# - references/ → 프로젝트별 아키텍처, 기술스택, 컨벤션 채우기
|
||||||
|
|
||||||
|
# 3. docs/devlog/ 디렉토리 생성
|
||||||
|
mkdir -p docs/devlog/entries
|
||||||
|
|
||||||
|
# 4. AI 에이전트에게 "시작" 또는 "/start" 명령
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
.agents/
|
||||||
|
├── AGENT.md ← 🧠 글로벌 NEVER/ALWAYS 규칙
|
||||||
|
├── GUIDE.md ← 📖 상세 가이드
|
||||||
|
├── references/ ← 📚 프로젝트 지식 베이스
|
||||||
|
│ ├── architecture.md ← 아키텍처 (템플릿)
|
||||||
|
│ ├── tech-stack.md ← 기술 스택 (템플릿)
|
||||||
|
│ ├── conventions.md ← 코딩 컨벤션 (템플릿)
|
||||||
|
│ └── known-issues.md ← 과거 실패 기록 (공통 이슈 포함)
|
||||||
|
└── workflows/ ← ⚙️ 행동 절차
|
||||||
|
├── start.md ← 세션 시작 (룰 로딩 + Git + Vikunja + Wiki)
|
||||||
|
├── end.md ← 세션 종료 (학습 기록 + Vikunja + Git)
|
||||||
|
├── pre-task.md ← 작업 전 필수 체크리스트
|
||||||
|
├── debug.md ← 체계적 디버깅
|
||||||
|
├── services.md ← 서비스 연동 정보 ({{PLACEHOLDER}})
|
||||||
|
├── check-gitea.md ← Gitea 현황 조회
|
||||||
|
├── check-vikunja.md ← Vikunja 태스크 조회
|
||||||
|
└── helpers/
|
||||||
|
├── vikunja_helper.py ← Vikunja API 안전 래퍼
|
||||||
|
└── wiki_helper.py ← Gitea Wiki 래퍼
|
||||||
|
```
|
||||||
|
|
||||||
|
## 교체해야 하는 값 (프로젝트별)
|
||||||
|
|
||||||
|
> Gitea/Vikunja 토큰은 이미 입력되어 있습니다. 프로젝트별로 아래 2개만 교체하면 됩니다.
|
||||||
|
|
||||||
|
| Placeholder | 설명 | 파일 |
|
||||||
|
|-------------|------|------|
|
||||||
|
| `{{GITEA_REPO}}` | Gitea 저장소명 | services.md, check-gitea.md, wiki_helper.py |
|
||||||
|
| `{{VIKUNJA_PROJECT_ID}}` | Vikunja 프로젝트 ID | services.md, vikunja_helper.py (`PROJECT_ID`) |
|
||||||
|
|
||||||
|
## 상세 가이드
|
||||||
|
|
||||||
|
[GUIDE.md](.agents/GUIDE.md) 참조.
|
||||||
|
|
||||||
|
## 연구 기반
|
||||||
|
|
||||||
|
7개 AI 에이전트 플랫폼 (Claude, GPT, Gemini, Cursor, Cline, Roo, Windsurf) 분석 + Reflexion Framework, Context Engineering, Sentinel Check 등 최신 연구 기반.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Internal — Variet
|
||||||
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Results (regenerable)
|
||||||
|
results/*.png
|
||||||
|
results/*.csv
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Data
|
||||||
|
data/raw/*.csv
|
||||||
|
data/raw/*.xlsx
|
||||||
52
config.yaml
Normal file
52
config.yaml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# ============================================
|
||||||
|
# Lifetime PD Model Configuration
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
# BOK ECOS API
|
||||||
|
ecos:
|
||||||
|
api_key: "C5220CGY8FYFDN43B7ON"
|
||||||
|
base_url: "https://ecos.bok.or.kr/api"
|
||||||
|
# 주요 통계코드
|
||||||
|
stat_codes:
|
||||||
|
gdp_growth: "111Y002" # 국내총생산(실질성장률)
|
||||||
|
unemployment: "901Y027" # 실업률
|
||||||
|
base_rate: "722Y001" # 한국은행 기준금리
|
||||||
|
cd_rate: "817Y002" # CD(91일) 금리
|
||||||
|
treasury_3y: "817Y002" # 국고채(3년) 수익률
|
||||||
|
cpi: "901Y009" # 소비자물가지수
|
||||||
|
composite_leading: "901Y067" # 경기선행지수
|
||||||
|
|
||||||
|
# 모형 파라미터
|
||||||
|
model:
|
||||||
|
# 자산상관계수 (Basel IRB 기준 0.12~0.24, 기업 평균 ~0.20)
|
||||||
|
rho: 0.20
|
||||||
|
# 신용등급 체계 (한국 3사 공통)
|
||||||
|
rating_grades: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "D"]
|
||||||
|
|
||||||
|
# 시나리오 설정
|
||||||
|
scenarios:
|
||||||
|
upside:
|
||||||
|
name: "호황 (Upside)"
|
||||||
|
z_multiplier: 1.0 # Zt = μ + 1.0σ
|
||||||
|
weight: 0.20 # ECB 방식 확률가중치
|
||||||
|
base:
|
||||||
|
name: "중립 (Base)"
|
||||||
|
z_multiplier: 0.0
|
||||||
|
weight: 0.50
|
||||||
|
downside:
|
||||||
|
name: "불황 (Downside)"
|
||||||
|
z_multiplier: -1.5 # Fed DFAST 역사적 하위 5%
|
||||||
|
weight: 0.30
|
||||||
|
|
||||||
|
# 50년 수렴 메커니즘
|
||||||
|
convergence:
|
||||||
|
pit_horizon: 5 # PIT 적용 기간 (년)
|
||||||
|
transition_horizon: 10 # TTC로의 전환 완료 기간 (년)
|
||||||
|
mean_reversion_lambda: 0.3 # Mean-reversion 속도
|
||||||
|
total_horizon: 50 # 총 예측 기간 (년)
|
||||||
|
|
||||||
|
# 출력 설정
|
||||||
|
output:
|
||||||
|
save_dir: "results"
|
||||||
|
figure_dpi: 150
|
||||||
|
figure_format: "png"
|
||||||
1
data/__init__.py
Normal file
1
data/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Data layer: 전이행렬 및 거시경제 데이터 모듈
|
||||||
287
data/macro_data.py
Normal file
287
data/macro_data.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""
|
||||||
|
한국은행 ECOS Open API 거시경제 데이터 수집 모듈
|
||||||
|
|
||||||
|
BOK ECOS API를 통해 주요 거시경제변수를 수집:
|
||||||
|
- GDP 실질성장률
|
||||||
|
- 실업률
|
||||||
|
- 한국은행 기준금리
|
||||||
|
- CD(91일) 금리
|
||||||
|
- 소비자물가지수 상승률
|
||||||
|
- 경기선행지수 순환변동치
|
||||||
|
|
||||||
|
API 문서: https://ecos.bok.or.kr/api/#/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import yaml
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class EcosAPI:
|
||||||
|
"""한국은행 ECOS Open API 클라이언트"""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str, base_url: str = "https://ecos.bok.or.kr/api"):
|
||||||
|
self.api_key = api_key
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
def fetch_stat(
|
||||||
|
self,
|
||||||
|
stat_code: str,
|
||||||
|
period: str = "A", # A=연간, Q=분기, M=월간
|
||||||
|
start_date: str = "2000",
|
||||||
|
end_date: str = "2025",
|
||||||
|
item_code1: str = "",
|
||||||
|
item_code2: str = "",
|
||||||
|
item_code3: str = "",
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
개별 통계 시계열 데이터 조회
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
stat_code : str - 통계표코드
|
||||||
|
period : str - A(연간), Q(분기), M(월간)
|
||||||
|
start_date : str - 검색시작일자 (YYYY, YYYYMM, YYYYQ1 등)
|
||||||
|
end_date : str - 검색종료일자
|
||||||
|
item_code1~3 : str - 항목코드
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pd.DataFrame with columns [TIME, STAT_NAME, ITEM_NAME, DATA_VALUE]
|
||||||
|
"""
|
||||||
|
# 항목코드가 비어있으면 공백 대체
|
||||||
|
ic1 = item_code1 if item_code1 else "?"
|
||||||
|
ic2 = item_code2 if item_code2 else "?"
|
||||||
|
ic3 = item_code3 if item_code3 else "?"
|
||||||
|
|
||||||
|
url = (
|
||||||
|
f"{self.base_url}/StatisticSearch/"
|
||||||
|
f"{self.api_key}/json/kr/1/100/"
|
||||||
|
f"{stat_code}/{period}/{start_date}/{end_date}/"
|
||||||
|
f"{ic1}/{ic2}/{ic3}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=30)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if "StatisticSearch" not in data:
|
||||||
|
error_msg = data.get("RESULT", {}).get("MESSAGE", "Unknown error")
|
||||||
|
logger.warning(f"ECOS API 조회 실패 ({stat_code}): {error_msg}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
rows = data["StatisticSearch"]["row"]
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
|
||||||
|
# 숫자 변환
|
||||||
|
if "DATA_VALUE" in df.columns:
|
||||||
|
df["DATA_VALUE"] = pd.to_numeric(df["DATA_VALUE"], errors="coerce")
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"ECOS API 요청 실패: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
def search_stat_list(self, keyword: str) -> pd.DataFrame:
|
||||||
|
"""통계표 코드 검색"""
|
||||||
|
url = (
|
||||||
|
f"{self.base_url}/StatisticTableList/"
|
||||||
|
f"{self.api_key}/json/kr/1/100/{keyword}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = requests.get(url, timeout=30)
|
||||||
|
data = resp.json()
|
||||||
|
if "StatisticTableList" in data:
|
||||||
|
return pd.DataFrame(data["StatisticTableList"]["row"])
|
||||||
|
return pd.DataFrame()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"통계표 검색 실패: {e}")
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_macro_data(
|
||||||
|
api_key: str,
|
||||||
|
start_year: int = 2000,
|
||||||
|
end_year: int = 2025
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
주요 거시경제변수 일괄 수집
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
api_key : str - ECOS API 인증키
|
||||||
|
start_year : int - 시작 연도
|
||||||
|
end_year : int - 종료 연도
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pd.DataFrame
|
||||||
|
index=연도, columns=[GDP_GROWTH, UNEMPLOYMENT, BASE_RATE,
|
||||||
|
CD_RATE, CPI_GROWTH, LEADING_INDEX]
|
||||||
|
"""
|
||||||
|
api = EcosAPI(api_key)
|
||||||
|
start = str(start_year)
|
||||||
|
end = str(end_year)
|
||||||
|
|
||||||
|
macro_vars = {}
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 1) GDP 실질성장률 (%)
|
||||||
|
# 통계표: 111Y002 (국민계정 - 주요지표 - 경제성장률)
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("GDP 성장률 조회 중...")
|
||||||
|
df_gdp = api.fetch_stat("111Y002", "A", start, end, "10111")
|
||||||
|
if not df_gdp.empty:
|
||||||
|
gdp_series = df_gdp.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
gdp_series.index = gdp_series.index.astype(int)
|
||||||
|
macro_vars["GDP_GROWTH"] = gdp_series
|
||||||
|
time.sleep(0.5) # API rate limit
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 2) 실업률 (%)
|
||||||
|
# 통계표: 901Y027 (고용 - 주요고용지표)
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("실업률 조회 중...")
|
||||||
|
df_unemp = api.fetch_stat("901Y027", "A", start, end, "3", " ")
|
||||||
|
if not df_unemp.empty:
|
||||||
|
unemp_series = df_unemp.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
unemp_series.index = unemp_series.index.astype(int)
|
||||||
|
macro_vars["UNEMPLOYMENT"] = unemp_series
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 3) 한국은행 기준금리 (%, 연말 기준)
|
||||||
|
# 통계표: 722Y001
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("기준금리 조회 중...")
|
||||||
|
df_rate = api.fetch_stat("722Y001", "A", start, end, "0101000")
|
||||||
|
if not df_rate.empty:
|
||||||
|
rate_series = df_rate.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
rate_series.index = rate_series.index.astype(int)
|
||||||
|
macro_vars["BASE_RATE"] = rate_series
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 4) CD(91일) 금리 (%)
|
||||||
|
# 통계표: 817Y002
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("CD 금리 조회 중...")
|
||||||
|
df_cd = api.fetch_stat("817Y002", "A", start, end, "010502000")
|
||||||
|
if not df_cd.empty:
|
||||||
|
cd_series = df_cd.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
cd_series.index = cd_series.index.astype(int)
|
||||||
|
macro_vars["CD_RATE"] = cd_series
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 5) 소비자물가지수 상승률 (%)
|
||||||
|
# 통계표: 901Y009
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("소비자물가 상승률 조회 중...")
|
||||||
|
df_cpi = api.fetch_stat("901Y009", "A", start, end, "0")
|
||||||
|
if not df_cpi.empty:
|
||||||
|
cpi_series = df_cpi.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
cpi_series.index = cpi_series.index.astype(int)
|
||||||
|
macro_vars["CPI_GROWTH"] = cpi_series
|
||||||
|
time.sleep(0.5)
|
||||||
|
|
||||||
|
# -------------------------------------------------------
|
||||||
|
# 6) 경기선행지수 순환변동치
|
||||||
|
# 통계표: 901Y067
|
||||||
|
# -------------------------------------------------------
|
||||||
|
logger.info("경기선행지수 조회 중...")
|
||||||
|
df_leading = api.fetch_stat("901Y067", "A", start, end, "I16A")
|
||||||
|
if not df_leading.empty:
|
||||||
|
leading_series = df_leading.set_index("TIME")["DATA_VALUE"].astype(float)
|
||||||
|
leading_series.index = leading_series.index.astype(int)
|
||||||
|
macro_vars["LEADING_INDEX"] = leading_series
|
||||||
|
|
||||||
|
# DataFrame 결합
|
||||||
|
if macro_vars:
|
||||||
|
result = pd.DataFrame(macro_vars)
|
||||||
|
result.index.name = "YEAR"
|
||||||
|
result = result.sort_index()
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
logger.warning("거시경제 데이터 수집 실패. 내장 fallback 데이터 사용.")
|
||||||
|
return _fallback_macro_data(start_year, end_year)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback_macro_data(start_year: int = 2000, end_year: int = 2025) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
API 실패시 사용할 내장 fallback 거시경제 데이터
|
||||||
|
출처: 한국은행 경제통계시스템 (실제 공표 수치 기반)
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
2000: {"GDP_GROWTH": 8.9, "UNEMPLOYMENT": 4.4, "BASE_RATE": 5.25, "CD_RATE": 7.09, "CPI_GROWTH": 2.3, "LEADING_INDEX": 101.2},
|
||||||
|
2001: {"GDP_GROWTH": 4.5, "UNEMPLOYMENT": 4.0, "BASE_RATE": 4.00, "CD_RATE": 5.34, "CPI_GROWTH": 4.1, "LEADING_INDEX": 99.5},
|
||||||
|
2002: {"GDP_GROWTH": 7.4, "UNEMPLOYMENT": 3.3, "BASE_RATE": 4.25, "CD_RATE": 4.99, "CPI_GROWTH": 2.8, "LEADING_INDEX": 102.3},
|
||||||
|
2003: {"GDP_GROWTH": 2.9, "UNEMPLOYMENT": 3.6, "BASE_RATE": 3.75, "CD_RATE": 4.24, "CPI_GROWTH": 3.5, "LEADING_INDEX": 98.8},
|
||||||
|
2004: {"GDP_GROWTH": 4.9, "UNEMPLOYMENT": 3.7, "BASE_RATE": 3.25, "CD_RATE": 3.77, "CPI_GROWTH": 3.6, "LEADING_INDEX": 100.5},
|
||||||
|
2005: {"GDP_GROWTH": 3.9, "UNEMPLOYMENT": 3.7, "BASE_RATE": 3.75, "CD_RATE": 3.81, "CPI_GROWTH": 2.8, "LEADING_INDEX": 101.8},
|
||||||
|
2006: {"GDP_GROWTH": 5.2, "UNEMPLOYMENT": 3.5, "BASE_RATE": 4.50, "CD_RATE": 4.72, "CPI_GROWTH": 2.2, "LEADING_INDEX": 102.5},
|
||||||
|
2007: {"GDP_GROWTH": 5.5, "UNEMPLOYMENT": 3.2, "BASE_RATE": 5.00, "CD_RATE": 5.36, "CPI_GROWTH": 2.5, "LEADING_INDEX": 103.1},
|
||||||
|
2008: {"GDP_GROWTH": 2.8, "UNEMPLOYMENT": 3.2, "BASE_RATE": 3.00, "CD_RATE": 5.70, "CPI_GROWTH": 4.7, "LEADING_INDEX": 96.5},
|
||||||
|
2009: {"GDP_GROWTH": 0.8, "UNEMPLOYMENT": 3.6, "BASE_RATE": 2.00, "CD_RATE": 2.63, "CPI_GROWTH": 2.8, "LEADING_INDEX": 98.2},
|
||||||
|
2010: {"GDP_GROWTH": 6.8, "UNEMPLOYMENT": 3.7, "BASE_RATE": 2.50, "CD_RATE": 2.80, "CPI_GROWTH": 2.9, "LEADING_INDEX": 103.0},
|
||||||
|
2011: {"GDP_GROWTH": 3.7, "UNEMPLOYMENT": 3.4, "BASE_RATE": 3.25, "CD_RATE": 3.55, "CPI_GROWTH": 4.0, "LEADING_INDEX": 101.2},
|
||||||
|
2012: {"GDP_GROWTH": 2.4, "UNEMPLOYMENT": 3.2, "BASE_RATE": 2.75, "CD_RATE": 3.13, "CPI_GROWTH": 2.2, "LEADING_INDEX": 100.3},
|
||||||
|
2013: {"GDP_GROWTH": 3.2, "UNEMPLOYMENT": 3.1, "BASE_RATE": 2.50, "CD_RATE": 2.72, "CPI_GROWTH": 1.3, "LEADING_INDEX": 100.8},
|
||||||
|
2014: {"GDP_GROWTH": 3.2, "UNEMPLOYMENT": 3.5, "BASE_RATE": 2.00, "CD_RATE": 2.36, "CPI_GROWTH": 1.3, "LEADING_INDEX": 101.0},
|
||||||
|
2015: {"GDP_GROWTH": 2.8, "UNEMPLOYMENT": 3.6, "BASE_RATE": 1.50, "CD_RATE": 1.72, "CPI_GROWTH": 0.7, "LEADING_INDEX": 100.5},
|
||||||
|
2016: {"GDP_GROWTH": 2.9, "UNEMPLOYMENT": 3.7, "BASE_RATE": 1.25, "CD_RATE": 1.48, "CPI_GROWTH": 1.0, "LEADING_INDEX": 99.8},
|
||||||
|
2017: {"GDP_GROWTH": 3.2, "UNEMPLOYMENT": 3.7, "BASE_RATE": 1.50, "CD_RATE": 1.52, "CPI_GROWTH": 1.9, "LEADING_INDEX": 101.5},
|
||||||
|
2018: {"GDP_GROWTH": 2.9, "UNEMPLOYMENT": 3.8, "BASE_RATE": 1.75, "CD_RATE": 1.85, "CPI_GROWTH": 1.5, "LEADING_INDEX": 100.8},
|
||||||
|
2019: {"GDP_GROWTH": 2.2, "UNEMPLOYMENT": 3.8, "BASE_RATE": 1.25, "CD_RATE": 1.63, "CPI_GROWTH": 0.4, "LEADING_INDEX": 99.3},
|
||||||
|
2020: {"GDP_GROWTH": -0.7, "UNEMPLOYMENT": 4.0, "BASE_RATE": 0.50, "CD_RATE": 0.76, "CPI_GROWTH": 0.5, "LEADING_INDEX": 97.0},
|
||||||
|
2021: {"GDP_GROWTH": 4.3, "UNEMPLOYMENT": 3.7, "BASE_RATE": 1.00, "CD_RATE": 1.09, "CPI_GROWTH": 2.5, "LEADING_INDEX": 102.8},
|
||||||
|
2022: {"GDP_GROWTH": 2.6, "UNEMPLOYMENT": 2.9, "BASE_RATE": 3.25, "CD_RATE": 3.77, "CPI_GROWTH": 5.1, "LEADING_INDEX": 99.2},
|
||||||
|
2023: {"GDP_GROWTH": 1.4, "UNEMPLOYMENT": 2.7, "BASE_RATE": 3.50, "CD_RATE": 3.75, "CPI_GROWTH": 3.6, "LEADING_INDEX": 98.8},
|
||||||
|
2024: {"GDP_GROWTH": 2.2, "UNEMPLOYMENT": 2.8, "BASE_RATE": 3.00, "CD_RATE": 3.30, "CPI_GROWTH": 2.3, "LEADING_INDEX": 99.5},
|
||||||
|
2025: {"GDP_GROWTH": 1.8, "UNEMPLOYMENT": 3.0, "BASE_RATE": 2.75, "CD_RATE": 3.00, "CPI_GROWTH": 1.8, "LEADING_INDEX": 99.8},
|
||||||
|
}
|
||||||
|
|
||||||
|
df = pd.DataFrame(data).T
|
||||||
|
df.index.name = "YEAR"
|
||||||
|
return df.loc[start_year:end_year]
|
||||||
|
|
||||||
|
|
||||||
|
def load_macro_data(config_path: str = "config.yaml") -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
설정 파일에서 API 키를 읽고 거시경제 데이터 수집
|
||||||
|
|
||||||
|
API 실패시 자동으로 fallback 데이터 사용
|
||||||
|
"""
|
||||||
|
config = _load_config(config_path)
|
||||||
|
api_key = config.get("ecos", {}).get("api_key", "sample")
|
||||||
|
|
||||||
|
logger.info(f"ECOS API로 거시경제 데이터 수집 시작 (API key: {api_key[:4]}...)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
df = collect_macro_data(api_key)
|
||||||
|
if df.empty or len(df) < 10:
|
||||||
|
logger.warning("API 데이터 부족. Fallback 데이터 사용.")
|
||||||
|
df = _fallback_macro_data()
|
||||||
|
return df
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"API 수집 실패: {e}. Fallback 데이터 사용.")
|
||||||
|
return _fallback_macro_data()
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config(config_path: str) -> dict:
|
||||||
|
"""YAML 설정 파일 로딩"""
|
||||||
|
try:
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning(f"설정 파일 '{config_path}' 없음. 기본값 사용.")
|
||||||
|
return {}
|
||||||
303
data/transition_matrices.py
Normal file
303
data/transition_matrices.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
"""
|
||||||
|
한국 신용등급 전이행렬 데이터 관리 모듈
|
||||||
|
|
||||||
|
금융감독원(FSS) 공시 기반 한국 3사(한국기업평가/NICE/한신평) 전이행렬 데이터.
|
||||||
|
- 내장 샘플 데이터: 2000-2025년 한국 대표 평균 전이행렬 (공시 데이터 기반 재구성)
|
||||||
|
- CSV/Excel 로딩: 사용자 커스텀 데이터 지원
|
||||||
|
- TTC 전이행렬 계산: 전 기간 단순 평균
|
||||||
|
|
||||||
|
참고: 한국 신용등급 체계 AAA, AA, A, BBB, BB, B, CCC, D (8개 등급)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
# 등급 레이블
|
||||||
|
RATING_GRADES = ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "D"]
|
||||||
|
N_GRADES = len(RATING_GRADES)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_sample_matrices() -> Dict[int, np.ndarray]:
|
||||||
|
"""
|
||||||
|
2000-2025년 한국 대표 연도별 전이행렬 내장 데이터
|
||||||
|
|
||||||
|
출처: 금융감독원 신용평가공시, 한국기업평가/NICE/한신평 공시자료 기반 재구성
|
||||||
|
각 행렬은 8×8 (AAA~CCC → AAA~CCC, D), 행 합 = 1.0
|
||||||
|
|
||||||
|
실제 한국 시장 특성 반영:
|
||||||
|
- 1998-2000: IMF 외환위기 영향 (높은 부도율)
|
||||||
|
- 2003: 카드사태
|
||||||
|
- 2008-2009: 글로벌 금융위기
|
||||||
|
- 2020: COVID-19
|
||||||
|
- 그 외: 상대적 안정기
|
||||||
|
|
||||||
|
행렬 구조: TM[i][j] = P(등급 j로 전이 | 시작 등급 i)
|
||||||
|
마지막 열(D)이 부도 전이확률, D에서의 전이는 [0,...,0,1] (흡수상태)
|
||||||
|
"""
|
||||||
|
matrices = {}
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# 기준 TTC 전이행렬 (장기 평균, 한국 3사 평균 근사)
|
||||||
|
# 이를 중심으로 경기 상황에 따라 변동
|
||||||
|
# =========================================================================
|
||||||
|
base_ttc = np.array([
|
||||||
|
# AAA AA A BBB BB B CCC D
|
||||||
|
[0.9120, 0.0820, 0.0050, 0.0005, 0.0002, 0.0001, 0.0001, 0.0001], # AAA
|
||||||
|
[0.0080, 0.9150, 0.0700, 0.0050, 0.0010, 0.0005, 0.0003, 0.0002], # AA
|
||||||
|
[0.0005, 0.0220, 0.9180, 0.0520, 0.0040, 0.0015, 0.0010, 0.0010], # A
|
||||||
|
[0.0002, 0.0030, 0.0520, 0.8950, 0.0350, 0.0080, 0.0030, 0.0038], # BBB
|
||||||
|
[0.0001, 0.0005, 0.0050, 0.0600, 0.8500, 0.0550, 0.0150, 0.0144], # BB
|
||||||
|
[0.0000, 0.0002, 0.0020, 0.0080, 0.0600, 0.8300, 0.0600, 0.0398], # B
|
||||||
|
[0.0000, 0.0001, 0.0005, 0.0020, 0.0200, 0.0800, 0.7500, 0.1474], # CCC
|
||||||
|
[0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 0.0000, 1.0000], # D
|
||||||
|
])
|
||||||
|
|
||||||
|
# 연도별 Zt 참값 (양수=호황/낮은부도, 음수=불황/높은부도)
|
||||||
|
# Belkin & Suchower (1998) 부호 규약: Z>0 → good times, Z<0 → bad times
|
||||||
|
# 실제 한국 경제 사이클 반영
|
||||||
|
year_zt_true = {
|
||||||
|
2000: -1.8, # IMF 위기 여파
|
||||||
|
2001: -0.8, # 회복기
|
||||||
|
2002: 0.3, # 안정기
|
||||||
|
2003: -1.2, # 카드사태
|
||||||
|
2004: -0.3, # 회복기
|
||||||
|
2005: 0.5, # 호황기
|
||||||
|
2006: 0.8, # 호황기
|
||||||
|
2007: 0.6, # 호황기
|
||||||
|
2008: -1.5, # 글로벌 금융위기
|
||||||
|
2009: -1.0, # 금융위기 여파
|
||||||
|
2010: 0.7, # V자 반등
|
||||||
|
2011: 0.3, # 안정기
|
||||||
|
2012: 0.1, # 안정기
|
||||||
|
2013: 0.0, # 중립
|
||||||
|
2014: 0.2, # 안정기
|
||||||
|
2015: 0.1, # 안정기
|
||||||
|
2016: -0.2, # 약간 둔화
|
||||||
|
2017: 0.4, # 회복
|
||||||
|
2018: 0.2, # 안정기
|
||||||
|
2019: -0.1, # 미중무역분쟁
|
||||||
|
2020: -1.3, # COVID-19
|
||||||
|
2021: 0.6, # 회복
|
||||||
|
2022: 0.1, # 금리인상기
|
||||||
|
2023: -0.3, # 긴축 여파
|
||||||
|
2024: -0.1, # 안정화
|
||||||
|
2025: 0.0, # 중립 (추정)
|
||||||
|
}
|
||||||
|
|
||||||
|
rho = 0.20 # 자산상관계수 (모형 일관성 유지)
|
||||||
|
|
||||||
|
for year, z_true in year_zt_true.items():
|
||||||
|
matrices[year] = _generate_model_consistent_matrix(base_ttc, z_true, rho)
|
||||||
|
|
||||||
|
return matrices
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_model_consistent_matrix(
|
||||||
|
ttc_tm: np.ndarray, z: float, rho: float
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Belkin & Suchower 모형과 일관된 방식으로 Z-조건부 전이행렬 생성
|
||||||
|
|
||||||
|
TTC 전이행렬에서 누적확률 임계값을 산출한 후,
|
||||||
|
Z 값을 적용하여 조건부 전이확률을 계산합니다.
|
||||||
|
|
||||||
|
이 방식으로 생성된 행렬에 대해 Zt 추정을 수행하면
|
||||||
|
원래의 Z 값을 정확히 복원할 수 있습니다.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ttc_tm : np.ndarray - TTC 전이행렬 (8×8)
|
||||||
|
z : float - 신용사이클 인덱스 (양수=호황, 음수=불황)
|
||||||
|
rho : float - 자산상관계수
|
||||||
|
"""
|
||||||
|
from scipy.stats import norm
|
||||||
|
|
||||||
|
n = ttc_tm.shape[0]
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
# 1. TTC 누적확률 → 임계값
|
||||||
|
thresholds = np.full((n, n), np.inf)
|
||||||
|
for i in range(n):
|
||||||
|
cum_prob = 0.0
|
||||||
|
for j in range(n - 1):
|
||||||
|
cum_prob += ttc_tm[i, j]
|
||||||
|
cum_prob_clipped = np.clip(cum_prob, 1e-10, 1.0 - 1e-10)
|
||||||
|
thresholds[i, j] = norm.ppf(cum_prob_clipped)
|
||||||
|
|
||||||
|
# 2. Z-조건부 전이확률 계산
|
||||||
|
cond_tm = np.zeros((n, n))
|
||||||
|
for i in range(n - 1):
|
||||||
|
for j in range(n):
|
||||||
|
d_upper = thresholds[i, j]
|
||||||
|
upper = norm.cdf((d_upper - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
if j == 0:
|
||||||
|
lower = 0.0
|
||||||
|
else:
|
||||||
|
d_lower = thresholds[i, j - 1]
|
||||||
|
lower = norm.cdf((d_lower - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
cond_tm[i, j] = max(upper - lower, 0.0)
|
||||||
|
|
||||||
|
# 행 합 정규화
|
||||||
|
row_sum = cond_tm[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
cond_tm[i] /= row_sum
|
||||||
|
|
||||||
|
# D행: 흡수상태
|
||||||
|
cond_tm[-1, -1] = 1.0
|
||||||
|
|
||||||
|
return cond_tm
|
||||||
|
|
||||||
|
|
||||||
|
def load_transition_matrices(
|
||||||
|
source: str = "builtin",
|
||||||
|
data_dir: Optional[str] = None,
|
||||||
|
file_pattern: str = "*.csv"
|
||||||
|
) -> Dict[int, np.ndarray]:
|
||||||
|
"""
|
||||||
|
전이행렬 로딩
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
source : str
|
||||||
|
"builtin": 내장 샘플 데이터 (2000-2025)
|
||||||
|
"csv": CSV 파일에서 로딩
|
||||||
|
"excel": Excel 파일에서 로딩
|
||||||
|
data_dir : str, optional
|
||||||
|
CSV/Excel 데이터 디렉토리 경로
|
||||||
|
file_pattern : str
|
||||||
|
파일 검색 패턴
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Dict[int, np.ndarray]
|
||||||
|
{연도: 8×8 전이행렬} 딕셔너리
|
||||||
|
"""
|
||||||
|
if source == "builtin":
|
||||||
|
return _build_sample_matrices()
|
||||||
|
|
||||||
|
elif source == "csv":
|
||||||
|
if data_dir is None:
|
||||||
|
raise ValueError("CSV 로딩시 data_dir를 지정해야 합니다.")
|
||||||
|
return _load_from_csv(Path(data_dir), file_pattern)
|
||||||
|
|
||||||
|
elif source == "excel":
|
||||||
|
if data_dir is None:
|
||||||
|
raise ValueError("Excel 로딩시 data_dir를 지정해야 합니다.")
|
||||||
|
return _load_from_excel(Path(data_dir))
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"지원하지 않는 소스: {source}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_csv(data_dir: Path, pattern: str) -> Dict[int, np.ndarray]:
|
||||||
|
"""CSV 파일에서 전이행렬 로딩 (파일명에 연도 포함 예상)"""
|
||||||
|
matrices = {}
|
||||||
|
for csv_file in sorted(data_dir.glob(pattern)):
|
||||||
|
# 파일명에서 연도 추출 시도
|
||||||
|
year = _extract_year_from_filename(csv_file.name)
|
||||||
|
if year is not None:
|
||||||
|
df = pd.read_csv(csv_file, index_col=0)
|
||||||
|
tm = df.values.astype(float)
|
||||||
|
# 행 합 정규화
|
||||||
|
for i in range(tm.shape[0]):
|
||||||
|
row_sum = tm[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
tm[i] /= row_sum
|
||||||
|
matrices[year] = tm
|
||||||
|
return matrices
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_excel(data_dir: Path) -> Dict[int, np.ndarray]:
|
||||||
|
"""Excel 파일에서 전이행렬 로딩 (시트별 연도 구분)"""
|
||||||
|
matrices = {}
|
||||||
|
for xlsx_file in sorted(data_dir.glob("*.xlsx")):
|
||||||
|
xls = pd.ExcelFile(xlsx_file)
|
||||||
|
for sheet_name in xls.sheet_names:
|
||||||
|
year = _extract_year_from_filename(sheet_name)
|
||||||
|
if year is not None:
|
||||||
|
df = pd.read_excel(xlsx_file, sheet_name=sheet_name, index_col=0)
|
||||||
|
tm = df.values.astype(float)
|
||||||
|
for i in range(tm.shape[0]):
|
||||||
|
row_sum = tm[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
tm[i] /= row_sum
|
||||||
|
matrices[year] = tm
|
||||||
|
return matrices
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_year_from_filename(name: str) -> Optional[int]:
|
||||||
|
"""파일명 또는 시트명에서 4자리 연도 추출"""
|
||||||
|
import re
|
||||||
|
match = re.search(r'(19|20)\d{2}', name)
|
||||||
|
if match:
|
||||||
|
return int(match.group())
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_ttc_matrix(matrices: Dict[int, np.ndarray]) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
TTC (Through-The-Cycle) 전이행렬 계산
|
||||||
|
|
||||||
|
전 기간 단순 평균. 행 합 재정규화.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
matrices : Dict[int, np.ndarray]
|
||||||
|
연도별 전이행렬 딕셔너리
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray
|
||||||
|
8×8 TTC 전이행렬
|
||||||
|
"""
|
||||||
|
all_matrices = np.array(list(matrices.values()))
|
||||||
|
ttc = all_matrices.mean(axis=0)
|
||||||
|
|
||||||
|
# 행 합 정규화
|
||||||
|
for i in range(ttc.shape[0]):
|
||||||
|
row_sum = ttc[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
ttc[i] /= row_sum
|
||||||
|
|
||||||
|
return ttc
|
||||||
|
|
||||||
|
|
||||||
|
def get_default_rates(matrices: Dict[int, np.ndarray]) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
연도별/등급별 부도율(PD) 추출
|
||||||
|
|
||||||
|
전이행렬의 마지막 열(D열)이 연간 부도 전이확률
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pd.DataFrame
|
||||||
|
index=연도, columns=등급, values=연간 PD
|
||||||
|
"""
|
||||||
|
years = sorted(matrices.keys())
|
||||||
|
grades = RATING_GRADES[:-1] # D 제외
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for year in years:
|
||||||
|
tm = matrices[year]
|
||||||
|
data[year] = {grade: tm[i, -1] for i, grade in enumerate(grades)}
|
||||||
|
|
||||||
|
return pd.DataFrame(data).T
|
||||||
|
|
||||||
|
|
||||||
|
def display_matrix(tm: np.ndarray, title: str = "전이행렬") -> str:
|
||||||
|
"""전이행렬을 보기 좋게 포매팅"""
|
||||||
|
df = pd.DataFrame(
|
||||||
|
tm,
|
||||||
|
index=RATING_GRADES,
|
||||||
|
columns=RATING_GRADES
|
||||||
|
)
|
||||||
|
# 백분율 표시
|
||||||
|
df_pct = df * 100
|
||||||
|
header = f"\n{'='*60}\n{title}\n{'='*60}\n"
|
||||||
|
return header + df_pct.to_string(float_format=lambda x: f"{x:.2f}%")
|
||||||
BIN
doc/260120143004692_KR 제출자료(2026년1월20일)_신용등급변화표(1년,3년).pdf
Normal file
BIN
doc/260120143004692_KR 제출자료(2026년1월20일)_신용등급변화표(1년,3년).pdf
Normal file
Binary file not shown.
BIN
doc/260122103003349_NICE신용평가_2025년_신용등급변화표_202601.pdf
Normal file
BIN
doc/260122103003349_NICE신용평가_2025년_신용등급변화표_202601.pdf
Normal file
Binary file not shown.
BIN
doc/260127134503220_1. 신용등급변화표_2025년.pdf
Normal file
BIN
doc/260127134503220_1. 신용등급변화표_2025년.pdf
Normal file
Binary file not shown.
373
docs/methodology.md
Normal file
373
docs/methodology.md
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
# Lifetime PD (50년) - 이론적 방법론 상세 문서
|
||||||
|
|
||||||
|
> **목표**: 한국 신용등급 전이행렬과 거시경제변수를 결합하여, 미래 경기를 반영한 50년 Lifetime PD를 호황/불황/중립 시나리오별로 산출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 전체 논리 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
[한국 3사 전이행렬 (2000-2025)]
|
||||||
|
↓
|
||||||
|
[TTC 전이행렬 산출 (전 기간 평균)]
|
||||||
|
↓
|
||||||
|
[Belkin & Suchower: Zt 추정 (WLS)] ← [TTC 임계값 (Φ⁻¹)]
|
||||||
|
↓
|
||||||
|
[Zt ~ 거시변수 회귀모형 (OLS + Stepwise)] ← [BOK ECOS 거시경제변수]
|
||||||
|
↓
|
||||||
|
[미래 시나리오 (호황/중립/불황)] → [Zt 경로 + Mean-Reversion]
|
||||||
|
↓
|
||||||
|
[Vasicek 조건부 전이행렬 (연도별)]
|
||||||
|
↓
|
||||||
|
[순차 행렬 곱 → 50년 누적/한계 PD]
|
||||||
|
↓
|
||||||
|
[확률 가중평균 PD Term Structure]
|
||||||
|
↓
|
||||||
|
[통계적 검증 (ADF, Ljung-Box, R², ARCH)]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 단계별 상세 이론
|
||||||
|
|
||||||
|
### 2.1 Through-The-Cycle (TTC) 전이행렬
|
||||||
|
|
||||||
|
**왜 TTC가 필요한가?**
|
||||||
|
|
||||||
|
연도별 전이행렬은 해당 연도의 경기 상황에 영향을 받아 변동합니다. TTC 전이행렬은 이러한 경기 변동을 평균화하여 "장기 균형" 상태의 전이확률을 나타냅니다. 이것이 Belkin & Suchower 모형에서 **기준점(anchor)** 역할을 합니다.
|
||||||
|
|
||||||
|
**산출 방법:**
|
||||||
|
|
||||||
|
```
|
||||||
|
TTC_{ij} = (1/T) × Σ_{t=1}^{T} TM_{ij}(t)
|
||||||
|
```
|
||||||
|
|
||||||
|
T개 연도의 단순 산술평균 후 행 합이 1이 되도록 재정규화합니다.
|
||||||
|
|
||||||
|
**논리적 근거:**
|
||||||
|
- Basel II IRB 프레임워크: PD 추정은 "경기 사이클 전체를 포괄하는 장기 평균"이어야 함 (BCBS §452)
|
||||||
|
- Moody's/S&P 방법론: 장기 평균 전이행렬을 TTC 기준으로 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Belkin & Suchower (1998) — 신용사이클 인덱스 Zt
|
||||||
|
|
||||||
|
**핵심 가정 (Merton-Vasicek 프레임워크):**
|
||||||
|
|
||||||
|
차입자 i의 자산가치 변화를 표준정규 확률변수 X_i로 모델링합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
X_i = √ρ × Z + √(1-ρ) × Y_i
|
||||||
|
```
|
||||||
|
|
||||||
|
여기서:
|
||||||
|
- **Z** ~ N(0,1): 체계적 요인 (Systematic Factor) — 모든 차입자에 공통
|
||||||
|
- **Y_i** ~ N(0,1): 개별적 요인 (Idiosyncratic Factor) — 차입자 고유
|
||||||
|
- **ρ**: 자산상관계수 (Asset Correlation) — 체계적 요인의 설명력
|
||||||
|
- Z와 Y_i는 상호 독립
|
||||||
|
|
||||||
|
**왜 이 분해가 필요한가?**
|
||||||
|
|
||||||
|
1. 부도율의 시간적 변동은 개별 기업의 고유 위험뿐 아니라, 경기 상황이라는 공통 요인에도 의존합니다
|
||||||
|
2. 이 공통 요인 Z를 분리하면, 특정 경기 상황(Z=z)에서의 "조건부 부도율"을 계산할 수 있습니다
|
||||||
|
3. 이것이 IFRS 9의 "미래 전망 정보(forward-looking)" 반영의 수학적 기초입니다
|
||||||
|
|
||||||
|
**임계값(Threshold) 산출:**
|
||||||
|
|
||||||
|
TTC 전이행렬의 누적확률로부터 등급 경계 임계값을 도출합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
d_{i,j} = Φ⁻¹( Σ_{k≤j} TTC_{i,k} )
|
||||||
|
```
|
||||||
|
|
||||||
|
- Φ⁻¹: 표준정규분포의 역함수
|
||||||
|
- 이 임계값은 "신용도 X_i가 특정 값 이하로 떨어지면 해당 등급으로 이동"한다는 구조적 모형의 경계입니다
|
||||||
|
|
||||||
|
**Z-조건부 전이확률:**
|
||||||
|
|
||||||
|
체계적 요인 Z가 주어진 상태에서의 전이확률:
|
||||||
|
|
||||||
|
```
|
||||||
|
p_{ij}(Z) = Φ( (d_{i,j} - √ρ × Z) / √(1-ρ) ) - Φ( (d_{i,j-1} - √ρ × Z) / √(1-ρ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
- Z > 0 (호황): 높은 등급 쪽으로 확률 이동 → 부도율 감소
|
||||||
|
- Z < 0 (불황): 낮은 등급 쪽으로 확률 이동 → 부도율 증가
|
||||||
|
- Z = 0: TTC 전이확률 복원 (무조건부 = 조건부의 기대값)
|
||||||
|
|
||||||
|
**Zt 추정 (가중최소자승법, WLS):**
|
||||||
|
|
||||||
|
연도 t의 관측 전이행렬 TM^obs(t)과 모형 예측 전이행렬 TM^model(Z)의 괴리를 최소화하는 Z_t를 추정합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
Ẑ_t = argmin_Z Σ_{i,j} w_{ij} × [ p_{ij}^obs(t) - p_{ij}^model(Z) ]²
|
||||||
|
```
|
||||||
|
|
||||||
|
가중치 w_{ij}:
|
||||||
|
- 부도 전이확률(D열): 10배 가중 — 부도율이 가장 중요한 리스크 지표
|
||||||
|
- 대각 원소(잔류확률): 5배 가중 — 안정성 지표로서 중요
|
||||||
|
- 기타: 1배 — 세부 전이는 상대적으로 덜 중요
|
||||||
|
|
||||||
|
**추정 결과 해석:**
|
||||||
|
- Zt > 0: 해당 연도는 "좋은 해" — 관측 부도율이 TTC보다 낮음
|
||||||
|
- Zt < 0: 해당 연도는 "나쁜 해" — 관측 부도율이 TTC보다 높음
|
||||||
|
- |Zt|의 크기: 경기 편차의 강도 (표준편차 단위)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 자산상관계수 ρ
|
||||||
|
|
||||||
|
**ρ의 의미:**
|
||||||
|
|
||||||
|
ρ는 차입자들의 자산수익률이 공통 경기 요인에 의해 얼마나 동시에 움직이는지를 나타냅니다:
|
||||||
|
- ρ → 0: 분산 완전 — 개별 위험만 존재, 경기 영향 없음
|
||||||
|
- ρ → 1: 완전 상관 — 모든 차입자가 동일하게 반응
|
||||||
|
|
||||||
|
**Basel IRB 기준:**
|
||||||
|
- 기업 대출: ρ = 0.12 ~ 0.24 (PD에 따라 역의 관계)
|
||||||
|
- 본 모형 기본값: ρ = 0.20 (한국 기업부문 중앙값)
|
||||||
|
|
||||||
|
**ρ 추정 방법:**
|
||||||
|
|
||||||
|
NLS(비선형최소자승법)으로 ρ와 Zt를 동시 추정할 수 있습니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
(ρ*, {Zt*}) = argmin_{ρ,{Zt}} Σ_t Σ_{i,j} w_{ij} × [ p_{ij}^obs(t) - p_{ij}^model(Zt, ρ) ]²
|
||||||
|
```
|
||||||
|
|
||||||
|
외부 루프: ρ 탐색 (bounded, 0.05~0.50)
|
||||||
|
내부 루프: 각 연도별 Zt 추정 (ρ 고정)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Vasicek 단일팩터 모델 — PIT PD
|
||||||
|
|
||||||
|
**TTC PD → PIT PD 변환:**
|
||||||
|
|
||||||
|
Vasicek 공식은 Belkin & Suchower의 특수한 경우로, 부도율만을 집중적으로 다룹니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
PD_PIT(Z) = Φ( (Φ⁻¹(PD_TTC) - √ρ × Z) / √(1-ρ) )
|
||||||
|
```
|
||||||
|
|
||||||
|
이 공식은 다음을 의미합니다:
|
||||||
|
1. PD_TTC: 장기 평균 부도율 — 경기 중립(Z=0)에서의 부도율
|
||||||
|
2. Φ⁻¹(PD_TTC): 부도 임계값을 표준정규 공간으로 변환
|
||||||
|
3. √ρ × Z: 경기 요인이 임계값을 이동시킴
|
||||||
|
4. √(1-ρ)로 나눔: 개별 요인의 분산으로 정규화
|
||||||
|
|
||||||
|
**수치 예시 (BBB 등급, PD_TTC = 0.38%, ρ = 0.20):**
|
||||||
|
|
||||||
|
| 시나리오 | Z값 | PD_PIT | 배수 |
|
||||||
|
|----------|------|--------|------|
|
||||||
|
| 심각한 호황 | +2.0 | 0.04% | 0.1× |
|
||||||
|
| 보통 호황 | +1.0 | 0.13% | 0.3× |
|
||||||
|
| 중립 | 0.0 | 0.38% | 1.0× |
|
||||||
|
| 보통 불황 | -1.0 | 0.96% | 2.5× |
|
||||||
|
| 심각한 불황 | -2.0 | 2.19% | 5.8× |
|
||||||
|
|
||||||
|
**논리적 근거:**
|
||||||
|
- IMF (2021): IFRS 9/CECL 호환 스트레스 테스트에서 Vasicek 공식 사용
|
||||||
|
- ECB: 금융안정성 평가에서 단일팩터 모형 기반 PIT PD 산출
|
||||||
|
- Fed DFAST/CCAR: 스트레스 시나리오에서 PD 조정 시 유사 구조 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.5 거시연계 회귀모형: Zt ~ 거시변수
|
||||||
|
|
||||||
|
**왜 거시변수와 연결하는가?**
|
||||||
|
|
||||||
|
Zt는 "신용사이클"이라는 추상적 개념입니다. 이를 관측 가능한 거시경제변수로 설명하면:
|
||||||
|
1. **해석 가능성**: Zt의 변동 원인을 이해할 수 있음
|
||||||
|
2. **예측 가능성**: 거시 전망치(IMF WEO, KDI 등)를 입력하면 미래 Zt를 예측할 수 있음
|
||||||
|
3. **시나리오 분석**: "만약 GDP가 -2%이고 실업률이 5%이면?"이라는 질문에 답할 수 있음
|
||||||
|
|
||||||
|
**모형 구조:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Z_t = β₀ + β₁·GDP_growth_t + β₂·Unemployment_t + β₃·Base_Rate_t
|
||||||
|
+ β₄·CD_Rate_t + β₅·CPI_growth_t + β₆·Leading_Index_t + ε_t
|
||||||
|
```
|
||||||
|
|
||||||
|
**변수 선택 (Forward Stepwise, AIC 기준):**
|
||||||
|
|
||||||
|
모든 6개 변수를 한꺼번에 넣으면 과적합(overfitting) 위험이 있습니다 (26개 관측치 대비 7개 파라미터).
|
||||||
|
|
||||||
|
Forward Stepwise 알고리즘:
|
||||||
|
1. 빈 모형에서 시작
|
||||||
|
2. AIC가 가장 많이 감소하는 변수를 하나 추가
|
||||||
|
3. 더 이상 AIC가 감소하지 않으면 중단
|
||||||
|
|
||||||
|
**실제 선택된 변수:** LEADING_INDEX, GDP_GROWTH, UNEMPLOYMENT, CD_RATE (4개)
|
||||||
|
|
||||||
|
**기대 부호:**
|
||||||
|
|
||||||
|
| 변수 | 기대 부호 | 근거 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| GDP_GROWTH | + | 경기 호황 → Zt 상승 (신용 개선) |
|
||||||
|
| UNEMPLOYMENT | - | 실업 증가 → Zt 하락 (부도 증가) |
|
||||||
|
| LEADING_INDEX | + | 경기 선행지수 상승 → Zt 상승 |
|
||||||
|
| CD_RATE | - | 금리 상승 → 기업 부담 증가 → Zt 하락 |
|
||||||
|
|
||||||
|
**왜 OLS인가?**
|
||||||
|
|
||||||
|
- 26개 연간 관측치로는 VAR, VECM 등 복잡한 시계열 모형의 자유도가 부족
|
||||||
|
- OLS는 소표본에서도 BLUE(Best Linear Unbiased Estimator) 조건 하에서 최적
|
||||||
|
- 잔차 진단으로 OLS 가정 위반 여부를 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.6 통계적 검증 (엄밀한 관점)
|
||||||
|
|
||||||
|
#### (a) ADF (Augmented Dickey-Fuller) 검정 — Zt 정상성
|
||||||
|
|
||||||
|
```
|
||||||
|
H₀: Zt에 단위근이 존재 (비정상 시계열)
|
||||||
|
H₁: Zt는 정상 시계열
|
||||||
|
```
|
||||||
|
|
||||||
|
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 — 잔차 자기상관
|
||||||
|
|
||||||
|
```
|
||||||
|
H₀: 잔차에 자기상관이 없음
|
||||||
|
DW ≈ 2이면 자기상관 없음
|
||||||
|
Ljung-Box: p > 0.05이면 자기상관 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
잔차에 자기상관이 존재하면 OLS 표준오차가 과소추정되어 유의성 검정이 왜곡됩니다.
|
||||||
|
**본 모형 결과: DW = 2.235, LB p = 0.2743 → 자기상관 없음 (Pass)**
|
||||||
|
|
||||||
|
#### (d) Breusch-Pagan / ARCH-LM — 이분산
|
||||||
|
|
||||||
|
```
|
||||||
|
H₀: 잔차의 분산이 일정 (등분산)
|
||||||
|
```
|
||||||
|
|
||||||
|
이분산이 존재하면 OLS 추정량은 여전히 불편이지만, 효율적이지 않습니다.
|
||||||
|
**본 모형 결과: BP p = 0.3951, ARCH p = 0.7885 → 등분산 (Pass)**
|
||||||
|
|
||||||
|
#### (e) R² / F-test — 모형 설명력
|
||||||
|
|
||||||
|
```
|
||||||
|
R² = 1 - (잔차변동/총변동)
|
||||||
|
F-test H₀: 모든 회귀계수 = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**본 모형 결과: R² = 0.889, F p = 0.0000 → 거시변수가 Zt 변동의 89%를 설명 (Pass)**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.7 시나리오 설계 (ECB/Fed 방식)
|
||||||
|
|
||||||
|
**IFRS 9 요구사항 (B5.5.42-44):**
|
||||||
|
|
||||||
|
ECL 산출 시 복수의 시나리오를 확률 가중하여 반영해야 합니다. "편향 없는(unbiased)" 추정을 위해 호황과 불황 양방향을 모두 고려해야 합니다.
|
||||||
|
|
||||||
|
| 시나리오 | Zt 설정 | 가중치 | 학술적 근거 |
|
||||||
|
|---------|---------|--------|------------|
|
||||||
|
| 호황 | μ + 1.0σ | 20% | ECB: 상위 시나리오에 15-25% |
|
||||||
|
| 중립 | μ + 0σ | 50% | IMF WEO 기본 전망 |
|
||||||
|
| 불황 | μ - 1.5σ | 30% | Fed DFAST: 역사적 하위 5% |
|
||||||
|
|
||||||
|
**가중치 비대칭의 이유:**
|
||||||
|
|
||||||
|
불황 시나리오에 더 높은 가중치(30% > 20%)를 부여하는 것은:
|
||||||
|
1. 신용 손실 함수의 비선형성 — 불황의 영향이 호황의 이익보다 큼
|
||||||
|
2. ECB/Fed의 감독 관행 — "보수적 추정" 원칙
|
||||||
|
3. 역사적으로 불황의 빈도가 호황보다 약간 높음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.8 50년 수렴 메커니즘
|
||||||
|
|
||||||
|
**왜 수렴이 필요한가?**
|
||||||
|
|
||||||
|
거시경제 예측은 현실적으로 3-5년이 한계입니다 (IMF WEO는 5년 전망). 50년 예측에서는:
|
||||||
|
1. **1~5년 (PIT 구간)**: 거시 시나리오 기반 Zt 적용 — 가장 신뢰도 높은 구간
|
||||||
|
2. **6~10년 (전환 구간)**: Mean-reversion으로 점진적 수렴 — 불확실성 증가에 대응
|
||||||
|
3. **11~50년 (TTC 구간)**: Z = 0 (장기 평균) — 경기 사이클이 반복된다는 가정
|
||||||
|
|
||||||
|
**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 적용 종료 시점
|
||||||
|
|
||||||
|
**학술적 근거:**
|
||||||
|
- Ornstein-Uhlenbeck 과정: 금리/스프레드 모형에서 널리 사용
|
||||||
|
- Basel III FRTB: 장기 리스크 파라미터의 평균회귀 가정
|
||||||
|
- IFRS 9 IG: 예측 불가능한 장기 구간에서는 역사적 평균 사용 권장
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 Lifetime PD 산출 엔진
|
||||||
|
|
||||||
|
**핵심 수학:**
|
||||||
|
|
||||||
|
연도별 조건부 전이행렬을 순차적으로 곱하여 누적 전이확률을 구합니다:
|
||||||
|
|
||||||
|
```
|
||||||
|
TM_cum(t) = TM(Z₁) × TM(Z₂) × ... × TM(Z_t)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **누적 PD(t)** = TM_cum(t) 의 D열 (마지막 열)
|
||||||
|
- **한계 PD(t)** = Cumulative PD(t) - Cumulative PD(t-1)
|
||||||
|
- **생존확률(t)** = 1 - Cumulative PD(t)
|
||||||
|
|
||||||
|
**왜 행렬 곱인가? (단순 PD 합산이 아닌 이유)**
|
||||||
|
|
||||||
|
단순히 연간 PD를 합산하면 "이미 부도한 기업이 다시 부도하는" 이중 계산이 발생합니다. 전이행렬 곱은:
|
||||||
|
1. 부도(D)를 흡수상태로 처리하여 이중 계산을 방지
|
||||||
|
2. 등급 이동(업그레이드/다운그레이드)을 경유한 부도 경로를 모두 포착
|
||||||
|
3. BBB → BB → B → D 같은 다단계 부도 경로를 정확히 반영
|
||||||
|
|
||||||
|
**확률 가중평균:**
|
||||||
|
|
||||||
|
```
|
||||||
|
PD_weighted(t) = Σ_s w_s × PD_s(t)
|
||||||
|
= 0.20 × PD_upside(t) + 0.50 × PD_base(t) + 0.30 × PD_downside(t)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 참고문헌
|
||||||
|
|
||||||
|
| 번호 | 문헌 | 활용 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | Belkin, B., Suchower, S., & Forest, L.R. (1998). "A One-Parameter Representation of Credit Risk and Transition Matrices" | 핵심 방법론: Zt 추정 |
|
||||||
|
| 2 | Vasicek, O. (2002). "The Distribution of Loan Portfolio Value" | 조건부 PD 공식 |
|
||||||
|
| 3 | Merton, R.C. (1974). "On the Pricing of Corporate Debt" | 구조적 모형 기초 |
|
||||||
|
| 4 | Basel Committee (2005). BCBS 128 "An Explanatory Note on the Basel II IRB Risk Weight Functions" | ρ 파라미터, WCPD |
|
||||||
|
| 5 | IFRS 9 Financial Instruments (IASB, 2014) B5.5.42-44 | 복수 시나리오 요구사항 |
|
||||||
|
| 6 | IMF (2021). "IFRS 9 and CECL Compatible Estimation for Top-Down Solvency Stress Testing" | 거시연계 PD 방법론 |
|
||||||
|
| 7 | ECB (2019). "Macro-financial scenarios for IFRS 9 ECL estimation" | 시나리오 가중치 |
|
||||||
|
| 8 | Federal Reserve (2023). "Dodd-Frank Act Stress Test Methodology" | 불황 시나리오 설계 |
|
||||||
|
| 9 | Greene, W.H. (2018). "Econometric Analysis" 8th ed. | OLS 진단, 변수 선택 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 코드 구조 ↔ 이론 매핑
|
||||||
|
|
||||||
|
| 모듈 | 이론 단계 | 핵심 함수 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `data/transition_matrices.py` | §2.1 TTC 산출 | `compute_ttc_matrix()` |
|
||||||
|
| `data/macro_data.py` | §2.5 거시데이터 | `collect_macro_data()` |
|
||||||
|
| `models/credit_cycle.py` | §2.2 Zt 추정 | `estimate_zt_series()` |
|
||||||
|
| `models/vasicek.py` | §2.4 조건부 PD | `conditional_pd()`, `conditional_transition_matrix()` |
|
||||||
|
| `models/macro_model.py` | §2.5 거시연계 | `MacroZtModel.fit()` |
|
||||||
|
| `scenarios/scenario_engine.py` | §2.7-2.8 시나리오 | `generate_z_paths()` |
|
||||||
|
| `projection/lifetime_pd.py` | §2.9 PD 산출 | `compute_all_scenarios()` |
|
||||||
|
| `validation/statistical_tests.py` | §2.6 검증 | `run_full_validation()` |
|
||||||
304
main.py
Normal file
304
main.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""
|
||||||
|
Lifetime PD (50년) 메인 실행 파일
|
||||||
|
|
||||||
|
전체 파이프라인:
|
||||||
|
1. 데이터 로딩 (전이행렬 + 거시경제변수)
|
||||||
|
2. Belkin & Suchower Zt 추정
|
||||||
|
3. 거시연계 회귀모형 구축
|
||||||
|
4. 시나리오별 Zt 경로 생성
|
||||||
|
5. 50년 Lifetime PD 산출
|
||||||
|
6. 통계적 검증
|
||||||
|
7. 시각화 및 리포트
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
python main.py
|
||||||
|
python main.py --horizon 30
|
||||||
|
python main.py --no-api # ECOS API 호출 없이 fallback 데이터 사용
|
||||||
|
python main.py --estimate-rho # 자산상관계수 동시 추정
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Windows CP949 인코딩 문제 해결
|
||||||
|
if sys.stdout.encoding != 'utf-8':
|
||||||
|
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
|
||||||
|
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import yaml
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
from tabulate import tabulate
|
||||||
|
|
||||||
|
# 프로젝트 모듈
|
||||||
|
from data.transition_matrices import (
|
||||||
|
load_transition_matrices, compute_ttc_matrix,
|
||||||
|
get_default_rates, display_matrix, RATING_GRADES
|
||||||
|
)
|
||||||
|
from data.macro_data import load_macro_data, _fallback_macro_data
|
||||||
|
from models.credit_cycle import estimate_zt_series, estimate_rho_and_zt
|
||||||
|
from models.vasicek import conditional_pd, worst_case_pd
|
||||||
|
from models.macro_model import build_macro_zt_model
|
||||||
|
from scenarios.scenario_engine import ScenarioEngine, load_config
|
||||||
|
from projection.lifetime_pd import LifetimePDEngine, compute_ecl_weights
|
||||||
|
from validation.statistical_tests import run_full_validation
|
||||||
|
from output.visualizer import generate_all_plots
|
||||||
|
|
||||||
|
# 로깅 설정
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
datefmt="%H:%M:%S"
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser(description="Lifetime PD (50년) 산출 시스템")
|
||||||
|
parser.add_argument("--config", default="config.yaml", help="설정 파일 경로")
|
||||||
|
parser.add_argument("--horizon", type=int, default=None, help="예측 기간 (기본: config 값)")
|
||||||
|
parser.add_argument("--no-api", action="store_true", help="ECOS API 미사용 (fallback 데이터)")
|
||||||
|
parser.add_argument("--estimate-rho", action="store_true", help="자산상관계수 동시 추정")
|
||||||
|
parser.add_argument("--output", default=None, help="결과 저장 디렉토리")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 0. 설정 로딩
|
||||||
|
# ================================================================
|
||||||
|
print("=" * 70)
|
||||||
|
print(" Lifetime PD (50년) - 미래 경기 반영 부도율 산출 시스템")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
config = load_config(args.config)
|
||||||
|
model_config = config.get("model", {})
|
||||||
|
rho = model_config.get("rho", 0.20)
|
||||||
|
|
||||||
|
conv_config = config.get("convergence", {})
|
||||||
|
horizon = args.horizon or conv_config.get("total_horizon", 50)
|
||||||
|
|
||||||
|
output_dir = args.output or config.get("output", {}).get("save_dir", "results")
|
||||||
|
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
print(f"\n ρ (자산상관계수) = {rho}")
|
||||||
|
print(f" 예측 기간 = {horizon}년")
|
||||||
|
print(f" 결과 저장 = {output_dir}/")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 1. 데이터 로딩
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [1/7] 데이터 로딩")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# 전이행렬
|
||||||
|
logger.info("전이행렬 로딩 중 (내장 데이터)...")
|
||||||
|
transition_matrices = load_transition_matrices("builtin")
|
||||||
|
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())})")
|
||||||
|
print(display_matrix(ttc_matrix, "TTC 전이행렬 (장기 평균)"))
|
||||||
|
|
||||||
|
# 거시경제변수
|
||||||
|
if args.no_api:
|
||||||
|
logger.info("Fallback 거시경제 데이터 사용")
|
||||||
|
macro_data = _fallback_macro_data()
|
||||||
|
else:
|
||||||
|
macro_data = load_macro_data(args.config)
|
||||||
|
|
||||||
|
print(f"\n 거시변수: {len(macro_data)}개 연도, {len(macro_data.columns)}개 변수")
|
||||||
|
print(f" 변수: {', '.join(macro_data.columns)}")
|
||||||
|
print(macro_data.tail(5).to_string())
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 2. Belkin & Suchower Zt 추정
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [2/7] 신용사이클 인덱스 (Zt) 추정")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
if args.estimate_rho:
|
||||||
|
logger.info("ρ와 Zt 동시 추정 중...")
|
||||||
|
rho, zt_dict = estimate_rho_and_zt(transition_matrices, ttc_matrix)
|
||||||
|
print(f"\n 추정된 ρ = {rho:.4f}")
|
||||||
|
else:
|
||||||
|
zt_dict = estimate_zt_series(transition_matrices, ttc_matrix, rho)
|
||||||
|
|
||||||
|
zt_series = pd.Series(zt_dict, name="Zt")
|
||||||
|
zt_series.index.name = "YEAR"
|
||||||
|
|
||||||
|
print(f"\n Zt 통계: μ={zt_series.mean():.4f}, σ={zt_series.std():.4f}")
|
||||||
|
print(f" 최소: {zt_series.min():.4f} ({zt_series.idxmin()})")
|
||||||
|
print(f" 최대: {zt_series.max():.4f} ({zt_series.idxmax()})")
|
||||||
|
|
||||||
|
zt_df = pd.DataFrame({"Year": zt_series.index, "Zt": zt_series.values})
|
||||||
|
print("\n" + tabulate(zt_df, headers="keys", tablefmt="simple", floatfmt=".4f"))
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 3. 거시연계 회귀모형
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [3/7] 거시연계 회귀모형 (Zt ~ 거시변수)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
macro_model = build_macro_zt_model(zt_dict, macro_data, method="stepwise_aic")
|
||||||
|
|
||||||
|
print(f"\n 선택된 변수: {macro_model.selected_vars}")
|
||||||
|
print(macro_model.summary())
|
||||||
|
|
||||||
|
diag = macro_model.diagnostics()
|
||||||
|
print(f"\n R² = {diag['r_squared']:.4f}")
|
||||||
|
print(f" Adj. R² = {diag['adj_r_squared']:.4f}")
|
||||||
|
print(f" AIC = {diag['aic']:.2f}")
|
||||||
|
print(f" DW = {diag['durbin_watson']:.3f}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 4. 시나리오 생성
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [4/7] 시나리오 생성 (호황/중립/불황)")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
scenario_engine = ScenarioEngine(config)
|
||||||
|
|
||||||
|
# 거시 시나리오 생성
|
||||||
|
macro_scenarios = scenario_engine.generate_default_macro_scenarios(
|
||||||
|
macro_data, base_year=2025, forecast_years=5
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zt 경로 생성
|
||||||
|
z_paths = scenario_engine.generate_z_paths(
|
||||||
|
zt_dict, macro_model, macro_scenarios, base_year=2025
|
||||||
|
)
|
||||||
|
|
||||||
|
weights = scenario_engine.get_scenario_weights()
|
||||||
|
print(f"\n 시나리오 가중치: {weights}")
|
||||||
|
|
||||||
|
for name, path in z_paths.items():
|
||||||
|
display = scenario_engine.get_display_name(name)
|
||||||
|
print(f"\n {display}:")
|
||||||
|
print(f" Zt[1-5] = {path[:5].round(3)}")
|
||||||
|
print(f" Zt[10] = {path[9]:.3f}")
|
||||||
|
print(f" Zt[50] = {path[-1]:.3f}")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 5. 50년 Lifetime PD 산출
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [5/7] 50년 Lifetime PD 산출")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
pd_engine = LifetimePDEngine(ttc_matrix, rho)
|
||||||
|
pd_results = pd_engine.compute_all_scenarios(z_paths, weights, horizon)
|
||||||
|
|
||||||
|
# 누적 PD 테이블
|
||||||
|
print("\n === 가중평균 누적 PD (%) ===")
|
||||||
|
cum_table = pd_engine.format_pd_table(pd_results)
|
||||||
|
print(tabulate(cum_table * 100, headers="keys", tablefmt="simple", floatfmt=".3f"))
|
||||||
|
|
||||||
|
# 시나리오별 주요 등급 비교
|
||||||
|
for scenario in z_paths.keys():
|
||||||
|
display = scenario_engine.get_display_name(scenario)
|
||||||
|
print(f"\n === {display} 누적 PD (%) ===")
|
||||||
|
s_table = pd_engine.format_pd_table(pd_results, scenario=scenario)
|
||||||
|
print(tabulate(s_table * 100, headers="keys", tablefmt="simple", floatfmt=".3f"))
|
||||||
|
|
||||||
|
# Vasicek Worst-Case 비교
|
||||||
|
print("\n === Basel II Worst-Case PD (99.9% VaR) ===")
|
||||||
|
ttc_pds = ttc_matrix[:-1, -1]
|
||||||
|
for i, grade in enumerate(RATING_GRADES[:-1]):
|
||||||
|
wc = worst_case_pd(ttc_pds[i], rho)
|
||||||
|
print(f" {grade}: TTC={ttc_pds[i]*100:.3f}% → WC={wc*100:.3f}%")
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 6. 통계적 검증
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [6/7] 통계적 검증")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
reg_result = macro_model.result if macro_model else None
|
||||||
|
validation_df = run_full_validation(
|
||||||
|
zt_series.values,
|
||||||
|
reg_result,
|
||||||
|
pd_results,
|
||||||
|
list(RATING_GRADES[:-1])
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n" + tabulate(validation_df, headers="keys", tablefmt="grid"))
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 7. 시각화
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" [7/7] 시각화 및 리포트 생성")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
generate_all_plots(
|
||||||
|
zt_history=zt_dict,
|
||||||
|
z_paths=z_paths,
|
||||||
|
zt_series_pd=zt_series,
|
||||||
|
macro_data=macro_data,
|
||||||
|
pd_results=pd_results,
|
||||||
|
ttc_matrix=ttc_matrix,
|
||||||
|
validation_df=validation_df,
|
||||||
|
output_dir=output_dir,
|
||||||
|
base_year=2025
|
||||||
|
)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 결과 저장 (CSV)
|
||||||
|
# ================================================================
|
||||||
|
out_path = Path(output_dir)
|
||||||
|
|
||||||
|
# Zt 시계열 저장
|
||||||
|
zt_series.to_csv(out_path / "zt_series.csv")
|
||||||
|
|
||||||
|
# 거시경제 데이터 저장
|
||||||
|
macro_data.to_csv(out_path / "macro_data.csv")
|
||||||
|
|
||||||
|
# PD 테이블 저장
|
||||||
|
for scenario in list(z_paths.keys()) + [None]:
|
||||||
|
label = scenario if scenario else "weighted"
|
||||||
|
cum_df = pd_engine.format_pd_table(
|
||||||
|
pd_results,
|
||||||
|
years=list(range(1, horizon + 1)),
|
||||||
|
scenario=scenario
|
||||||
|
)
|
||||||
|
cum_df.to_csv(out_path / f"cumulative_pd_{label}.csv")
|
||||||
|
|
||||||
|
marg_df = pd_engine.format_marginal_pd_table(
|
||||||
|
pd_results,
|
||||||
|
years=list(range(1, horizon + 1)),
|
||||||
|
scenario=scenario
|
||||||
|
)
|
||||||
|
marg_df.to_csv(out_path / f"marginal_pd_{label}.csv")
|
||||||
|
|
||||||
|
# 검증 결과 저장
|
||||||
|
validation_df.to_csv(out_path / "validation_results.csv", index=False)
|
||||||
|
|
||||||
|
# ================================================================
|
||||||
|
# 완료
|
||||||
|
# ================================================================
|
||||||
|
print("\n" + "=" * 70)
|
||||||
|
print(" ✅ 완료!")
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"\n 결과 파일:{output_dir}/")
|
||||||
|
print(f" - 차트: 01~07_*.png")
|
||||||
|
print(f" - 데이터: zt_series.csv, macro_data.csv")
|
||||||
|
print(f" - PD: cumulative_pd_*.csv, marginal_pd_*.csv")
|
||||||
|
print(f" - 검증: validation_results.csv")
|
||||||
|
print()
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
1
models/__init__.py
Normal file
1
models/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Core models: 신용사이클, Vasicek, 거시연계 모형
|
||||||
279
models/credit_cycle.py
Normal file
279
models/credit_cycle.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
Belkin & Suchower (1998) 신용사이클 인덱스 Zt 추정 모듈
|
||||||
|
|
||||||
|
핵심 방법론:
|
||||||
|
X_i = √ρ · Z + √(1-ρ) · Y_i
|
||||||
|
|
||||||
|
여기서:
|
||||||
|
X_i: 차입자 i의 신용도 변화 (표준정규)
|
||||||
|
Z: 체계적 요인 (credit cycle index, 표준정규)
|
||||||
|
Y_i: 개별적 요인 (표준정규, Z와 독립)
|
||||||
|
ρ: 자산상관계수
|
||||||
|
|
||||||
|
TTC 전이행렬의 누적확률 임계값을 Φ⁻¹로 변환한 후,
|
||||||
|
관측 연도별 전이행렬과 모델 전이행렬 사이의 WLS를 최소화하여 Zt 추정.
|
||||||
|
|
||||||
|
참고문헌:
|
||||||
|
- Belkin, B., Suchower, S., & Forest, L.R. (1998).
|
||||||
|
"A One-Parameter Representation of Credit Risk and Transition Matrices"
|
||||||
|
- Basel Committee on Banking Supervision (2005).
|
||||||
|
"An Explanatory Note on the Basel II IRB Risk Weight Functions"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.stats import norm
|
||||||
|
from scipy.optimize import minimize_scalar, minimize
|
||||||
|
from typing import Dict, Tuple, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_thresholds(ttc_matrix: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
TTC 전이행렬에서 등급 경계 임계값(thresholds) 산출
|
||||||
|
|
||||||
|
각 시작등급 i에 대해, 누적 전이확률의 역정규분포로 임계값 산출:
|
||||||
|
d_{i,j} = Φ⁻¹(Σ_{k≤j} p̄_{i,k})
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ttc_matrix : np.ndarray
|
||||||
|
N×N TTC 전이행렬 (행 합 = 1)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray
|
||||||
|
N×N 임계값 행렬 (마지막 열은 항상 +∞)
|
||||||
|
"""
|
||||||
|
n = ttc_matrix.shape[0]
|
||||||
|
thresholds = np.full((n, n), np.inf)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
cum_prob = 0.0
|
||||||
|
for j in range(n - 1):
|
||||||
|
cum_prob += ttc_matrix[i, j]
|
||||||
|
# 누적확률을 [1e-10, 1-1e-10] 범위로 클리핑 (Φ⁻¹ 발산 방지)
|
||||||
|
cum_prob_clipped = np.clip(cum_prob, 1e-10, 1.0 - 1e-10)
|
||||||
|
thresholds[i, j] = norm.ppf(cum_prob_clipped)
|
||||||
|
|
||||||
|
return thresholds
|
||||||
|
|
||||||
|
|
||||||
|
def model_transition_prob(
|
||||||
|
thresholds: np.ndarray,
|
||||||
|
z: float,
|
||||||
|
rho: float,
|
||||||
|
i: int,
|
||||||
|
j: int
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Z 조건부 전이확률 계산
|
||||||
|
|
||||||
|
p_{ij}(Z) = Φ((d_{i,j} - √ρ·Z) / √(1-ρ)) - Φ((d_{i,j-1} - √ρ·Z) / √(1-ρ))
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
thresholds : np.ndarray - 임계값 행렬
|
||||||
|
z : float - 신용사이클 인덱스
|
||||||
|
rho : float - 자산상관계수
|
||||||
|
i : int - 시작 등급 인덱스
|
||||||
|
j : int - 목표 등급 인덱스
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float : 조건부 전이확률
|
||||||
|
"""
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
# 상한 임계값
|
||||||
|
d_upper = thresholds[i, j]
|
||||||
|
upper = norm.cdf((d_upper - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
# 하한 임계값 (j=0이면 -∞)
|
||||||
|
if j == 0:
|
||||||
|
lower = 0.0
|
||||||
|
else:
|
||||||
|
d_lower = thresholds[i, j - 1]
|
||||||
|
lower = norm.cdf((d_lower - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
return max(upper - lower, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def model_transition_matrix(
|
||||||
|
thresholds: np.ndarray,
|
||||||
|
z: float,
|
||||||
|
rho: float
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Z 조건부 전체 전이행렬 산출
|
||||||
|
"""
|
||||||
|
n = thresholds.shape[0]
|
||||||
|
tm = np.zeros((n, n))
|
||||||
|
|
||||||
|
for i in range(n - 1): # D행은 흡수상태
|
||||||
|
for j in range(n):
|
||||||
|
tm[i, j] = model_transition_prob(thresholds, z, rho, i, j)
|
||||||
|
# 행 합 정규화 (수치오차 보정)
|
||||||
|
row_sum = tm[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
tm[i] /= row_sum
|
||||||
|
|
||||||
|
# D행: 흡수상태
|
||||||
|
tm[-1, -1] = 1.0
|
||||||
|
|
||||||
|
return tm
|
||||||
|
|
||||||
|
|
||||||
|
def zt_objective(
|
||||||
|
z: float,
|
||||||
|
observed_tm: np.ndarray,
|
||||||
|
thresholds: np.ndarray,
|
||||||
|
rho: float,
|
||||||
|
weights: Optional[np.ndarray] = None
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
Zt 추정을 위한 WLS 목적함수
|
||||||
|
|
||||||
|
minimize_Z Σ_{i,j} w_{ij} * (p_{ij}^{obs} - p_{ij}^{model}(Z))²
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
z : float - 신용사이클 인덱스 후보값
|
||||||
|
observed_tm : np.ndarray - 관측된 전이행렬
|
||||||
|
thresholds : np.ndarray - TTC 임계값
|
||||||
|
rho : float - 자산상관계수
|
||||||
|
weights : np.ndarray - 가중치 행렬 (기본: 부도열에 높은 가중치)
|
||||||
|
"""
|
||||||
|
n = observed_tm.shape[0]
|
||||||
|
|
||||||
|
if weights is None:
|
||||||
|
# 가중치: 부도열(D)에 10배 가중, 대각에 5배, 나머지 1배
|
||||||
|
weights = np.ones((n, n))
|
||||||
|
weights[:, -1] = 10.0 # 부도 전이확률에 높은 가중
|
||||||
|
for i in range(n):
|
||||||
|
weights[i, i] = 5.0 # 잔류 확률에도 가중
|
||||||
|
|
||||||
|
wss = 0.0
|
||||||
|
for i in range(n - 1): # D행 제외
|
||||||
|
for j in range(n):
|
||||||
|
p_obs = observed_tm[i, j]
|
||||||
|
p_model = model_transition_prob(thresholds, z, rho, i, j)
|
||||||
|
wss += weights[i, j] * (p_obs - p_model) ** 2
|
||||||
|
|
||||||
|
return wss
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_zt(
|
||||||
|
observed_tm: np.ndarray,
|
||||||
|
thresholds: np.ndarray,
|
||||||
|
rho: float,
|
||||||
|
z_bounds: Tuple[float, float] = (-4.0, 4.0)
|
||||||
|
) -> float:
|
||||||
|
"""
|
||||||
|
단일 연도의 Zt 추정
|
||||||
|
|
||||||
|
scipy.optimize.minimize_scalar로 WLS 목적함수 최소화
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
observed_tm : np.ndarray - 해당 연도 관측 전이행렬
|
||||||
|
thresholds : np.ndarray - TTC 임계값
|
||||||
|
rho : float - 자산상관계수
|
||||||
|
z_bounds : tuple - Z 탐색 범위
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float : 추정된 Zt 값
|
||||||
|
"""
|
||||||
|
result = minimize_scalar(
|
||||||
|
zt_objective,
|
||||||
|
bounds=z_bounds,
|
||||||
|
method="bounded",
|
||||||
|
args=(observed_tm, thresholds, rho)
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.x
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_zt_series(
|
||||||
|
transition_matrices: Dict[int, np.ndarray],
|
||||||
|
ttc_matrix: np.ndarray,
|
||||||
|
rho: float = 0.20
|
||||||
|
) -> Dict[int, float]:
|
||||||
|
"""
|
||||||
|
전체 기간에 대한 Zt 시계열 추정
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
transition_matrices : Dict[int, np.ndarray]
|
||||||
|
연도별 관측 전이행렬
|
||||||
|
ttc_matrix : np.ndarray
|
||||||
|
TTC 전이행렬
|
||||||
|
rho : float
|
||||||
|
자산상관계수
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Dict[int, float]
|
||||||
|
{연도: Zt값} 딕셔너리
|
||||||
|
"""
|
||||||
|
logger.info("TTC 전이행렬에서 임계값 산출 중...")
|
||||||
|
thresholds = compute_thresholds(ttc_matrix)
|
||||||
|
|
||||||
|
zt_series = {}
|
||||||
|
years = sorted(transition_matrices.keys())
|
||||||
|
|
||||||
|
logger.info(f"Zt 시계열 추정 중 ({years[0]}-{years[-1]}, rho={rho})...")
|
||||||
|
|
||||||
|
for year in years:
|
||||||
|
observed_tm = transition_matrices[year]
|
||||||
|
z_hat = estimate_zt(observed_tm, thresholds, rho)
|
||||||
|
zt_series[year] = z_hat
|
||||||
|
logger.debug(f" {year}: Zt = {z_hat:+.4f}")
|
||||||
|
|
||||||
|
logger.info(f"Zt 추정 완료. 범위: [{min(zt_series.values()):.3f}, {max(zt_series.values()):.3f}]")
|
||||||
|
|
||||||
|
return zt_series
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_rho_and_zt(
|
||||||
|
transition_matrices: Dict[int, np.ndarray],
|
||||||
|
ttc_matrix: np.ndarray,
|
||||||
|
rho_bounds: Tuple[float, float] = (0.05, 0.50)
|
||||||
|
) -> Tuple[float, Dict[int, float]]:
|
||||||
|
"""
|
||||||
|
자산상관계수 ρ와 Zt 시계열 동시 추정 (NLS)
|
||||||
|
|
||||||
|
총 목적함수 = Σ_t Σ_{i,j} w_{ij} * (p_{ij,t}^{obs} - p_{ij,t}^{model}(Z_t(ρ), ρ))²
|
||||||
|
|
||||||
|
외부 루프: ρ 탐색
|
||||||
|
내부 루프: 각 연도별 Zt 추정 (ρ 고정)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[float, Dict[int, float]]
|
||||||
|
(최적 ρ, Zt 시계열)
|
||||||
|
"""
|
||||||
|
years = sorted(transition_matrices.keys())
|
||||||
|
|
||||||
|
def total_objective(rho):
|
||||||
|
thresholds = compute_thresholds(ttc_matrix)
|
||||||
|
total_wss = 0.0
|
||||||
|
for year in years:
|
||||||
|
observed_tm = transition_matrices[year]
|
||||||
|
z_hat = estimate_zt(observed_tm, thresholds, rho)
|
||||||
|
total_wss += zt_objective(z_hat, observed_tm, thresholds, rho)
|
||||||
|
return total_wss
|
||||||
|
|
||||||
|
logger.info(f"ρ 동시 추정 중 (범위: {rho_bounds})...")
|
||||||
|
result = minimize_scalar(total_objective, bounds=rho_bounds, method="bounded")
|
||||||
|
optimal_rho = result.x
|
||||||
|
|
||||||
|
logger.info(f"최적 ρ = {optimal_rho:.4f}")
|
||||||
|
|
||||||
|
# 최적 ρ로 Zt 재추정
|
||||||
|
zt_series = estimate_zt_series(transition_matrices, ttc_matrix, optimal_rho)
|
||||||
|
|
||||||
|
return optimal_rho, zt_series
|
||||||
307
models/macro_model.py
Normal file
307
models/macro_model.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
거시경제 변수 ↔ Zt 연계 통계모형
|
||||||
|
|
||||||
|
Zt(신용사이클 인덱스)를 거시경제변수로 설명하는 회귀모형을 구축하고,
|
||||||
|
미래 거시 시나리오에 따른 Zt 전망을 생성합니다.
|
||||||
|
|
||||||
|
모형:
|
||||||
|
Z_t = β₀ + β₁·GDP_growth + β₂·Unemployment + β₃·Base_Rate
|
||||||
|
+ β₄·CD_Rate + β₅·CPI_growth + β₆·Leading_Index + ε_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"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import statsmodels.api as sm
|
||||||
|
from statsmodels.stats.diagnostic import het_breuschpagan, acorr_ljungbox
|
||||||
|
from statsmodels.stats.stattools import durbin_watson
|
||||||
|
from statsmodels.stats.outliers_influence import variance_inflation_factor
|
||||||
|
from scipy import stats
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||||
|
|
||||||
|
|
||||||
|
class MacroZtModel:
|
||||||
|
"""
|
||||||
|
거시경제변수 → Zt 회귀모형
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- OLS 다중회귀
|
||||||
|
- 변수 선택 (Stepwise AIC/BIC)
|
||||||
|
- 잔차 진단 (ADF, Ljung-Box, Breusch-Pagan, DW)
|
||||||
|
- VIF 다중공선성 체크
|
||||||
|
- 시나리오별 Zt 예측
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.model = None
|
||||||
|
self.result = None
|
||||||
|
self.selected_vars = None
|
||||||
|
self.scaler_params = {} # 정규화 파라미터
|
||||||
|
|
||||||
|
def fit(
|
||||||
|
self,
|
||||||
|
zt_series: pd.Series,
|
||||||
|
macro_data: pd.DataFrame,
|
||||||
|
method: str = "stepwise_aic",
|
||||||
|
standardize: bool = True
|
||||||
|
) -> "MacroZtModel":
|
||||||
|
"""
|
||||||
|
Zt ~ 거시변수 회귀모형 적합
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
zt_series : pd.Series
|
||||||
|
index=연도, values=Zt 추정값
|
||||||
|
macro_data : pd.DataFrame
|
||||||
|
index=연도, columns=거시변수
|
||||||
|
method : str
|
||||||
|
변수 선택 방법:
|
||||||
|
- "all": 모든 변수 사용
|
||||||
|
- "stepwise_aic": Forward stepwise (AIC 기준)
|
||||||
|
- "stepwise_bic": Forward stepwise (BIC 기준)
|
||||||
|
standardize : bool
|
||||||
|
거시변수 표준화 여부
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
self
|
||||||
|
"""
|
||||||
|
# 인덱스 정렬 및 교집합
|
||||||
|
common_years = sorted(set(zt_series.index) & set(macro_data.index))
|
||||||
|
if len(common_years) < 5:
|
||||||
|
raise ValueError(f"공통 데이터 포인트가 부족합니다: {len(common_years)}개")
|
||||||
|
|
||||||
|
y = zt_series.loc[common_years].values.astype(float)
|
||||||
|
X = macro_data.loc[common_years].copy()
|
||||||
|
|
||||||
|
# 결측치 처리
|
||||||
|
X = X.ffill().bfill().dropna(axis=1)
|
||||||
|
|
||||||
|
# 표준화
|
||||||
|
if standardize:
|
||||||
|
for col in X.columns:
|
||||||
|
mean = X[col].mean()
|
||||||
|
std = X[col].std()
|
||||||
|
if std > 0:
|
||||||
|
self.scaler_params[col] = {"mean": mean, "std": std}
|
||||||
|
X[col] = (X[col] - mean) / std
|
||||||
|
else:
|
||||||
|
X = X.drop(columns=[col])
|
||||||
|
|
||||||
|
# 변수 선택
|
||||||
|
if method == "all":
|
||||||
|
self.selected_vars = list(X.columns)
|
||||||
|
elif method.startswith("stepwise"):
|
||||||
|
criterion = "aic" if "aic" in method else "bic"
|
||||||
|
self.selected_vars = self._stepwise_selection(y, X, criterion)
|
||||||
|
else:
|
||||||
|
self.selected_vars = list(X.columns)
|
||||||
|
|
||||||
|
if not self.selected_vars:
|
||||||
|
logger.warning("변수 선택 결과 선택된 변수가 없습니다. 전체 변수 사용.")
|
||||||
|
self.selected_vars = list(X.columns)
|
||||||
|
|
||||||
|
# 최종 모형 적합
|
||||||
|
X_selected = sm.add_constant(X[self.selected_vars].values)
|
||||||
|
self.model = sm.OLS(y, X_selected)
|
||||||
|
self.result = self.model.fit()
|
||||||
|
|
||||||
|
logger.info(f"회귀모형 적합 완료: 선택변수 = {self.selected_vars}")
|
||||||
|
logger.info(f" R² = {self.result.rsquared:.4f}, "
|
||||||
|
f"Adj.R² = {self.result.rsquared_adj:.4f}, "
|
||||||
|
f"AIC = {self.result.aic:.2f}")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def _stepwise_selection(
|
||||||
|
self,
|
||||||
|
y: np.ndarray,
|
||||||
|
X: pd.DataFrame,
|
||||||
|
criterion: str = "aic"
|
||||||
|
) -> List[str]:
|
||||||
|
"""Forward Stepwise 변수 선택"""
|
||||||
|
remaining = list(X.columns)
|
||||||
|
selected = []
|
||||||
|
current_score = np.inf
|
||||||
|
|
||||||
|
while remaining:
|
||||||
|
scores = {}
|
||||||
|
for var in remaining:
|
||||||
|
trial_vars = selected + [var]
|
||||||
|
X_trial = sm.add_constant(X[trial_vars].values)
|
||||||
|
try:
|
||||||
|
model = sm.OLS(y, X_trial).fit()
|
||||||
|
score = model.aic if criterion == "aic" else model.bic
|
||||||
|
scores[var] = score
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not scores:
|
||||||
|
break
|
||||||
|
|
||||||
|
best_var = min(scores, key=scores.get)
|
||||||
|
best_score = scores[best_var]
|
||||||
|
|
||||||
|
if best_score < current_score:
|
||||||
|
selected.append(best_var)
|
||||||
|
remaining.remove(best_var)
|
||||||
|
current_score = best_score
|
||||||
|
logger.debug(f" + {best_var} ({criterion.upper()} = {best_score:.2f})")
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def predict(self, macro_scenario: pd.DataFrame) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
거시 시나리오로 Zt 예측
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
macro_scenario : pd.DataFrame
|
||||||
|
columns에 selected_vars가 포함되어야 함
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray : Zt 예측값 배열
|
||||||
|
"""
|
||||||
|
if self.result is None:
|
||||||
|
raise ValueError("모형이 적합되지 않았습니다. fit()을 먼저 실행하세요.")
|
||||||
|
|
||||||
|
X = macro_scenario[self.selected_vars].copy()
|
||||||
|
|
||||||
|
# 학습 데이터와 동일한 표준화 적용
|
||||||
|
for col in X.columns:
|
||||||
|
if col in self.scaler_params:
|
||||||
|
mean = self.scaler_params[col]["mean"]
|
||||||
|
std = self.scaler_params[col]["std"]
|
||||||
|
X[col] = (X[col] - mean) / std
|
||||||
|
|
||||||
|
X_const = sm.add_constant(X.values, has_constant="add")
|
||||||
|
return self.result.predict(X_const)
|
||||||
|
|
||||||
|
def diagnostics(self) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
회귀 모형 진단 결과 반환
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
- r_squared, adj_r_squared
|
||||||
|
- f_stat, f_pvalue
|
||||||
|
- aic, bic
|
||||||
|
- durbin_watson
|
||||||
|
- ljung_box (p-value)
|
||||||
|
- breusch_pagan (p-value)
|
||||||
|
- vif (각 변수별)
|
||||||
|
- coefficients (DataFrame)
|
||||||
|
"""
|
||||||
|
if self.result is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
diag = {
|
||||||
|
"r_squared": self.result.rsquared,
|
||||||
|
"adj_r_squared": self.result.rsquared_adj,
|
||||||
|
"f_stat": self.result.fvalue,
|
||||||
|
"f_pvalue": self.result.f_pvalue,
|
||||||
|
"aic": self.result.aic,
|
||||||
|
"bic": self.result.bic,
|
||||||
|
"n_obs": int(self.result.nobs),
|
||||||
|
"selected_vars": self.selected_vars,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Durbin-Watson
|
||||||
|
residuals = self.result.resid
|
||||||
|
diag["durbin_watson"] = durbin_watson(residuals)
|
||||||
|
|
||||||
|
# Ljung-Box (자기상관 검정)
|
||||||
|
try:
|
||||||
|
lb_result = acorr_ljungbox(residuals, lags=[5], return_df=True)
|
||||||
|
diag["ljung_box_stat"] = lb_result["lb_stat"].values[0]
|
||||||
|
diag["ljung_box_pvalue"] = lb_result["lb_pvalue"].values[0]
|
||||||
|
except Exception:
|
||||||
|
diag["ljung_box_pvalue"] = np.nan
|
||||||
|
|
||||||
|
# Breusch-Pagan (이분산 검정)
|
||||||
|
try:
|
||||||
|
bp_stat, bp_pvalue, _, _ = het_breuschpagan(
|
||||||
|
residuals, self.result.model.exog
|
||||||
|
)
|
||||||
|
diag["breusch_pagan_stat"] = bp_stat
|
||||||
|
diag["breusch_pagan_pvalue"] = bp_pvalue
|
||||||
|
except Exception:
|
||||||
|
diag["breusch_pagan_pvalue"] = np.nan
|
||||||
|
|
||||||
|
# VIF (다중공선성)
|
||||||
|
try:
|
||||||
|
X = self.result.model.exog
|
||||||
|
vif_values = {}
|
||||||
|
var_names = ["const"] + self.selected_vars
|
||||||
|
for i in range(X.shape[1]):
|
||||||
|
vif_values[var_names[i]] = variance_inflation_factor(X, i)
|
||||||
|
diag["vif"] = vif_values
|
||||||
|
except Exception:
|
||||||
|
diag["vif"] = {}
|
||||||
|
|
||||||
|
# 계수 요약
|
||||||
|
coef_df = pd.DataFrame({
|
||||||
|
"변수": ["const"] + self.selected_vars,
|
||||||
|
"계수": self.result.params,
|
||||||
|
"표준오차": self.result.bse,
|
||||||
|
"t값": self.result.tvalues,
|
||||||
|
"p값": self.result.pvalues,
|
||||||
|
})
|
||||||
|
diag["coefficients"] = coef_df
|
||||||
|
|
||||||
|
return diag
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
"""모형 요약 출력"""
|
||||||
|
if self.result is None:
|
||||||
|
return "모형이 적합되지 않았습니다."
|
||||||
|
return str(self.result.summary())
|
||||||
|
|
||||||
|
def residual_series(self) -> np.ndarray:
|
||||||
|
"""잔차 시계열 반환"""
|
||||||
|
if self.result is None:
|
||||||
|
return np.array([])
|
||||||
|
return self.result.resid
|
||||||
|
|
||||||
|
|
||||||
|
def build_macro_zt_model(
|
||||||
|
zt_dict: Dict[int, float],
|
||||||
|
macro_df: pd.DataFrame,
|
||||||
|
method: str = "stepwise_aic"
|
||||||
|
) -> MacroZtModel:
|
||||||
|
"""
|
||||||
|
편의 함수: Zt 딕셔너리 + 거시 DataFrame → 회귀모형 구축
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
zt_dict : Dict[int, float]
|
||||||
|
{연도: Zt값}
|
||||||
|
macro_df : pd.DataFrame
|
||||||
|
index=연도, columns=거시변수
|
||||||
|
method : str
|
||||||
|
변수 선택 방법
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
MacroZtModel : 적합된 모형
|
||||||
|
"""
|
||||||
|
zt_series = pd.Series(zt_dict, name="Zt")
|
||||||
|
zt_series.index.name = "YEAR"
|
||||||
|
|
||||||
|
model = MacroZtModel()
|
||||||
|
model.fit(zt_series, macro_df, method=method)
|
||||||
|
|
||||||
|
return model
|
||||||
218
models/vasicek.py
Normal file
218
models/vasicek.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""
|
||||||
|
Vasicek 단일팩터 모델 기반 조건부 PD 및 전이행렬 모듈
|
||||||
|
|
||||||
|
핵심 공식:
|
||||||
|
PD_PIT(Z) = Φ( (Φ⁻¹(PD_TTC) - √ρ · Z) / √(1-ρ) )
|
||||||
|
|
||||||
|
이 모듈은 Belkin & Suchower의 임계값 방식 대신,
|
||||||
|
Vasicek 공식을 직접 적용하는 간편 버전도 제공합니다.
|
||||||
|
|
||||||
|
참고문헌:
|
||||||
|
- Vasicek, O. (2002). "The Distribution of Loan Portfolio Value"
|
||||||
|
- Basel Committee (2005). "An Explanatory Note on the Basel II IRB Risk Weight Functions"
|
||||||
|
- Merton, R.C. (1974). "On the Pricing of Corporate Debt"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.stats import norm
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_pd(pd_ttc: float, z: float, rho: float) -> float:
|
||||||
|
"""
|
||||||
|
Vasicek 공식으로 PIT PD 계산
|
||||||
|
|
||||||
|
PD_PIT(Z) = Φ( (Φ⁻¹(PD_TTC) - √ρ · Z) / √(1-ρ) )
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pd_ttc : float - TTC (Through-the-Cycle) 부도확률
|
||||||
|
z : float - 체계적 요인 (Z > 0: 호황, Z < 0: 불황)
|
||||||
|
rho : float - 자산상관계수 (0 < ρ < 1)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
float : PIT (Point-in-Time) 부도확률
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
>>> conditional_pd(0.02, 0, 0.20) # Z=0이면 PD_PIT = PD_TTC
|
||||||
|
0.02
|
||||||
|
>>> conditional_pd(0.02, -2, 0.20) # 불황시 PD 상승
|
||||||
|
0.1016...
|
||||||
|
>>> conditional_pd(0.02, 2, 0.20) # 호황시 PD 하락
|
||||||
|
0.0024...
|
||||||
|
"""
|
||||||
|
if pd_ttc <= 0:
|
||||||
|
return 0.0
|
||||||
|
if pd_ttc >= 1:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
numerator = norm.ppf(pd_ttc) - sqrt_rho * z
|
||||||
|
pd_pit = norm.cdf(numerator / sqrt_1_rho)
|
||||||
|
|
||||||
|
return float(np.clip(pd_pit, 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_pd_array(pd_ttc_array: np.ndarray, z: float, rho: float) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
벡터화된 Vasicek 공식 (등급별 TTC PD 배열 → PIT PD 배열)
|
||||||
|
"""
|
||||||
|
pd_ttc_clipped = np.clip(pd_ttc_array, 1e-10, 1.0 - 1e-10)
|
||||||
|
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
numerator = norm.ppf(pd_ttc_clipped) - sqrt_rho * z
|
||||||
|
pd_pit = norm.cdf(numerator / sqrt_1_rho)
|
||||||
|
|
||||||
|
return np.clip(pd_pit, 0.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def conditional_transition_matrix(
|
||||||
|
ttc_tm: np.ndarray,
|
||||||
|
z: float,
|
||||||
|
rho: float
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
임계값 기반 Z-조건부 전이행렬 산출
|
||||||
|
|
||||||
|
TTC 전이행렬로부터 누적확률 임계값을 산출하고,
|
||||||
|
Z 값에 따라 조건부 전이확률을 계산합니다.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ttc_tm : np.ndarray - N×N TTC 전이행렬
|
||||||
|
z : float - 체계적 요인
|
||||||
|
rho : float - 자산상관계수
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray : N×N 조건부 전이행렬
|
||||||
|
"""
|
||||||
|
n = ttc_tm.shape[0]
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
# 임계값 산출 (누적확률 → Φ⁻¹)
|
||||||
|
thresholds = np.full((n, n), np.inf)
|
||||||
|
for i in range(n):
|
||||||
|
cum_prob = 0.0
|
||||||
|
for j in range(n - 1):
|
||||||
|
cum_prob += ttc_tm[i, j]
|
||||||
|
cum_prob_clipped = np.clip(cum_prob, 1e-10, 1.0 - 1e-10)
|
||||||
|
thresholds[i, j] = norm.ppf(cum_prob_clipped)
|
||||||
|
|
||||||
|
# 조건부 전이행렬 계산
|
||||||
|
cond_tm = np.zeros((n, n))
|
||||||
|
|
||||||
|
for i in range(n - 1):
|
||||||
|
for j in range(n):
|
||||||
|
d_upper = thresholds[i, j]
|
||||||
|
upper = norm.cdf((d_upper - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
if j == 0:
|
||||||
|
lower = 0.0
|
||||||
|
else:
|
||||||
|
d_lower = thresholds[i, j - 1]
|
||||||
|
lower = norm.cdf((d_lower - sqrt_rho * z) / sqrt_1_rho)
|
||||||
|
|
||||||
|
cond_tm[i, j] = max(upper - lower, 0.0)
|
||||||
|
|
||||||
|
# 행 합 정규화
|
||||||
|
row_sum = cond_tm[i].sum()
|
||||||
|
if row_sum > 0:
|
||||||
|
cond_tm[i] /= row_sum
|
||||||
|
|
||||||
|
# D행: 흡수상태
|
||||||
|
cond_tm[-1, -1] = 1.0
|
||||||
|
|
||||||
|
return cond_tm
|
||||||
|
|
||||||
|
|
||||||
|
def multi_period_pd(
|
||||||
|
annual_tm: np.ndarray,
|
||||||
|
horizon: int,
|
||||||
|
initial_grade_idx: Optional[int] = None
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
전이행렬 거듭제곱으로 다기간 누적/한계 PD 계산
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
annual_tm : np.ndarray - 1년 전이행렬
|
||||||
|
horizon : int - 예측 기간 (년)
|
||||||
|
initial_grade_idx : int - 특정 등급만 계산 (None이면 전체)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray
|
||||||
|
shape (horizon, N-1): 연도별 각 등급의 누적 PD
|
||||||
|
또는 shape (horizon,): 특정 등급의 누적 PD
|
||||||
|
"""
|
||||||
|
n = annual_tm.shape[0]
|
||||||
|
cumulative_tm = np.eye(n)
|
||||||
|
|
||||||
|
cumulative_pds = []
|
||||||
|
for t in range(1, horizon + 1):
|
||||||
|
cumulative_tm = cumulative_tm @ annual_tm
|
||||||
|
# 부도열(마지막 열)이 누적 PD
|
||||||
|
if initial_grade_idx is not None:
|
||||||
|
cumulative_pds.append(cumulative_tm[initial_grade_idx, -1])
|
||||||
|
else:
|
||||||
|
cumulative_pds.append(cumulative_tm[:-1, -1].copy())
|
||||||
|
|
||||||
|
return np.array(cumulative_pds)
|
||||||
|
|
||||||
|
|
||||||
|
def marginal_pd_from_cumulative(cumulative_pds: np.ndarray) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
누적 PD에서 한계 PD(Marginal PD) 계산
|
||||||
|
|
||||||
|
Marginal PD(t) = Cumulative PD(t) - Cumulative PD(t-1)
|
||||||
|
"""
|
||||||
|
if cumulative_pds.ndim == 1:
|
||||||
|
marginal = np.diff(cumulative_pds, prepend=0.0)
|
||||||
|
else:
|
||||||
|
first_row = np.zeros((1, cumulative_pds.shape[1]))
|
||||||
|
marginal = np.diff(cumulative_pds, axis=0, prepend=first_row)
|
||||||
|
|
||||||
|
return np.maximum(marginal, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def survival_probability(cumulative_pds: np.ndarray) -> np.ndarray:
|
||||||
|
"""생존확률 = 1 - 누적 PD"""
|
||||||
|
return 1.0 - cumulative_pds
|
||||||
|
|
||||||
|
|
||||||
|
def annualized_pd(cumulative_pd: float, horizon: int) -> float:
|
||||||
|
"""
|
||||||
|
누적 PD를 연환산 PD로 변환
|
||||||
|
|
||||||
|
AnnualizedPD = 1 - (1 - CumulativePD)^(1/horizon)
|
||||||
|
"""
|
||||||
|
if cumulative_pd >= 1.0:
|
||||||
|
return 1.0
|
||||||
|
return 1.0 - (1.0 - cumulative_pd) ** (1.0 / horizon)
|
||||||
|
|
||||||
|
|
||||||
|
def worst_case_pd(pd_ttc: float, rho: float, confidence: float = 0.999) -> float:
|
||||||
|
"""
|
||||||
|
Basel II IRB 방식 Worst-Case PD (99.9% 신뢰수준)
|
||||||
|
|
||||||
|
WCPD = Φ( (Φ⁻¹(PD) + √ρ · Φ⁻¹(confidence)) / √(1-ρ) )
|
||||||
|
"""
|
||||||
|
if pd_ttc <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
sqrt_rho = np.sqrt(rho)
|
||||||
|
sqrt_1_rho = np.sqrt(1.0 - rho)
|
||||||
|
|
||||||
|
numerator = norm.ppf(pd_ttc) + sqrt_rho * norm.ppf(confidence)
|
||||||
|
return float(norm.cdf(numerator / sqrt_1_rho))
|
||||||
1
output/__init__.py
Normal file
1
output/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Output: 시각화 및 리포트
|
||||||
354
output/visualizer.py
Normal file
354
output/visualizer.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
시각화 및 리포트 모듈
|
||||||
|
|
||||||
|
Lifetime PD 분석 결과를 차트와 테이블로 시각화합니다.
|
||||||
|
|
||||||
|
차트 목록:
|
||||||
|
1. Zt 시계열 (과거 + 예측)
|
||||||
|
2. Zt vs 거시변수 산점도
|
||||||
|
3. 시나리오별 Marginal PD 곡선
|
||||||
|
4. 시나리오별 Cumulative PD 곡선
|
||||||
|
5. 시나리오 가중평균 PD Term Structure
|
||||||
|
6. 전이행렬 히트맵
|
||||||
|
7. 통계 검증 결과 요약
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.ticker as mticker
|
||||||
|
import seaborn as sns
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 한글 폰트 설정
|
||||||
|
plt.rcParams["font.family"] = "Malgun Gothic"
|
||||||
|
plt.rcParams["axes.unicode_minus"] = False
|
||||||
|
plt.rcParams["figure.dpi"] = 150
|
||||||
|
|
||||||
|
# 시나리오 색상
|
||||||
|
SCENARIO_COLORS = {
|
||||||
|
"upside": "#2ecc71", # 초록
|
||||||
|
"base": "#3498db", # 파랑
|
||||||
|
"downside": "#e74c3c", # 빨강
|
||||||
|
}
|
||||||
|
|
||||||
|
SCENARIO_LABELS = {
|
||||||
|
"upside": "호황 (Upside)",
|
||||||
|
"base": "중립 (Base)",
|
||||||
|
"downside": "불황 (Downside)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def plot_zt_timeseries(
|
||||||
|
zt_history: Dict[int, float],
|
||||||
|
z_paths: Dict[str, np.ndarray] = None,
|
||||||
|
base_year: int = 2025,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""Zt 시계열 차트 (과거 + 시나리오별 예측)"""
|
||||||
|
fig, ax = plt.subplots(figsize=(14, 6))
|
||||||
|
|
||||||
|
# 과거 Zt
|
||||||
|
years = sorted(zt_history.keys())
|
||||||
|
values = [zt_history[y] for y in years]
|
||||||
|
ax.plot(years, values, "ko-", markersize=4, linewidth=1.5, label="과거 Zt (추정)")
|
||||||
|
ax.fill_between(years, values, 0, alpha=0.15, color="gray")
|
||||||
|
|
||||||
|
# 시나리오별 예측
|
||||||
|
if z_paths:
|
||||||
|
for scenario, z_path in z_paths.items():
|
||||||
|
future_years = list(range(base_year + 1, base_year + len(z_path) + 1))
|
||||||
|
color = SCENARIO_COLORS.get(scenario, "gray")
|
||||||
|
label = SCENARIO_LABELS.get(scenario, scenario)
|
||||||
|
ax.plot(future_years, z_path, color=color, linewidth=1.5,
|
||||||
|
linestyle="--", alpha=0.8, label=label)
|
||||||
|
|
||||||
|
ax.axhline(y=0, color="navy", linestyle=":", alpha=0.5, label="TTC (Z=0)")
|
||||||
|
ax.set_xlabel("연도")
|
||||||
|
ax.set_ylabel("Credit Cycle Index (Zt)")
|
||||||
|
ax.set_title("Belkin & Suchower 신용사이클 인덱스 (Zt)")
|
||||||
|
ax.legend(loc="best", fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# 주요 이벤트 표시
|
||||||
|
events = {2000: "IMF\n여파", 2003: "카드\n사태", 2008: "GFC", 2020: "COVID"}
|
||||||
|
for yr, label in events.items():
|
||||||
|
if yr in zt_history:
|
||||||
|
ax.annotate(label, xy=(yr, zt_history[yr]),
|
||||||
|
xytext=(yr, zt_history[yr] - 0.5),
|
||||||
|
fontsize=7, ha="center", color="red",
|
||||||
|
arrowprops=dict(arrowstyle="->", color="red", lw=0.8))
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
logger.info(f"차트 저장: {save_path}")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_macro_vs_zt(
|
||||||
|
zt_series: pd.Series,
|
||||||
|
macro_data: pd.DataFrame,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""거시변수 vs Zt 산점도 (회귀선 포함)"""
|
||||||
|
n_vars = min(len(macro_data.columns), 6)
|
||||||
|
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
|
||||||
|
axes = axes.flatten()
|
||||||
|
|
||||||
|
common = sorted(set(zt_series.index) & set(macro_data.index))
|
||||||
|
|
||||||
|
for idx, col in enumerate(macro_data.columns[:n_vars]):
|
||||||
|
ax = axes[idx]
|
||||||
|
x = macro_data.loc[common, col].values
|
||||||
|
y = zt_series.loc[common].values
|
||||||
|
|
||||||
|
ax.scatter(x, y, color="#3498db", alpha=0.7, s=30)
|
||||||
|
|
||||||
|
# 회귀선
|
||||||
|
if len(x) > 2:
|
||||||
|
z = np.polyfit(x, y, 1)
|
||||||
|
p = np.poly1d(z)
|
||||||
|
x_line = np.linspace(x.min(), x.max(), 50)
|
||||||
|
ax.plot(x_line, p(x_line), "r--", alpha=0.7, linewidth=1.2)
|
||||||
|
|
||||||
|
corr = np.corrcoef(x, y)[0, 1]
|
||||||
|
ax.set_title(f"{col}\n(ρ = {corr:.3f})", fontsize=10)
|
||||||
|
else:
|
||||||
|
ax.set_title(col, fontsize=10)
|
||||||
|
|
||||||
|
ax.set_xlabel(col, fontsize=9)
|
||||||
|
ax.set_ylabel("Zt", fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
# 빈 서브플롯 숨기기
|
||||||
|
for idx in range(n_vars, len(axes)):
|
||||||
|
axes[idx].set_visible(False)
|
||||||
|
|
||||||
|
fig.suptitle("거시경제변수 vs 신용사이클 인덱스 (Zt)", fontsize=13, y=1.02)
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_lifetime_pd(
|
||||||
|
results: Dict,
|
||||||
|
pd_type: str = "cumulative",
|
||||||
|
grades_to_show: List[str] = None,
|
||||||
|
base_year: int = 2025,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
시나리오별 Lifetime PD 곡선
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
pd_type : str - "cumulative" or "marginal"
|
||||||
|
grades_to_show : List[str] - 표시할 등급 (기본: BBB, BB, B)
|
||||||
|
"""
|
||||||
|
from data.transition_matrices import RATING_GRADES
|
||||||
|
non_default = RATING_GRADES[:-1]
|
||||||
|
|
||||||
|
if grades_to_show is None:
|
||||||
|
grades_to_show = ["BBB", "BB", "B"]
|
||||||
|
|
||||||
|
grade_indices = [non_default.index(g) for g in grades_to_show if g in non_default]
|
||||||
|
n_grades = len(grade_indices)
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(1, n_grades, figsize=(6 * n_grades, 5))
|
||||||
|
if n_grades == 1:
|
||||||
|
axes = [axes]
|
||||||
|
|
||||||
|
pd_key = f"{pd_type}_pd"
|
||||||
|
|
||||||
|
for ax_idx, (ax, gi) in enumerate(zip(axes, grade_indices)):
|
||||||
|
grade = non_default[gi]
|
||||||
|
|
||||||
|
for scenario, data in results["by_scenario"].items():
|
||||||
|
pds = data[pd_key][:, gi]
|
||||||
|
horizon = len(pds)
|
||||||
|
years = list(range(base_year + 1, base_year + horizon + 1))
|
||||||
|
|
||||||
|
color = SCENARIO_COLORS.get(scenario, "gray")
|
||||||
|
label = SCENARIO_LABELS.get(scenario, scenario)
|
||||||
|
ax.plot(years, pds * 100, color=color, linewidth=1.5, label=label)
|
||||||
|
|
||||||
|
# 가중평균
|
||||||
|
weighted_key = f"weighted_{pd_type}_pd"
|
||||||
|
if weighted_key in results:
|
||||||
|
w_pds = results[weighted_key][:, gi]
|
||||||
|
years = list(range(base_year + 1, base_year + len(w_pds) + 1))
|
||||||
|
ax.plot(years, w_pds * 100, color="purple", linewidth=2.5,
|
||||||
|
linestyle="-.", label="가중평균", alpha=0.8)
|
||||||
|
|
||||||
|
ax.set_xlabel("연도")
|
||||||
|
ax.set_ylabel(f"{'누적' if pd_type == 'cumulative' else '한계'} PD (%)")
|
||||||
|
ax.set_title(f"{grade} 등급")
|
||||||
|
ax.legend(fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, p: f"{x:.1f}%"))
|
||||||
|
|
||||||
|
title = f"50년 {'누적' if pd_type == 'cumulative' else '한계'} PD — 시나리오별"
|
||||||
|
fig.suptitle(title, fontsize=13, y=1.02)
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_pd_term_structure(
|
||||||
|
results: Dict,
|
||||||
|
base_year: int = 2025,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""전 등급 가중평균 누적 PD Term Structure"""
|
||||||
|
from data.transition_matrices import RATING_GRADES
|
||||||
|
non_default = RATING_GRADES[:-1]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(12, 7))
|
||||||
|
|
||||||
|
weighted = results["weighted_cumulative_pd"]
|
||||||
|
horizon = weighted.shape[0]
|
||||||
|
years = list(range(1, horizon + 1))
|
||||||
|
|
||||||
|
colors = plt.cm.RdYlGn_r(np.linspace(0.1, 0.9, len(non_default)))
|
||||||
|
|
||||||
|
for gi, (grade, color) in enumerate(zip(non_default, colors)):
|
||||||
|
ax.plot(years, weighted[:, gi] * 100, color=color, linewidth=1.5, label=grade)
|
||||||
|
|
||||||
|
ax.set_xlabel("기간 (년)")
|
||||||
|
ax.set_ylabel("누적 PD (%)")
|
||||||
|
ax.set_title("가중평균 누적 PD Term Structure (전 등급)", fontsize=13)
|
||||||
|
ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=9)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
ax.set_xlim(1, horizon)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_transition_heatmap(
|
||||||
|
tm: np.ndarray,
|
||||||
|
title: str = "전이행렬",
|
||||||
|
grades: List[str] = None,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""전이행렬 히트맵"""
|
||||||
|
from data.transition_matrices import RATING_GRADES
|
||||||
|
if grades is None:
|
||||||
|
grades = RATING_GRADES
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(9, 7))
|
||||||
|
|
||||||
|
sns.heatmap(
|
||||||
|
tm * 100,
|
||||||
|
annot=True, fmt=".2f",
|
||||||
|
xticklabels=grades, yticklabels=grades,
|
||||||
|
cmap="YlOrRd", ax=ax,
|
||||||
|
cbar_kws={"label": "전이확률 (%)"},
|
||||||
|
linewidths=0.5
|
||||||
|
)
|
||||||
|
|
||||||
|
ax.set_xlabel("전이 후 등급")
|
||||||
|
ax.set_ylabel("전이 전 등급")
|
||||||
|
ax.set_title(title, fontsize=13)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_validation_summary(
|
||||||
|
validation_df: pd.DataFrame,
|
||||||
|
save_path: str = None
|
||||||
|
):
|
||||||
|
"""검증 결과 요약 테이블 이미지"""
|
||||||
|
fig, ax = plt.subplots(figsize=(14, max(3, len(validation_df) * 0.6 + 1)))
|
||||||
|
ax.axis("off")
|
||||||
|
ax.set_title("통계적 검증 결과 요약", fontsize=14, pad=20)
|
||||||
|
|
||||||
|
# 테이블 색상
|
||||||
|
colors = []
|
||||||
|
for _, row in validation_df.iterrows():
|
||||||
|
if "Pass" in str(row.get("결과", "")):
|
||||||
|
colors.append(["#d4edda"] * len(validation_df.columns))
|
||||||
|
else:
|
||||||
|
colors.append(["#f8d7da"] * len(validation_df.columns))
|
||||||
|
|
||||||
|
table = ax.table(
|
||||||
|
cellText=validation_df.values,
|
||||||
|
colLabels=validation_df.columns,
|
||||||
|
cellColours=colors if colors else None,
|
||||||
|
loc="center",
|
||||||
|
cellLoc="center"
|
||||||
|
)
|
||||||
|
table.auto_set_font_size(False)
|
||||||
|
table.set_fontsize(8)
|
||||||
|
table.scale(1.0, 1.5)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
if save_path:
|
||||||
|
fig.savefig(save_path, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def generate_all_plots(
|
||||||
|
zt_history: Dict[int, float],
|
||||||
|
z_paths: Dict[str, np.ndarray],
|
||||||
|
zt_series_pd: pd.Series,
|
||||||
|
macro_data: pd.DataFrame,
|
||||||
|
pd_results: Dict,
|
||||||
|
ttc_matrix: np.ndarray,
|
||||||
|
validation_df: pd.DataFrame,
|
||||||
|
output_dir: str = "results",
|
||||||
|
base_year: int = 2025
|
||||||
|
):
|
||||||
|
"""모든 차트 일괄 생성"""
|
||||||
|
out = Path(output_dir)
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"차트 생성 중... (저장 경로: {out})")
|
||||||
|
|
||||||
|
# 1. Zt 시계열
|
||||||
|
plot_zt_timeseries(zt_history, z_paths, base_year,
|
||||||
|
str(out / "01_zt_timeseries.png"))
|
||||||
|
|
||||||
|
# 2. 거시변수 vs Zt
|
||||||
|
plot_macro_vs_zt(zt_series_pd, macro_data,
|
||||||
|
str(out / "02_macro_vs_zt.png"))
|
||||||
|
|
||||||
|
# 3. 누적 PD (주요 등급)
|
||||||
|
plot_lifetime_pd(pd_results, "cumulative", ["BBB", "BB", "B"],
|
||||||
|
base_year, str(out / "03_cumulative_pd.png"))
|
||||||
|
|
||||||
|
# 4. 한계 PD (주요 등급)
|
||||||
|
plot_lifetime_pd(pd_results, "marginal", ["BBB", "BB", "B"],
|
||||||
|
base_year, str(out / "04_marginal_pd.png"))
|
||||||
|
|
||||||
|
# 5. 전 등급 Term Structure
|
||||||
|
plot_pd_term_structure(pd_results, base_year,
|
||||||
|
str(out / "05_pd_term_structure.png"))
|
||||||
|
|
||||||
|
# 6. TTC 전이행렬 히트맵
|
||||||
|
plot_transition_heatmap(ttc_matrix, "TTC 전이행렬 (장기 평균)",
|
||||||
|
save_path=str(out / "06_ttc_heatmap.png"))
|
||||||
|
|
||||||
|
# 7. 검증 결과
|
||||||
|
plot_validation_summary(validation_df,
|
||||||
|
str(out / "07_validation_summary.png"))
|
||||||
|
|
||||||
|
logger.info(f"총 7개 차트 생성 완료 → {out}/")
|
||||||
1
projection/__init__.py
Normal file
1
projection/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Projection: 50년 Lifetime PD 산출
|
||||||
281
projection/lifetime_pd.py
Normal file
281
projection/lifetime_pd.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
"""
|
||||||
|
50년 Lifetime PD 산출 엔진
|
||||||
|
|
||||||
|
시나리오별 Zt 경로와 TTC 전이행렬을 결합하여:
|
||||||
|
1. 연도별 조건부 전이행렬 산출
|
||||||
|
2. 순차적 행렬 곱으로 누적 전이확률 계산
|
||||||
|
3. Marginal PD / Cumulative PD / 시나리오 가중평균 PD 산출
|
||||||
|
|
||||||
|
IFRS 9 ECL에 직접 사용 가능한 PD Term Structure 출력
|
||||||
|
|
||||||
|
참고문헌:
|
||||||
|
- IFRS 9 Financial Instruments (IASB, 2014)
|
||||||
|
- EBA Guidelines on IFRS 9 implementation
|
||||||
|
- Basel Committee BCBS 350 (Credit Risk)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from models.vasicek import conditional_transition_matrix
|
||||||
|
from data.transition_matrices import RATING_GRADES
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LifetimePDEngine:
|
||||||
|
"""
|
||||||
|
50년 Lifetime PD 산출 엔진
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. 각 연도 t에 대해 Zt로 조건부 전이행렬 TM(Zt) 산출
|
||||||
|
2. 누적 전이행렬 = TM(Z1) × TM(Z2) × ... × TM(Zt)
|
||||||
|
3. 누적 전이행렬의 D열이 누적 PD
|
||||||
|
4. 한계 PD = Cumulative PD(t) - Cumulative PD(t-1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
ttc_matrix: np.ndarray,
|
||||||
|
rho: float = 0.20,
|
||||||
|
rating_grades: List[str] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
ttc_matrix : np.ndarray
|
||||||
|
N×N TTC 전이행렬
|
||||||
|
rho : float
|
||||||
|
자산상관계수
|
||||||
|
rating_grades : List[str]
|
||||||
|
등급 레이블
|
||||||
|
"""
|
||||||
|
self.ttc_matrix = ttc_matrix
|
||||||
|
self.rho = rho
|
||||||
|
self.n_grades = ttc_matrix.shape[0]
|
||||||
|
self.grades = rating_grades or RATING_GRADES
|
||||||
|
self.non_default_grades = self.grades[:-1] # D 제외
|
||||||
|
|
||||||
|
def compute_lifetime_pd(
|
||||||
|
self,
|
||||||
|
z_path: np.ndarray,
|
||||||
|
horizon: Optional[int] = None
|
||||||
|
) -> Dict[str, np.ndarray]:
|
||||||
|
"""
|
||||||
|
단일 시나리오의 Lifetime PD 산출
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
z_path : np.ndarray
|
||||||
|
Zt 경로 (길이 = horizon)
|
||||||
|
horizon : int
|
||||||
|
예측 기간 (기본: z_path 길이)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
- "cumulative_pd": shape (horizon, N-1) — 등급별 누적 PD
|
||||||
|
- "marginal_pd": shape (horizon, N-1) — 등급별 한계 PD
|
||||||
|
- "survival_prob": shape (horizon, N-1) — 등급별 생존확률
|
||||||
|
- "conditional_tms": list of 전이행렬 (디버깅용)
|
||||||
|
"""
|
||||||
|
if horizon is None:
|
||||||
|
horizon = len(z_path)
|
||||||
|
|
||||||
|
cumulative_tm = np.eye(self.n_grades)
|
||||||
|
cumulative_pds = []
|
||||||
|
conditional_tms = []
|
||||||
|
|
||||||
|
for t in range(horizon):
|
||||||
|
z_t = z_path[t] if t < len(z_path) else 0.0
|
||||||
|
|
||||||
|
# 조건부 전이행렬 산출
|
||||||
|
cond_tm = conditional_transition_matrix(self.ttc_matrix, z_t, self.rho)
|
||||||
|
conditional_tms.append(cond_tm)
|
||||||
|
|
||||||
|
# 누적 전이행렬
|
||||||
|
cumulative_tm = cumulative_tm @ cond_tm
|
||||||
|
|
||||||
|
# 누적 PD = D열 (마지막 열)
|
||||||
|
cum_pd = cumulative_tm[:-1, -1].copy()
|
||||||
|
cumulative_pds.append(cum_pd)
|
||||||
|
|
||||||
|
cumulative_pds = np.array(cumulative_pds) # shape: (horizon, N-1)
|
||||||
|
|
||||||
|
# 한계 PD
|
||||||
|
marginal_pds = np.zeros_like(cumulative_pds)
|
||||||
|
marginal_pds[0] = cumulative_pds[0]
|
||||||
|
for t in range(1, horizon):
|
||||||
|
marginal_pds[t] = np.maximum(cumulative_pds[t] - cumulative_pds[t - 1], 0.0)
|
||||||
|
|
||||||
|
# 생존확률
|
||||||
|
survival_probs = 1.0 - cumulative_pds
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cumulative_pd": cumulative_pds,
|
||||||
|
"marginal_pd": marginal_pds,
|
||||||
|
"survival_prob": survival_probs,
|
||||||
|
"conditional_tms": conditional_tms,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_all_scenarios(
|
||||||
|
self,
|
||||||
|
z_paths: Dict[str, np.ndarray],
|
||||||
|
scenario_weights: Dict[str, float],
|
||||||
|
horizon: Optional[int] = None
|
||||||
|
) -> Dict[str, any]:
|
||||||
|
"""
|
||||||
|
전체 시나리오 Lifetime PD 산출 + 가중평균
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
z_paths : Dict[str, np.ndarray]
|
||||||
|
시나리오별 Zt 경로
|
||||||
|
scenario_weights : Dict[str, float]
|
||||||
|
시나리오별 확률가중치
|
||||||
|
horizon : int
|
||||||
|
예측 기간
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
- "by_scenario": {scenario: {cumulative_pd, marginal_pd, ...}}
|
||||||
|
- "weighted_cumulative_pd": shape (horizon, N-1)
|
||||||
|
- "weighted_marginal_pd": shape (horizon, N-1)
|
||||||
|
"""
|
||||||
|
results = {"by_scenario": {}}
|
||||||
|
|
||||||
|
weighted_cum = None
|
||||||
|
weighted_marginal = None
|
||||||
|
|
||||||
|
for scenario_name, z_path in z_paths.items():
|
||||||
|
logger.info(f"시나리오 '{scenario_name}' PD 산출 중...")
|
||||||
|
|
||||||
|
result = self.compute_lifetime_pd(z_path, horizon)
|
||||||
|
results["by_scenario"][scenario_name] = result
|
||||||
|
|
||||||
|
weight = scenario_weights.get(scenario_name, 1.0 / len(z_paths))
|
||||||
|
|
||||||
|
if weighted_cum is None:
|
||||||
|
weighted_cum = weight * result["cumulative_pd"]
|
||||||
|
weighted_marginal = weight * result["marginal_pd"]
|
||||||
|
else:
|
||||||
|
weighted_cum += weight * result["cumulative_pd"]
|
||||||
|
weighted_marginal += weight * result["marginal_pd"]
|
||||||
|
|
||||||
|
results["weighted_cumulative_pd"] = weighted_cum
|
||||||
|
results["weighted_marginal_pd"] = weighted_marginal
|
||||||
|
results["weighted_survival_prob"] = 1.0 - weighted_cum
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def format_pd_table(
|
||||||
|
self,
|
||||||
|
results: Dict,
|
||||||
|
years: List[int] = None,
|
||||||
|
scenario: str = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
PD 결과를 DataFrame 테이블로 포매팅
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
results : dict
|
||||||
|
compute_all_scenarios() 결과
|
||||||
|
years : List[int]
|
||||||
|
표시할 연도 목록 (기본: 1,2,3,5,7,10,15,20,30,50)
|
||||||
|
scenario : str
|
||||||
|
특정 시나리오 (None이면 가중평균)
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pd.DataFrame
|
||||||
|
index=연도, columns=등급
|
||||||
|
"""
|
||||||
|
if years is None:
|
||||||
|
years = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50]
|
||||||
|
|
||||||
|
if scenario is not None:
|
||||||
|
cum_pd = results["by_scenario"][scenario]["cumulative_pd"]
|
||||||
|
else:
|
||||||
|
cum_pd = results["weighted_cumulative_pd"]
|
||||||
|
|
||||||
|
# 호라이즌 범위 내 연도만 선택
|
||||||
|
max_t = cum_pd.shape[0]
|
||||||
|
valid_years = [y for y in years if y <= max_t]
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for y in valid_years:
|
||||||
|
data[y] = cum_pd[y - 1] # 0-indexed
|
||||||
|
|
||||||
|
df = pd.DataFrame(data, index=self.non_default_grades).T
|
||||||
|
df.index.name = "년"
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
def format_marginal_pd_table(
|
||||||
|
self,
|
||||||
|
results: Dict,
|
||||||
|
years: List[int] = None,
|
||||||
|
scenario: str = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""한계 PD를 DataFrame으로 포매팅"""
|
||||||
|
if years is None:
|
||||||
|
years = [1, 2, 3, 5, 7, 10, 15, 20, 30, 50]
|
||||||
|
|
||||||
|
if scenario is not None:
|
||||||
|
m_pd = results["by_scenario"][scenario]["marginal_pd"]
|
||||||
|
else:
|
||||||
|
m_pd = results["weighted_marginal_pd"]
|
||||||
|
|
||||||
|
max_t = m_pd.shape[0]
|
||||||
|
valid_years = [y for y in years if y <= max_t]
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for y in valid_years:
|
||||||
|
data[y] = m_pd[y - 1]
|
||||||
|
|
||||||
|
df = pd.DataFrame(data, index=self.non_default_grades).T
|
||||||
|
df.index.name = "년"
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def compute_ecl_weights(
|
||||||
|
marginal_pds: np.ndarray,
|
||||||
|
lgd: float = 0.45,
|
||||||
|
discount_rate: float = 0.03,
|
||||||
|
horizon: int = None
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
ECL (Expected Credit Loss) 계산 보조 함수
|
||||||
|
|
||||||
|
ECL = Σ_t [PD_marginal(t) × LGD × DF(t)]
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
marginal_pds : np.ndarray
|
||||||
|
한계 PD 배열 (등급별)
|
||||||
|
lgd : float
|
||||||
|
부도시 손실률 (LGD), 기본 45% (Basel IRB)
|
||||||
|
discount_rate : float
|
||||||
|
할인율, 기본 3%
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
np.ndarray : 등급별 누적 ECL
|
||||||
|
"""
|
||||||
|
if horizon is None:
|
||||||
|
horizon = marginal_pds.shape[0]
|
||||||
|
|
||||||
|
ecl = np.zeros(marginal_pds.shape[1] if marginal_pds.ndim > 1 else 1)
|
||||||
|
|
||||||
|
for t in range(horizon):
|
||||||
|
df = 1.0 / (1.0 + discount_rate) ** (t + 1)
|
||||||
|
if marginal_pds.ndim > 1:
|
||||||
|
ecl += marginal_pds[t] * lgd * df
|
||||||
|
else:
|
||||||
|
ecl += marginal_pds[t] * lgd * df
|
||||||
|
|
||||||
|
return ecl
|
||||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
numpy>=1.24
|
||||||
|
scipy>=1.10
|
||||||
|
pandas>=2.0
|
||||||
|
statsmodels>=0.14
|
||||||
|
matplotlib>=3.7
|
||||||
|
seaborn>=0.12
|
||||||
|
requests>=2.28
|
||||||
|
pyyaml>=6.0
|
||||||
|
openpyxl>=3.1
|
||||||
|
tabulate>=0.9
|
||||||
|
pytest>=7.0
|
||||||
1
scenarios/__init__.py
Normal file
1
scenarios/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Scenario engine: 시나리오 생성 및 관리
|
||||||
243
scenarios/scenario_engine.py
Normal file
243
scenarios/scenario_engine.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
시나리오 엔진: 호황/불황/중립 거시경제 시나리오 생성
|
||||||
|
|
||||||
|
IFRS 9 / ECB / Fed 방식을 참고한 3개 시나리오:
|
||||||
|
- Upside (호황): 경기 확장기 지속, 낮은 실업률, 완만한 금리
|
||||||
|
- Base (중립): IMF WEO 전망 등 기본 시나리오
|
||||||
|
- Downside (불황): 경기 침체, 높은 실업률, 신용경색
|
||||||
|
|
||||||
|
각 시나리오별 거시변수 경로 → Zt 경로 → mean-reversion 적용
|
||||||
|
|
||||||
|
참고:
|
||||||
|
- ECB (2020). "The ECB's macroprudential stress test"
|
||||||
|
- Fed (2023). "2023 Stress Test Scenarios"
|
||||||
|
- IFRS 9 B5.5.42-44 (Multiple scenarios requirement)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import yaml
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioEngine:
|
||||||
|
"""
|
||||||
|
시나리오 생성 및 관리 엔진
|
||||||
|
|
||||||
|
3가지 접근법 지원:
|
||||||
|
1) Z-직접 설정: Zt의 평균/표준편차에서 σ 배수로 시나리오 생성
|
||||||
|
2) 거시변수 시나리오: 거시변수 경로 → 회귀모형 → Zt 경로
|
||||||
|
3) 하이브리드: 단기(1-5년)는 거시 기반, 장기(6년+)는 Z-직접
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: dict):
|
||||||
|
"""
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : dict
|
||||||
|
scenarios 및 convergence 설정
|
||||||
|
"""
|
||||||
|
self.scenario_config = config.get("scenarios", {})
|
||||||
|
self.convergence_config = config.get("convergence", {})
|
||||||
|
|
||||||
|
self.pit_horizon = self.convergence_config.get("pit_horizon", 5)
|
||||||
|
self.transition_horizon = self.convergence_config.get("transition_horizon", 10)
|
||||||
|
self.mean_reversion_lambda = self.convergence_config.get("mean_reversion_lambda", 0.3)
|
||||||
|
self.total_horizon = self.convergence_config.get("total_horizon", 50)
|
||||||
|
|
||||||
|
def generate_z_paths(
|
||||||
|
self,
|
||||||
|
zt_history: Dict[int, float],
|
||||||
|
macro_model=None,
|
||||||
|
macro_scenarios: Optional[Dict[str, pd.DataFrame]] = None,
|
||||||
|
base_year: int = 2025
|
||||||
|
) -> Dict[str, np.ndarray]:
|
||||||
|
"""
|
||||||
|
시나리오별 Zt 경로 생성 (50년)
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
zt_history : Dict[int, float]
|
||||||
|
과거 Zt 시계열
|
||||||
|
macro_model : MacroZtModel, optional
|
||||||
|
거시→Zt 회귀모형
|
||||||
|
macro_scenarios : Dict[str, pd.DataFrame], optional
|
||||||
|
시나리오별 거시변수 경로
|
||||||
|
base_year : int
|
||||||
|
기준 연도
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Dict[str, np.ndarray]
|
||||||
|
{"upside": [Z_1,...,Z_50], "base": [...], "downside": [...]}
|
||||||
|
"""
|
||||||
|
zt_values = np.array(list(zt_history.values()))
|
||||||
|
z_mean = zt_values.mean()
|
||||||
|
z_std = zt_values.std()
|
||||||
|
|
||||||
|
logger.info(f"Zt 통계: μ={z_mean:.4f}, σ={z_std:.4f}")
|
||||||
|
|
||||||
|
z_paths = {}
|
||||||
|
|
||||||
|
for scenario_name, scenario_cfg in self.scenario_config.items():
|
||||||
|
z_multiplier = scenario_cfg.get("z_multiplier", 0.0)
|
||||||
|
|
||||||
|
# 시나리오별 초기 Z 수준
|
||||||
|
z_scenario = z_mean + z_multiplier * z_std
|
||||||
|
|
||||||
|
# 거시 모형이 있으면 단기(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)
|
||||||
|
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)):
|
||||||
|
z_path[t] = z_short[t] if t < len(z_short) 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}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return z_paths
|
||||||
|
|
||||||
|
def generate_default_macro_scenarios(
|
||||||
|
self,
|
||||||
|
macro_history: pd.DataFrame,
|
||||||
|
base_year: int = 2025,
|
||||||
|
forecast_years: int = 5
|
||||||
|
) -> Dict[str, pd.DataFrame]:
|
||||||
|
"""
|
||||||
|
간이 거시경제 시나리오 생성
|
||||||
|
|
||||||
|
과거 데이터의 통계 특성을 기반으로 3개 시나리오 생성
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Dict[str, pd.DataFrame]
|
||||||
|
{"upside": DataFrame, "base": DataFrame, "downside": DataFrame}
|
||||||
|
"""
|
||||||
|
# 과거 통계
|
||||||
|
macro_mean = macro_history.mean()
|
||||||
|
macro_std = macro_history.std()
|
||||||
|
last_row = macro_history.iloc[-1]
|
||||||
|
|
||||||
|
scenarios = {}
|
||||||
|
years = list(range(base_year + 1, base_year + forecast_years + 1))
|
||||||
|
|
||||||
|
# 호황 시나리오
|
||||||
|
upside_data = {}
|
||||||
|
for col in macro_history.columns:
|
||||||
|
# 호황: GDP↑, 실업률↓, 금리 적정, 선행지수↑
|
||||||
|
if col == "UNEMPLOYMENT":
|
||||||
|
upside_data[col] = np.linspace(
|
||||||
|
last_row[col], max(macro_mean[col] - 0.5 * macro_std[col], 2.0),
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
elif col == "GDP_GROWTH":
|
||||||
|
upside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col] + 0.5 * macro_std[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
elif col == "LEADING_INDEX":
|
||||||
|
upside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col] + 1.0 * macro_std[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
upside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
scenarios["upside"] = pd.DataFrame(upside_data, index=years)
|
||||||
|
|
||||||
|
# 중립 시나리오
|
||||||
|
base_data = {}
|
||||||
|
for col in macro_history.columns:
|
||||||
|
base_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
scenarios["base"] = pd.DataFrame(base_data, index=years)
|
||||||
|
|
||||||
|
# 불황 시나리오
|
||||||
|
downside_data = {}
|
||||||
|
for col in macro_history.columns:
|
||||||
|
if col == "UNEMPLOYMENT":
|
||||||
|
downside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col] + 1.5 * macro_std[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
elif col == "GDP_GROWTH":
|
||||||
|
downside_val = max(macro_mean[col] - 2.0 * macro_std[col], -3.0)
|
||||||
|
downside_data[col] = np.linspace(
|
||||||
|
last_row[col], downside_val, forecast_years
|
||||||
|
)
|
||||||
|
elif col == "LEADING_INDEX":
|
||||||
|
downside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col] - 2.0 * macro_std[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
downside_data[col] = np.linspace(
|
||||||
|
last_row[col], macro_mean[col] + 0.5 * macro_std[col],
|
||||||
|
forecast_years
|
||||||
|
)
|
||||||
|
scenarios["downside"] = pd.DataFrame(downside_data, index=years)
|
||||||
|
|
||||||
|
return scenarios
|
||||||
|
|
||||||
|
def get_scenario_weights(self) -> Dict[str, float]:
|
||||||
|
"""시나리오별 확률가중치 반환"""
|
||||||
|
weights = {}
|
||||||
|
for name, cfg in self.scenario_config.items():
|
||||||
|
weights[name] = cfg.get("weight", 1.0 / len(self.scenario_config))
|
||||||
|
|
||||||
|
# 정규화
|
||||||
|
total = sum(weights.values())
|
||||||
|
if total > 0:
|
||||||
|
weights = {k: v / total for k, v in weights.items()}
|
||||||
|
|
||||||
|
return weights
|
||||||
|
|
||||||
|
def get_scenario_names(self) -> List[str]:
|
||||||
|
"""시나리오 이름 목록"""
|
||||||
|
return list(self.scenario_config.keys())
|
||||||
|
|
||||||
|
def get_display_name(self, scenario_key: str) -> str:
|
||||||
|
"""시나리오 표시 이름"""
|
||||||
|
cfg = self.scenario_config.get(scenario_key, {})
|
||||||
|
return cfg.get("name", scenario_key)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: str = "config.yaml") -> dict:
|
||||||
|
"""설정 파일 로딩"""
|
||||||
|
with open(config_path, "r", encoding="utf-8") as f:
|
||||||
|
return yaml.safe_load(f)
|
||||||
1
validation/__init__.py
Normal file
1
validation/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Validation: 통계적 유의성 검증
|
||||||
334
validation/statistical_tests.py
Normal file
334
validation/statistical_tests.py
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
"""
|
||||||
|
통계적 유의성 검증 모듈
|
||||||
|
|
||||||
|
Zt 시계열 및 거시연계 회귀모형의 통계적 타당성을 엄밀하게 검증합니다.
|
||||||
|
|
||||||
|
검증 항목:
|
||||||
|
1. Zt 시계열: ADF 단위근 검정, Shapiro-Wilk 정규성 검정
|
||||||
|
2. 회귀 모형: R², F-test, AIC/BIC, 잔차 진단
|
||||||
|
3. 잔차: Durbin-Watson, Ljung-Box, ARCH-LM, Breusch-Pagan
|
||||||
|
4. 구조적 안정성: CUSUM(추정 가능시)
|
||||||
|
5. 다중공선성: VIF
|
||||||
|
|
||||||
|
참고:
|
||||||
|
- Greene, W.H. (2018). "Econometric Analysis" 8th ed.
|
||||||
|
- Hamilton, J.D. (1994). "Time Series Analysis"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from scipy import stats
|
||||||
|
from statsmodels.tsa.stattools import adfuller
|
||||||
|
from statsmodels.stats.diagnostic import (
|
||||||
|
het_breuschpagan, acorr_ljungbox, het_arch
|
||||||
|
)
|
||||||
|
from statsmodels.stats.stattools import durbin_watson
|
||||||
|
from typing import Dict, Optional, List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stationarity(
|
||||||
|
series: np.ndarray,
|
||||||
|
name: str = "Zt",
|
||||||
|
significance: float = 0.05
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
ADF (Augmented Dickey-Fuller) 단위근 검정
|
||||||
|
|
||||||
|
H0: 단위근 존재 (비정상 시계열)
|
||||||
|
H1: 정상 시계열
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with test_statistic, p_value, critical_values, is_stationary
|
||||||
|
"""
|
||||||
|
result = adfuller(series, autolag="AIC")
|
||||||
|
|
||||||
|
is_stationary = result[1] < significance
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"test_name": "ADF (Augmented Dickey-Fuller)",
|
||||||
|
"variable": name,
|
||||||
|
"test_statistic": result[0],
|
||||||
|
"p_value": result[1],
|
||||||
|
"lags_used": result[2],
|
||||||
|
"n_obs": result[3],
|
||||||
|
"critical_values": result[4],
|
||||||
|
"is_stationary": is_stationary,
|
||||||
|
"conclusion": f"{'정상' if is_stationary else '비정상'} 시계열 "
|
||||||
|
f"(p={result[1]:.4f}, α={significance})"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"ADF 검정 [{name}]: statistic={result[0]:.4f}, "
|
||||||
|
f"p-value={result[1]:.4f} → {'Pass' if is_stationary else 'FAIL'}")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def test_normality(
|
||||||
|
series: np.ndarray,
|
||||||
|
name: str = "Zt",
|
||||||
|
significance: float = 0.05
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Shapiro-Wilk 정규성 검정
|
||||||
|
|
||||||
|
H0: 정규분포를 따름
|
||||||
|
H1: 정규분포를 따르지 않음
|
||||||
|
"""
|
||||||
|
stat, p_value = stats.shapiro(series)
|
||||||
|
is_normal = p_value > significance
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"test_name": "Shapiro-Wilk Normality Test",
|
||||||
|
"variable": name,
|
||||||
|
"test_statistic": stat,
|
||||||
|
"p_value": p_value,
|
||||||
|
"is_normal": is_normal,
|
||||||
|
"mean": float(np.mean(series)),
|
||||||
|
"std": float(np.std(series)),
|
||||||
|
"skewness": float(stats.skew(series)),
|
||||||
|
"kurtosis": float(stats.kurtosis(series)),
|
||||||
|
"conclusion": f"{'정규분포' if is_normal else '비정규분포'} "
|
||||||
|
f"(p={p_value:.4f}, α={significance})"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"정규성 검정 [{name}]: W={stat:.4f}, "
|
||||||
|
f"p-value={p_value:.4f} → {'Pass' if is_normal else 'FAIL'}")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def test_serial_correlation(
|
||||||
|
residuals: np.ndarray,
|
||||||
|
lags: int = 5,
|
||||||
|
significance: float = 0.05
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
잔차 자기상관 검정
|
||||||
|
|
||||||
|
1) Durbin-Watson: d ≈ 2이면 자기상관 없음
|
||||||
|
2) Ljung-Box Q-test: H0 = 자기상관 없음
|
||||||
|
"""
|
||||||
|
# Durbin-Watson
|
||||||
|
dw = durbin_watson(residuals)
|
||||||
|
|
||||||
|
# Ljung-Box
|
||||||
|
lb_result = acorr_ljungbox(residuals, lags=[lags], return_df=True)
|
||||||
|
lb_stat = lb_result["lb_stat"].values[0]
|
||||||
|
lb_pvalue = lb_result["lb_pvalue"].values[0]
|
||||||
|
|
||||||
|
no_autocorr = lb_pvalue > significance
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"test_name": "Serial Correlation Tests",
|
||||||
|
"durbin_watson": float(dw),
|
||||||
|
"dw_interpretation": (
|
||||||
|
"양의 자기상관" if dw < 1.5 else
|
||||||
|
"음의 자기상관" if dw > 2.5 else
|
||||||
|
"자기상관 없음"
|
||||||
|
),
|
||||||
|
"ljung_box_statistic": float(lb_stat),
|
||||||
|
"ljung_box_pvalue": float(lb_pvalue),
|
||||||
|
"ljung_box_lags": lags,
|
||||||
|
"no_autocorrelation": no_autocorr,
|
||||||
|
"conclusion": f"{'자기상관 없음' if no_autocorr else '자기상관 존재'} "
|
||||||
|
f"(DW={dw:.3f}, LB p={lb_pvalue:.4f})"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"자기상관 검정: DW={dw:.3f}, LB p-value={lb_pvalue:.4f} "
|
||||||
|
f"→ {'Pass' if no_autocorr else 'FAIL'}")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def test_heteroscedasticity(
|
||||||
|
residuals: np.ndarray,
|
||||||
|
exog: np.ndarray,
|
||||||
|
significance: float = 0.05
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
이분산 검정
|
||||||
|
|
||||||
|
1) Breusch-Pagan: H0 = 등분산
|
||||||
|
2) ARCH-LM: H0 = ARCH 효과 없음
|
||||||
|
"""
|
||||||
|
# Breusch-Pagan
|
||||||
|
try:
|
||||||
|
bp_stat, bp_pvalue, _, _ = het_breuschpagan(residuals, exog)
|
||||||
|
except Exception:
|
||||||
|
bp_stat, bp_pvalue = np.nan, np.nan
|
||||||
|
|
||||||
|
# ARCH-LM
|
||||||
|
try:
|
||||||
|
arch_result = het_arch(residuals, nlags=3)
|
||||||
|
arch_stat = arch_result[0]
|
||||||
|
arch_pvalue = arch_result[1]
|
||||||
|
except Exception:
|
||||||
|
arch_stat, arch_pvalue = np.nan, np.nan
|
||||||
|
|
||||||
|
homoscedastic = (
|
||||||
|
(np.isnan(bp_pvalue) or bp_pvalue > significance) and
|
||||||
|
(np.isnan(arch_pvalue) or arch_pvalue > significance)
|
||||||
|
)
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"test_name": "Heteroscedasticity Tests",
|
||||||
|
"breusch_pagan_stat": float(bp_stat) if not np.isnan(bp_stat) else None,
|
||||||
|
"breusch_pagan_pvalue": float(bp_pvalue) if not np.isnan(bp_pvalue) else None,
|
||||||
|
"arch_lm_stat": float(arch_stat) if not np.isnan(arch_stat) else None,
|
||||||
|
"arch_lm_pvalue": float(arch_pvalue) if not np.isnan(arch_pvalue) else None,
|
||||||
|
"is_homoscedastic": homoscedastic,
|
||||||
|
"conclusion": f"{'등분산' if homoscedastic else '이분산'} "
|
||||||
|
f"(BP p={bp_pvalue:.4f}, ARCH p={arch_pvalue:.4f})"
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"이분산 검정: BP p={bp_pvalue:.4f}, ARCH p={arch_pvalue:.4f} "
|
||||||
|
f"→ {'Pass' if homoscedastic else 'FAIL'}")
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pd_properties(
|
||||||
|
cumulative_pds: np.ndarray,
|
||||||
|
grade_names: List[str] = None
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
PD 결과의 수학적 성질 검증
|
||||||
|
|
||||||
|
1) 0 ≤ PD ≤ 1
|
||||||
|
2) 누적 PD 단조증가
|
||||||
|
3) 등급간 순서 유지 (낮은 등급 PD > 높은 등급 PD)
|
||||||
|
"""
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# 1) 범위 검증
|
||||||
|
if np.any(cumulative_pds < 0) or np.any(cumulative_pds > 1.0001):
|
||||||
|
issues.append("PD 값이 [0,1] 범위를 벗어남")
|
||||||
|
|
||||||
|
# 2) 단조증가 검증
|
||||||
|
for j in range(cumulative_pds.shape[1]):
|
||||||
|
diffs = np.diff(cumulative_pds[:, j])
|
||||||
|
if np.any(diffs < -1e-10):
|
||||||
|
grade_name = grade_names[j] if grade_names else f"Grade{j}"
|
||||||
|
issues.append(f"누적 PD 단조증가 위반: {grade_name}")
|
||||||
|
|
||||||
|
# 3) 등급간 순서 검증 (마지막 행, 즉 최종 누적 PD에서)
|
||||||
|
final_pds = cumulative_pds[-1]
|
||||||
|
for j in range(len(final_pds) - 1):
|
||||||
|
if final_pds[j] > final_pds[j + 1] + 1e-6:
|
||||||
|
g1 = grade_names[j] if grade_names else f"Grade{j}"
|
||||||
|
g2 = grade_names[j + 1] if grade_names else f"Grade{j+1}"
|
||||||
|
issues.append(f"등급 순서 위반: PD({g1}) > PD({g2})")
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"test_name": "PD Properties Validation",
|
||||||
|
"range_valid": not any("범위" in i for i in issues),
|
||||||
|
"monotone_valid": not any("단조" in i for i in issues),
|
||||||
|
"order_valid": not any("순서" in i for i in issues),
|
||||||
|
"all_valid": len(issues) == 0,
|
||||||
|
"issues": issues,
|
||||||
|
"conclusion": "모든 검증 통과" if len(issues) == 0 else f"이슈 {len(issues)}건"
|
||||||
|
}
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def run_full_validation(
|
||||||
|
zt_series: np.ndarray,
|
||||||
|
regression_result,
|
||||||
|
pd_results: Dict,
|
||||||
|
grade_names: List[str] = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
전체 검증 실행 및 결과 요약 테이블 생성
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
zt_series : np.ndarray
|
||||||
|
Zt 추정값 시계열
|
||||||
|
regression_result : statsmodels.RegressionResults
|
||||||
|
회귀 모형 결과 (또는 None)
|
||||||
|
pd_results : Dict
|
||||||
|
compute_all_scenarios() 결과
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
pd.DataFrame
|
||||||
|
검증 결과 요약 테이블
|
||||||
|
"""
|
||||||
|
all_tests = []
|
||||||
|
|
||||||
|
# 1. Zt 시계열 검증
|
||||||
|
adf_result = test_stationarity(zt_series, "Zt")
|
||||||
|
all_tests.append({
|
||||||
|
"검정": adf_result["test_name"],
|
||||||
|
"대상": "Zt 시계열",
|
||||||
|
"통계량": f"{adf_result['test_statistic']:.4f}",
|
||||||
|
"p-value": f"{adf_result['p_value']:.4f}",
|
||||||
|
"결과": "Pass O" if adf_result["is_stationary"] else "Fail X",
|
||||||
|
"해석": adf_result["conclusion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
norm_result = test_normality(zt_series, "Zt")
|
||||||
|
all_tests.append({
|
||||||
|
"검정": norm_result["test_name"],
|
||||||
|
"대상": "Zt 시계열",
|
||||||
|
"통계량": f"{norm_result['test_statistic']:.4f}",
|
||||||
|
"p-value": f"{norm_result['p_value']:.4f}",
|
||||||
|
"결과": "Pass O" if norm_result["is_normal"] else "Fail X",
|
||||||
|
"해석": norm_result["conclusion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 회귀모형 잔차 검증
|
||||||
|
if regression_result is not None:
|
||||||
|
residuals = regression_result.resid
|
||||||
|
exog = regression_result.model.exog
|
||||||
|
|
||||||
|
serial_result = test_serial_correlation(residuals)
|
||||||
|
all_tests.append({
|
||||||
|
"검정": "Ljung-Box Q-test",
|
||||||
|
"대상": "잔차 자기상관",
|
||||||
|
"통계량": f"{serial_result['ljung_box_statistic']:.4f}",
|
||||||
|
"p-value": f"{serial_result['ljung_box_pvalue']:.4f}",
|
||||||
|
"결과": "Pass O" if serial_result["no_autocorrelation"] else "Fail X",
|
||||||
|
"해석": serial_result["conclusion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
het_result = test_heteroscedasticity(residuals, exog)
|
||||||
|
all_tests.append({
|
||||||
|
"검정": "Breusch-Pagan / ARCH-LM",
|
||||||
|
"대상": "잔차 이분산",
|
||||||
|
"통계량": f"BP={het_result['breusch_pagan_stat']:.4f}" if het_result['breusch_pagan_stat'] else "N/A",
|
||||||
|
"p-value": f"{het_result['breusch_pagan_pvalue']:.4f}" if het_result['breusch_pagan_pvalue'] else "N/A",
|
||||||
|
"결과": "Pass O" if het_result["is_homoscedastic"] else "Fail X",
|
||||||
|
"해석": het_result["conclusion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# R², F-test
|
||||||
|
all_tests.append({
|
||||||
|
"검정": "R² / F-test",
|
||||||
|
"대상": "모형 설명력",
|
||||||
|
"통계량": f"R²={regression_result.rsquared:.4f}",
|
||||||
|
"p-value": f"{regression_result.f_pvalue:.4f}",
|
||||||
|
"결과": "Pass O" if regression_result.f_pvalue < 0.05 else "Fail X",
|
||||||
|
"해석": f"R²={regression_result.rsquared:.3f}, "
|
||||||
|
f"Adj.R²={regression_result.rsquared_adj:.3f}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. PD 성질 검증
|
||||||
|
for scenario_name in pd_results.get("by_scenario", {}):
|
||||||
|
cum_pd = pd_results["by_scenario"][scenario_name]["cumulative_pd"]
|
||||||
|
pd_valid = validate_pd_properties(cum_pd, grade_names)
|
||||||
|
all_tests.append({
|
||||||
|
"검정": "PD Properties",
|
||||||
|
"대상": f"Cumulative PD ({scenario_name})",
|
||||||
|
"통계량": "-",
|
||||||
|
"p-value": "-",
|
||||||
|
"결과": "Pass O" if pd_valid["all_valid"] else "Fail X",
|
||||||
|
"해석": pd_valid["conclusion"]
|
||||||
|
})
|
||||||
|
|
||||||
|
return pd.DataFrame(all_tests)
|
||||||
Reference in New Issue
Block a user