v3.1 과매수 방지, 앙상블 학습, KRX 캘린더 기반 장중 전용 운영 구현
[잔고 관리] - _today_buy_total 인스턴스 변수로 당일 누적 매수 추적 (KIS T+2 미차감 보완) - MAX_BUY_PER_CYCLE, MAX_DAILY_BUY_RATIO 설정 추가 - available_deposit = max_daily_buy - effective_today_buy 계산 [앙상블 & 포지션 사이징] - AdaptiveEnsemble 실제 연동 (하드코딩 가중치 제거) - Kelly Criterion Half-Kelly 포지션 비중 계산 - SignalWeights.normalize() Water-Filling 알고리즘으로 경계 위반 해결 - _accuracy_weighted() 크기 가중 정확도로 통일 - ensemble_weights.json → ensemble_history.json 통합 [LLM 클라이언트] - GeminiLLMClient 추가 (Gemini → Ollama 폴백 체인) - _class_last_call_ts 클래스 변수로 워커 재시작 후에도 스로틀 유지 - Ollama 미실행 조기 감지 및 명확한 오류 메시지 [KIS API] - 모든 requests.get/post에 timeout=Config.HTTP_TIMEOUT 적용 - get_balance()에 today_buy_amt 필드 추가 [장중 전용 운영] - KRXCalendar: exchange_calendars 기반, 2024~2026 공휴일 하드코딩 폴백 - EOD 셧다운: 15:35에 전체 상태 저장 후 서버 자동 종료 - Watchdog: .eod_date 마커로 EOD 후 재시작 차단 - daily_launcher.py: 매일 08:30 실행, 휴장일 감지 후 봇 미시작 - Windows 작업 스케줄러 WebAI_DailyLauncher 등록 [텔레그램 스킬 수정] - PYTHONIOENCODING=utf-8 서브프로세스 환경 설정 (cp949 이모지 오류 해결) - /regime: IPC macro_indices 파싱 구현, --json 모드 input() 블로킹 제거 - /weights: ensemble_history.json 형식 파싱 업데이트 - /model_health: glob 패턴 *_v3.pt 수정 - /postmortem: 거래 없을 때 빈 JSON 출력으로 Telegram 오류 해결 - /macro: price=0 시 prev_close 폴백 표시 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
696
CLAUDE.md
Normal file
696
CLAUDE.md
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
# 🤖 AI Trading Bot — 프로젝트 설계 문서 (CLAUDE.md)
|
||||||
|
|
||||||
|
> **최종 갱신**: 2026-03-19
|
||||||
|
> **런타임**: Windows (Python 3.x, PyTorch CUDA, FastAPI, Ollama)
|
||||||
|
> **하드웨어**: AMD 9800X3D + RTX 5070 Ti (16 GB VRAM)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 시스템 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ main_server.py │
|
||||||
|
│ FastAPI (uvicorn, port 8000) — 프로세스 매니저 & REST API 서버 │
|
||||||
|
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Trading Bot │ │ Telegram Bot │ │ ProcessWatchdog │ │
|
||||||
|
│ │ (Process #1) │ │ (Process #2) │ │ (Daemon Thread) │ │
|
||||||
|
│ └──────┬───────┘ └────────┬────────┘ └──────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └─── Shared Memory (IPC) ───┘ Health Check / Restart │
|
||||||
|
│ + Command Queue │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.1 멀티 프로세스 구성
|
||||||
|
|
||||||
|
| 프로세스 | 역할 | 진입점 |
|
||||||
|
|---------|------|--------|
|
||||||
|
| **Main Server (Uvicorn)** | FastAPI REST API 서버, 프로세스 오케스트레이터 | `main_server.py` |
|
||||||
|
| **Trading Bot** | 자동매매 메인 루프 (스케줄러, 분석, 주문) | `modules/bot.py` → `AutoTradingBot.loop()` |
|
||||||
|
| **Telegram Bot** | 사용자 인터랙션 (명령어 처리, 알림) | `modules/services/telegram_bot/runner.py` |
|
||||||
|
| **ProcessWatchdog** | 자식 프로세스 헬스체크 & 자동 재시작 (30초 간격) | `modules/utils/process_tracker.py` |
|
||||||
|
|
||||||
|
### 1.2 프로세스 간 통신 (IPC)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ SharedMemory (128KB) ┌──────────────┐
|
||||||
|
│ Trading Bot │ ─── write_status() ───────► │ Telegram Bot │
|
||||||
|
│ │ ◄── read_status() ──────── │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ multiprocessing.Queue │ │
|
||||||
|
│ │ ◄── send_command() ──────── │ │
|
||||||
|
│ │ (텔레그램 → 봇 명령) │ │
|
||||||
|
└─────────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **SharedMemory** (`web_ai_bot_ipc`, 128KB): 메인 봇이 상태 데이터(잔고, GPU, 매크로 지표 등)를 JSON으로 기록, 텔레그램 봇이 읽기
|
||||||
|
- **Command Queue** (`multiprocessing.Queue`): 텔레그램 → 메인 봇 양방향 명령 채널 (`restart`, `evaluate` 등)
|
||||||
|
- **Lock** (`multiprocessing.Lock`): SharedMemory 동시 접근 보호
|
||||||
|
- **IPC Staleness**: 600초 (10분 이상 오래된 데이터는 무시)
|
||||||
|
|
||||||
|
### 1.3 서버 생명주기 (Lifespan)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# main_server.py > lifespan()
|
||||||
|
1. Config.validate() # 환경변수 검증
|
||||||
|
2. ProcessTracker.check_and_kill_zombies() # 좀비 프로세스 정리
|
||||||
|
3. 전역 객체 초기화 (OllamaManager, KISClient, NewsCollector)
|
||||||
|
4. Shared Resources 생성 (Lock, Queue, Event)
|
||||||
|
5. Trading Bot 프로세스 생성 & 시작
|
||||||
|
6. Telegram Bot 프로세스 생성 & 시작
|
||||||
|
7. ProcessWatchdog 시작 (30초 간격 헬스체크)
|
||||||
|
8. → yield (서버 정상 운영)
|
||||||
|
9. [종료] shutdown_event 설정 → 자식 종료 → SharedMemory 해제
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ai/
|
||||||
|
├── main_server.py # [Entry Point] FastAPI + 프로세스 매니저
|
||||||
|
├── warmup_and_restart.py # LSTM 사전학습 + 봇 자동 시작 스크립트
|
||||||
|
├── watchlist_manager.py # 뉴스 기반 일일 Watchlist 자동 업데이트
|
||||||
|
├── backtester.py # 전략 백테스팅 CLI
|
||||||
|
├── theme_manager.py # 종목별 테마/섹터 관리
|
||||||
|
├── .env # 환경변수 (KIS, Telegram, Ollama 등)
|
||||||
|
│
|
||||||
|
├── modules/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config.py # [Config] 환경변수 & 상수 정의
|
||||||
|
│ ├── bot.py # [Core] AutoTradingBot (상태 머신 & 스케줄러)
|
||||||
|
│ │
|
||||||
|
│ ├── analysis/ # [AI Brain] 분석 엔진
|
||||||
|
│ │ ├── deep_learning.py # Attention-LSTM (7D 피처, PyTorch GPU)
|
||||||
|
│ │ ├── technical.py # 기술적 지표 (RSI, MACD, BB, ADX, OBV...)
|
||||||
|
│ │ ├── macro.py # 거시경제 분석 (KOSPI/KOSDAQ/MSI)
|
||||||
|
│ │ ├── ensemble.py # 적응형 앙상블 (3신호 가중치 자동조정)
|
||||||
|
│ │ ├── evaluator.py # 주간 성과 평가 + LLM 전문가 패널
|
||||||
|
│ │ └── backtest.py # 백테스팅 프레임워크 (Sharpe, MDD 등)
|
||||||
|
│ │
|
||||||
|
│ ├── strategy/ # [Decision] 매매 의사결정
|
||||||
|
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
|
||||||
|
│ │
|
||||||
|
│ ├── services/ # [I/O] 외부 서비스 연동
|
||||||
|
│ │ ├── kis.py # 한국투자증권 REST API (동기 + 비동기)
|
||||||
|
│ │ ├── ollama.py # Ollama LLM 인터페이스 (GPU 충돌 방지)
|
||||||
|
│ │ ├── news.py # Google News RSS 크롤링 (동기 + 비동기)
|
||||||
|
│ │ ├── telegram.py # 텔레그램 메시지 발송 (Fire-and-forget)
|
||||||
|
│ │ └── telegram_bot/
|
||||||
|
│ │ ├── server.py # 텔레그램 봇 서버 (명령어 핸들러)
|
||||||
|
│ │ └── runner.py # 텔레그램 봇 독립 프로세스 실행기
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # [Util] 유틸리티
|
||||||
|
│ ├── ipc.py # SharedMemory + Command Queue IPC
|
||||||
|
│ ├── process_tracker.py # PID 추적 & 좀비 정리 & Watchdog
|
||||||
|
│ ├── monitor.py # CPU/GPU/RAM 서킷 브레이커
|
||||||
|
│ └── performance_db.py # 일별 스냅샷 & 매매 기록 영구 저장
|
||||||
|
│
|
||||||
|
├── data/ # [Runtime Data]
|
||||||
|
│ ├── watchlist.json # 현재 감시 종목 리스트
|
||||||
|
│ ├── daily_trade_history.json # 일일 매매 기록
|
||||||
|
│ ├── kis_token.json # KIS OAuth 토큰 캐시
|
||||||
|
│ ├── peak_prices.json # 트레일링 스탑용 최고가
|
||||||
|
│ ├── ensemble_history.json # AdaptiveEnsemble 가중치 + 매매 히스토리 (종목별)
|
||||||
|
│ ├── models/ # LSTM 체크포인트 (종목별 .pt 파일)
|
||||||
|
│ └── performance/ # 성과 데이터 (daily_snapshots, trade_records)
|
||||||
|
│
|
||||||
|
└── tests/ # 테스트
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 핵심 모듈 상세
|
||||||
|
|
||||||
|
### 3.1 AutoTradingBot (`modules/bot.py`)
|
||||||
|
|
||||||
|
**메인 트레이딩 루프** — 장 시작(09:00) ~ 장 마감(15:30) 사이에 자동 실행
|
||||||
|
|
||||||
|
```
|
||||||
|
[v3.1 주요 기능]
|
||||||
|
├── ATR 기반 동적 손절/익절 + 트레일링 스탑
|
||||||
|
├── Kelly Criterion 포지션 사이징 (실전 승률·손익비 기반, Half-Kelly)
|
||||||
|
├── AdaptiveEnsemble 연동 (매도 후 가중치 자동 학습)
|
||||||
|
├── 당일 누적 매수 추적 (_today_buy_total) - KIS T+2 미차감 보완
|
||||||
|
├── 사이클당 최대 매수 종목 수 제한 (MAX_BUY_PER_CYCLE)
|
||||||
|
├── ProcessPoolExecutor 병렬 분석 (워커 1개, OOM 대응 자동 재시작)
|
||||||
|
├── 일별 자산 스냅샷 (09:05~09:15)
|
||||||
|
├── 주간 성과 평가 (월요일 아침)
|
||||||
|
├── CPU 서킷 브레이커 연동
|
||||||
|
└── IPC Command Queue 폴링 (텔레그램 명령 처리)
|
||||||
|
```
|
||||||
|
|
||||||
|
**잔고 추적 로직 (v3.1 — 과매수 방지)**:
|
||||||
|
```
|
||||||
|
KIS get_balance() → raw_deposit (dnca_tot_amt)
|
||||||
|
↓
|
||||||
|
max_daily_buy = raw_deposit × MAX_DAILY_BUY_RATIO (80%)
|
||||||
|
tracking_deposit = max_daily_buy - effective_today_buy
|
||||||
|
↑
|
||||||
|
max(kis_today_buy, self._today_buy_total)
|
||||||
|
(KIS thdt_buy_amt vs 로컬 누적 중 큰 값)
|
||||||
|
```
|
||||||
|
- `_today_buy_total`: 인스턴스 변수, 사이클 간 유지 (09:00 리셋)
|
||||||
|
- `_buy_scores`: BUY 시 신호 점수 저장 → SELL 시 `record_trade()` 전달
|
||||||
|
|
||||||
|
**run_cycle() 흐름**:
|
||||||
|
1. 시스템 헬스 체크 (CPU/GPU/RAM)
|
||||||
|
2. 거시경제 분석 (KOSPI/KOSDAQ/MSI)
|
||||||
|
3. 위험 상태별 분기 (SAFE/CAUTION/DANGER)
|
||||||
|
4. Watchlist 종목 OHLCV 수집 (KIS 비동기 배치)
|
||||||
|
5. 잔고 조회 + 당일 누적 매수 차감 → 실제 가용 예수금 계산
|
||||||
|
6. `ProcessPoolExecutor`로 종목 병렬 분석 (Kelly Criterion + Ensemble 가중치)
|
||||||
|
7. 앙상블 점수 기반 매수/매도 판단 (사이클당 MAX_BUY_PER_CYCLE 제한)
|
||||||
|
8. 주문 실행 & 결과 텔레그램 알림
|
||||||
|
9. 매도 시 `record_trade()` → Ensemble 가중치 학습
|
||||||
|
10. IPC 상태 갱신
|
||||||
|
|
||||||
|
### 3.2 AI 분석 파이프라인
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ analyze_stock_ │
|
||||||
|
│ process() │
|
||||||
|
│ (strategy/process)│
|
||||||
|
└─────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────────┼────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────┐ ┌────────────────┐ ┌─────────────────┐
|
||||||
|
│ Technical │ │ Deep Learning │ │ LLM (Ollama) │
|
||||||
|
│ Analyzer │ │ LSTM │ │ Sentiment │
|
||||||
|
│ (기술적 지표) │ │ (주가 예측) │ │ (뉴스 감성분석) │
|
||||||
|
├───────────────┤ ├────────────────┤ ├─────────────────┤
|
||||||
|
│ RSI 25% │ │ Attention-LSTM │ │ qwen2.5:7b │
|
||||||
|
│ 이격도 15% │ │ 4L×512H │ │ JSON 포맷 요청 │
|
||||||
|
│ MACD 15% │ │ 7차원 피처 │ │ 뉴스+지표 통합 │
|
||||||
|
│ Stochastic 5% │ │ 60일 시퀀스 │ │ 감성+신뢰도 │
|
||||||
|
│ BB 15% │ │ GPU 가속 │ │ │
|
||||||
|
│ ADX 15% │ │ 종목별 모델 │ │ │
|
||||||
|
│ MTF 10% │ │ (ModelRegistry)│ │ │
|
||||||
|
│ OBV ±보너스 │ │ │ │ │
|
||||||
|
└───────┬───────┘ └───────┬────────┘ └───────┬─────────┘
|
||||||
|
│ │ │
|
||||||
|
└──────────┬────────┘ │
|
||||||
|
▼ │
|
||||||
|
┌─────────────────┐ │
|
||||||
|
│ AdaptiveEnsemble│ ◄───────────────────┘
|
||||||
|
│ (학습형 가중치) │
|
||||||
|
├─────────────────┤
|
||||||
|
│ get_weights() │ ← 과거 매매 결과 반영
|
||||||
|
│ (ADX+macro+conf)│ 크기 가중 정확도 기준
|
||||||
|
│ 경계: 0.10~0.65 │ Water-Filling 정규화
|
||||||
|
│ Kelly Fraction │ ← 승률·손익비 기반
|
||||||
|
└────────┬────────┘
|
||||||
|
▼
|
||||||
|
┌────────────────┐
|
||||||
|
│ 매수/매도/홀드 │
|
||||||
|
│ 최종 판단 │
|
||||||
|
└────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.1 Deep Learning — Attention-LSTM (`analysis/deep_learning.py`)
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **아키텍처** | 4-Layer Stacked LSTM + Attention + FC |
|
||||||
|
| **Hidden Size** | 512 |
|
||||||
|
| **Input Features** | 7 (close, open, high, low, volume_norm, rsi_14, macd_hist) |
|
||||||
|
| **시퀀스 길이** | 60일 |
|
||||||
|
| **학습 에포크** | 최대 200 (Early Stopping patience=15) |
|
||||||
|
| **빠른 재학습** | 30 에포크 (체크포인트 존재 시) |
|
||||||
|
| **쿨다운** | 1200초 (20분, 동일 종목 재학습 방지) |
|
||||||
|
| **ModelRegistry** | LRU 방식, 최대 5개 모델 동시 적재 |
|
||||||
|
| **체크포인트** | `data/models/{ticker}_v3.pt` |
|
||||||
|
| **GPU 관리** | LSTM 학습 시 Ollama 자동 언로드/리로드 |
|
||||||
|
|
||||||
|
#### 3.2.2 기술적 분석 (`analysis/technical.py`)
|
||||||
|
|
||||||
|
`TechnicalAnalyzer.get_technical_score()` → 0.0 ~ 1.0 통합 점수
|
||||||
|
|
||||||
|
| 지표 | 비중 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| RSI (14일) | 25% | Wilder 방식, 30 이하 과매도/70 이상 과매수 |
|
||||||
|
| 이동평균 이격도 | 15% | 20일 MA 대비 현재가 위치 |
|
||||||
|
| MACD | 15% | 12/26/9, 히스토그램 방향 |
|
||||||
|
| Stochastic | 5% | Fast %K/%D (14/3/3) |
|
||||||
|
| Bollinger Bands | 15% | 20일/2σ, %B 위치 + 밴드폭 |
|
||||||
|
| ADX | 15% | 추세 강도 (>25 강한 추세) |
|
||||||
|
| Multi-Timeframe | 10% | 5일/20일/60일 추세 일관성 |
|
||||||
|
| OBV | ±0.1 보너스 | 거래량 기반 매집/분산 감지 |
|
||||||
|
|
||||||
|
추가 기능:
|
||||||
|
- `calculate_atr()` → ATR 기반 동적 손절/익절
|
||||||
|
- `calculate_dynamic_sl_tp()` → 변동성 적응형 SL/TP
|
||||||
|
- `calculate_obv()` → 스마트 머니 다이버전스 감지
|
||||||
|
|
||||||
|
#### 3.2.3 거시경제 분석 (`analysis/macro.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
MacroAnalyzer.get_macro_status(kis_client) → {
|
||||||
|
"status": "SAFE" | "CAUTION" | "DANGER",
|
||||||
|
"risk_score": int,
|
||||||
|
"indicators": {
|
||||||
|
"KOSPI": {"price", "change", "high", "low", "prev_close", "volume"},
|
||||||
|
"KOSDAQ": {"price", "change", ...},
|
||||||
|
"KOSPI200":{"price", "change", ...},
|
||||||
|
"MSI": float # Market Stress Index (0~100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **SAFE** (risk_score < 1): 정상 매매
|
||||||
|
- **CAUTION** (1 ≤ risk_score < 3): 매수 규모 축소
|
||||||
|
- **DANGER** (risk_score ≥ 3): 매수 중단, 보유분만 관리
|
||||||
|
|
||||||
|
#### 3.2.4 앙상블 (`analysis/ensemble.py`)
|
||||||
|
|
||||||
|
`AdaptiveEnsemble` — 과거 매매 결과 기반 가중치 자동 조정 + Kelly Criterion:
|
||||||
|
|
||||||
|
**가중치 학습 흐름**:
|
||||||
|
```
|
||||||
|
BUY 체결 → bot._buy_scores[ticker] = {tech, sentiment, lstm} 저장
|
||||||
|
SELL 체결 → ensemble.record_trade(ticker, ..., outcome_pct=yld)
|
||||||
|
→ _update_weights() → EMA(alpha=0.10) 가중치 점진 조정
|
||||||
|
→ _save() → data/ensemble_history.json
|
||||||
|
워커 프로세스 → reload_if_stale() → 파일 mtime 감지 시 재로드
|
||||||
|
```
|
||||||
|
|
||||||
|
**주요 메서드**:
|
||||||
|
- `get_weights(ticker, adx, macro_state, ai_confidence)` → `SignalWeights`
|
||||||
|
- 시장 컨텍스트 (strong_trend/sideways/danger/default) 별 기본 가중치
|
||||||
|
- 종목별 최근 10거래 크기 가중 정확도 반영
|
||||||
|
- ai_confidence >= 0.75 → LSTM 가중치 +25% (confidence 상한 0.80 반영)
|
||||||
|
- `get_kelly_fraction(ticker, half_kelly=True)` → 0.03~0.25 범위 투자 비중
|
||||||
|
- f* = (p·b - q) / b (p=승률, b=손익비)
|
||||||
|
- 거래 데이터 < 10건 → 보수적 기본값 8%
|
||||||
|
- Half-Kelly 적용으로 변동성 과대추정 보완
|
||||||
|
- `compute_ensemble_score(tech, sentiment, lstm, investor, weights)` → 통합 점수
|
||||||
|
- `reload_if_stale()` → 파일 mtime 기반 cross-process 동기화
|
||||||
|
|
||||||
|
**`SignalWeights.normalize()` — Water-Filling 알고리즘**:
|
||||||
|
- 경계(0.10~0.65) 위반 시 해당 값을 경계에 고정, 나머지에 잔여 비중 비례 배분
|
||||||
|
- 2차 정규화(합=1 보장)와 경계 클램핑이 상충하는 문제 해결
|
||||||
|
- 영구 저장: `data/ensemble_history.json` (가중치 + 매매 히스토리 통합)
|
||||||
|
|
||||||
|
#### 3.2.5 성과 평가 (`analysis/evaluator.py`)
|
||||||
|
|
||||||
|
`PerformanceEvaluator.generate_weekly_report()`:
|
||||||
|
- 핵심 지표: 총수익률, Sharpe Ratio, MDD, 승률, 평균손익비, KOSPI 상관도
|
||||||
|
- S/A/B/C/D/F 등급 산출
|
||||||
|
- **5명 전문가 LLM 패널** (Ollama): 각각 다른 관점으로 평가
|
||||||
|
- HTML 포맷 텔레그램 주간 보고서 자동 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 외부 서비스 연동
|
||||||
|
|
||||||
|
### 4.1 한국투자증권 KIS API (`services/kis.py`)
|
||||||
|
|
||||||
|
#### 인증
|
||||||
|
|
||||||
|
```python
|
||||||
|
KISClient.ensure_token()
|
||||||
|
# OAuth 2.0 → access_token 발급 → data/kis_token.json에 캐시
|
||||||
|
# 토큰 만료 시 자동 갱신 (_request_api에서 처리)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 설정 | 모의투자 | 실전투자 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| Base URL | `openapivts.koreainvestment.com:29443` | `openapi.koreainvestment.com:9443` |
|
||||||
|
| 환경변수 | `KIS_VIRTUAL_APP_KEY/SECRET/ACCOUNT` | `KIS_REAL_APP_KEY/SECRET/ACCOUNT` |
|
||||||
|
| 전환 | `.env` → `KIS_ENV_TYPE=virtual` | `.env` → `KIS_ENV_TYPE=real` |
|
||||||
|
|
||||||
|
#### API 스로틀링
|
||||||
|
|
||||||
|
- 초당 2회 제한 (`_throttle()` — 0.5초 딜레이)
|
||||||
|
- 토큰 만료 시 자동 갱신 (403 → retry with new token)
|
||||||
|
|
||||||
|
#### 주요 API 엔드포인트 매핑
|
||||||
|
|
||||||
|
| 기능 | KISClient 메서드 | KIS TR_ID |
|
||||||
|
|------|-----------------|-----------|
|
||||||
|
| 잔고 조회 | `get_balance()` → `{holdings, total_eval, deposit, today_buy_amt}` | `VTTC8434R` (모의) / `TTTC8434R` (실전) |
|
||||||
|
| 주문 (매수/매도) | `order()` | `VTTC0802U` / `VTTC0801U` (모의) |
|
||||||
|
| 현재가 조회 | `get_current_price()` | `FHKST01010100` |
|
||||||
|
| 일봉 OHLCV | `get_daily_ohlcv()` → `_get_daily_ohlcv_by_range()` | `FHKST03010100` |
|
||||||
|
| 일봉 종가 | `get_daily_price()` → `_get_daily_price_by_range()` | `FHKST03010100` |
|
||||||
|
| 거래량 순위 | `get_volume_rank()` | `FHPST01710000` |
|
||||||
|
| 지수 현재가 | `get_current_index()` | `FHPUP02100000` |
|
||||||
|
| 지수 일봉 | `get_daily_index_price()` | `FHKUP03500100` |
|
||||||
|
| 투자자 동향 | `get_investor_trend()` | `FHKST01010900` |
|
||||||
|
| Hash Key | `get_hash_key()` | - |
|
||||||
|
|
||||||
|
#### 비동기 클라이언트 (`KISAsyncClient`)
|
||||||
|
|
||||||
|
`aiohttp` 기반 — 다중 종목 동시 수집용:
|
||||||
|
- `get_daily_price_batch()` — 여러 종목 일봉 병렬 수집
|
||||||
|
- `get_daily_ohlcv_batch()` — 여러 종목 OHLCV 병렬 수집
|
||||||
|
- `get_investor_trends_batch()` — 여러 종목 투자자 동향 병렬 수집
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Ollama LLM (`services/ollama.py`)
|
||||||
|
|
||||||
|
| 설정 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **모델** | `qwen2.5:7b-instruct-q4_K_M` (VRAM ~4GB) |
|
||||||
|
| **API URL** | `http://localhost:11434` |
|
||||||
|
| **Context Window** | 4096 토큰 |
|
||||||
|
| **Max Output** | 200 토큰 |
|
||||||
|
| **Temperature** | 0.1 (결정론적, JSON 안정성) |
|
||||||
|
| **Keep Alive** | 5분 (비활성 시 자동 언로드) |
|
||||||
|
| **Timeout** | 90초 |
|
||||||
|
| **CPU Threads** | 8 (9800X3D 최적화) |
|
||||||
|
| **응답 포맷** | JSON (format: "json") |
|
||||||
|
|
||||||
|
**GPU 충돌 방지**:
|
||||||
|
- LSTM 학습 중 → Ollama 추론 최대 60초 대기
|
||||||
|
- VRAM > 12GB → 모델 즉시 언로드 (`keep_alive=0`)
|
||||||
|
- LSTM 학습 전 → Ollama 자동 언로드, 학습 후 → 자동 리로드
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 뉴스 수집 (`services/news.py`)
|
||||||
|
|
||||||
|
- **소스**: Google News RSS (`news.google.com/rss/search`)
|
||||||
|
- **동기**: `NewsCollector.get_market_news()` — 시장 일반 뉴스 5건
|
||||||
|
- **비동기**: `AsyncNewsCollector`
|
||||||
|
- `get_market_news_async()` — 시장 뉴스 (5분 캐시)
|
||||||
|
- `get_stock_news_async()` — 종목별 뉴스 (5분 캐시)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 웹 백엔드 서버 API (FastAPI)
|
||||||
|
|
||||||
|
### 5.1 서버 정보
|
||||||
|
|
||||||
|
| 항목 | 값 |
|
||||||
|
|------|-----|
|
||||||
|
| **프레임워크** | FastAPI + Uvicorn |
|
||||||
|
| **호스트** | `0.0.0.0:8000` |
|
||||||
|
| **NAS 백엔드** | `http://192.168.45.54:18500` (웹 프론트엔드 서버) |
|
||||||
|
|
||||||
|
### 5.2 API 엔드포인트
|
||||||
|
|
||||||
|
#### `GET /` — 서버 상태
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "online",
|
||||||
|
"gpu_vram": 4.2,
|
||||||
|
"service": "Windows AI Server (Refactored)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GET /trade/balance` | `GET /api/trade/balance` — 잔고 조회
|
||||||
|
|
||||||
|
KIS API를 통해 현재 계좌 잔고(예수금, 보유종목, 평가금액) 조회.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_eval": 10500000,
|
||||||
|
"deposit": 5000000,
|
||||||
|
"holdings": [
|
||||||
|
{
|
||||||
|
"ticker": "005930",
|
||||||
|
"name": "삼성전자",
|
||||||
|
"qty": 10,
|
||||||
|
"avg_price": 72000,
|
||||||
|
"current_price": 73500,
|
||||||
|
"profit_rate": 2.08
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /trade/order` | `POST /api/trade/order` — 수동 주문
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Request Body
|
||||||
|
{
|
||||||
|
"ticker": "005930",
|
||||||
|
"action": "BUY", // "BUY" | "SELL"
|
||||||
|
"quantity": 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"status": "executed",
|
||||||
|
"kis_result": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `POST /analyze/portfolio` | `POST /api/analyze/portfolio` — AI 포트폴리오 분석
|
||||||
|
|
||||||
|
현재 잔고 + 최신 뉴스를 종합하여 Ollama LLM으로 포트폴리오 분석.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"analysis": "... AI 분석 결과 (한국어) ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 NAS 서버와의 통신 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ HTTP Request ┌────────────────────┐
|
||||||
|
│ NAS Backend │ ─────────────────────► │ Windows AI Server │
|
||||||
|
│ (웹 프론트) │ │ (FastAPI:8000) │
|
||||||
|
│ :18500 │ ◄──────────────────── │ │
|
||||||
|
│ │ JSON Response │ │
|
||||||
|
└──────────────┘ └────────────────────┘
|
||||||
|
|
||||||
|
[통신 시나리오]
|
||||||
|
1. 웹 → /api/trade/balance → 잔고 데이터 표시
|
||||||
|
2. 웹 → /api/trade/order → 수동 매수/매도 실행
|
||||||
|
3. 웹 → /api/analyze/portfolio → AI 분석 결과 표시
|
||||||
|
4. 웹 → / → 서버 상태 및 GPU 정보
|
||||||
|
```
|
||||||
|
|
||||||
|
- **NAS 서버** (`192.168.45.54:18500`): 웹 프론트엔드 호스팅, 사용자 인터페이스 제공
|
||||||
|
- **Windows AI 서버** (`0.0.0.0:8000`): GPU 연산, KIS API 통신, AI 분석 처리
|
||||||
|
- 내부 네트워크 (LAN) 통신, 외부 노출 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 텔레그램 봇 설정 & 명령어
|
||||||
|
|
||||||
|
### 6.1 환경변수
|
||||||
|
|
||||||
|
```env
|
||||||
|
TELEGRAM_BOT_TOKEN=8546032918:AAF5GJcP92DrtpSoQdaimMIZe7bz_xtGGPo
|
||||||
|
TELEGRAM_CHAT_ID=7388056964
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 봇 프로세스 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
runner.py
|
||||||
|
└── run_telegram_bot_standalone()
|
||||||
|
├── SharedIPC 초기화 (lock, queue, shutdown_event)
|
||||||
|
├── TelegramBotServer 생성
|
||||||
|
├── IPC에서 초기 데이터 로드
|
||||||
|
├── bot_server.run() (python-telegram-bot polling)
|
||||||
|
└── Conflict 감지 시 백오프 재시도 (최대 10회)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **라이브러리**: `python-telegram-bot` (Application, CommandHandler)
|
||||||
|
- **메시지 포맷**: HTML (`parse_mode="HTML"`)
|
||||||
|
- **동시 업데이트**: `concurrent_updates=True`
|
||||||
|
- **로깅**: `telegram_bot.log` (파일 + 콘솔)
|
||||||
|
|
||||||
|
### 6.3 명령어 목록
|
||||||
|
|
||||||
|
| 명령어 | 설명 | 데이터 소스 |
|
||||||
|
|--------|------|------------|
|
||||||
|
| `/start` | 봇 시작 & 전체 명령어 안내 | - |
|
||||||
|
| `/status` | 봇 상태, 시장 지수, AI 모델 상태 | IPC (SharedMemory) |
|
||||||
|
| `/portfolio` | 보유 종목 & 수익률 조회 | IPC → FakeKIS.get_balance() |
|
||||||
|
| `/watchlist` | 현재 감시 종목 리스트 | IPC → watchlist 데이터 |
|
||||||
|
| `/update_watchlist` | Watchlist 즉시 업데이트 요청 | Command Queue → 메인 봇 |
|
||||||
|
| `/macro` | 거시경제 분석 (KOSPI/KOSDAQ/MSI) | IPC → macro_indices |
|
||||||
|
| `/system` | CPU/GPU/RAM 시스템 상태 | IPC → gpu_status + psutil |
|
||||||
|
| `/ai` | AI 모델 상태 (VRAM, 학습 여부) | IPC → gpu_status |
|
||||||
|
| `/restart` | 메인 봇 재시작 명령 | Command Queue |
|
||||||
|
| `/stop` | 봇 종료 | shutdown_event.set() |
|
||||||
|
| `/exec <cmd>` | 서버 쉘 명령어 직접 실행 | subprocess (10초 타임아웃) |
|
||||||
|
| `/evaluate` | 즉시 성과 평가 보고서 생성 | PerformanceEvaluator |
|
||||||
|
|
||||||
|
### 6.4 TelegramMessenger (`services/telegram.py`)
|
||||||
|
|
||||||
|
단방향 알림 전용 (메인 봇 → 사용자):
|
||||||
|
- **비동기 전송**: `threading.Thread(daemon=True)` — Fire-and-forget
|
||||||
|
- **HTML 파싱**: 마크다운 에러 방지
|
||||||
|
- 매매 실행, 서버 시작/종료, 에러 알림 등에 사용
|
||||||
|
|
||||||
|
### 6.5 Conflict 처리
|
||||||
|
|
||||||
|
텔레그램 봇 API는 동시에 하나의 polling 인스턴스만 허용:
|
||||||
|
- `Conflict` 에러 감지 시 지수 백오프 (5s → 10s → ... → 30s)
|
||||||
|
- 최대 10회 재시도 후 프로세스 종료
|
||||||
|
- Watchdog가 감지하여 자동 재시작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 환경 설정 (`modules/config.py`)
|
||||||
|
|
||||||
|
### 7.1 주요 설정 상수
|
||||||
|
|
||||||
|
| 그룹 | 키 | 값 | 설명 |
|
||||||
|
|------|-----|-----|------|
|
||||||
|
| **매매** | `MAX_INVESTMENT_PER_STOCK` | 3,000,000원 | 종목당 최대 투자금 |
|
||||||
|
| **매매** | `MAX_BUY_PER_CYCLE` | 2 | 사이클당 최대 매수 종목 수 (env: `MAX_BUY_PER_CYCLE`) |
|
||||||
|
| **매매** | `MAX_DAILY_BUY_RATIO` | 0.80 | 예수금 대비 일일 최대 매수 비율 (env: `MAX_DAILY_BUY_RATIO`) |
|
||||||
|
| **IPC** | `SHM_NAME` | `web_ai_bot_ipc` | SharedMemory 이름 |
|
||||||
|
| **IPC** | `SHM_SIZE` | 131,072 (128KB) | SharedMemory 크기 |
|
||||||
|
| **IPC** | `IPC_STALENESS` | 600초 | 데이터 유효 기간 |
|
||||||
|
| **GPU** | `VRAM_WARNING_THRESHOLD` | 12.0 GB | VRAM 경고 임계값 |
|
||||||
|
| **프로세스** | `WATCHDOG_INTERVAL` | 30초 | 헬스체크 간격 |
|
||||||
|
| **프로세스** | `MAX_RESTART_COUNT` | 3 | 최대 자동 재시작 횟수 |
|
||||||
|
| **LSTM** | `LSTM_COOLDOWN` | 1,200초 | 동일 종목 재학습 방지 |
|
||||||
|
| **LSTM** | `LSTM_FAST_EPOCHS` | 30 | 빠른 재학습 에포크 |
|
||||||
|
| **CPU** | `CPU_CIRCUIT_BREAKER_THRESHOLD` | 92% | 서킷 브레이커 임계값 |
|
||||||
|
| **CPU** | `CPU_CIRCUIT_BREAKER_CONSECUTIVE` | 2회 | 연속 초과 시 발동 |
|
||||||
|
| **Ollama** | `OLLAMA_NUM_CTX` | 4,096 | 컨텍스트 윈도우 |
|
||||||
|
| **Ollama** | `OLLAMA_NUM_PREDICT` | 200 | 최대 출력 토큰 |
|
||||||
|
| **Ollama** | `OLLAMA_NUM_THREAD` | 8 | CPU 스레드 수 |
|
||||||
|
| **Network** | `HTTP_TIMEOUT` | 10초 | 기본 HTTP 요청 타임아웃 |
|
||||||
|
|
||||||
|
### 7.2 .env 파일 구조
|
||||||
|
|
||||||
|
```env
|
||||||
|
# NAS Backend (웹 프론트엔드 서버)
|
||||||
|
NAS_API_URL=http://192.168.45.54:18500
|
||||||
|
|
||||||
|
# Ollama LLM
|
||||||
|
OLLAMA_API_URL=http://localhost:11434
|
||||||
|
OLLAMA_MODEL=qwen2.5:7b-instruct-q4_K_M
|
||||||
|
|
||||||
|
# KIS API (virtual/real 전환)
|
||||||
|
KIS_ENV_TYPE=virtual
|
||||||
|
KIS_REAL_APP_KEY=...
|
||||||
|
KIS_REAL_APP_SECRET=...
|
||||||
|
KIS_REAL_ACCOUNT=XXXXXXXX-XX
|
||||||
|
KIS_VIRTUAL_APP_KEY=...
|
||||||
|
KIS_VIRTUAL_APP_SECRET=...
|
||||||
|
KIS_VIRTUAL_ACCOUNT=XXXXXXXX-XX
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
TELEGRAM_BOT_TOKEN=...
|
||||||
|
TELEGRAM_CHAT_ID=...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 운영 가이드
|
||||||
|
|
||||||
|
### 8.1 시작 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 일반 시작
|
||||||
|
python main_server.py
|
||||||
|
|
||||||
|
# LSTM 사전학습 후 자동 시작
|
||||||
|
python warmup_and_restart.py
|
||||||
|
|
||||||
|
# 텔레그램 봇만 단독 실행 (디버깅용)
|
||||||
|
python -m modules.services.telegram_bot.runner
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 좀비 프로세스 관리
|
||||||
|
|
||||||
|
- `main_server.py` 실행 시 자동으로 이전 좀비 프로세스 정리
|
||||||
|
- `pids.txt` 기반 → 메모리 기반 PID 추적으로 전환 완료
|
||||||
|
- 수동 확인: `Get-Process python` (PowerShell)
|
||||||
|
|
||||||
|
### 8.3 로그 파일
|
||||||
|
|
||||||
|
| 파일 | 용도 |
|
||||||
|
|------|------|
|
||||||
|
| `server.log` | Uvicorn 서버 로그 |
|
||||||
|
| `telegram_bot.log` | 텔레그램 봇 로그 |
|
||||||
|
| `warmup.log` | LSTM 사전학습 진행 로그 |
|
||||||
|
| `bot_output.log` | 트레이딩 봇 출력 로그 |
|
||||||
|
|
||||||
|
### 8.4 트러블슈팅
|
||||||
|
|
||||||
|
| 증상 | 원인 | 해결 |
|
||||||
|
|------|------|------|
|
||||||
|
| KIS 403 Forbidden | 토큰 만료 또는 Rate Limit | `data/kis_token.json` 삭제 후 재시작 |
|
||||||
|
| Telegram Conflict | 이전 봇 프로세스 미종료 | `main_server.py` 재시작 (자동 정리) |
|
||||||
|
| GPU OOM | LSTM + Ollama 동시 적재 | `VRAM_WARNING_THRESHOLD` 낮추기 |
|
||||||
|
| CPU 100% 고정 | 좀비 워커 프로세스 | `main_server.py` 재시작 |
|
||||||
|
| IPC 데이터 오래됨 | 메인 봇 크래시 | Watchdog 자동 재시작 확인, 수동 재시작 |
|
||||||
|
| 예수금 초과 매수 | KIS 모의투자 T+2 미차감 | `MAX_DAILY_BUY_RATIO` / `MAX_BUY_PER_CYCLE` 조정 |
|
||||||
|
| Kelly 비중이 너무 낮음 | 거래 기록 부족 (< 10건) | 초기에는 기본값 8% 사용, 거래 누적 후 자동 조정 |
|
||||||
|
| 앙상블 가중치 갱신 안 됨 | 매도 체결 없음 또는 `_buy_scores` 누락 | 봇 재시작 전 매도 완료 확인; `data/ensemble_history.json` 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 데이터 흐름 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
[시장 개장 전]
|
||||||
|
WatchlistManager → 뉴스 분석 → Watchlist 갱신
|
||||||
|
|
||||||
|
[장중 사이클 (≈5분 간격)]
|
||||||
|
1. SystemMonitor.check_health() → CPU/GPU 확인
|
||||||
|
2. MacroAnalyzer.get_macro_status() → 시장 상태 판단
|
||||||
|
3. KIS → get_balance() → raw_deposit - today_buy_total = 가용 예수금
|
||||||
|
4. KIS → get_daily_ohlcv_batch() → OHLCV 수집
|
||||||
|
5. ProcessPool → analyze_stock_process() × N종목
|
||||||
|
├── ensemble.reload_if_stale() → 파일 mtime 감지 시 가중치 재로드
|
||||||
|
├── TechnicalAnalyzer → 기술적 점수
|
||||||
|
├── PricePredictor → LSTM 예측
|
||||||
|
├── OllamaManager → LLM 감성 분석
|
||||||
|
├── AdaptiveEnsemble.get_weights() → 학습된 동적 가중치
|
||||||
|
└── calculate_position_size() → Kelly Criterion 수량 산출
|
||||||
|
6. 매수 판단 → 예수금 확인 → KIS 주문
|
||||||
|
├── _buy_scores[ticker] 저장 (앙상블 학습용)
|
||||||
|
├── _today_buy_total += 매수금액
|
||||||
|
└── buys_this_cycle++ (MAX_BUY_PER_CYCLE 제한)
|
||||||
|
7. 매도 판단 → KIS 주문
|
||||||
|
└── ensemble.record_trade() → 가중치 학습 + ensemble_history.json 저장
|
||||||
|
8. SharedIPC.write_status() → 텔레그램 봇에 공유
|
||||||
|
9. TelegramMessenger → 결과 알림
|
||||||
|
|
||||||
|
[장 마감 후]
|
||||||
|
PerformanceDB.save_daily_snapshot() → 일별 자산 기록
|
||||||
|
Evaluator → 주간 보고서 (월요일)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 버전 변경 이력
|
||||||
|
|
||||||
|
### v3.1 (2026-03-19) — 잔고 관리 & 앙상블 학습 완성
|
||||||
|
|
||||||
|
**버그 수정**:
|
||||||
|
- `tracking_deposit` 사이클 간 초기화 문제 → `_today_buy_total` 인스턴스 변수로 누적 추적
|
||||||
|
- KIS 모의투자 T+2 미차감으로 인한 예수금 초과 매수 방지
|
||||||
|
- `ai_confidence >= 0.85` 임계값 버그 (LSTM confidence 상한 0.80 미반영) → 0.75로 수정
|
||||||
|
- OHLCV 피처 누락 시 silent fallback → 경고 로그 출력
|
||||||
|
|
||||||
|
**신규 기능**:
|
||||||
|
- `MAX_BUY_PER_CYCLE`: 사이클당 최대 매수 종목 수 제한 (기본 2)
|
||||||
|
- `MAX_DAILY_BUY_RATIO`: 예수금 대비 일일 최대 매수 비율 (기본 80%)
|
||||||
|
- `kis.get_balance()` → `today_buy_amt` 필드 추가 (`thdt_buy_amt`)
|
||||||
|
|
||||||
|
**앙상블 (`analysis/ensemble.py`)**:
|
||||||
|
- `AdaptiveEnsemble`을 `process.py`에 실제 연동 (하드코딩 가중치 제거)
|
||||||
|
- `get_kelly_fraction()`: Half-Kelly Criterion 포지션 비중 계산 추가
|
||||||
|
- `SignalWeights.normalize()`: Water-Filling 알고리즘으로 경계 위반 문제 해결
|
||||||
|
- `_accuracy()` 이진 지표 제거 → `_accuracy_weighted()` (크기 가중) 통일
|
||||||
|
- `reload_if_stale()`: 파일 mtime 기반 cross-process 동기화
|
||||||
|
|
||||||
|
**포지션 사이징 (`strategy/process.py`)**:
|
||||||
|
- `calculate_position_size()`: 하드코딩 10% → Kelly Criterion (과거 승률·손익비 기반)
|
||||||
|
- `bot.py` 중복 계산 제거 → 워커의 `suggested_qty` 직접 사용
|
||||||
|
|
||||||
|
**앙상블 학습 루프 (`bot.py`)**:
|
||||||
|
- BUY 체결 시 `_buy_scores[ticker]` 신호 점수 저장
|
||||||
|
- SELL 체결 시 `ensemble.record_trade()` → `ensemble_history.json` 갱신
|
||||||
|
- 워커 프로세스는 `reload_if_stale()`로 자동 반영
|
||||||
155
daily_launcher.py
Normal file
155
daily_launcher.py
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"""
|
||||||
|
daily_launcher.py — KRX 거래일 자동 런처
|
||||||
|
|
||||||
|
[동작 흐름]
|
||||||
|
1. 오늘이 KRX 거래일인지 확인
|
||||||
|
2. 휴장일이면: 텔레그램 알림 후 종료
|
||||||
|
3. 거래일이면: LSTM 워밍업 → main_server.py 시작
|
||||||
|
4. 봇은 15:35에 스스로 EOD 셧다운
|
||||||
|
|
||||||
|
[설치: Windows 작업 스케줄러]
|
||||||
|
트리거: 매일 08:30 (주말 포함 — 봇이 내부에서 휴장일 체크)
|
||||||
|
동작: python C:\\path\\to\\web-ai\\daily_launcher.py
|
||||||
|
시작 위치: C:\\path\\to\\web-ai
|
||||||
|
실행 계정: 현재 사용자 (로그인 여부 무관 실행 권장)
|
||||||
|
|
||||||
|
[수동 실행]
|
||||||
|
python daily_launcher.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
ROOT = Path(__file__).parent
|
||||||
|
LOG_FILE = ROOT / "daily_launcher.log"
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [Launcher] %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler(LOG_FILE, encoding="utf-8"),
|
||||||
|
logging.StreamHandler(sys.stdout),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger("daily_launcher")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_path():
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def send_notify(msg: str):
|
||||||
|
"""텔레그램 알림 발송 (실패해도 런처 계속 진행)"""
|
||||||
|
try:
|
||||||
|
from modules.services.telegram import TelegramMessenger
|
||||||
|
TelegramMessenger().send_message(msg)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"텔레그램 알림 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def clear_eod_marker():
|
||||||
|
"""전일 EOD 마커 파일 삭제 (새 거래일 시작)"""
|
||||||
|
eod_file = ROOT / "data" / ".eod_date"
|
||||||
|
if not eod_file.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
prev = datetime.date.fromisoformat(eod_file.read_text().strip())
|
||||||
|
today = datetime.datetime.now(KST).date()
|
||||||
|
if prev < today:
|
||||||
|
eod_file.unlink()
|
||||||
|
log.info(f"전일({prev}) EOD 마커 삭제 완료")
|
||||||
|
except Exception:
|
||||||
|
eod_file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def wait_until_warmup_time(cal) -> None:
|
||||||
|
"""
|
||||||
|
워밍업 시작 시각까지 대기
|
||||||
|
- 장 시작 30분 전이면 즉시 워밍업
|
||||||
|
- 그보다 일찍 실행되면 '장 시작 30분 전'까지 대기
|
||||||
|
"""
|
||||||
|
secs = cal.seconds_to_open()
|
||||||
|
if secs <= 0:
|
||||||
|
log.info("이미 장 중 — 즉시 워밍업 시작")
|
||||||
|
return
|
||||||
|
|
||||||
|
warmup_start_secs = max(0, secs - 30 * 60) # 장 시작 30분 전
|
||||||
|
if warmup_start_secs > 0:
|
||||||
|
warmup_at = datetime.datetime.now(KST) + datetime.timedelta(seconds=warmup_start_secs)
|
||||||
|
log.info(f"워밍업 대기 중 ({warmup_start_secs/60:.0f}분 후 {warmup_at.strftime('%H:%M')} 시작)")
|
||||||
|
time.sleep(warmup_start_secs)
|
||||||
|
else:
|
||||||
|
log.info(f"장 시작 {secs/60:.0f}분 전 — 즉시 워밍업")
|
||||||
|
|
||||||
|
|
||||||
|
def run_warmup_and_server() -> int:
|
||||||
|
"""
|
||||||
|
warmup_and_restart.py 실행
|
||||||
|
- warmup: LSTM 사전학습
|
||||||
|
- 이후 main_server.py를 새 콘솔에서 자동 시작
|
||||||
|
"""
|
||||||
|
log.info("LSTM 워밍업 시작...")
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "warmup_and_restart.py"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
setup_path()
|
||||||
|
|
||||||
|
from modules.utils.market_calendar import KRXCalendar
|
||||||
|
cal = KRXCalendar()
|
||||||
|
today = datetime.datetime.now(KST).date()
|
||||||
|
log.info(f"실행 날짜: {today} | 시장 상태: {cal.status_summary()}")
|
||||||
|
|
||||||
|
# ── 1. 휴장일 체크 ────────────────────────────────────────────────────────
|
||||||
|
if not cal.is_trading_day(today):
|
||||||
|
try:
|
||||||
|
nxt = cal.next_trading_open()
|
||||||
|
next_str = nxt.strftime("%m/%d(%a) %H:%M")
|
||||||
|
except Exception:
|
||||||
|
next_str = "미정"
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"[자동매매] {today.strftime('%m/%d(%a)')} 휴장일\n"
|
||||||
|
f"다음 거래일: {next_str} KST 자동 시작"
|
||||||
|
)
|
||||||
|
log.info(f"휴장일 — 봇 시작 안 함 (다음: {next_str})")
|
||||||
|
send_notify(msg)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── 2. EOD 마커 초기화 ────────────────────────────────────────────────────
|
||||||
|
clear_eod_marker()
|
||||||
|
|
||||||
|
# ── 3. 워밍업 시각까지 대기 ───────────────────────────────────────────────
|
||||||
|
wait_until_warmup_time(cal)
|
||||||
|
|
||||||
|
# ── 4. 거래일 시작 알림 ───────────────────────────────────────────────────
|
||||||
|
log.info(f"거래일 확인 — 워밍업 및 봇 시작 ({datetime.datetime.now(KST).strftime('%H:%M')})")
|
||||||
|
send_notify(
|
||||||
|
f"[자동매매] {today.strftime('%m/%d(%a)')} 거래일 시작\n"
|
||||||
|
f"LSTM 워밍업 중..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 5. 워밍업 + 서버 시작 ─────────────────────────────────────────────────
|
||||||
|
rc = run_warmup_and_server()
|
||||||
|
if rc != 0:
|
||||||
|
log.error(f"워밍업 실패 (exit={rc}) — 수동 확인 필요")
|
||||||
|
send_notify(f"[Bot] 워밍업 실패! (exit={rc})\n수동으로 확인해 주세요.")
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("워밍업 완료. main_server.py가 백그라운드에서 실행 중.")
|
||||||
|
log.info("봇은 15:35에 스스로 EOD 셧다운합니다.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
import uvicorn
|
import uvicorn
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
@@ -21,11 +24,11 @@ news_collector = None
|
|||||||
watchdog = None
|
watchdog = None
|
||||||
|
|
||||||
|
|
||||||
def run_trading_bot(ipc_lock, command_queue, shutdown_event):
|
def run_trading_bot(ipc_lock, command_queue, shutdown_event, eod_event=None):
|
||||||
"""트레이딩 봇 실행 래퍼"""
|
"""트레이딩 봇 실행 래퍼"""
|
||||||
ProcessTracker.register("Trading Bot Main")
|
ProcessTracker.register("Trading Bot Main")
|
||||||
bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue,
|
bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue,
|
||||||
shutdown_event=shutdown_event)
|
shutdown_event=shutdown_event, eod_event=eod_event)
|
||||||
bot.loop()
|
bot.loop()
|
||||||
|
|
||||||
|
|
||||||
@@ -53,11 +56,12 @@ async def lifespan(app: FastAPI):
|
|||||||
ipc_lock = multiprocessing.Lock()
|
ipc_lock = multiprocessing.Lock()
|
||||||
command_queue = multiprocessing.Queue()
|
command_queue = multiprocessing.Queue()
|
||||||
shutdown_event = multiprocessing.Event()
|
shutdown_event = multiprocessing.Event()
|
||||||
|
eod_event = multiprocessing.Event() # [v3.1] EOD 셧다운 시그널
|
||||||
|
|
||||||
print("[Server] Starting AI Trading Bot & Telegram Bot...")
|
print("[Server] Starting AI Trading Bot & Telegram Bot...")
|
||||||
|
|
||||||
# 5. 자식 프로세스 생성
|
# 5. 자식 프로세스 생성
|
||||||
bot_args = (ipc_lock, command_queue, shutdown_event)
|
bot_args = (ipc_lock, command_queue, shutdown_event, eod_event)
|
||||||
telegram_args = (ipc_lock, command_queue, shutdown_event)
|
telegram_args = (ipc_lock, command_queue, shutdown_event)
|
||||||
|
|
||||||
bot_process = multiprocessing.Process(
|
bot_process = multiprocessing.Process(
|
||||||
@@ -77,6 +81,25 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
messenger.send_message("[Server Started] Windows AI Server Online.")
|
messenger.send_message("[Server Started] Windows AI Server Online.")
|
||||||
|
|
||||||
|
# [v3.1] EOD 모니터 스레드: 봇이 EOD 시그널을 보내면 서버 프로세스 자동 종료
|
||||||
|
_server_pid = os.getpid()
|
||||||
|
|
||||||
|
def _eod_monitor():
|
||||||
|
"""eod_event 감지 시 SIGTERM으로 uvicorn 우아하게 종료"""
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
if eod_event.is_set():
|
||||||
|
print("[Server] EOD 시그널 수신 — 서버 종료 중 (15초 후)...")
|
||||||
|
import time as _time
|
||||||
|
_time.sleep(15) # 자식 프로세스 정리 시간
|
||||||
|
print(f"[Server] SIGTERM → PID {_server_pid}")
|
||||||
|
os.kill(_server_pid, signal.SIGTERM)
|
||||||
|
return
|
||||||
|
import time as _time
|
||||||
|
_time.sleep(5)
|
||||||
|
|
||||||
|
_eod_thread = threading.Thread(target=_eod_monitor, daemon=True, name="EODMonitor")
|
||||||
|
_eod_thread.start()
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# [Shutdown]
|
# [Shutdown]
|
||||||
|
|||||||
445
modules/analysis/ai_council.py
Normal file
445
modules/analysis/ai_council.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
AI 전문가 회의 시스템 (Multi-Agent Council)
|
||||||
|
- 4명의 전문가 에이전트가 독립 분석 후 의장 AI가 최종 결정
|
||||||
|
- 코스피 레짐 기반 모델 교체 권고
|
||||||
|
- process.py 분석 결과를 입력받아 심층 검토 수행
|
||||||
|
|
||||||
|
흐름:
|
||||||
|
전문가 1~4 (각 역할별 Ollama 호출)
|
||||||
|
↓
|
||||||
|
의장 AI (전문가 의견 취합 + 최종 결정 + 모델 건전성 평가)
|
||||||
|
↓
|
||||||
|
CouncilDecision (결정 + 모델 교체 권고 + 회의록)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List, Any
|
||||||
|
|
||||||
|
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExpertOpinion:
|
||||||
|
"""개별 전문가 의견"""
|
||||||
|
expert_name: str
|
||||||
|
role: str
|
||||||
|
decision: str # BUY / SELL / HOLD
|
||||||
|
confidence: float # 0~1
|
||||||
|
reasoning: str
|
||||||
|
key_concern: str
|
||||||
|
model_feedback: str # 현재 AI 모델 적합성 평가
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CouncilDecision:
|
||||||
|
"""회의 최종 결정"""
|
||||||
|
final_decision: str # BUY / SELL / HOLD
|
||||||
|
consensus_score: float # 0~1 (1 = 만장일치)
|
||||||
|
confidence: float # 0~1
|
||||||
|
majority_reasoning: str # 주요 결정 근거
|
||||||
|
dissenting_views: str # 소수 의견
|
||||||
|
model_health_score: float # 0~1 (현재 모델 신뢰도)
|
||||||
|
model_replacement_recommended: bool # 모델 교체 필요 여부
|
||||||
|
recommended_model: str # 교체 권고 모델명
|
||||||
|
council_summary: str # 회의 전체 요약
|
||||||
|
expert_opinions: List[dict] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# 전문가 페르소나 정의
|
||||||
|
_EXPERTS = [
|
||||||
|
{
|
||||||
|
"name": "기술분석가",
|
||||||
|
"role": "technical",
|
||||||
|
"persona": (
|
||||||
|
"20년 경력의 코스피 전문 기술분석가. "
|
||||||
|
"RSI, MACD, 볼린저밴드, 추세선, 거래량 분석을 주로 사용. "
|
||||||
|
"단기 가격 모멘텀과 지지/저항 구간을 중시함."
|
||||||
|
),
|
||||||
|
"focus": (
|
||||||
|
"RSI 과매수/과매도, 볼린저밴드 위치, ADX 추세 강도, "
|
||||||
|
"거래량 급증, 멀티타임프레임 정렬 여부를 핵심 근거로 사용하세요."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "퀀트전문가",
|
||||||
|
"role": "quant",
|
||||||
|
"persona": (
|
||||||
|
"AI/ML 기반 퀀트 투자 전문가. "
|
||||||
|
"LSTM 예측 신뢰도, 통계적 유의성, 백테스트 성과를 중시. "
|
||||||
|
"모델의 현재 시장 환경 적합성을 항상 평가함."
|
||||||
|
),
|
||||||
|
"focus": (
|
||||||
|
"LSTM 신뢰도와 예측 방향을 중심으로 분석하세요. "
|
||||||
|
"현재 코스피 레짐에서 LSTM v3 모델이 적합한지 반드시 평가하고, "
|
||||||
|
"더 나은 대안 모델이 있으면 구체적으로 제안하세요."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "리스크관리자",
|
||||||
|
"role": "risk",
|
||||||
|
"persona": (
|
||||||
|
"글로벌 헤지펀드 리스크 관리 전문가. "
|
||||||
|
"포지션 사이징, 최대 낙폭(MDD), VaR, 손절 기준을 최우선으로 고려. "
|
||||||
|
"수익보다 손실 방어를 먼저 생각함."
|
||||||
|
),
|
||||||
|
"focus": (
|
||||||
|
"변동성 대비 포지션 크기 적절성, 손절 기준 타당성, "
|
||||||
|
"현재 보유 중이라면 추가 하락 리스크를 집중 평가하세요."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "거시경제분석가",
|
||||||
|
"role": "macro",
|
||||||
|
"persona": (
|
||||||
|
"글로벌 매크로 및 한국 증시 전문가. "
|
||||||
|
"코스피 지수 수준, 원/달러 환율, 미국 금리, 외국인 수급을 중시. "
|
||||||
|
"현재 시장이 역사적으로 어떤 위치인지 판단함."
|
||||||
|
),
|
||||||
|
"focus": (
|
||||||
|
"코스피 지수 현재 수준이 역사적으로 어떤 의미인지, "
|
||||||
|
"이 가격대에서 매수/보유가 타당한지 거시경제 관점에서 평가하세요."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_expert_prompt(expert: dict, ticker: str, data: dict) -> str:
|
||||||
|
"""전문가 역할에 맞는 분석 프롬프트 생성"""
|
||||||
|
kospi = data.get("kospi_price", 2500)
|
||||||
|
regime_label = MarketRegimeDetector.get_regime_label(kospi)
|
||||||
|
|
||||||
|
base = (
|
||||||
|
f"종목: {ticker} | 현재가: {data.get('current_price', 0):,.0f}원\n"
|
||||||
|
f"코스피: {kospi:.0f} [{regime_label}]\n"
|
||||||
|
f"시장상태: {data.get('macro_state', 'SAFE')}\n"
|
||||||
|
f"---기술지표---\n"
|
||||||
|
f"기술점수: {data.get('tech_score', 0.5):.3f} | "
|
||||||
|
f"RSI: {data.get('rsi', 50):.1f} | ADX: {data.get('adx', 20):.1f}\n"
|
||||||
|
f"변동성: {data.get('volatility', 2.0):.2f}% | BB위치: {data.get('bb_zone', '중간')}\n"
|
||||||
|
f"MTF정렬: {data.get('mtf_alignment', 'N/A')}\n"
|
||||||
|
f"---AI모델---\n"
|
||||||
|
f"LSTM예측: {data.get('lstm_predicted', 0):,.0f}원 "
|
||||||
|
f"(변화율: {data.get('lstm_change_rate', 0):+.2f}%)\n"
|
||||||
|
f"LSTM신뢰도: {data.get('ai_confidence', 0.5):.2f} | "
|
||||||
|
f"LSTM점수: {data.get('lstm_score', 0.5):.3f}\n"
|
||||||
|
f"---수급/감성---\n"
|
||||||
|
f"감성점수: {data.get('sentiment_score', 0.5):.3f} | "
|
||||||
|
f"수급점수: {data.get('investor_score', 0):.3f}\n"
|
||||||
|
f"외인순매수: {data.get('frgn_net_buy', 0):+,} "
|
||||||
|
f"({data.get('consecutive_frgn_buy', 0)}일 연속)\n"
|
||||||
|
f"---포지션---\n"
|
||||||
|
f"보유중: {data.get('is_holding', False)} | "
|
||||||
|
f"보유수익률: {data.get('holding_yield', 0):+.2f}%\n"
|
||||||
|
f"통합점수: {data.get('total_score', 0.5):.3f}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
role_addition = (
|
||||||
|
f"\n당신은 {expert['persona']}\n"
|
||||||
|
f"분석 초점: {expert['focus']}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
output_format = (
|
||||||
|
"\n반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 금지):\n"
|
||||||
|
"{\n"
|
||||||
|
' "decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||||
|
' "confidence": 0.0~1.0,\n'
|
||||||
|
' "reasoning": "주요 판단 근거 (1~2문장, 한국어)",\n'
|
||||||
|
' "key_concern": "가장 우려되는 리스크 (1문장, 한국어)",\n'
|
||||||
|
' "model_feedback": "현재 LSTM v3 모델이 이 시장 환경에서 적합한지 평가 (1문장)"\n'
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return base + role_addition + output_format
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chairman_prompt(
|
||||||
|
ticker: str,
|
||||||
|
opinions: List[ExpertOpinion],
|
||||||
|
data: dict,
|
||||||
|
regime: RegimeAnalysis,
|
||||||
|
) -> str:
|
||||||
|
"""의장 AI 최종 결정 프롬프트"""
|
||||||
|
opinions_text = "\n".join([
|
||||||
|
f"[{op.expert_name}] {op.decision} (확신도: {op.confidence:.2f})\n"
|
||||||
|
f" 근거: {op.reasoning}\n"
|
||||||
|
f" 우려: {op.key_concern}\n"
|
||||||
|
f" 모델평가: {op.model_feedback}"
|
||||||
|
for op in opinions
|
||||||
|
])
|
||||||
|
|
||||||
|
votes = [op.decision for op in opinions]
|
||||||
|
buy_n = votes.count("BUY")
|
||||||
|
sell_n = votes.count("SELL")
|
||||||
|
hold_n = votes.count("HOLD")
|
||||||
|
avg_conf = sum(op.confidence for op in opinions) / max(len(opinions), 1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
"당신은 AI 투자 전문가 회의를 주재하는 의장입니다.\n\n"
|
||||||
|
f"=== 종목: {ticker} ===\n"
|
||||||
|
f"현재가: {data.get('current_price', 0):,.0f}원 | "
|
||||||
|
f"코스피: {data.get('kospi_price', 2500):.0f}\n"
|
||||||
|
f"시장 레짐: {regime.regime.value} ({regime.description})\n"
|
||||||
|
f"레짐 권고: {regime.model_recommendation}\n\n"
|
||||||
|
f"=== 전문가 의견 ===\n{opinions_text}\n\n"
|
||||||
|
f"=== 투표: 매수 {buy_n} / 매도 {sell_n} / 보유 {hold_n} "
|
||||||
|
f"(평균 확신도: {avg_conf:.2f}) ===\n\n"
|
||||||
|
"당신의 임무:\n"
|
||||||
|
"1. 4명 의견을 종합하여 최종 매매 결정\n"
|
||||||
|
f"2. LSTM v3 모델이 코스피 {data.get('kospi_price', 2500):.0f} 레짐에서 적합한지 평가\n"
|
||||||
|
"3. 필요 시 대안 모델 구체적으로 권고\n\n"
|
||||||
|
"반드시 아래 JSON 형식으로만 응답하세요:\n"
|
||||||
|
"{\n"
|
||||||
|
' "final_decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||||
|
' "consensus_score": 0.0~1.0,\n'
|
||||||
|
' "confidence": 0.0~1.0,\n'
|
||||||
|
' "majority_reasoning": "최종 결정 근거 2~3문장 (한국어)",\n'
|
||||||
|
' "dissenting_views": "소수 의견 요약 (없으면 빈 문자열)",\n'
|
||||||
|
' "model_health_score": 0.0~1.0,\n'
|
||||||
|
' "model_replacement_recommended": true 또는 false,\n'
|
||||||
|
' "recommended_model": "교체 권고 모델명 (없으면 \'현재 모델 유지\')",\n'
|
||||||
|
' "council_summary": "회의 전체 요약 3~4문장 (한국어)"\n'
|
||||||
|
"}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_json_response(raw: Optional[str]) -> Optional[dict]:
|
||||||
|
"""LLM 응답에서 JSON 추출 (폴백 포함)"""
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
import re
|
||||||
|
match = re.search(r'\{[\s\S]*\}', raw)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return json.loads(match.group())
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _vote_fallback(opinions: List[ExpertOpinion]) -> CouncilDecision:
|
||||||
|
"""의장 AI 실패 시 단순 다수결 폴백"""
|
||||||
|
from collections import Counter
|
||||||
|
if not opinions:
|
||||||
|
return CouncilDecision(
|
||||||
|
final_decision="HOLD", consensus_score=0.5, confidence=0.5,
|
||||||
|
majority_reasoning="분석 데이터 부족", dissenting_views="",
|
||||||
|
model_health_score=0.5, model_replacement_recommended=False,
|
||||||
|
recommended_model="현재 모델 유지",
|
||||||
|
council_summary="전문가 의견 수집 실패로 HOLD 처리",
|
||||||
|
)
|
||||||
|
|
||||||
|
votes = [op.decision for op in opinions]
|
||||||
|
final = Counter(votes).most_common(1)[0][0]
|
||||||
|
avg_conf = sum(op.confidence for op in opinions) / len(opinions)
|
||||||
|
vote_counts = Counter(votes)
|
||||||
|
consensus = vote_counts[final] / len(votes)
|
||||||
|
|
||||||
|
return CouncilDecision(
|
||||||
|
final_decision=final,
|
||||||
|
consensus_score=round(consensus, 3),
|
||||||
|
confidence=round(avg_conf, 3),
|
||||||
|
majority_reasoning=f"전문가 {vote_counts[final]}/{len(votes)} 다수결 결과",
|
||||||
|
dissenting_views="",
|
||||||
|
model_health_score=0.5,
|
||||||
|
model_replacement_recommended=False,
|
||||||
|
recommended_model="현재 모델 유지",
|
||||||
|
council_summary="의장 AI 오류 - 전문가 투표로 대체",
|
||||||
|
expert_opinions=[
|
||||||
|
{"name": op.expert_name, "decision": op.decision,
|
||||||
|
"confidence": op.confidence, "reasoning": op.reasoning}
|
||||||
|
for op in opinions
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AICouncil:
|
||||||
|
"""
|
||||||
|
AI 전문가 회의 시스템
|
||||||
|
|
||||||
|
사용 방법:
|
||||||
|
council = AICouncil(llm_client)
|
||||||
|
decision = council.convene(ticker, analysis_data, regime_analysis)
|
||||||
|
|
||||||
|
fast_mode=True 시 전문가 생략, 의장 AI 단독 판단 (속도 약 4배 향상)
|
||||||
|
llm_client: GeminiLLMClient 또는 OllamaManager (request_inference 인터페이스 공용)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, llm_client: Any = None):
|
||||||
|
self._ollama = llm_client # 내부 변수명 유지 (하위호환)
|
||||||
|
|
||||||
|
def _get_ollama(self) -> Any:
|
||||||
|
if self._ollama is None:
|
||||||
|
from modules.services.llm_client import get_llm_client
|
||||||
|
self._ollama = get_llm_client()
|
||||||
|
return self._ollama
|
||||||
|
|
||||||
|
def _ask_expert(self, expert: dict, ticker: str, data: dict) -> ExpertOpinion:
|
||||||
|
"""단일 전문가 의견 수집"""
|
||||||
|
prompt = _build_expert_prompt(expert, ticker, data)
|
||||||
|
raw = self._get_ollama().request_inference(prompt)
|
||||||
|
parsed = _parse_json_response(raw)
|
||||||
|
|
||||||
|
if parsed:
|
||||||
|
return ExpertOpinion(
|
||||||
|
expert_name=expert["name"],
|
||||||
|
role=expert["role"],
|
||||||
|
decision=str(parsed.get("decision", "HOLD")).upper(),
|
||||||
|
confidence=float(parsed.get("confidence", 0.5)),
|
||||||
|
reasoning=str(parsed.get("reasoning", "")),
|
||||||
|
key_concern=str(parsed.get("key_concern", "")),
|
||||||
|
model_feedback=str(parsed.get("model_feedback", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 파싱 실패 → 중립
|
||||||
|
print(f"[Council] {expert['name']} 응답 파싱 실패 → HOLD 처리")
|
||||||
|
return ExpertOpinion(
|
||||||
|
expert_name=expert["name"],
|
||||||
|
role=expert["role"],
|
||||||
|
decision="HOLD",
|
||||||
|
confidence=0.5,
|
||||||
|
reasoning="응답 파싱 실패",
|
||||||
|
key_concern="",
|
||||||
|
model_feedback="",
|
||||||
|
)
|
||||||
|
|
||||||
|
def convene(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
analysis_data: dict,
|
||||||
|
regime_analysis: Optional[RegimeAnalysis] = None,
|
||||||
|
fast_mode: bool = True,
|
||||||
|
) -> CouncilDecision:
|
||||||
|
"""
|
||||||
|
전문가 회의 소집 및 최종 결정
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 종목 코드
|
||||||
|
analysis_data: process.py 분석 결과 딕셔너리
|
||||||
|
regime_analysis: MarketRegimeDetector.detect() 결과
|
||||||
|
fast_mode: True=의장 AI 단독(빠름), False=전문가 4명+의장(심층)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CouncilDecision
|
||||||
|
"""
|
||||||
|
# 레짐 기본값
|
||||||
|
if regime_analysis is None:
|
||||||
|
kospi = analysis_data.get("kospi_price", 2500)
|
||||||
|
regime_analysis = MarketRegimeDetector.detect(kospi)
|
||||||
|
|
||||||
|
expert_opinions: List[ExpertOpinion] = []
|
||||||
|
|
||||||
|
if not fast_mode:
|
||||||
|
print(f"[Council] {ticker} - 전문가 회의 시작 (4명)")
|
||||||
|
for expert in _EXPERTS:
|
||||||
|
print(f"[Council] {expert['name']} 분석 중...")
|
||||||
|
opinion = self._ask_expert(expert, ticker, analysis_data)
|
||||||
|
expert_opinions.append(opinion)
|
||||||
|
time.sleep(0.3) # Ollama 연속 요청 간격
|
||||||
|
else:
|
||||||
|
print(f"[Council] {ticker} - Fast mode (의장 단독)")
|
||||||
|
|
||||||
|
# 의장 AI 취합
|
||||||
|
chairman_prompt = _build_chairman_prompt(
|
||||||
|
ticker, expert_opinions, analysis_data, regime_analysis
|
||||||
|
)
|
||||||
|
raw_chairman = self._get_ollama().request_inference(chairman_prompt)
|
||||||
|
parsed_chairman = _parse_json_response(raw_chairman)
|
||||||
|
|
||||||
|
if parsed_chairman:
|
||||||
|
decision = CouncilDecision(
|
||||||
|
final_decision=str(parsed_chairman.get("final_decision", "HOLD")).upper(),
|
||||||
|
consensus_score=float(parsed_chairman.get("consensus_score", 0.5)),
|
||||||
|
confidence=float(parsed_chairman.get("confidence", 0.5)),
|
||||||
|
majority_reasoning=str(parsed_chairman.get("majority_reasoning", "")),
|
||||||
|
dissenting_views=str(parsed_chairman.get("dissenting_views", "")),
|
||||||
|
model_health_score=float(parsed_chairman.get("model_health_score", 0.7)),
|
||||||
|
model_replacement_recommended=bool(
|
||||||
|
parsed_chairman.get("model_replacement_recommended", False)
|
||||||
|
),
|
||||||
|
recommended_model=str(
|
||||||
|
parsed_chairman.get("recommended_model", "현재 모델 유지")
|
||||||
|
),
|
||||||
|
council_summary=str(parsed_chairman.get("council_summary", "")),
|
||||||
|
expert_opinions=[
|
||||||
|
{
|
||||||
|
"name": op.expert_name,
|
||||||
|
"decision": op.decision,
|
||||||
|
"confidence": op.confidence,
|
||||||
|
"reasoning": op.reasoning,
|
||||||
|
}
|
||||||
|
for op in expert_opinions
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
status_icon = "⚠️" if decision.model_replacement_recommended else "✅"
|
||||||
|
print(
|
||||||
|
f"[Council] {ticker} → {decision.final_decision} "
|
||||||
|
f"(합의율: {decision.consensus_score:.0%}, "
|
||||||
|
f"모델건전성: {decision.model_health_score:.0%}) "
|
||||||
|
f"{status_icon}"
|
||||||
|
)
|
||||||
|
if decision.model_replacement_recommended:
|
||||||
|
print(f"[Council] 모델 교체 권고: {decision.recommended_model}")
|
||||||
|
|
||||||
|
return decision
|
||||||
|
|
||||||
|
# 의장 실패 → 투표 폴백
|
||||||
|
print(f"[Council] {ticker} - 의장 AI 실패, 투표 폴백 사용")
|
||||||
|
return _vote_fallback(expert_opinions)
|
||||||
|
|
||||||
|
def quick_validate(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
kospi_price: float,
|
||||||
|
ai_confidence: float,
|
||||||
|
backtest_sharpe: Optional[float] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
LLM 호출 없이 규칙 기반 빠른 모델 검증
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"regime": str,
|
||||||
|
"model_ok": bool,
|
||||||
|
"score": float,
|
||||||
|
"recommendation": str,
|
||||||
|
"should_replace": bool,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||||
|
validation = MarketRegimeDetector.validate_model_for_regime(
|
||||||
|
regime_analysis.regime,
|
||||||
|
backtest_sharpe=backtest_sharpe,
|
||||||
|
)
|
||||||
|
|
||||||
|
# AI 신뢰도 하락 시 추가 감점
|
||||||
|
score = validation["confidence_score"]
|
||||||
|
if ai_confidence < 0.4:
|
||||||
|
score *= 0.8
|
||||||
|
|
||||||
|
return {
|
||||||
|
"regime": regime_analysis.regime.value,
|
||||||
|
"regime_description": regime_analysis.description,
|
||||||
|
"model_ok": score >= 0.5 and not validation["should_replace"],
|
||||||
|
"score": round(score, 3),
|
||||||
|
"recommendation": validation["recommendation"],
|
||||||
|
"should_replace": validation["should_replace"],
|
||||||
|
"alternative_models": validation["alternative_models"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 전역 싱글톤
|
||||||
|
_council_instance: Optional[AICouncil] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_council(llm_client: Any = None) -> AICouncil:
|
||||||
|
"""워커 프로세스 내 AICouncil 싱글톤 반환 (GeminiLLMClient 또는 OllamaManager 수용)"""
|
||||||
|
global _council_instance
|
||||||
|
if _council_instance is None:
|
||||||
|
_council_instance = AICouncil(llm_client)
|
||||||
|
return _council_instance
|
||||||
@@ -4,6 +4,7 @@ import pickle
|
|||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from sklearn.preprocessing import MinMaxScaler
|
from sklearn.preprocessing import MinMaxScaler
|
||||||
|
|
||||||
@@ -164,15 +165,21 @@ def _build_feature_matrix(ohlcv_data):
|
|||||||
volume = np.array(ohlcv_data.get('volume', []), dtype=np.float64)
|
volume = np.array(ohlcv_data.get('volume', []), dtype=np.float64)
|
||||||
|
|
||||||
n = len(close)
|
n = len(close)
|
||||||
if len(open_) != n: open_ = close.copy()
|
_degraded = []
|
||||||
if len(high) != n: high = close.copy()
|
if len(open_) != n: open_ = close.copy(); _degraded.append('open')
|
||||||
if len(low) != n: low = close.copy()
|
if len(high) != n: high = close.copy(); _degraded.append('high')
|
||||||
|
if len(low) != n: low = close.copy(); _degraded.append('low')
|
||||||
|
if _degraded:
|
||||||
|
print(f"[LSTM] ⚠️ OHLCV 피처 불완전 ({', '.join(_degraded)} → close 대체). 예측 신뢰도 저하 가능")
|
||||||
|
|
||||||
# 거래량 정규화 (최대값 기준, 0이면 0)
|
# 거래량 정규화 (20일 이동평균 대비 비율, max 기준보다 정보량이 높음)
|
||||||
if len(volume) == n and volume.max() > 0:
|
if len(volume) == n and volume.max() > 0:
|
||||||
volume_norm = volume / (volume.max() + 1e-9)
|
vol_series = pd.Series(volume)
|
||||||
|
vol_ma20 = vol_series.rolling(20, min_periods=1).mean().values
|
||||||
|
volume_norm = volume / (vol_ma20 + 1e-9)
|
||||||
|
volume_norm = np.clip(volume_norm, 0.0, 5.0) / 5.0 # 0~5배 → 0~1 정규화
|
||||||
else:
|
else:
|
||||||
volume_norm = np.zeros(n)
|
volume_norm = np.full(n, 0.2) # 데이터 없으면 중립값
|
||||||
|
|
||||||
rsi = _compute_rsi(close, period=14)
|
rsi = _compute_rsi(close, period=14)
|
||||||
rsi_norm = rsi / 100.0 # 0~1 정규화
|
rsi_norm = rsi / 100.0 # 0~1 정규화
|
||||||
@@ -375,8 +382,10 @@ class PricePredictor:
|
|||||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||||
|
|
||||||
cached_loss = self.training_status.get("loss", 0.5)
|
cached_loss = self.training_status.get("loss", 0.5)
|
||||||
|
# 캐시 신뢰도: 마지막 학습 loss 기반 동적 계산 (고정값 제거)
|
||||||
|
cached_conf = min(0.70, 1.0 / (1.0 + (cached_loss * 200)))
|
||||||
print(f"[AI] {ticker or '?'}: 쿨다운 중 → 캐시 예측 사용 "
|
print(f"[AI] {ticker or '?'}: 쿨다운 중 → 캐시 예측 사용 "
|
||||||
f"({predicted_price:.0f} / {change_rate:+.2f}%)")
|
f"({predicted_price:.0f} / {change_rate:+.2f}% / conf={cached_conf:.2f})")
|
||||||
return {
|
return {
|
||||||
"current": current_price,
|
"current": current_price,
|
||||||
"predicted": float(predicted_price),
|
"predicted": float(predicted_price),
|
||||||
@@ -384,7 +393,7 @@ class PricePredictor:
|
|||||||
"trend": trend,
|
"trend": trend,
|
||||||
"loss": cached_loss,
|
"loss": cached_loss,
|
||||||
"val_loss": cached_loss,
|
"val_loss": cached_loss,
|
||||||
"confidence": 0.62,
|
"confidence": round(cached_conf, 2),
|
||||||
"epochs": 0,
|
"epochs": 0,
|
||||||
"device": str(self.device),
|
"device": str(self.device),
|
||||||
"lr": self.optimizer.param_groups[0]['lr'],
|
"lr": self.optimizer.param_groups[0]['lr'],
|
||||||
@@ -578,24 +587,28 @@ class PricePredictor:
|
|||||||
trend = "UP" if predicted_price > current_price else "DOWN"
|
trend = "UP" if predicted_price > current_price else "DOWN"
|
||||||
change_rate = ((predicted_price - current_price) / current_price) * 100
|
change_rate = ((predicted_price - current_price) / current_price) * 100
|
||||||
|
|
||||||
# 신뢰도 계산
|
# ── 신뢰도 계산 (보수적 버전) ──────────────────────────────
|
||||||
loss_confidence = 1.0 / (1.0 + (best_val_loss * 50))
|
# val_loss 기반: 0.001→0.74, 0.003→0.62, 0.01→0.50 (이전보다 보수적)
|
||||||
|
loss_confidence = 1.0 / (1.0 + (best_val_loss * 200))
|
||||||
|
|
||||||
|
# 오버피팅 페널티
|
||||||
overfit_ratio = final_loss / (best_val_loss + 1e-9)
|
overfit_ratio = final_loss / (best_val_loss + 1e-9)
|
||||||
if overfit_ratio < 0.5:
|
if overfit_ratio < 0.5:
|
||||||
overfit_penalty = 0.7
|
overfit_penalty = 0.65 # 심각한 언더피팅
|
||||||
elif overfit_ratio > 2.0:
|
elif overfit_ratio > 2.5:
|
||||||
overfit_penalty = 0.8
|
overfit_penalty = 0.75 # 오버피팅
|
||||||
else:
|
else:
|
||||||
overfit_penalty = 1.0
|
overfit_penalty = 1.0
|
||||||
|
|
||||||
|
# 에포크 수 기반 수렴 판단
|
||||||
epoch_factor = 1.0
|
epoch_factor = 1.0
|
||||||
if actual_epochs < 10:
|
if actual_epochs < 10:
|
||||||
epoch_factor = 0.6
|
epoch_factor = 0.55 # 너무 이른 수렴 → 불신뢰
|
||||||
elif actual_epochs >= max_epochs:
|
elif actual_epochs >= max_epochs:
|
||||||
epoch_factor = 0.8
|
epoch_factor = 0.80 # 미수렴 → 부분 신뢰
|
||||||
|
|
||||||
confidence = min(0.95, loss_confidence * overfit_penalty * epoch_factor)
|
# 최종 상한: 0.80 (이전 0.95보다 보수적 — LSTM 70% 가중치 남발 방지)
|
||||||
|
confidence = min(0.80, loss_confidence * overfit_penalty * epoch_factor)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"current": current_price,
|
"current": current_price,
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
"""
|
"""
|
||||||
앙상블 예측 모듈 (Phase 3-2)
|
앙상블 예측 모듈 (Phase 3-3)
|
||||||
- LSTM + 기술지표 + LLM 감성 → 적응형 가중치
|
- LSTM + 기술지표 + LLM 감성 → 적응형 가중치
|
||||||
- 과거 매매 결과 기반 가중치 자동 조정
|
- 과거 매매 결과 기반 가중치 자동 조정
|
||||||
|
- Kelly Criterion 기반 포지션 비중 계산
|
||||||
- process.py의 하드코딩된 w_tech/w_news/w_ai 대체
|
- process.py의 하드코딩된 w_tech/w_news/w_ai 대체
|
||||||
|
- 파일 mtime 기반 cross-process 동기화 (워커 ↔ 메인 프로세스)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Dict, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from modules.config import Config
|
from modules.config import Config
|
||||||
@@ -21,12 +24,61 @@ class SignalWeights:
|
|||||||
sentiment: float = 0.30
|
sentiment: float = 0.30
|
||||||
lstm: float = 0.35
|
lstm: float = 0.35
|
||||||
|
|
||||||
|
# 각 신호의 허용 범위
|
||||||
|
MIN_WEIGHT = 0.10
|
||||||
|
MAX_WEIGHT = 0.65
|
||||||
|
|
||||||
def normalize(self):
|
def normalize(self):
|
||||||
total = self.tech + self.sentiment + self.lstm
|
"""
|
||||||
if total > 0:
|
경계 보존 정규화 (합=1, MIN≤각값≤MAX 동시 보장)
|
||||||
self.tech /= total
|
|
||||||
self.sentiment /= total
|
단순 1/2차 정규화는 경계 위반을 반복 유발하므로
|
||||||
self.lstm /= total
|
반복 배분 알고리즘(Water-Filling) 사용:
|
||||||
|
1. 단순 정규화 (비율 유지)
|
||||||
|
2. 경계 위반 값 → 경계에 고정, 나머지에 잔여 비중 비례 배분
|
||||||
|
3. 모든 값이 경계 내에 들 때까지 반복 (최대 10회)
|
||||||
|
"""
|
||||||
|
MIN, MAX = self.MIN_WEIGHT, self.MAX_WEIGHT
|
||||||
|
vals = [max(MIN * 0.1, self.tech),
|
||||||
|
max(MIN * 0.1, self.sentiment),
|
||||||
|
max(MIN * 0.1, self.lstm)]
|
||||||
|
|
||||||
|
for _ in range(10):
|
||||||
|
total = sum(vals)
|
||||||
|
if total > 0:
|
||||||
|
vals = [v / total for v in vals]
|
||||||
|
|
||||||
|
fixed = [None, None, None]
|
||||||
|
has_violation = False
|
||||||
|
for i, v in enumerate(vals):
|
||||||
|
if v < MIN:
|
||||||
|
fixed[i] = MIN
|
||||||
|
has_violation = True
|
||||||
|
elif v > MAX:
|
||||||
|
fixed[i] = MAX
|
||||||
|
has_violation = True
|
||||||
|
|
||||||
|
if not has_violation:
|
||||||
|
break
|
||||||
|
|
||||||
|
fixed_sum = sum(f for f in fixed if f is not None)
|
||||||
|
remaining = 1.0 - fixed_sum
|
||||||
|
free = [(i, vals[i]) for i, f in enumerate(fixed) if f is None]
|
||||||
|
free_sum = sum(v for _, v in free)
|
||||||
|
|
||||||
|
new_vals = list(fixed)
|
||||||
|
if free and free_sum > 0:
|
||||||
|
factor = remaining / free_sum
|
||||||
|
for i, v in free:
|
||||||
|
new_vals[i] = v * factor
|
||||||
|
elif free:
|
||||||
|
per = remaining / len(free)
|
||||||
|
for i, _ in free:
|
||||||
|
new_vals[i] = per
|
||||||
|
|
||||||
|
vals = [v if v is not None else 0.0 for v in new_vals]
|
||||||
|
|
||||||
|
self.tech, self.sentiment, self.lstm = vals
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -45,9 +97,11 @@ class AdaptiveEnsemble:
|
|||||||
|
|
||||||
핵심 로직:
|
핵심 로직:
|
||||||
1. 종목별 최근 N 매매의 결과를 추적
|
1. 종목별 최근 N 매매의 결과를 추적
|
||||||
2. 어떤 신호가 정확했는지 소급 평가
|
2. 어떤 신호가 정확했는지 소급 평가 (크기 가중 정확도)
|
||||||
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
|
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
|
||||||
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
|
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
|
||||||
|
5. Kelly Criterion 기반 최적 포지션 비중 제공
|
||||||
|
6. 파일 mtime 기반 cross-process 동기화 (워커 프로세스 갱신)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, history_file=None, max_history=50):
|
def __init__(self, history_file=None, max_history=50):
|
||||||
@@ -55,17 +109,23 @@ class AdaptiveEnsemble:
|
|||||||
self.history_file = history_file or os.path.join(
|
self.history_file = history_file or os.path.join(
|
||||||
Config.DATA_DIR, "ensemble_history.json"
|
Config.DATA_DIR, "ensemble_history.json"
|
||||||
)
|
)
|
||||||
# {ticker: [{"tech": f, "sentiment": f, "lstm": f, "decision": str, "outcome": float}, ...]}
|
# {ticker: [{"tech_score": f, "sentiment_score": f, "lstm_score": f,
|
||||||
|
# "decision": str, "outcome": float}, ...]}
|
||||||
self._trade_history: Dict[str, list] = {}
|
self._trade_history: Dict[str, list] = {}
|
||||||
# {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger"
|
# {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger" | "default"
|
||||||
self._context_weights: Dict[str, SignalWeights] = {
|
self._context_weights: Dict[str, SignalWeights] = {
|
||||||
"strong_trend": SignalWeights(tech=0.50, sentiment=0.20, lstm=0.30),
|
"strong_trend": SignalWeights(tech=0.50, sentiment=0.20, lstm=0.30),
|
||||||
"sideways": SignalWeights(tech=0.30, sentiment=0.40, lstm=0.30),
|
"sideways": SignalWeights(tech=0.30, sentiment=0.40, lstm=0.30),
|
||||||
"danger": SignalWeights(tech=0.20, sentiment=0.50, lstm=0.30),
|
"danger": SignalWeights(tech=0.20, sentiment=0.50, lstm=0.30),
|
||||||
"default": SignalWeights(tech=0.35, sentiment=0.30, lstm=0.35),
|
"default": SignalWeights(tech=0.35, sentiment=0.30, lstm=0.35),
|
||||||
}
|
}
|
||||||
|
self._load_mtime: float = 0.0 # 마지막 파일 로드 시각
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 파일 I/O
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _load(self):
|
def _load(self):
|
||||||
if os.path.exists(self.history_file):
|
if os.path.exists(self.history_file):
|
||||||
try:
|
try:
|
||||||
@@ -75,6 +135,7 @@ class AdaptiveEnsemble:
|
|||||||
weights_raw = data.get("weights", {})
|
weights_raw = data.get("weights", {})
|
||||||
for ctx, w in weights_raw.items():
|
for ctx, w in weights_raw.items():
|
||||||
self._context_weights[ctx] = SignalWeights.from_dict(w)
|
self._context_weights[ctx] = SignalWeights.from_dict(w)
|
||||||
|
self._load_mtime = os.path.getmtime(self.history_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Ensemble] Load failed: {e}")
|
print(f"[Ensemble] Load failed: {e}")
|
||||||
|
|
||||||
@@ -86,9 +147,29 @@ class AdaptiveEnsemble:
|
|||||||
}
|
}
|
||||||
with open(self.history_file, "w", encoding="utf-8") as f:
|
with open(self.history_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||||
|
self._load_mtime = os.path.getmtime(self.history_file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Ensemble] Save failed: {e}")
|
print(f"[Ensemble] Save failed: {e}")
|
||||||
|
|
||||||
|
def reload_if_stale(self):
|
||||||
|
"""
|
||||||
|
파일이 마지막 로드 이후 수정되었으면 재로드.
|
||||||
|
워커 프로세스가 메인 프로세스의 record_trade 결과를 반영하기 위해 사용.
|
||||||
|
"""
|
||||||
|
if not os.path.exists(self.history_file):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(self.history_file)
|
||||||
|
if mtime > self._load_mtime:
|
||||||
|
self._load()
|
||||||
|
print("[Ensemble] 파일 변경 감지, 가중치 재로드")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 컨텍스트 & 가중치
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
def get_context(self, adx: float, macro_state: str) -> str:
|
def get_context(self, adx: float, macro_state: str) -> str:
|
||||||
"""현재 시장 컨텍스트 결정"""
|
"""현재 시장 컨텍스트 결정"""
|
||||||
if macro_state == "DANGER":
|
if macro_state == "DANGER":
|
||||||
@@ -105,85 +186,45 @@ class AdaptiveEnsemble:
|
|||||||
"""
|
"""
|
||||||
종목 + 시장 컨텍스트에 맞는 가중치 반환
|
종목 + 시장 컨텍스트에 맞는 가중치 반환
|
||||||
|
|
||||||
1. 기본: 컨텍스트별 기준 가중치
|
1. 컨텍스트별 기준 가중치 선택
|
||||||
2. AI 신뢰도 높으면 lstm 가중치 보정
|
2. AI 신뢰도 높으면 lstm 가중치 보정
|
||||||
3. 종목별 학습 결과 반영
|
3. 종목별 학습 결과 반영 (크기 가중 정확도 사용)
|
||||||
"""
|
"""
|
||||||
context = self.get_context(adx, macro_state)
|
context = self.get_context(adx, macro_state)
|
||||||
base = self._context_weights.get(context, self._context_weights["default"])
|
base = self._context_weights.get(context, self._context_weights["default"])
|
||||||
|
|
||||||
# 적응형 조정: 해당 종목의 과거 성과 반영
|
|
||||||
ticker_history = self._trade_history.get(ticker, [])
|
ticker_history = self._trade_history.get(ticker, [])
|
||||||
adjusted = SignalWeights(tech=base.tech, sentiment=base.sentiment, lstm=base.lstm)
|
adjusted = SignalWeights(tech=base.tech, sentiment=base.sentiment, lstm=base.lstm)
|
||||||
|
|
||||||
if len(ticker_history) >= 5:
|
if len(ticker_history) >= 5:
|
||||||
# 최근 5회 신호별 정확도 평가
|
|
||||||
recent = ticker_history[-10:]
|
recent = ticker_history[-10:]
|
||||||
tech_acc = self._accuracy([h["tech_score"] for h in recent],
|
# _accuracy_weighted: 방향 일치 + 수익 크기 가중 반영 (단순 binary X)
|
||||||
[h["outcome"] for h in recent])
|
tech_acc = self._accuracy_weighted(
|
||||||
news_acc = self._accuracy([h["sentiment_score"] for h in recent],
|
[h.get("tech_score", 0.5) for h in recent],
|
||||||
[h["outcome"] for h in recent])
|
[h["outcome"] for h in recent])
|
||||||
lstm_acc = self._accuracy([h["lstm_score"] for h in recent],
|
news_acc = self._accuracy_weighted(
|
||||||
[h["outcome"] for h in recent])
|
[h.get("sentiment_score", 0.5) for h in recent],
|
||||||
|
[h["outcome"] for h in recent])
|
||||||
|
lstm_acc = self._accuracy_weighted(
|
||||||
|
[h.get("lstm_score", 0.5) for h in recent],
|
||||||
|
[h["outcome"] for h in recent])
|
||||||
|
|
||||||
# 정확도 기반 가중치 미세 조정 (±0.1 범위)
|
alpha = 0.05 # 미세 조정폭 (±0.1 범위)
|
||||||
alpha = 0.05
|
adjusted.tech = max(0.10, min(0.60, base.tech + alpha * (tech_acc - 0.5)))
|
||||||
adjusted.tech = max(0.1, min(0.6, base.tech + alpha * (tech_acc - 0.5)))
|
adjusted.sentiment = max(0.10, min(0.60, base.sentiment + alpha * (news_acc - 0.5)))
|
||||||
adjusted.sentiment = max(0.1, min(0.6, base.sentiment + alpha * (news_acc - 0.5)))
|
adjusted.lstm = max(0.10, min(0.60, base.lstm + alpha * (lstm_acc - 0.5)))
|
||||||
adjusted.lstm = max(0.1, min(0.6, base.lstm + alpha * (lstm_acc - 0.5)))
|
|
||||||
|
|
||||||
# AI 신뢰도 보정
|
# AI 신뢰도 보정 (LSTM confidence 상한 0.80 기준 조정)
|
||||||
if ai_confidence >= 0.85:
|
if ai_confidence >= 0.75:
|
||||||
adjusted.lstm = min(0.70, adjusted.lstm * 1.3)
|
adjusted.lstm = min(0.65, adjusted.lstm * 1.25)
|
||||||
elif ai_confidence < 0.5:
|
elif ai_confidence < 0.5:
|
||||||
adjusted.lstm = max(0.10, adjusted.lstm * 0.7)
|
adjusted.lstm = max(0.10, adjusted.lstm * 0.75)
|
||||||
|
|
||||||
return adjusted.normalize()
|
return adjusted.normalize()
|
||||||
|
|
||||||
def record_trade(self, ticker: str, tech_score: float, sentiment_score: float,
|
# ──────────────────────────────────────────────
|
||||||
lstm_score: float, decision: str, outcome_pct: float):
|
# 앙상블 점수
|
||||||
"""
|
# ──────────────────────────────────────────────
|
||||||
매매 결과 기록 (가중치 학습 데이터)
|
|
||||||
|
|
||||||
outcome_pct: 실현 수익률 (%). 양수=이익, 음수=손실
|
|
||||||
"""
|
|
||||||
if ticker not in self._trade_history:
|
|
||||||
self._trade_history[ticker] = []
|
|
||||||
|
|
||||||
record = {
|
|
||||||
"tech_score": tech_score,
|
|
||||||
"sentiment_score": sentiment_score,
|
|
||||||
"lstm_score": lstm_score,
|
|
||||||
"decision": decision,
|
|
||||||
"outcome": outcome_pct
|
|
||||||
}
|
|
||||||
self._trade_history[ticker].append(record)
|
|
||||||
# 히스토리 크기 제한
|
|
||||||
if len(self._trade_history[ticker]) > self.max_history:
|
|
||||||
self._trade_history[ticker] = self._trade_history[ticker][-self.max_history:]
|
|
||||||
|
|
||||||
# 가중치 점진적 업데이트
|
|
||||||
self._update_weights(ticker)
|
|
||||||
self._save()
|
|
||||||
|
|
||||||
def _update_weights(self, ticker: str):
|
|
||||||
"""종목별 성과를 반영해 컨텍스트 가중치 점진적 업데이트"""
|
|
||||||
history = self._trade_history.get(ticker, [])
|
|
||||||
if len(history) < 5:
|
|
||||||
return
|
|
||||||
|
|
||||||
recent = history[-10:]
|
|
||||||
outcomes = [h["outcome"] for h in recent]
|
|
||||||
mean_outcome = np.mean(outcomes)
|
|
||||||
|
|
||||||
if mean_outcome > 0:
|
|
||||||
# 전략이 효과적 → 현재 가중치 유지 (강화)
|
|
||||||
pass
|
|
||||||
elif mean_outcome < -2.0:
|
|
||||||
# 손실이 큰 경우 → 기본값으로 리셋
|
|
||||||
for ctx in self._context_weights:
|
|
||||||
self._context_weights[ctx] = SignalWeights(
|
|
||||||
tech=0.35, sentiment=0.30, lstm=0.35)
|
|
||||||
|
|
||||||
def compute_ensemble_score(self, tech_score: float, sentiment_score: float,
|
def compute_ensemble_score(self, tech_score: float, sentiment_score: float,
|
||||||
lstm_score: float, investor_score: float = 0.0,
|
lstm_score: float, investor_score: float = 0.0,
|
||||||
@@ -205,25 +246,170 @@ class AdaptiveEnsemble:
|
|||||||
total += min(investor_score, 0.15)
|
total += min(investor_score, 0.15)
|
||||||
return min(1.0, max(0.0, total))
|
return min(1.0, max(0.0, total))
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# Kelly Criterion
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_kelly_fraction(self, ticker: str = None, half_kelly: bool = True) -> float:
|
||||||
|
"""
|
||||||
|
Modified Kelly Criterion 기반 최적 투자 비중 계산
|
||||||
|
|
||||||
|
f* = (p * b - q) / b
|
||||||
|
where:
|
||||||
|
p = 과거 승리 거래 비율 (win rate)
|
||||||
|
q = 1 - p
|
||||||
|
b = 평균이익 / 평균손실 비율 (avg profit / avg loss, Risk-Reward)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
0.03 ~ 0.25 범위의 Kelly 분수
|
||||||
|
- half_kelly=True: 변동성 과대추정 보완을 위해 1/2 적용
|
||||||
|
- 거래 데이터 < 10건: 보수적 기본값 0.08 반환
|
||||||
|
"""
|
||||||
|
# 해당 종목 우선, 없으면 전체 통합 히스토리 사용
|
||||||
|
if ticker and ticker in self._trade_history:
|
||||||
|
outcomes = [h["outcome"] for h in self._trade_history[ticker]
|
||||||
|
if h.get("outcome") is not None]
|
||||||
|
else:
|
||||||
|
# 전체 종목 결과 통합 (시장 전반 win rate)
|
||||||
|
outcomes = [
|
||||||
|
h["outcome"]
|
||||||
|
for records in self._trade_history.values()
|
||||||
|
for h in records
|
||||||
|
if h.get("outcome") is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(outcomes) < 10:
|
||||||
|
return 0.08 # 데이터 부족 → 보수적 8%
|
||||||
|
|
||||||
|
wins = [o for o in outcomes if o > 0]
|
||||||
|
losses = [abs(o) for o in outcomes if o <= 0]
|
||||||
|
|
||||||
|
if not wins:
|
||||||
|
return 0.03 # 승리 거래 없음 → 최소 비중
|
||||||
|
if not losses:
|
||||||
|
return 0.20 # 손실 거래 없음 → 낙관적이나 상한 제한
|
||||||
|
|
||||||
|
p = len(wins) / len(outcomes)
|
||||||
|
q = 1.0 - p
|
||||||
|
avg_win = sum(wins) / len(wins)
|
||||||
|
avg_loss = sum(losses) / len(losses)
|
||||||
|
|
||||||
|
if avg_loss == 0:
|
||||||
|
return 0.20
|
||||||
|
|
||||||
|
b = avg_win / avg_loss # Risk-Reward ratio
|
||||||
|
kelly = (p * b - q) / b
|
||||||
|
|
||||||
|
if half_kelly:
|
||||||
|
kelly /= 2.0 # Half-Kelly: 실제 활용 시 표준
|
||||||
|
|
||||||
|
result = max(0.03, min(0.25, kelly)) # 3% ~ 25% 범위 제한
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 거래 결과 기록 & 가중치 학습
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def record_trade(self, ticker: str, tech_score: float, sentiment_score: float,
|
||||||
|
lstm_score: float, decision: str, outcome_pct: float):
|
||||||
|
"""
|
||||||
|
매매 결과 기록 → 가중치 학습 데이터 축적
|
||||||
|
|
||||||
|
Args:
|
||||||
|
outcome_pct: 실현 수익률 (%). 양수=이익, 음수=손실
|
||||||
|
"""
|
||||||
|
if ticker not in self._trade_history:
|
||||||
|
self._trade_history[ticker] = []
|
||||||
|
|
||||||
|
record = {
|
||||||
|
"tech_score": tech_score,
|
||||||
|
"sentiment_score": sentiment_score,
|
||||||
|
"lstm_score": lstm_score,
|
||||||
|
"decision": decision,
|
||||||
|
"outcome": outcome_pct
|
||||||
|
}
|
||||||
|
self._trade_history[ticker].append(record)
|
||||||
|
if len(self._trade_history[ticker]) > self.max_history:
|
||||||
|
self._trade_history[ticker] = self._trade_history[ticker][-self.max_history:]
|
||||||
|
|
||||||
|
self._update_weights(ticker)
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _update_weights(self, ticker: str):
|
||||||
|
"""
|
||||||
|
종목별 성과를 반영해 컨텍스트 가중치 점진적 업데이트.
|
||||||
|
|
||||||
|
- 크기 가중 정확도(accuracy_weighted) 사용 → 큰 손실에 강한 패널티
|
||||||
|
- 지수이동평균(alpha=0.10)으로 점진 반영 → 급격한 가중치 전환 방지
|
||||||
|
- normalize() 후 재경계 적용 → 경계값 위반 방지
|
||||||
|
"""
|
||||||
|
history = self._trade_history.get(ticker, [])
|
||||||
|
if len(history) < 5:
|
||||||
|
return
|
||||||
|
|
||||||
|
recent = history[-10:]
|
||||||
|
outcomes = [h["outcome"] for h in recent]
|
||||||
|
|
||||||
|
tech_acc = self._accuracy_weighted(
|
||||||
|
[h.get("tech_score", 0.5) for h in recent], outcomes)
|
||||||
|
news_acc = self._accuracy_weighted(
|
||||||
|
[h.get("sentiment_score", 0.5) for h in recent], outcomes)
|
||||||
|
lstm_acc = self._accuracy_weighted(
|
||||||
|
[h.get("lstm_score", 0.5) for h in recent], outcomes)
|
||||||
|
|
||||||
|
alpha = 0.10 # EMA 계수 (10회 거래 후 완전 반영)
|
||||||
|
|
||||||
|
for ctx, w in self._context_weights.items():
|
||||||
|
delta_tech = alpha * (tech_acc - 0.5) * 0.4 # 최대 ±0.02
|
||||||
|
delta_news = alpha * (news_acc - 0.5) * 0.4
|
||||||
|
delta_lstm = alpha * (lstm_acc - 0.5) * 0.4
|
||||||
|
|
||||||
|
# 경계 적용 → normalize (경계 재반영) → normalize (합=1 보장)
|
||||||
|
w.tech = max(0.10, min(0.65, w.tech + delta_tech))
|
||||||
|
w.sentiment = max(0.10, min(0.65, w.sentiment + delta_news))
|
||||||
|
w.lstm = max(0.10, min(0.65, w.lstm + delta_lstm))
|
||||||
|
w.normalize() # normalize() 내부에서 경계 재클램핑 + 2차 정규화 수행
|
||||||
|
|
||||||
|
print(f"[Ensemble] {ctx} tech={w.tech:.2f} news={w.sentiment:.2f} lstm={w.lstm:.2f} "
|
||||||
|
f"(acc T={tech_acc:.2f} N={news_acc:.2f} L={lstm_acc:.2f})")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 정확도 지표
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _accuracy(scores: list, outcomes: list) -> float:
|
def _accuracy_weighted(scores: list, outcomes: list) -> float:
|
||||||
"""신호와 결과의 상관도 계산 (0.5 = 무관, 1.0 = 완전 일치)"""
|
"""
|
||||||
|
신호-결과 크기 가중 정확도 (0.0~1.0, 0.5=무관)
|
||||||
|
|
||||||
|
- 단순 방향 일치(0/1)가 아닌 수익률 절댓값으로 가중
|
||||||
|
- 큰 손실 예측 실패는 작은 이익 예측 성공보다 강하게 패널티
|
||||||
|
"""
|
||||||
if len(scores) < 3:
|
if len(scores) < 3:
|
||||||
return 0.5
|
return 0.5
|
||||||
# 신호가 높을 때 수익, 낮을 때 손실이면 정확
|
|
||||||
correct = sum(
|
total_weight = 0.0
|
||||||
1 for s, o in zip(scores, outcomes)
|
weighted_correct = 0.0
|
||||||
if (s >= 0.5 and o > 0) or (s < 0.5 and o <= 0)
|
|
||||||
)
|
for s, o in zip(scores, outcomes):
|
||||||
return correct / len(scores)
|
weight = max(1.0, abs(o)) # 수익률 절댓값 기반 가중치 (최소 1.0)
|
||||||
|
total_weight += weight
|
||||||
|
if (s >= 0.5 and o > 0) or (s < 0.5 and o <= 0):
|
||||||
|
weighted_correct += weight
|
||||||
|
|
||||||
|
if total_weight == 0:
|
||||||
|
return 0.5
|
||||||
|
return weighted_correct / total_weight
|
||||||
|
|
||||||
|
|
||||||
# 전역 싱글톤
|
# ──────────────────────────────────────────────
|
||||||
|
# 전역 싱글톤 (프로세스별)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
_ensemble_instance: Optional[AdaptiveEnsemble] = None
|
_ensemble_instance: Optional[AdaptiveEnsemble] = None
|
||||||
|
|
||||||
|
|
||||||
def get_ensemble() -> AdaptiveEnsemble:
|
def get_ensemble() -> AdaptiveEnsemble:
|
||||||
"""워커 프로세스 내 싱글톤 앙상블 관리자"""
|
"""프로세스 내 싱글톤 앙상블 관리자 반환 (워커/메인 각각 독립 인스턴스)"""
|
||||||
global _ensemble_instance
|
global _ensemble_instance
|
||||||
if _ensemble_instance is None:
|
if _ensemble_instance is None:
|
||||||
_ensemble_instance = AdaptiveEnsemble()
|
_ensemble_instance = AdaptiveEnsemble()
|
||||||
|
|||||||
279
modules/analysis/market_regime.py
Normal file
279
modules/analysis/market_regime.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
"""
|
||||||
|
시장 레짐 감지 모듈
|
||||||
|
- 코스피 지수 수준에 따른 시장 레짐 분류
|
||||||
|
- 코스피 6300 목표 수준에서의 모델 적합성 평가
|
||||||
|
- 레짐별 전략 파라미터 자동 조정
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRegime(Enum):
|
||||||
|
BULL_EXTREME = "bull_extreme" # 코스피 5000+ (역사적 극고점, 6300 시나리오)
|
||||||
|
BULL_STRONG = "bull_strong" # 코스피 3500~5000 (강한 상승장)
|
||||||
|
BULL_NORMAL = "bull_normal" # 코스피 2500~3500 (정상 상승장)
|
||||||
|
SIDEWAYS = "sideways" # 코스피 2000~2500 (횡보)
|
||||||
|
BEAR_MILD = "bear_mild" # 코스피 1500~2000 (약세)
|
||||||
|
BEAR_SEVERE = "bear_severe" # 코스피 1500 미만 (심각한 약세)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RegimeAnalysis:
|
||||||
|
"""레짐 분석 결과"""
|
||||||
|
regime: MarketRegime
|
||||||
|
kospi_level: float
|
||||||
|
description: str
|
||||||
|
recommended_strategy: str
|
||||||
|
buy_threshold_adj: float # 매수 임계값 조정치 (+: 더 엄격, -: 완화)
|
||||||
|
position_size_adj: float # 포지션 크기 조정 배수 (1.0 = 기본)
|
||||||
|
lstm_weight_adj: float # LSTM 앙상블 가중치 조정 (+0.1 = 10% 증가)
|
||||||
|
model_recommendation: str # 모델 유지/교체 권고
|
||||||
|
risk_level: str # LOW / MEDIUM / HIGH / EXTREME
|
||||||
|
|
||||||
|
|
||||||
|
class MarketRegimeDetector:
|
||||||
|
"""
|
||||||
|
코스피 지수 수준 기반 시장 레짐 감지기
|
||||||
|
|
||||||
|
코스피 6300 시나리오:
|
||||||
|
- 현재 한국 증시 역대 최고점(2021년 3300) 대비 약 2배 수준
|
||||||
|
- BULL_EXTREME 레짐에 해당 → LSTM 단독 의존 지양, Transformer/Mamba 검토 필요
|
||||||
|
- 추세 추종 강화 + 고점 리스크 관리 병행
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 레짐별 상세 파라미터
|
||||||
|
_REGIME_PARAMS: Dict[MarketRegime, dict] = {
|
||||||
|
MarketRegime.BULL_EXTREME: {
|
||||||
|
"description": "코스피 극강세장 5000+ (6300 시나리오)",
|
||||||
|
"recommended_strategy": (
|
||||||
|
"추세 추종 극대화, 트레일링 스탑 확대(ATR×4), "
|
||||||
|
"고점 과열 구간으로 포지션 축소 병행"
|
||||||
|
),
|
||||||
|
"buy_threshold_adj": -0.04, # 강세 모멘텀 → 진입 소폭 완화
|
||||||
|
"position_size_adj": 0.75, # 고점 리스크로 포지션 축소
|
||||||
|
"lstm_weight_adj": -0.12, # LSTM 비중 축소 (비선형 가격 동작)
|
||||||
|
"model_recommendation": (
|
||||||
|
"Temporal Fusion Transformer(TFT) 또는 Mamba(SSM) 교체 권고 - "
|
||||||
|
"LSTM은 극강세 과열 구간에서 비선형 가격 동작 포착 한계"
|
||||||
|
),
|
||||||
|
"risk_level": "EXTREME",
|
||||||
|
},
|
||||||
|
MarketRegime.BULL_STRONG: {
|
||||||
|
"description": "코스피 강상승장 3500~5000",
|
||||||
|
"recommended_strategy": "추세 추종, 모멘텀 강화, 손절 완화(ATR×2.5)",
|
||||||
|
"buy_threshold_adj": -0.03,
|
||||||
|
"position_size_adj": 1.1,
|
||||||
|
"lstm_weight_adj": 0.05,
|
||||||
|
"model_recommendation": "현재 LSTM v3 적합 - 성능 모니터링 유지",
|
||||||
|
"risk_level": "MEDIUM",
|
||||||
|
},
|
||||||
|
MarketRegime.BULL_NORMAL: {
|
||||||
|
"description": "코스피 정상 상승장 2500~3500",
|
||||||
|
"recommended_strategy": "기본 전략 유지 (기술+LSTM+LLM 균형)",
|
||||||
|
"buy_threshold_adj": 0.0,
|
||||||
|
"position_size_adj": 1.0,
|
||||||
|
"lstm_weight_adj": 0.0,
|
||||||
|
"model_recommendation": "현재 LSTM v3 최적 환경",
|
||||||
|
"risk_level": "LOW",
|
||||||
|
},
|
||||||
|
MarketRegime.SIDEWAYS: {
|
||||||
|
"description": "코스피 횡보장 2000~2500",
|
||||||
|
"recommended_strategy": "박스권 매매, LLM 감성 비중 확대, 빠른 익절",
|
||||||
|
"buy_threshold_adj": 0.03,
|
||||||
|
"position_size_adj": 0.85,
|
||||||
|
"lstm_weight_adj": -0.05,
|
||||||
|
"model_recommendation": "현재 LSTM v3 적합 - 감성 분석 가중치 강화",
|
||||||
|
"risk_level": "LOW",
|
||||||
|
},
|
||||||
|
MarketRegime.BEAR_MILD: {
|
||||||
|
"description": "코스피 약세장 1500~2000",
|
||||||
|
"recommended_strategy": "현금 비중 확대(50%+), 방어주 선별 매수",
|
||||||
|
"buy_threshold_adj": 0.08,
|
||||||
|
"position_size_adj": 0.5,
|
||||||
|
"lstm_weight_adj": 0.0,
|
||||||
|
"model_recommendation": "현재 LSTM v3 적합 - 리스크 관리 파라미터 강화",
|
||||||
|
"risk_level": "HIGH",
|
||||||
|
},
|
||||||
|
MarketRegime.BEAR_SEVERE: {
|
||||||
|
"description": "코스피 극약세장 1500 미만",
|
||||||
|
"recommended_strategy": "전면 현금화, 매수 중단",
|
||||||
|
"buy_threshold_adj": 0.20,
|
||||||
|
"position_size_adj": 0.2,
|
||||||
|
"lstm_weight_adj": 0.0,
|
||||||
|
"model_recommendation": "매크로 팩터 기반 방어 모델 전환 필요",
|
||||||
|
"risk_level": "EXTREME",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def detect(
|
||||||
|
cls,
|
||||||
|
kospi_price: float,
|
||||||
|
kospi_change_pct: float = 0.0,
|
||||||
|
volatility_20d: float = 0.0,
|
||||||
|
) -> RegimeAnalysis:
|
||||||
|
"""
|
||||||
|
코스피 지수 수준 + 변동성으로 시장 레짐 감지
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kospi_price: 현재 코스피 지수 (예: 2600, 6300)
|
||||||
|
kospi_change_pct: 전일 대비 등락률 (%)
|
||||||
|
volatility_20d: 20일 변동성 (선택, 0이면 무시)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RegimeAnalysis: 레짐 분석 결과 및 전략 파라미터
|
||||||
|
"""
|
||||||
|
# 1. 지수 수준으로 기본 레짐 결정
|
||||||
|
if kospi_price >= 5000:
|
||||||
|
regime = MarketRegime.BULL_EXTREME
|
||||||
|
elif kospi_price >= 3500:
|
||||||
|
regime = MarketRegime.BULL_STRONG
|
||||||
|
elif kospi_price >= 2500:
|
||||||
|
regime = MarketRegime.BULL_NORMAL
|
||||||
|
elif kospi_price >= 2000:
|
||||||
|
regime = MarketRegime.SIDEWAYS
|
||||||
|
elif kospi_price >= 1500:
|
||||||
|
regime = MarketRegime.BEAR_MILD
|
||||||
|
else:
|
||||||
|
regime = MarketRegime.BEAR_SEVERE
|
||||||
|
|
||||||
|
params = cls._REGIME_PARAMS[regime]
|
||||||
|
|
||||||
|
# 2. 변동성 기반 포지션 사이징 추가 조정
|
||||||
|
position_adj = params["position_size_adj"]
|
||||||
|
if volatility_20d > 30:
|
||||||
|
position_adj *= 0.6 # 극단적 변동성 → 추가 50% 축소
|
||||||
|
elif volatility_20d > 20:
|
||||||
|
position_adj *= 0.8 # 높은 변동성 → 20% 축소
|
||||||
|
|
||||||
|
# 3. 급락 중 레짐 하향 조정 (패닉 감지)
|
||||||
|
if kospi_change_pct <= -3.0:
|
||||||
|
# 극단적 일일 급락 → 포지션 추가 축소
|
||||||
|
position_adj *= 0.5
|
||||||
|
print(f"[Regime] PANIC DETECTED (일일 {kospi_change_pct:.1f}%) → 포지션 50% 추가 축소")
|
||||||
|
|
||||||
|
return RegimeAnalysis(
|
||||||
|
regime=regime,
|
||||||
|
kospi_level=kospi_price,
|
||||||
|
description=params["description"],
|
||||||
|
recommended_strategy=params["recommended_strategy"],
|
||||||
|
buy_threshold_adj=params["buy_threshold_adj"],
|
||||||
|
position_size_adj=round(position_adj, 3),
|
||||||
|
lstm_weight_adj=params["lstm_weight_adj"],
|
||||||
|
model_recommendation=params["model_recommendation"],
|
||||||
|
risk_level=params["risk_level"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def validate_model_for_regime(
|
||||||
|
cls,
|
||||||
|
regime: MarketRegime,
|
||||||
|
backtest_sharpe: Optional[float] = None,
|
||||||
|
backtest_winrate: Optional[float] = None,
|
||||||
|
backtest_mdd: Optional[float] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
현재 LSTM v3 모델이 해당 레짐에서 적합한지 검증
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
{
|
||||||
|
"is_suitable": bool,
|
||||||
|
"confidence_score": float (0~1),
|
||||||
|
"recommendation": str,
|
||||||
|
"should_replace": bool,
|
||||||
|
"alternative_models": list[str],
|
||||||
|
"reason": str,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
"is_suitable": True,
|
||||||
|
"confidence_score": 0.75,
|
||||||
|
"recommendation": "현재 LSTM v3 모델 유지",
|
||||||
|
"should_replace": False,
|
||||||
|
"alternative_models": [],
|
||||||
|
"reason": "정상 상승장 구간 - LSTM v3 최적 환경",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 레짐 기반 기본 평가
|
||||||
|
if regime == MarketRegime.BULL_EXTREME:
|
||||||
|
result.update({
|
||||||
|
"is_suitable": False,
|
||||||
|
"confidence_score": 0.38,
|
||||||
|
"recommendation": "Transformer 계열 모델 교체 강력 권고",
|
||||||
|
"should_replace": True,
|
||||||
|
"alternative_models": [
|
||||||
|
"Temporal Fusion Transformer (TFT) - 장기 시계열 최강",
|
||||||
|
"Mamba (SSM) - 초고속 추론 + 긴 컨텍스트",
|
||||||
|
"PatchTST - Transformer 기반 주가 예측 특화",
|
||||||
|
"TimesNet - 2D 시계열 변환 + CNN",
|
||||||
|
"N-BEATS / N-HiTS - 해석 가능 딥러닝",
|
||||||
|
],
|
||||||
|
"reason": (
|
||||||
|
"코스피 5000+ 극강세장에서 LSTM은 비선형적 가격 급등 패턴을 "
|
||||||
|
"충분히 학습하지 못함. Attention 메커니즘만으로는 장기 상승 추세의 "
|
||||||
|
"복잡한 의존성 포착에 한계 존재."
|
||||||
|
),
|
||||||
|
})
|
||||||
|
elif regime == MarketRegime.BEAR_SEVERE:
|
||||||
|
result.update({
|
||||||
|
"is_suitable": False,
|
||||||
|
"confidence_score": 0.30,
|
||||||
|
"recommendation": "매크로 팩터 + Regime-Switching 모델 교체 권고",
|
||||||
|
"should_replace": True,
|
||||||
|
"alternative_models": [
|
||||||
|
"Regime-Switching LSTM (HMM + LSTM)",
|
||||||
|
"매크로 멀티팩터 모델 (환율, 금리, VIX 통합)",
|
||||||
|
"GRU + Attention (LSTM 경량 대안)",
|
||||||
|
],
|
||||||
|
"reason": "극약세장에서는 기술적 지표보다 거시경제 팩터가 지배적",
|
||||||
|
})
|
||||||
|
elif regime == MarketRegime.BULL_STRONG:
|
||||||
|
result.update({
|
||||||
|
"confidence_score": 0.72,
|
||||||
|
"reason": "강상승장 - LSTM 추세 학습 양호하나 성능 모니터링 필요",
|
||||||
|
})
|
||||||
|
elif regime == MarketRegime.SIDEWAYS:
|
||||||
|
result.update({
|
||||||
|
"confidence_score": 0.68,
|
||||||
|
"reason": "횡보장 - LSTM 예측력 저하, LLM 감성 보완 필수",
|
||||||
|
"recommendation": "현재 LSTM v3 유지 + LLM 감성 가중치 상향",
|
||||||
|
})
|
||||||
|
|
||||||
|
# 백테스트 결과 반영
|
||||||
|
if backtest_sharpe is not None:
|
||||||
|
if backtest_sharpe < 0:
|
||||||
|
result["confidence_score"] *= 0.5
|
||||||
|
result["should_replace"] = True
|
||||||
|
result["recommendation"] += " ⚠️ Sharpe < 0 → 즉시 교체 검토"
|
||||||
|
elif backtest_sharpe < 0.5:
|
||||||
|
result["confidence_score"] *= 0.75
|
||||||
|
result["recommendation"] += f" (Sharpe={backtest_sharpe:.2f} 미흡)"
|
||||||
|
|
||||||
|
if backtest_winrate is not None and backtest_winrate < 45:
|
||||||
|
result["confidence_score"] *= 0.8
|
||||||
|
result["recommendation"] += f" (승률={backtest_winrate:.1f}% 미흡)"
|
||||||
|
|
||||||
|
if backtest_mdd is not None and backtest_mdd < -25:
|
||||||
|
result["confidence_score"] *= 0.7
|
||||||
|
result["should_replace"] = True
|
||||||
|
result["recommendation"] += f" ⚠️ MDD={backtest_mdd:.1f}% 과다"
|
||||||
|
|
||||||
|
result["confidence_score"] = round(max(0.0, min(1.0, result["confidence_score"])), 3)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_regime_label(kospi_price: float) -> str:
|
||||||
|
"""간략 레짐 라벨 반환 (로그/UI 표시용)"""
|
||||||
|
if kospi_price >= 5000:
|
||||||
|
return f"BULL_EXTREME({kospi_price:.0f})"
|
||||||
|
elif kospi_price >= 3500:
|
||||||
|
return f"BULL_STRONG({kospi_price:.0f})"
|
||||||
|
elif kospi_price >= 2500:
|
||||||
|
return f"BULL_NORMAL({kospi_price:.0f})"
|
||||||
|
elif kospi_price >= 2000:
|
||||||
|
return f"SIDEWAYS({kospi_price:.0f})"
|
||||||
|
elif kospi_price >= 1500:
|
||||||
|
return f"BEAR_MILD({kospi_price:.0f})"
|
||||||
|
return f"BEAR_SEVERE({kospi_price:.0f})"
|
||||||
348
modules/analysis/model_validator.py
Normal file
348
modules/analysis/model_validator.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""
|
||||||
|
모델 검증 시스템 (Market-Regime Aware Model Validator)
|
||||||
|
- 백테스트 기반 현재 LSTM v3 성능 검증
|
||||||
|
- 코스피 레짐별 모델 적합성 평가
|
||||||
|
- 코스피 6300 강세장 시나리오 대응 점검
|
||||||
|
- 모델 교체 권고 보고서 생성
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
validator = ModelValidator()
|
||||||
|
report = validator.validate(ticker, ohlcv_data, strategy_fn, kospi_price=2600)
|
||||||
|
print(report.summary())
|
||||||
|
validator.send_alert(report) # 텔레그램 알림 (심각한 경우만)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from modules.config import Config
|
||||||
|
from modules.analysis.backtest import Backtester, BacktestResult
|
||||||
|
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||||
|
|
||||||
|
|
||||||
|
# 모델 적합성 최소 기준
|
||||||
|
_MIN_SHARPE = 0.5
|
||||||
|
_MIN_WIN_RATE = 50.0 # %
|
||||||
|
_MAX_MDD = -20.0 # % (초과 시 문제)
|
||||||
|
_MIN_PROFIT_FACTOR = 1.2
|
||||||
|
_CACHE_TTL_SECONDS = 86400 # 24시간
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationReport:
|
||||||
|
"""모델 검증 보고서"""
|
||||||
|
ticker: str
|
||||||
|
kospi_level: float
|
||||||
|
regime: str
|
||||||
|
regime_description: str
|
||||||
|
backtest_result: Optional[BacktestResult]
|
||||||
|
model_suitable: bool
|
||||||
|
suitability_score: float # 0~1
|
||||||
|
issues: List[str] = field(default_factory=list)
|
||||||
|
recommendations: List[str] = field(default_factory=list)
|
||||||
|
alternative_models: List[str] = field(default_factory=list)
|
||||||
|
regime_strategy_hint: str = ""
|
||||||
|
risk_level: str = "LOW"
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [
|
||||||
|
"=" * 55,
|
||||||
|
f"🔍 모델 검증 보고서 [{self.ticker}]",
|
||||||
|
"=" * 55,
|
||||||
|
f"코스피 수준 : {self.kospi_level:.0f} ({self.regime_description})",
|
||||||
|
f"시장 레짐 : {self.regime} [리스크: {self.risk_level}]",
|
||||||
|
f"모델 적합성 : {'✅ 적합' if self.model_suitable else '⚠️ 부적합'} "
|
||||||
|
f"({self.suitability_score:.0%})",
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.backtest_result:
|
||||||
|
bt = self.backtest_result
|
||||||
|
lines += [
|
||||||
|
"",
|
||||||
|
"📊 백테스트 성과",
|
||||||
|
f" 총 수익률 : {bt.total_return_pct:+.2f}%",
|
||||||
|
f" Sharpe Ratio : {bt.sharpe_ratio:.3f}",
|
||||||
|
f" Max Drawdown : {bt.max_drawdown_pct:.2f}%",
|
||||||
|
f" 승률 : {bt.win_rate:.1f}% ({bt.winning_trades}/{bt.total_trades})",
|
||||||
|
f" 손익비(PF) : {bt.profit_factor:.2f}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if self.issues:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"⚠️ 발견된 문제 ({len(self.issues)}건)")
|
||||||
|
for issue in self.issues:
|
||||||
|
lines.append(f" - {issue}")
|
||||||
|
|
||||||
|
if self.recommendations:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("💡 권고사항")
|
||||||
|
for rec in self.recommendations:
|
||||||
|
lines.append(f" → {rec}")
|
||||||
|
|
||||||
|
if self.alternative_models:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("🔄 대안 모델 목록")
|
||||||
|
for model in self.alternative_models:
|
||||||
|
lines.append(f" • {model}")
|
||||||
|
|
||||||
|
if self.regime_strategy_hint:
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"📌 레짐 전략: {self.regime_strategy_hint}")
|
||||||
|
|
||||||
|
lines.append("=" * 55)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def is_critical(self) -> bool:
|
||||||
|
"""즉각적인 조치가 필요한 수준인지 (텔레그램 알림 기준)"""
|
||||||
|
if not self.model_suitable and self.suitability_score < 0.4:
|
||||||
|
return True
|
||||||
|
if self.backtest_result and self.backtest_result.sharpe_ratio < 0:
|
||||||
|
return True
|
||||||
|
if self.backtest_result and self.backtest_result.max_drawdown_pct < -30:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ModelValidator:
|
||||||
|
"""
|
||||||
|
LSTM v3 모델 검증기
|
||||||
|
|
||||||
|
검증 흐름:
|
||||||
|
1. 시장 레짐 감지 (코스피 수준)
|
||||||
|
2. 백테스트 실행 (선택)
|
||||||
|
3. 레짐별 모델 적합성 평가
|
||||||
|
4. 종합 보고서 생성
|
||||||
|
5. 심각한 경우 텔레그램 알림
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CACHE_FILE = "model_validation_cache.json"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._cache_path = os.path.join(Config.DATA_DIR, self._CACHE_FILE)
|
||||||
|
self._cache: dict = self._load_cache()
|
||||||
|
|
||||||
|
def _load_cache(self) -> dict:
|
||||||
|
if os.path.exists(self._cache_path):
|
||||||
|
try:
|
||||||
|
with open(self._cache_path, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _save_cache(self):
|
||||||
|
try:
|
||||||
|
with open(self._cache_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Validator] 캐시 저장 실패: {e}")
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self,
|
||||||
|
ticker: str,
|
||||||
|
ohlcv_data: dict,
|
||||||
|
strategy_fn=None,
|
||||||
|
kospi_price: float = 2500.0,
|
||||||
|
kospi_change_pct: float = 0.0,
|
||||||
|
run_backtest: bool = True,
|
||||||
|
) -> ValidationReport:
|
||||||
|
"""
|
||||||
|
모델 검증 실행
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ticker: 종목 코드
|
||||||
|
ohlcv_data: OHLCV 딕셔너리
|
||||||
|
strategy_fn: 백테스트용 전략 함수 (None이면 백테스트 생략)
|
||||||
|
kospi_price: 현재 코스피 지수
|
||||||
|
kospi_change_pct: 코스피 당일 등락률
|
||||||
|
run_backtest: 백테스트 실행 여부
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationReport
|
||||||
|
"""
|
||||||
|
issues: List[str] = []
|
||||||
|
recommendations: List[str] = []
|
||||||
|
|
||||||
|
# ── 1. 시장 레짐 감지 ────────────────────────────────
|
||||||
|
regime_analysis: RegimeAnalysis = MarketRegimeDetector.detect(
|
||||||
|
kospi_price, kospi_change_pct
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. 백테스트 (선택) ───────────────────────────────
|
||||||
|
backtest_result: Optional[BacktestResult] = None
|
||||||
|
if run_backtest and strategy_fn is not None:
|
||||||
|
try:
|
||||||
|
backtester = Backtester()
|
||||||
|
backtest_result = backtester.run(ohlcv_data, strategy_fn, ticker)
|
||||||
|
except Exception as e:
|
||||||
|
issues.append(f"백테스트 실행 오류: {e}")
|
||||||
|
|
||||||
|
# ── 3. 백테스트 결과 기준 위반 체크 ─────────────────
|
||||||
|
bt_sharpe = backtest_result.sharpe_ratio if backtest_result else None
|
||||||
|
bt_winrate = backtest_result.win_rate if backtest_result else None
|
||||||
|
bt_mdd = backtest_result.max_drawdown_pct if backtest_result else None
|
||||||
|
bt_pf = backtest_result.profit_factor if backtest_result else None
|
||||||
|
|
||||||
|
if backtest_result:
|
||||||
|
if bt_sharpe < _MIN_SHARPE:
|
||||||
|
issues.append(
|
||||||
|
f"Sharpe Ratio 미흡: {bt_sharpe:.3f} (최소 {_MIN_SHARPE})"
|
||||||
|
)
|
||||||
|
recommendations.append("LSTM 피처 확장 또는 모델 아키텍처 재검토")
|
||||||
|
if bt_winrate < _MIN_WIN_RATE:
|
||||||
|
issues.append(
|
||||||
|
f"승률 미흡: {bt_winrate:.1f}% (최소 {_MIN_WIN_RATE:.0f}%)"
|
||||||
|
)
|
||||||
|
recommendations.append("매수 진입 임계값 상향 조정 (+0.05)")
|
||||||
|
if bt_mdd < _MAX_MDD:
|
||||||
|
issues.append(
|
||||||
|
f"MDD 과다: {bt_mdd:.2f}% (허용 {_MAX_MDD:.0f}%)"
|
||||||
|
)
|
||||||
|
recommendations.append("ATR 손절 배수 축소 (ATR×2 → ATR×1.5)")
|
||||||
|
if bt_pf < _MIN_PROFIT_FACTOR:
|
||||||
|
issues.append(
|
||||||
|
f"손익비 미흡: {bt_pf:.2f} (최소 {_MIN_PROFIT_FACTOR})"
|
||||||
|
)
|
||||||
|
recommendations.append("익절 배수 확대 (ATR×3 → ATR×4)")
|
||||||
|
|
||||||
|
# ── 4. 레짐 기반 모델 적합성 평가 ───────────────────
|
||||||
|
regime_validation = MarketRegimeDetector.validate_model_for_regime(
|
||||||
|
regime_analysis.regime,
|
||||||
|
backtest_sharpe=bt_sharpe,
|
||||||
|
backtest_winrate=bt_winrate,
|
||||||
|
backtest_mdd=bt_mdd,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not regime_validation["is_suitable"]:
|
||||||
|
issues.append(
|
||||||
|
f"레짐 부적합: {regime_analysis.regime.value} 환경에서 "
|
||||||
|
f"LSTM v3 한계 감지"
|
||||||
|
)
|
||||||
|
recommendations.append(regime_validation["recommendation"])
|
||||||
|
|
||||||
|
# 코스피 6300 특별 경고
|
||||||
|
if kospi_price >= 5000:
|
||||||
|
issues.append(
|
||||||
|
f"⚠️ 코스피 {kospi_price:.0f} - 역사적 극고점 수준 "
|
||||||
|
"LSTM 비선형 패턴 포착 한계 주의"
|
||||||
|
)
|
||||||
|
recommendations.append(
|
||||||
|
"Temporal Fusion Transformer(TFT) 또는 Mamba 모델 전환 검토"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 5. 종합 적합성 점수 ──────────────────────────────
|
||||||
|
suitability_score = regime_validation["confidence_score"]
|
||||||
|
# 문제 건수에 따라 감점 (건당 10%, 최대 50% 감점)
|
||||||
|
penalty = min(len(issues) * 0.10, 0.50)
|
||||||
|
suitability_score = max(0.0, suitability_score - penalty)
|
||||||
|
suitability_score = round(suitability_score, 3)
|
||||||
|
|
||||||
|
# ── 6. 보고서 생성 ───────────────────────────────────
|
||||||
|
report = ValidationReport(
|
||||||
|
ticker=ticker,
|
||||||
|
kospi_level=kospi_price,
|
||||||
|
regime=regime_analysis.regime.value,
|
||||||
|
regime_description=regime_analysis.description,
|
||||||
|
backtest_result=backtest_result,
|
||||||
|
model_suitable=(suitability_score >= 0.5 and not regime_validation["should_replace"]),
|
||||||
|
suitability_score=suitability_score,
|
||||||
|
issues=issues,
|
||||||
|
recommendations=list(set(recommendations)), # 중복 제거
|
||||||
|
alternative_models=regime_validation.get("alternative_models", []),
|
||||||
|
regime_strategy_hint=regime_analysis.recommended_strategy,
|
||||||
|
risk_level=regime_analysis.risk_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 7. 캐시 저장 ─────────────────────────────────────
|
||||||
|
self._cache[ticker] = {
|
||||||
|
"timestamp": time.time(),
|
||||||
|
"kospi_level": kospi_price,
|
||||||
|
"regime": regime_analysis.regime.value,
|
||||||
|
"suitability_score": suitability_score,
|
||||||
|
"should_replace": regime_validation["should_replace"],
|
||||||
|
"issue_count": len(issues),
|
||||||
|
}
|
||||||
|
self._save_cache()
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def get_cached(self, ticker: str) -> Optional[dict]:
|
||||||
|
"""캐시된 검증 결과 반환 (24시간 이내)"""
|
||||||
|
cached = self._cache.get(ticker)
|
||||||
|
if not cached:
|
||||||
|
return None
|
||||||
|
if time.time() - cached.get("timestamp", 0) > _CACHE_TTL_SECONDS:
|
||||||
|
return None
|
||||||
|
return cached
|
||||||
|
|
||||||
|
def send_alert(self, report: ValidationReport):
|
||||||
|
"""심각한 검증 결과 텔레그램 알림"""
|
||||||
|
if not report.is_critical():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from modules.services.telegram import TelegramMessenger
|
||||||
|
msg = (
|
||||||
|
f"🚨 [모델 경고] {report.ticker}\n"
|
||||||
|
f"코스피 {report.kospi_level:.0f} | 레짐: {report.regime}\n"
|
||||||
|
f"적합성: {report.suitability_score:.0%}\n"
|
||||||
|
)
|
||||||
|
if report.issues:
|
||||||
|
msg += "문제:\n" + "\n".join(f"• {i}" for i in report.issues[:3])
|
||||||
|
if report.alternative_models:
|
||||||
|
msg += f"\n권고 모델: {report.alternative_models[0]}"
|
||||||
|
TelegramMessenger().send_message(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def generate_regime_report(self, kospi_price: float) -> str:
|
||||||
|
"""코스피 수준만으로 빠른 레짐 보고서 생성 (백테스트 없음)"""
|
||||||
|
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||||
|
validation = MarketRegimeDetector.validate_model_for_regime(regime_analysis.regime)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"=" * 55,
|
||||||
|
f"📈 코스피 {kospi_price:.0f} 레짐 분석",
|
||||||
|
"=" * 55,
|
||||||
|
f"레짐 : {regime_analysis.regime.value}",
|
||||||
|
f"설명 : {regime_analysis.description}",
|
||||||
|
f"리스크 수준 : {regime_analysis.risk_level}",
|
||||||
|
"",
|
||||||
|
"─ 전략 파라미터 조정 ─",
|
||||||
|
f"매수 임계값 : {'+' if regime_analysis.buy_threshold_adj >= 0 else ''}"
|
||||||
|
f"{regime_analysis.buy_threshold_adj:+.2f} 조정",
|
||||||
|
f"포지션 크기 : x{regime_analysis.position_size_adj:.2f}",
|
||||||
|
f"LSTM 가중치 : {'+' if regime_analysis.lstm_weight_adj >= 0 else ''}"
|
||||||
|
f"{regime_analysis.lstm_weight_adj:+.2f}",
|
||||||
|
"",
|
||||||
|
"─ 모델 평가 ─",
|
||||||
|
f"현재 모델 적합: {'✅' if validation['is_suitable'] else '⚠️'} "
|
||||||
|
f"(신뢰도 {validation['confidence_score']:.0%})",
|
||||||
|
f"교체 필요 : {'예' if validation['should_replace'] else '아니오'}",
|
||||||
|
f"권고사항 : {validation['recommendation']}",
|
||||||
|
]
|
||||||
|
|
||||||
|
if validation["alternative_models"]:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("대안 모델 목록:")
|
||||||
|
for model in validation["alternative_models"]:
|
||||||
|
lines.append(f" • {model}")
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"📌 전략: {regime_analysis.recommended_strategy}")
|
||||||
|
lines.append("=" * 55)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# 전역 싱글톤
|
||||||
|
_validator_instance: Optional[ModelValidator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_validator() -> ModelValidator:
|
||||||
|
"""ModelValidator 싱글톤 반환"""
|
||||||
|
global _validator_instance
|
||||||
|
if _validator_instance is None:
|
||||||
|
_validator_instance = ModelValidator()
|
||||||
|
return _validator_instance
|
||||||
267
modules/bot.py
267
modules/bot.py
@@ -4,7 +4,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
from concurrent.futures.process import BrokenProcessPool
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from modules.config import Config
|
from modules.config import Config
|
||||||
from modules.services.kis import KISClient
|
from modules.services.kis import KISClient
|
||||||
@@ -15,6 +15,7 @@ from modules.analysis.macro import MacroAnalyzer
|
|||||||
from modules.utils.monitor import SystemMonitor
|
from modules.utils.monitor import SystemMonitor
|
||||||
from modules.utils.performance_db import PerformanceDB
|
from modules.utils.performance_db import PerformanceDB
|
||||||
from modules.strategy.process import analyze_stock_process, calculate_position_size
|
from modules.strategy.process import analyze_stock_process, calculate_position_size
|
||||||
|
from modules.analysis.ensemble import get_ensemble
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from theme_manager import ThemeManager
|
from theme_manager import ThemeManager
|
||||||
@@ -43,7 +44,7 @@ class AutoTradingBot:
|
|||||||
5. 최고가 추적 (트레일링 스탑용)
|
5. 최고가 추적 (트레일링 스탑용)
|
||||||
6. 상세한 매매 로그 및 텔레그램 알림
|
6. 상세한 매매 로그 및 텔레그램 알림
|
||||||
"""
|
"""
|
||||||
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
|
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None):
|
||||||
# 1. 서비스 초기화
|
# 1. 서비스 초기화
|
||||||
self.kis = KISClient()
|
self.kis = KISClient()
|
||||||
self.news = AsyncNewsCollector()
|
self.news = AsyncNewsCollector()
|
||||||
@@ -70,8 +71,27 @@ class AutoTradingBot:
|
|||||||
# [v2.0] 최근 매크로 상태 캐싱
|
# [v2.0] 최근 매크로 상태 캐싱
|
||||||
self.last_macro_status = None
|
self.last_macro_status = None
|
||||||
|
|
||||||
|
# [v2.1] 연속 손절 안전장치
|
||||||
|
# 당일 손절 횟수가 임계치 초과 시 매수 일시 중단
|
||||||
|
self._consecutive_stop_losses_today = 0
|
||||||
|
self._buy_paused_until = None # datetime or None
|
||||||
|
|
||||||
|
# [v3.1] 사이클 간 당일 매수 금액 추적 (KIS T+2 미차감 문제 보완)
|
||||||
|
self._today_buy_total = 0 # 당일 누적 매수 집행 금액 (원)
|
||||||
|
self._today_buy_date = None # 날짜 리셋용
|
||||||
|
|
||||||
|
# [v3.1] 앙상블 학습용 매수 당시 신호 점수 보관 {ticker: {tech, sentiment, lstm}}
|
||||||
|
# 매도 시 실현 수익률과 함께 ensemble.record_trade()에 전달
|
||||||
|
self._buy_scores: dict = {}
|
||||||
|
|
||||||
# 4. 프로세스 관리
|
# 4. 프로세스 관리
|
||||||
self.shutdown_event = shutdown_event
|
self.shutdown_event = shutdown_event
|
||||||
|
self.eod_event = eod_event # EOD 셧다운 시그널 (→ main_server 자동 종료)
|
||||||
|
self._eod_shutdown_done = False # 당일 EOD 처리 완료 여부
|
||||||
|
|
||||||
|
# KRX 캘린더 (장 운영 여부 판단)
|
||||||
|
from modules.utils.market_calendar import get_calendar
|
||||||
|
self._calendar = get_calendar()
|
||||||
|
|
||||||
# 5. IPC (Shared Memory)
|
# 5. IPC (Shared Memory)
|
||||||
try:
|
try:
|
||||||
@@ -159,6 +179,86 @@ class AutoTradingBot:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[Bot] Daily snapshot error: {e}")
|
print(f"[Bot] Daily snapshot error: {e}")
|
||||||
|
|
||||||
|
async def _end_of_day_shutdown(self):
|
||||||
|
"""
|
||||||
|
[EOD] 장 마감 후 전체 학습 상태 저장 + 봇 프로세스 종료
|
||||||
|
|
||||||
|
저장 항목:
|
||||||
|
1. 앙상블 가중치 & 매매 히스토리 (ensemble_history.json)
|
||||||
|
2. 트레일링 스탑 최고가 (peak_prices.json)
|
||||||
|
3. 일일 거래 기록 (daily_trade_history.json)
|
||||||
|
4. 일별 자산 스냅샷 (perf_db)
|
||||||
|
5. EOD 마커 파일 (data/.eod_date → Watchdog 재시작 차단)
|
||||||
|
"""
|
||||||
|
print("[Bot] ===== EOD 상태 저장 시작 =====")
|
||||||
|
|
||||||
|
# 1. 앙상블 가중치 강제 저장
|
||||||
|
try:
|
||||||
|
ensemble = get_ensemble()
|
||||||
|
ensemble._save()
|
||||||
|
print("[Bot] [EOD] 앙상블 가중치 저장 완료")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 앙상블 저장 오류: {e}")
|
||||||
|
|
||||||
|
# 2. 트레일링 스탑 최고가 저장
|
||||||
|
try:
|
||||||
|
self._save_peak_prices()
|
||||||
|
print("[Bot] [EOD] 최고가 데이터 저장 완료")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 최고가 저장 오류: {e}")
|
||||||
|
|
||||||
|
# 3. 일일 거래 기록 저장
|
||||||
|
try:
|
||||||
|
self.save_trade_history()
|
||||||
|
print(f"[Bot] [EOD] 거래 기록 저장 완료 ({len(self.daily_trade_history)}건)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 거래 기록 저장 오류: {e}")
|
||||||
|
|
||||||
|
# 4. 일별 자산 스냅샷 (미완료 시)
|
||||||
|
if not self._snapshot_taken_today:
|
||||||
|
try:
|
||||||
|
balance_snap = self.kis.get_balance()
|
||||||
|
macro_cached = self.last_macro_status or {"indicators": {}}
|
||||||
|
self._take_daily_snapshot(macro_cached, balance_snap)
|
||||||
|
print("[Bot] [EOD] 자산 스냅샷 저장 완료")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 스냅샷 저장 오류: {e}")
|
||||||
|
|
||||||
|
# 5. EOD 마커 파일 기록 (Watchdog 재시작 차단)
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
import datetime as _dt
|
||||||
|
eod_file = Path(Config.DATA_DIR) / ".eod_date"
|
||||||
|
eod_file.parent.mkdir(exist_ok=True)
|
||||||
|
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
|
||||||
|
print(f"[Bot] [EOD] 마커 파일 기록: {eod_file}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 마커 파일 오류: {e}")
|
||||||
|
|
||||||
|
# 6. 텔레그램 알림
|
||||||
|
try:
|
||||||
|
today_trades = len(self.daily_trade_history)
|
||||||
|
try:
|
||||||
|
nxt = self._calendar.next_trading_open()
|
||||||
|
next_str = nxt.strftime('%m/%d(%a) %H:%M')
|
||||||
|
except Exception:
|
||||||
|
next_str = "미정"
|
||||||
|
self.messenger.send_message(
|
||||||
|
f"[장 마감] EOD 상태 저장 완료\n"
|
||||||
|
f"오늘 매매: {today_trades}건\n"
|
||||||
|
f"다음 거래일: {next_str} KST 자동 시작"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[Bot] [EOD] 알림 오류: {e}")
|
||||||
|
|
||||||
|
print("[Bot] ===== EOD 상태 저장 완료 =====")
|
||||||
|
|
||||||
|
# 7. 종료 시그널
|
||||||
|
if self.eod_event:
|
||||||
|
self.eod_event.set() # main_server → 서버 프로세스 자동 종료
|
||||||
|
if self.shutdown_event:
|
||||||
|
self.shutdown_event.set() # 텔레그램 봇 등 자식 프로세스 종료
|
||||||
|
|
||||||
async def _run_weekly_evaluation(self):
|
async def _run_weekly_evaluation(self):
|
||||||
"""주간 성과 평가 실행 후 텔레그램으로 전송."""
|
"""주간 성과 평가 실행 후 텔레그램으로 전송."""
|
||||||
try:
|
try:
|
||||||
@@ -376,6 +476,11 @@ class AutoTradingBot:
|
|||||||
self.watchlist_updated_today = False
|
self.watchlist_updated_today = False
|
||||||
# 전일 최고가 초기화 (보유하지 않는 종목)
|
# 전일 최고가 초기화 (보유하지 않는 종목)
|
||||||
self._load_peak_prices()
|
self._load_peak_prices()
|
||||||
|
# [v3.1] 당일 매수 추적 리셋
|
||||||
|
self._today_buy_total = 0
|
||||||
|
self._today_buy_date = now.date()
|
||||||
|
self._buy_scores.clear() # 미매도 종목 신호 점수도 초기화
|
||||||
|
print(f"[Bot] 일일 매수 추적 리셋 (날짜: {now.date()})")
|
||||||
|
|
||||||
# 5. 시스템 감시 (3분 간격)
|
# 5. 시스템 감시 (3분 간격)
|
||||||
self.monitor.check_health()
|
self.monitor.check_health()
|
||||||
@@ -395,9 +500,19 @@ class AutoTradingBot:
|
|||||||
if (now.weekday() == 4 and now.hour == 15
|
if (now.weekday() == 4 and now.hour == 15
|
||||||
and 35 <= now.minute <= 45 and not self.weekly_eval_sent):
|
and 35 <= now.minute <= 45 and not self.weekly_eval_sent):
|
||||||
await self._run_weekly_evaluation()
|
await self._run_weekly_evaluation()
|
||||||
|
|
||||||
|
# [EOD 셧다운] 장 마감 후 Config.EOD_SHUTDOWN_BUFFER_MIN 분 경과 시 저장 후 종료
|
||||||
|
eod_buffer = now.hour == 15 and now.minute >= (30 + Config.EOD_SHUTDOWN_BUFFER_MIN)
|
||||||
|
eod_buffer = eod_buffer or (now.hour >= 16) # 16시 이후도 포함
|
||||||
|
if eod_buffer and not self._eod_shutdown_done:
|
||||||
|
self._eod_shutdown_done = True
|
||||||
|
await self._end_of_day_shutdown()
|
||||||
|
return
|
||||||
|
|
||||||
# 장 외 시간에는 서킷 브레이커도 리셋
|
# 장 외 시간에는 서킷 브레이커도 리셋
|
||||||
self.monitor.reset_circuit()
|
self.monitor.reset_circuit()
|
||||||
print("[Bot] Market Closed. Waiting...")
|
if not self._eod_shutdown_done:
|
||||||
|
print("[Bot] Market Closed. Waiting...")
|
||||||
return
|
return
|
||||||
|
|
||||||
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
|
# [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단
|
||||||
@@ -438,7 +553,31 @@ class AutoTradingBot:
|
|||||||
analysis_tasks = []
|
analysis_tasks = []
|
||||||
news_data = await self.news.get_market_news_async()
|
news_data = await self.news.get_market_news_async()
|
||||||
|
|
||||||
tracking_deposit = int(balance.get("deposit", 0))
|
raw_deposit = int(balance.get("deposit", 0))
|
||||||
|
|
||||||
|
# [v3.1] 사이클 간 누적 매수금액 추적 (KIS 모의투자 T+2 미차감 보완)
|
||||||
|
# KIS API의 dnca_tot_amt(예수금)는 당일 매수를 즉시 차감하지 않아
|
||||||
|
# 매 사이클마다 전체 잔고처럼 보이는 문제를 방지
|
||||||
|
today = now.date()
|
||||||
|
if self._today_buy_date != today:
|
||||||
|
# 날짜 변경 시 리셋 (09:00 리셋 블록에서 이미 처리되지만 안전망으로 이중 체크)
|
||||||
|
self._today_buy_total = 0
|
||||||
|
self._today_buy_date = today
|
||||||
|
|
||||||
|
# KIS가 제공하는 금일매수금액이 있으면 그것을 우선 사용 (더 정확)
|
||||||
|
kis_today_buy = int(balance.get("today_buy_amt", 0))
|
||||||
|
if kis_today_buy > 0:
|
||||||
|
# KIS 값이 유효하면 로컬 추적값과 최댓값으로 사용 (둘 다 참조)
|
||||||
|
effective_today_buy = max(kis_today_buy, self._today_buy_total)
|
||||||
|
else:
|
||||||
|
effective_today_buy = self._today_buy_total
|
||||||
|
|
||||||
|
# 실제 사용 가능한 예수금 = KIS 예수금 - 당일 이미 집행한 매수금액
|
||||||
|
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
|
||||||
|
tracking_deposit = max(0, min(raw_deposit, max_daily_buy) - effective_today_buy)
|
||||||
|
|
||||||
|
print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | "
|
||||||
|
f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)")
|
||||||
|
|
||||||
# [v3.0] 비동기 OHLCV + 투자자 동향 배치 조회
|
# [v3.0] 비동기 OHLCV + 투자자 동향 배치 조회
|
||||||
tickers_list = list(target_dict.keys())
|
tickers_list = list(target_dict.keys())
|
||||||
@@ -455,6 +594,9 @@ class AutoTradingBot:
|
|||||||
ohlcv_batch = {}
|
ohlcv_batch = {}
|
||||||
investor_batch = {}
|
investor_batch = {}
|
||||||
|
|
||||||
|
# [v3.1] 사이클당 매수 횟수 제한
|
||||||
|
buys_this_cycle = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for ticker, name in target_dict.items():
|
for ticker, name in target_dict.items():
|
||||||
# OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
|
# OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback)
|
||||||
@@ -483,7 +625,8 @@ class AutoTradingBot:
|
|||||||
|
|
||||||
future = self.executor.submit(
|
future = self.executor.submit(
|
||||||
analyze_stock_process, ticker, ohlcv_data, news_data,
|
analyze_stock_process, ticker, ohlcv_data, news_data,
|
||||||
investor_trend, macro_status, holding_info)
|
investor_trend, macro_status, holding_info,
|
||||||
|
total_eval if total_eval > 0 else None)
|
||||||
analysis_tasks.append(future)
|
analysis_tasks.append(future)
|
||||||
|
|
||||||
# 결과 처리
|
# 결과 처리
|
||||||
@@ -504,31 +647,41 @@ class AutoTradingBot:
|
|||||||
print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}")
|
print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# [v3.1] 사이클당 최대 매수 종목 수 제한
|
||||||
|
if buys_this_cycle >= Config.MAX_BUY_PER_CYCLE:
|
||||||
|
print(f"[Bot] [Skip Buy] 사이클 최대 매수 횟수 초과 "
|
||||||
|
f"({buys_this_cycle}/{Config.MAX_BUY_PER_CYCLE}) - {ticker_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# [v2.1] 연속 손절 후 매수 일시 중단 체크
|
||||||
|
if self._buy_paused_until and datetime.now() < self._buy_paused_until:
|
||||||
|
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
|
||||||
|
f"{self._buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
|
||||||
|
continue
|
||||||
|
elif self._buy_paused_until and datetime.now() >= self._buy_paused_until:
|
||||||
|
self._buy_paused_until = None
|
||||||
|
self._consecutive_stop_losses_today = 0
|
||||||
|
print("[Bot] 매수 일시 중단 해제")
|
||||||
|
|
||||||
current_price = float(res['current_price'])
|
current_price = float(res['current_price'])
|
||||||
if current_price <= 0:
|
if current_price <= 0:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# [v2.0] 포지션 사이징 (동적 수량)
|
# [v3.1] 워커에서 Kelly Criterion으로 계산한 수량 직접 사용
|
||||||
qty = calculate_position_size(
|
# (중복 계산 제거 — 워커가 total_eval 기준으로 이미 계산 완료)
|
||||||
total_capital=total_eval if total_eval > 0 else tracking_deposit,
|
qty = res.get('suggested_qty', 0)
|
||||||
current_price=current_price,
|
|
||||||
volatility=res.get('volatility', 2.0),
|
|
||||||
score=res['score'],
|
|
||||||
ai_confidence=res.get('ai_confidence', 0.5)
|
|
||||||
)
|
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})")
|
print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
required_amount = current_price * qty
|
required_amount = current_price * qty
|
||||||
|
|
||||||
# 예수금 확인
|
# 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액)
|
||||||
if tracking_deposit < required_amount:
|
if tracking_deposit < required_amount:
|
||||||
# 수량 줄여서 재시도
|
|
||||||
qty = int(tracking_deposit / current_price)
|
qty = int(tracking_deposit / current_price)
|
||||||
if qty <= 0:
|
if qty <= 0:
|
||||||
print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): "
|
print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): "
|
||||||
f"필요 {required_amount:,.0f} > 잔고 {tracking_deposit:,.0f}")
|
f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}")
|
||||||
continue
|
continue
|
||||||
required_amount = current_price * qty
|
required_amount = current_price * qty
|
||||||
|
|
||||||
@@ -574,12 +727,24 @@ class AutoTradingBot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
tracking_deposit -= required_amount
|
tracking_deposit -= required_amount
|
||||||
|
# [v3.1] 사이클 간 추적 (KIS T+2 미차감 보완)
|
||||||
|
self._today_buy_total += required_amount
|
||||||
|
buys_this_cycle += 1
|
||||||
|
print(f"[Bot] 당일 누적 매수: {self._today_buy_total:,}원 "
|
||||||
|
f"(잔여 예수금: {tracking_deposit:,}원)")
|
||||||
|
|
||||||
|
# [v3.1] 앙상블 학습용 매수 신호 점수 보관 (매도 시 record_trade에 활용)
|
||||||
|
self._buy_scores[ticker] = {
|
||||||
|
"tech": res.get("tech", 0.5),
|
||||||
|
"sentiment": res.get("sentiment", 0.5),
|
||||||
|
"lstm": res.get("lstm_score", 0.5),
|
||||||
|
}
|
||||||
|
|
||||||
# 최고가 초기 설정
|
# 최고가 초기 설정
|
||||||
self.peak_prices[ticker] = current_price
|
self.peak_prices[ticker] = current_price
|
||||||
self._save_peak_prices()
|
self._save_peak_prices()
|
||||||
|
|
||||||
# ===== 매도 처리 (v2.0 - 분석 기반 매도) =====
|
# ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) =====
|
||||||
elif res['decision'] == "SELL" and ticker in current_holdings:
|
elif res['decision'] == "SELL" and ticker in current_holdings:
|
||||||
h = current_holdings[ticker]
|
h = current_holdings[ticker]
|
||||||
qty = int(h.get('qty', 0))
|
qty = int(h.get('qty', 0))
|
||||||
@@ -611,6 +776,40 @@ class AutoTradingBot:
|
|||||||
# 성과 DB 매도 결과 기록
|
# 성과 DB 매도 결과 기록
|
||||||
self.perf_db.close_trade(ticker, sell_price, yld)
|
self.perf_db.close_trade(ticker, sell_price, yld)
|
||||||
|
|
||||||
|
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
|
||||||
|
buy_sig = self._buy_scores.pop(ticker, None)
|
||||||
|
if buy_sig is not None:
|
||||||
|
try:
|
||||||
|
get_ensemble().record_trade(
|
||||||
|
ticker=ticker,
|
||||||
|
tech_score=buy_sig["tech"],
|
||||||
|
sentiment_score=buy_sig["sentiment"],
|
||||||
|
lstm_score=buy_sig["lstm"],
|
||||||
|
decision="BUY",
|
||||||
|
outcome_pct=yld
|
||||||
|
)
|
||||||
|
print(f"[Bot] [Ensemble] {ticker_name} 학습 기록: "
|
||||||
|
f"outcome={yld:+.1f}%")
|
||||||
|
except Exception as _ee:
|
||||||
|
print(f"[Bot] [Ensemble] record_trade 실패: {_ee}")
|
||||||
|
|
||||||
|
# [v2.1] 손절 횟수 추적 → 연속 3회 손절 시 매수 30분 일시 중단
|
||||||
|
if yld < 0:
|
||||||
|
self._consecutive_stop_losses_today += 1
|
||||||
|
if self._consecutive_stop_losses_today >= 3:
|
||||||
|
self._buy_paused_until = datetime.now() + timedelta(minutes=30)
|
||||||
|
warn_msg = (
|
||||||
|
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
|
||||||
|
f"{self._consecutive_stop_losses_today}회 → "
|
||||||
|
f"30분간 매수 정지 (재개: "
|
||||||
|
f"{self._buy_paused_until.strftime('%H:%M')})"
|
||||||
|
)
|
||||||
|
self.messenger.send_message(warn_msg)
|
||||||
|
print(f"[Bot] 연속 손절 {self._consecutive_stop_losses_today}회 → 매수 30분 중단")
|
||||||
|
else:
|
||||||
|
# 수익 실현 시 연속 손절 카운터 리셋
|
||||||
|
self._consecutive_stop_losses_today = 0
|
||||||
|
|
||||||
# 최고가 기록 삭제
|
# 최고가 기록 삭제
|
||||||
if ticker in self.peak_prices:
|
if ticker in self.peak_prices:
|
||||||
del self.peak_prices[ticker]
|
del self.peak_prices[ticker]
|
||||||
@@ -637,12 +836,40 @@ class AutoTradingBot:
|
|||||||
print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초")
|
print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초")
|
||||||
|
|
||||||
def loop(self):
|
def loop(self):
|
||||||
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.0]")
|
print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]")
|
||||||
|
|
||||||
|
# [캘린더 체크] 오늘이 휴장일이면 알림 후 즉시 EOD 종료
|
||||||
|
if not self._calendar.is_trading_day():
|
||||||
|
summary = self._calendar.status_summary()
|
||||||
|
print(f"[Bot] 오늘은 휴장일 ({summary}) — 봇을 시작하지 않습니다.")
|
||||||
|
self.messenger.send_message(
|
||||||
|
f"[Bot] 오늘은 휴장일입니다.\n{summary}"
|
||||||
|
)
|
||||||
|
# EOD 마커 기록 후 종료
|
||||||
|
try:
|
||||||
|
from pathlib import Path
|
||||||
|
import datetime as _dt
|
||||||
|
eod_file = Path(Config.DATA_DIR) / ".eod_date"
|
||||||
|
eod_file.write_text(str(_dt.date.today()), encoding="utf-8")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if self.eod_event:
|
||||||
|
self.eod_event.set()
|
||||||
|
if self.shutdown_event:
|
||||||
|
self.shutdown_event.set()
|
||||||
|
return
|
||||||
|
|
||||||
|
_llm_label = (
|
||||||
|
f"Gemini ({Config.GEMINI_MODEL})"
|
||||||
|
if Config.GEMINI_API_KEY
|
||||||
|
else f"Ollama ({Config.OLLAMA_MODEL})"
|
||||||
|
)
|
||||||
self.messenger.send_message(
|
self.messenger.send_message(
|
||||||
"🚀 <b>[Bot Started v3.0]</b>\n"
|
"🚀 <b>[Bot Started v3.1]</b>\n"
|
||||||
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n"
|
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n"
|
||||||
f"✅ AI 모델: {Config.OLLAMA_MODEL}\n"
|
f"✅ LLM 엔진: {_llm_label}\n"
|
||||||
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
|
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
|
||||||
|
f"✅ 장 상태: {self._calendar.status_summary()}\n"
|
||||||
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
|
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
|
||||||
|
|
||||||
# 최고가 데이터 로드
|
# 최고가 데이터 로드
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ class Config:
|
|||||||
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) # 8192→4096 (2배 속도)
|
OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) # 8192→4096 (2배 속도)
|
||||||
OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "200")) # 응답 토큰 제한
|
OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "200")) # 응답 토큰 제한
|
||||||
OLLAMA_NUM_THREAD = int(os.getenv("OLLAMA_NUM_THREAD", "8")) # CPU 스레드 (9800X3D 최적화)
|
OLLAMA_NUM_THREAD = int(os.getenv("OLLAMA_NUM_THREAD", "8")) # CPU 스레드 (9800X3D 최적화)
|
||||||
|
|
||||||
|
# 2-1. Gemini API (Primary LLM — Ollama 폴백)
|
||||||
|
# API 키: https://aistudio.google.com/apikey 에서 무료 발급
|
||||||
|
# 무료 티어: 15 RPM / 1,500 RPD (봇 필요량 ~240/일 → 여유 충분)
|
||||||
|
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
|
||||||
|
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
|
||||||
|
|
||||||
# 3. KIS 한국투자증권
|
# 3. KIS 한국투자증권
|
||||||
KIS_ENV_TYPE = os.getenv("KIS_ENV_TYPE", "virtual").lower()
|
KIS_ENV_TYPE = os.getenv("KIS_ENV_TYPE", "virtual").lower()
|
||||||
@@ -41,6 +47,9 @@ class Config:
|
|||||||
|
|
||||||
# 5. 매매 설정 (상수)
|
# 5. 매매 설정 (상수)
|
||||||
MAX_INVESTMENT_PER_STOCK = 3000000 # 종목당 최대 300만원
|
MAX_INVESTMENT_PER_STOCK = 3000000 # 종목당 최대 300만원
|
||||||
|
MAX_BUY_PER_CYCLE = int(os.getenv("MAX_BUY_PER_CYCLE", "2")) # 사이클당 최대 매수 종목 수
|
||||||
|
EOD_SHUTDOWN_BUFFER_MIN = int(os.getenv("EOD_SHUTDOWN_BUFFER_MIN", "5")) # 장 마감 후 EOD 처리까지 대기 분
|
||||||
|
MAX_DAILY_BUY_RATIO = float(os.getenv("MAX_DAILY_BUY_RATIO", "0.80")) # 예수금 대비 일일 최대 매수 비율
|
||||||
|
|
||||||
# 6. 데이터 경로
|
# 6. 데이터 경로
|
||||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||||
@@ -80,6 +89,22 @@ class Config:
|
|||||||
CPU_CIRCUIT_BREAKER_THRESHOLD = 92 # CPU% 이상 시 분석 스킵
|
CPU_CIRCUIT_BREAKER_THRESHOLD = 92 # CPU% 이상 시 분석 스킵
|
||||||
CPU_CIRCUIT_BREAKER_CONSECUTIVE = 2 # 연속 N회 초과 시 발동
|
CPU_CIRCUIT_BREAKER_CONSECUTIVE = 2 # 연속 N회 초과 시 발동
|
||||||
|
|
||||||
|
# 13. AI 전문가 회의 (AICouncil) 설정
|
||||||
|
# True: 매 분석 사이클에 회의 통합 (느림), False: 수동 호출만 허용
|
||||||
|
AI_COUNCIL_ENABLED = os.getenv("AI_COUNCIL_ENABLED", "false").lower() == "true"
|
||||||
|
# True: 의장 AI 단독 판단 (1회 LLM 호출), False: 전문가 4명 + 의장 (5회)
|
||||||
|
AI_COUNCIL_FAST_MODE = os.getenv("AI_COUNCIL_FAST_MODE", "true").lower() == "true"
|
||||||
|
# 종목당 최소 회의 간격(초) - 동일 종목 과다 호출 방지
|
||||||
|
AI_COUNCIL_MIN_INTERVAL = int(os.getenv("AI_COUNCIL_MIN_INTERVAL", "3600")) # 1시간
|
||||||
|
|
||||||
|
# 14. 시장 레짐 / 코스피 목표 수준 설정
|
||||||
|
# 코스피 레짐 감지 활성화 (process.py 임계값/포지션 자동 조정)
|
||||||
|
MARKET_REGIME_ENABLED = os.getenv("MARKET_REGIME_ENABLED", "true").lower() == "true"
|
||||||
|
# 모델 검증 활성화 (일일 1회 레짐 보고서 생성)
|
||||||
|
MODEL_VALIDATION_ENABLED = os.getenv("MODEL_VALIDATION_ENABLED", "true").lower() == "true"
|
||||||
|
# 코스피 목표/기준 수준 (레짐 전환 알림 기준)
|
||||||
|
KOSPI_REFERENCE_LEVEL = float(os.getenv("KOSPI_REFERENCE_LEVEL", "2600"))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate():
|
def validate():
|
||||||
"""필수 설정 검증"""
|
"""필수 설정 검증"""
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import time
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
except ImportError:
|
||||||
|
aiohttp = None
|
||||||
|
|
||||||
from modules.config import Config
|
from modules.config import Config
|
||||||
|
|
||||||
class KISClient:
|
class KISClient:
|
||||||
@@ -120,7 +125,7 @@ class KISClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
print(f"🔑 [KIS] 토큰 발급 요청: {url}")
|
||||||
res = requests.post(url, json=payload)
|
res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
|
|
||||||
@@ -164,7 +169,7 @@ class KISClient:
|
|||||||
"appsecret": self.app_secret
|
"appsecret": self.app_secret
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
res = requests.post(url, headers=headers, json=datas)
|
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
||||||
return res.json()["HASH"]
|
return res.json()["HASH"]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"❌ Hash Key 생성 실패: {e}")
|
print(f"❌ Hash Key 생성 실패: {e}")
|
||||||
@@ -185,10 +190,12 @@ class KISClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
res = requests.get(url, headers=headers, params=params)
|
res = requests.get(url, headers=headers, params=params,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
else:
|
else:
|
||||||
res = requests.post(url, headers=headers, json=data)
|
res = requests.post(url, headers=headers, json=data,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
|
|
||||||
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
|
# 토큰 만료 체크 (500 에러 or msg_cd 확인)
|
||||||
is_token_error = False
|
is_token_error = False
|
||||||
try:
|
try:
|
||||||
@@ -200,18 +207,20 @@ class KISClient:
|
|||||||
is_token_error = True
|
is_token_error = True
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if is_token_error:
|
if is_token_error:
|
||||||
print("🔄 [KIS] Token expired (caught). Refreshing...")
|
print("🔄 [KIS] Token expired (caught). Refreshing...")
|
||||||
self.ensure_token(force=True)
|
self.ensure_token(force=True)
|
||||||
headers = self._get_headers(tr_id)
|
headers = self._get_headers(tr_id)
|
||||||
if use_hash and data and "hashkey" in headers:
|
if use_hash and data and "hashkey" in headers:
|
||||||
pass # Hash 재활용
|
pass # Hash 재활용
|
||||||
|
|
||||||
if method == "GET":
|
if method == "GET":
|
||||||
res = requests.get(url, headers=headers, params=params)
|
res = requests.get(url, headers=headers, params=params,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
else:
|
else:
|
||||||
res = requests.post(url, headers=headers, json=data)
|
res = requests.post(url, headers=headers, json=data,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
|
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
return res.json()
|
return res.json()
|
||||||
@@ -266,7 +275,8 @@ class KISClient:
|
|||||||
return {
|
return {
|
||||||
"holdings": holdings,
|
"holdings": holdings,
|
||||||
"total_eval": int(summary['tot_evlu_amt']),
|
"total_eval": int(summary['tot_evlu_amt']),
|
||||||
"deposit": int(summary['dnca_tot_amt'])
|
"deposit": int(summary['dnca_tot_amt']),
|
||||||
|
"today_buy_amt": int(summary.get('thdt_buy_amt', 0)), # 금일매수금액 (T+2 차감 전 당일 집행액)
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"error": str(e)}
|
return {"error": str(e)}
|
||||||
@@ -321,7 +331,7 @@ class KISClient:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
|
print(f"📤 [KIS] 주문 전송: {buy_sell} {ticker} {qty}ea ({order_type_str})")
|
||||||
res = requests.post(url, headers=headers, json=datas)
|
res = requests.post(url, headers=headers, json=datas, timeout=Config.HTTP_TIMEOUT)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
|
|
||||||
@@ -348,7 +358,8 @@ class KISClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = requests.get(url, headers=headers, params=params)
|
res = requests.get(url, headers=headers, params=params,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
if data['rt_cd'] != '0':
|
if data['rt_cd'] != '0':
|
||||||
@@ -564,12 +575,13 @@ class KISClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = requests.get(url, headers=headers, params=params)
|
res = requests.get(url, headers=headers, params=params,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
if data['rt_cd'] != '0':
|
if data['rt_cd'] != '0':
|
||||||
return []
|
return []
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
for item in data['output'][:limit]:
|
for item in data['output'][:limit]:
|
||||||
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
|
# 코드는 shtn_iscd, 이름은 hts_kor_isnm
|
||||||
@@ -664,7 +676,8 @@ class KISClient:
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
res = requests.get(url, headers=headers, params=params)
|
res = requests.get(url, headers=headers, params=params,
|
||||||
|
timeout=Config.HTTP_TIMEOUT)
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
data = res.json()
|
data = res.json()
|
||||||
if data['rt_cd'] != '0':
|
if data['rt_cd'] != '0':
|
||||||
@@ -699,7 +712,9 @@ class KISAsyncClient:
|
|||||||
async def _async_get(self, session, url, headers, params):
|
async def _async_get(self, session, url, headers, params):
|
||||||
"""비동기 GET 요청"""
|
"""비동기 GET 요청"""
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers, params=params) as resp:
|
timeout = aiohttp.ClientTimeout(total=Config.HTTP_TIMEOUT) if aiohttp else None
|
||||||
|
async with session.get(url, headers=headers, params=params,
|
||||||
|
timeout=timeout) as resp:
|
||||||
return await resp.json()
|
return await resp.json()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[KIS Async] Request failed: {e}")
|
print(f"[KIS Async] Request failed: {e}")
|
||||||
|
|||||||
199
modules/services/llm_client.py
Normal file
199
modules/services/llm_client.py
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
통합 LLM 클라이언트 — Gemini 2.5 Flash (Primary) + Ollama (Fallback)
|
||||||
|
|
||||||
|
설계 원칙:
|
||||||
|
- OllamaManager.request_inference(prompt) 와 동일한 인터페이스 유지
|
||||||
|
→ process.py, ai_council.py 코드 변경 최소화
|
||||||
|
- Gemini 실패(네트워크, Rate Limit) 시 자동으로 로컬 Ollama 폴백
|
||||||
|
- 15 RPM 제한 준수를 위한 자동 스로틀링
|
||||||
|
- VRAM 충돌 없음 (외부 API 호출이므로 LSTM 학습과 간섭 없음)
|
||||||
|
|
||||||
|
Rate Limit (Gemini 2.5 Flash 무료 티어):
|
||||||
|
- 15 RPM, 1,500 RPD (봇 필요량 ~240/일 → 여유 6배)
|
||||||
|
|
||||||
|
추가 패키지 불필요:
|
||||||
|
- requests (이미 설치됨) 기반 REST API 직접 호출
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
|
||||||
|
from modules.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiLLMClient:
|
||||||
|
"""
|
||||||
|
Gemini API 클라이언트
|
||||||
|
|
||||||
|
사용법:
|
||||||
|
client = GeminiLLMClient()
|
||||||
|
result = client.request_inference(prompt) # str | None
|
||||||
|
"""
|
||||||
|
|
||||||
|
_GENERATE_URL = (
|
||||||
|
"https://generativelanguage.googleapis.com/v1beta/models"
|
||||||
|
"/{model}:generateContent?key={key}"
|
||||||
|
)
|
||||||
|
# 15 RPM → 최소 4초 간격 (여유 0.1초 추가)
|
||||||
|
_MIN_INTERVAL = 4.1
|
||||||
|
# 클래스 변수: 같은 프로세스 내 재생성 시에도 마지막 호출 시각 유지
|
||||||
|
# (워커 OOM 재시작 후 싱글톤 교체 시에도 스로틀 유효)
|
||||||
|
_class_last_call_ts: float = 0.0
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = Config.GEMINI_API_KEY
|
||||||
|
self.model = Config.GEMINI_MODEL
|
||||||
|
self._ollama = None # Ollama 폴백 (lazy init)
|
||||||
|
self._use_gemini = bool(self.api_key)
|
||||||
|
|
||||||
|
if self._use_gemini:
|
||||||
|
print(f"✅ [LLMClient] Primary: Gemini {self.model}")
|
||||||
|
else:
|
||||||
|
print("⚠️ [LLMClient] GEMINI_API_KEY 미설정 → Ollama 전용 모드")
|
||||||
|
|
||||||
|
# ── 내부 헬퍼 ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _throttle(self):
|
||||||
|
"""15 RPM 제한 준수 — 최소 호출 간격 강제 대기 (클래스 공유 타임스탬프)"""
|
||||||
|
elapsed = time.time() - GeminiLLMClient._class_last_call_ts
|
||||||
|
if elapsed < self._MIN_INTERVAL:
|
||||||
|
time.sleep(self._MIN_INTERVAL - elapsed)
|
||||||
|
|
||||||
|
def _call_gemini(self, prompt: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Gemini REST API 단일 호출
|
||||||
|
|
||||||
|
설정:
|
||||||
|
- systemInstruction: JSON 전용 응답 강제
|
||||||
|
- thinkingBudget=0: 내부 추론 비활성 (속도 1.5초 / 토큰 절약)
|
||||||
|
- maxOutputTokens=512: 200은 thinking 소모로 잘리므로 여유 확보
|
||||||
|
"""
|
||||||
|
self._throttle()
|
||||||
|
|
||||||
|
url = self._GENERATE_URL.format(model=self.model, key=self.api_key)
|
||||||
|
payload = {
|
||||||
|
"system_instruction": {
|
||||||
|
"parts": [{"text": (
|
||||||
|
"You are a Korean stock market analyst. "
|
||||||
|
"Respond with valid JSON only. "
|
||||||
|
"No markdown, no code blocks, no explanations."
|
||||||
|
)}]
|
||||||
|
},
|
||||||
|
"contents": [{"parts": [{"text": prompt}]}],
|
||||||
|
"generationConfig": {
|
||||||
|
"maxOutputTokens": 512, # 200→512 (thinking 비활성 후 실제 응답 공간 확보)
|
||||||
|
"temperature": 0.1, # 결정론적 출력
|
||||||
|
"thinkingConfig": {"thinkingBudget": 0}, # 내부 추론 끔 (속도↑, 토큰↓)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=30)
|
||||||
|
GeminiLLMClient._class_last_call_ts = time.time()
|
||||||
|
|
||||||
|
# Rate Limit 초과
|
||||||
|
if resp.status_code == 429:
|
||||||
|
print("[LLMClient] Gemini Rate Limit (429) → Ollama 폴백")
|
||||||
|
return None
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# thinking 파트 제외, 실제 텍스트 파트만 결합
|
||||||
|
candidate = data.get("candidates", [{}])[0]
|
||||||
|
parts = candidate.get("content", {}).get("parts", [])
|
||||||
|
text = "".join(
|
||||||
|
p.get("text", "") for p in parts
|
||||||
|
if "text" in p and not p.get("thought")
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
return text if text else None
|
||||||
|
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print("[LLMClient] Gemini Timeout (30s) → Ollama 폴백")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[LLMClient] Gemini Error: {e} → Ollama 폴백")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_ollama(self):
|
||||||
|
"""Ollama 폴백 인스턴스 (lazy init — 필요할 때만 로드)"""
|
||||||
|
if self._ollama is None:
|
||||||
|
from modules.services.ollama import OllamaManager
|
||||||
|
self._ollama = OllamaManager()
|
||||||
|
# Ollama 실행 여부 사전 확인 (WinError 10061 조기 감지)
|
||||||
|
try:
|
||||||
|
requests.get(
|
||||||
|
f"{Config.OLLAMA_API_URL}/api/tags",
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
print(
|
||||||
|
f"❌ [LLMClient] Ollama 미실행 (localhost:11434 연결 거부) — "
|
||||||
|
f"`ollama serve` 명령으로 Ollama를 시작하세요."
|
||||||
|
)
|
||||||
|
return self._ollama
|
||||||
|
|
||||||
|
# ── 공개 인터페이스 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def request_inference(self, prompt: str, context_data=None) -> str | None:
|
||||||
|
"""
|
||||||
|
LLM 추론 요청 — OllamaManager.request_inference()와 동일한 시그니처
|
||||||
|
|
||||||
|
순서:
|
||||||
|
1) GEMINI_API_KEY 있음 → Gemini API 호출
|
||||||
|
2) Gemini 실패(에러/타임아웃/Rate Limit) → Ollama 로컬 폴백
|
||||||
|
3) GEMINI_API_KEY 없음 → 바로 Ollama 사용
|
||||||
|
"""
|
||||||
|
if self._use_gemini:
|
||||||
|
result = self._call_gemini(prompt)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
# Gemini 실패 → Ollama 폴백
|
||||||
|
print("[LLMClient] Ollama 폴백 시도 중...")
|
||||||
|
|
||||||
|
return self._get_ollama().request_inference(prompt, context_data)
|
||||||
|
|
||||||
|
# ── OllamaManager 호환 메서드 (ai_council, evaluator 등에서 사용) ─────────
|
||||||
|
|
||||||
|
def check_vram(self) -> float:
|
||||||
|
"""VRAM 사용량 반환 (Ollama 측 정보, Gemini 호출 시엔 무관)"""
|
||||||
|
if self._ollama:
|
||||||
|
return self._ollama.check_vram()
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def get_gpu_status(self) -> dict:
|
||||||
|
"""GPU 상태 반환 (OllamaManager 호환)"""
|
||||||
|
return self._get_ollama().get_gpu_status()
|
||||||
|
|
||||||
|
def unload_model(self):
|
||||||
|
"""Ollama 모델 언로드 (LSTM 학습 전 호출용, Gemini는 무작동)"""
|
||||||
|
if self._ollama:
|
||||||
|
try:
|
||||||
|
requests.post(
|
||||||
|
f"{Config.OLLAMA_API_URL}/api/generate",
|
||||||
|
json={"model": Config.OLLAMA_MODEL, "keep_alive": 0},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── 워커 프로세스 전역 싱글톤 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_llm_client: GeminiLLMClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_client() -> GeminiLLMClient:
|
||||||
|
"""
|
||||||
|
워커 프로세스 내 GeminiLLMClient 싱글톤 반환
|
||||||
|
|
||||||
|
process.py에서 기존 get_ollama() 대신 이 함수를 사용:
|
||||||
|
ollama = get_llm_client()
|
||||||
|
result = ollama.request_inference(prompt)
|
||||||
|
"""
|
||||||
|
global _llm_client
|
||||||
|
if _llm_client is None:
|
||||||
|
_llm_client = GeminiLLMClient()
|
||||||
|
return _llm_client
|
||||||
@@ -62,6 +62,14 @@ class TelegramBotServer:
|
|||||||
"/system - PC 리소스(CPU/GPU) 상태\n"
|
"/system - PC 리소스(CPU/GPU) 상태\n"
|
||||||
"/ai - AI 모델 학습 상태 조회\n"
|
"/ai - AI 모델 학습 상태 조회\n"
|
||||||
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
|
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
|
||||||
|
"<b>[AI 진단 스킬]</b>\n"
|
||||||
|
"/syshealth - 시스템 종합 건강 진단\n"
|
||||||
|
"/risk - 리스크 대시보드 (MDD, 연속손절)\n"
|
||||||
|
"/regime - 코스피 시장 레짐 감지\n"
|
||||||
|
"/model_health - LSTM 모델 건강 체크\n"
|
||||||
|
"/weights - 앙상블 가중치 분석\n"
|
||||||
|
"/postmortem [일수] - 매매 사후 분석 (기본 30일)\n"
|
||||||
|
"/watchlist_check - 감시 종목 스코어링\n\n"
|
||||||
"<b>[관리 명령어]</b>\n"
|
"<b>[관리 명령어]</b>\n"
|
||||||
"/restart - 메인 봇 재시작 요청\n"
|
"/restart - 메인 봇 재시작 요청\n"
|
||||||
"/exec <code>명령어</code> - 원격 명령어 실행\n"
|
"/exec <code>명령어</code> - 원격 명령어 실행\n"
|
||||||
@@ -222,7 +230,11 @@ class TelegramBotServer:
|
|||||||
volume = int(v.get('volume', 0))
|
volume = int(v.get('volume', 0))
|
||||||
|
|
||||||
if price == 0:
|
if price == 0:
|
||||||
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
|
# 장 마감 후: prev_close(전일 종가)라도 표시
|
||||||
|
if prev_close > 0:
|
||||||
|
msg += f"⚫ <b>{k}:</b> <code>{prev_close:,.2f}</code> <i>(전일 종가 기준, 장 마감)</i>\n\n"
|
||||||
|
else:
|
||||||
|
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if change > 0:
|
if change > 0:
|
||||||
@@ -303,9 +315,18 @@ class TelegramBotServer:
|
|||||||
from modules.config import Config
|
from modules.config import Config
|
||||||
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
|
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
|
||||||
|
|
||||||
|
if Config.GEMINI_API_KEY:
|
||||||
|
llm_primary = f"Gemini ({Config.GEMINI_MODEL})"
|
||||||
|
llm_fallback = f"Ollama ({Config.OLLAMA_MODEL})"
|
||||||
|
else:
|
||||||
|
llm_primary = f"Ollama ({Config.OLLAMA_MODEL})"
|
||||||
|
llm_fallback = None
|
||||||
|
|
||||||
msg = "<b>AI Model Status</b>\n"
|
msg = "<b>AI Model Status</b>\n"
|
||||||
msg += f"* <b>LLM Engine:</b> Ollama ({Config.OLLAMA_MODEL})\n"
|
msg += f"* <b>LLM Engine:</b> {llm_primary}\n"
|
||||||
msg += f"* <b>Device:</b> {gpu.get('name', 'GPU')}\n"
|
if llm_fallback:
|
||||||
|
msg += f"* <b>Fallback:</b> {llm_fallback}\n"
|
||||||
|
msg += f"* <b>LSTM Device:</b> {gpu.get('name', 'GPU')}\n"
|
||||||
|
|
||||||
if gpu:
|
if gpu:
|
||||||
msg += f"* <b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
|
msg += f"* <b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
|
||||||
@@ -417,6 +438,121 @@ class TelegramBotServer:
|
|||||||
logging.error(f"[Command] /evaluate error: {e}")
|
logging.error(f"[Command] /evaluate error: {e}")
|
||||||
await update.message.reply_text(f"평가 오류: {e}")
|
await update.message.reply_text(f"평가 오류: {e}")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# AI 진단 스킬 명령어 (skill_runner 기반)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def syshealth_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/syshealth: 시스템 종합 건강 진단"""
|
||||||
|
await update.message.reply_text("🔍 시스템 건강 진단 중... (최대 30초 소요)", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_syshealth()
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /syshealth error: {e}")
|
||||||
|
await update.message.reply_text(f"진단 오류: {e}")
|
||||||
|
|
||||||
|
async def risk_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/risk: 리스크 대시보드 (MDD, 연속손절, 포지션 집중도)"""
|
||||||
|
await update.message.reply_text("📊 리스크 데이터 분석 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_risk()
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /risk error: {e}")
|
||||||
|
await update.message.reply_text(f"리스크 분석 오류: {e}")
|
||||||
|
|
||||||
|
async def regime_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/regime: 코스피 시장 레짐 감지"""
|
||||||
|
await update.message.reply_text("📈 시장 레짐 분석 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_regime()
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /regime error: {e}")
|
||||||
|
await update.message.reply_text(f"레짐 분석 오류: {e}")
|
||||||
|
|
||||||
|
async def model_health_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/model_health: LSTM 모델 건강 체크"""
|
||||||
|
await update.message.reply_text("🧠 LSTM 모델 체크포인트 스캔 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_model_health()
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /model_health error: {e}")
|
||||||
|
await update.message.reply_text(f"모델 건강 체크 오류: {e}")
|
||||||
|
|
||||||
|
async def weights_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/weights: 앙상블 가중치 분석"""
|
||||||
|
await update.message.reply_text("⚖️ 앙상블 가중치 분석 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_weights()
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /weights error: {e}")
|
||||||
|
await update.message.reply_text(f"가중치 분석 오류: {e}")
|
||||||
|
|
||||||
|
async def postmortem_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/postmortem [days]: 매매 사후 분석 (기본 30일)"""
|
||||||
|
args = context.args
|
||||||
|
days = 30
|
||||||
|
if args:
|
||||||
|
try:
|
||||||
|
days = int(args[0])
|
||||||
|
days = max(7, min(days, 365))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await update.message.reply_text(
|
||||||
|
f"🔬 최근 {days}일 매매 사후 분석 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
result = await skill_runner.run_postmortem(days)
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /postmortem error: {e}")
|
||||||
|
await update.message.reply_text(f"사후 분석 오류: {e}")
|
||||||
|
|
||||||
|
async def watchlist_check_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""/watchlist_check: 현재 감시 종목 스코어링"""
|
||||||
|
await update.message.reply_text("🔎 감시 종목 스코어링 중...", parse_mode="HTML")
|
||||||
|
try:
|
||||||
|
from modules.services.telegram_bot import skill_runner
|
||||||
|
|
||||||
|
# 현재 watchlist에서 종목 코드 목록 로드
|
||||||
|
candidates = []
|
||||||
|
try:
|
||||||
|
import json, os
|
||||||
|
from modules.config import Config
|
||||||
|
wl_path = Config.WATCHLIST_FILE
|
||||||
|
if os.path.exists(wl_path):
|
||||||
|
with open(wl_path, encoding="utf-8") as f:
|
||||||
|
wl_data = json.load(f)
|
||||||
|
if isinstance(wl_data, dict):
|
||||||
|
candidates = list(wl_data.keys())
|
||||||
|
elif isinstance(wl_data, list):
|
||||||
|
candidates = wl_data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = await skill_runner.run_watchlist_check(candidates)
|
||||||
|
for chunk in result:
|
||||||
|
await update.message.reply_text(chunk, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"[Command] /watchlist_check error: {e}")
|
||||||
|
await update.message.reply_text(f"스코어링 오류: {e}")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
handlers = [
|
handlers = [
|
||||||
("start", self.start_command),
|
("start", self.start_command),
|
||||||
@@ -428,6 +564,13 @@ class TelegramBotServer:
|
|||||||
("system", self.system_command),
|
("system", self.system_command),
|
||||||
("ai", self.ai_status_command),
|
("ai", self.ai_status_command),
|
||||||
("evaluate", self.evaluate_command),
|
("evaluate", self.evaluate_command),
|
||||||
|
("syshealth", self.syshealth_command),
|
||||||
|
("risk", self.risk_command),
|
||||||
|
("regime", self.regime_command),
|
||||||
|
("model_health", self.model_health_command),
|
||||||
|
("weights", self.weights_command),
|
||||||
|
("postmortem", self.postmortem_command),
|
||||||
|
("watchlist_check", self.watchlist_check_command),
|
||||||
("restart", self.restart_command),
|
("restart", self.restart_command),
|
||||||
("stop", self.stop_command),
|
("stop", self.stop_command),
|
||||||
("exec", self.exec_command)
|
("exec", self.exec_command)
|
||||||
|
|||||||
463
modules/services/telegram_bot/skill_runner.py
Normal file
463
modules/services/telegram_bot/skill_runner.py
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
"""
|
||||||
|
Skill Runner — 텔레그램 봇에서 Claude Skills 스크립트를 실행하는 유틸리티
|
||||||
|
|
||||||
|
각 스킬 스크립트를 subprocess로 실행하고, 결과를 텔레그램 HTML 메시지로 포맷합니다.
|
||||||
|
Claude Code 없이도 텔레그램 명령어만으로 분석 리포트를 받을 수 있습니다.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 봇 프로젝트 루트 (이 파일 기준 3단계 상위)
|
||||||
|
BOT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
SKILLS_DIR = BOT_ROOT / ".claude" / "skills"
|
||||||
|
PYTHON_EXE = sys.executable # 현재 봇과 동일한 Python 인터프리터 사용
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_script(skill_name: str, script_name: str) -> Path:
|
||||||
|
return SKILLS_DIR / skill_name / "scripts" / script_name
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_script(script_path: Path, extra_args: Optional[list] = None,
|
||||||
|
timeout: int = 60) -> dict:
|
||||||
|
"""
|
||||||
|
스킬 스크립트를 비동기 subprocess로 실행.
|
||||||
|
--bot-path, --json 플래그를 자동으로 추가.
|
||||||
|
반환: {"ok": bool, "output": str, "json_data": dict|None}
|
||||||
|
"""
|
||||||
|
if not script_path.exists():
|
||||||
|
return {"ok": False, "output": f"스크립트 없음: {script_path}", "json_data": None}
|
||||||
|
|
||||||
|
cmd = [PYTHON_EXE, str(script_path),
|
||||||
|
"--bot-path", str(BOT_ROOT),
|
||||||
|
"--json"]
|
||||||
|
if extra_args:
|
||||||
|
cmd.extend(extra_args)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# PYTHONIOENCODING=utf-8: 서브프로세스 stdout에서 유니코드/이모지 출력 허용
|
||||||
|
_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||||
|
result = await loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=timeout,
|
||||||
|
cwd=str(BOT_ROOT),
|
||||||
|
env=_env,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_out = result.stdout.strip()
|
||||||
|
raw_err = result.stderr.strip()
|
||||||
|
|
||||||
|
# JSON 파싱 시도
|
||||||
|
json_data = None
|
||||||
|
if raw_out:
|
||||||
|
try:
|
||||||
|
json_data = json.loads(raw_out)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if result.returncode != 0 and not raw_out:
|
||||||
|
return {"ok": False, "output": raw_err or "알 수 없는 오류", "json_data": None}
|
||||||
|
|
||||||
|
return {"ok": True, "output": raw_out, "json_data": json_data}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {"ok": False, "output": f"실행 시간 초과 ({timeout}초)", "json_data": None}
|
||||||
|
except Exception as e:
|
||||||
|
return {"ok": False, "output": str(e), "json_data": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int = 3800) -> str:
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return text[:limit] + "\n<i>... (일부 생략)</i>"
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_html(text: str) -> str:
|
||||||
|
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 스킬별 포맷터
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fmt_syshealth(data: dict) -> str:
|
||||||
|
ipc = data.get("ipc", {})
|
||||||
|
gpu = data.get("gpu", {})
|
||||||
|
token = data.get("kis_token", {})
|
||||||
|
procs = data.get("processes", {})
|
||||||
|
|
||||||
|
ipc_status = ipc.get("status", "?")
|
||||||
|
ipc_emoji = {"FRESH": "✅", "NORMAL": "✅", "STALE": "⚠️",
|
||||||
|
"EXPIRED": "🔴", "EMPTY": "⚠️", "ERROR": "🔴"}.get(ipc_status, "❓")
|
||||||
|
age = ipc.get("age_seconds")
|
||||||
|
age_str = f"{age}초 전" if age is not None else "알 수 없음"
|
||||||
|
|
||||||
|
api_str = "✅ 실행 중" if procs.get("api_running") else "🔴 오프라인"
|
||||||
|
token_str = "✅ 유효" if token.get("status") == "VALID" else f"🔴 {token.get('status','?')}"
|
||||||
|
token_env = token.get("env", "?")
|
||||||
|
|
||||||
|
vram = gpu.get("vram_used_gb")
|
||||||
|
vram_str = f"{vram}GB / {gpu.get('vram_total_gb', 16)}GB" if vram else "측정 불가"
|
||||||
|
cuda_str = "✅" if gpu.get("cuda_available") else "❌"
|
||||||
|
|
||||||
|
# 로그 에러 집계
|
||||||
|
logs = data.get("logs", {})
|
||||||
|
all_errors = {}
|
||||||
|
for ld in logs.values():
|
||||||
|
for k, v in ld.get("errors", {}).items():
|
||||||
|
all_errors[k] = all_errors.get(k, 0) + v
|
||||||
|
err_lines = "\n".join(
|
||||||
|
f" ⚠️ {k}: {v}회" for k, v in sorted(all_errors.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
) or " ✅ 없음"
|
||||||
|
|
||||||
|
balance = ipc.get("balance")
|
||||||
|
balance_str = f"\n 잔고: <code>{int(balance):,}원</code>" if balance else ""
|
||||||
|
wl_count = ipc.get("watchlist_count", 0)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>🔧 시스템 헬스 진단</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"<b>API 서버:</b> {api_str}\n"
|
||||||
|
f"<b>IPC 상태:</b> {ipc_emoji} {ipc_status} ({age_str})"
|
||||||
|
f"{balance_str}\n"
|
||||||
|
f" 감시종목: {wl_count}개\n"
|
||||||
|
f"<b>GPU/CUDA:</b> {cuda_str} VRAM: <code>{vram_str}</code>\n"
|
||||||
|
f"<b>KIS 토큰:</b> {token_str} ({token_env})\n\n"
|
||||||
|
f"<b>로그 에러 (최근):</b>\n{err_lines}"
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_risk(data: dict) -> str:
|
||||||
|
mdd = data.get("mdd", {})
|
||||||
|
dl = data.get("daily_loss", {})
|
||||||
|
cl = data.get("consecutive_losses", {})
|
||||||
|
cap = data.get("total_capital", 0)
|
||||||
|
|
||||||
|
mdd_val = mdd.get("mdd", 0) or 0
|
||||||
|
mdd_emoji = "✅" if mdd_val > -5 else ("⚠️" if mdd_val > -10 else "🔴")
|
||||||
|
|
||||||
|
dl_ratio = dl.get("ratio", 0) or 0
|
||||||
|
dl_emoji = "✅" if dl_ratio < 50 else ("⚠️" if dl_ratio < 75 else "🔴")
|
||||||
|
|
||||||
|
cl_count = cl.get("count", 0)
|
||||||
|
cl_active = cl.get("cooldown_active", False)
|
||||||
|
cl_emoji = "🚨" if cl_active else ("⚠️" if cl_count >= 2 else "✅")
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>🛡️ 리스크 대시보드</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"<b>총 자산:</b> <code>{int(cap):,}원</code>\n\n"
|
||||||
|
f"<b>MDD:</b> {mdd_emoji} <code>{mdd_val:.1f}%</code> ({mdd.get('level','?')})\n"
|
||||||
|
f" 최고점: <code>{int(mdd.get('peak',0) or 0):,}원</code> ({mdd.get('peak_days_ago','?')}일 전)\n"
|
||||||
|
f" 복구 필요: <code>+{mdd.get('recovery_needed',0):.1f}%</code>\n\n"
|
||||||
|
f"<b>일일 손실한도:</b> {dl_emoji} {dl_ratio:.0f}% 소진\n"
|
||||||
|
f" 한도: <code>{int(dl.get('limit',0) or 0):,}원</code> "
|
||||||
|
f"사용: <code>{int(dl.get('used',0) or 0):,}원</code>\n\n"
|
||||||
|
f"<b>연속 손절:</b> {cl_emoji} {cl_count}회"
|
||||||
|
)
|
||||||
|
if cl_active:
|
||||||
|
msg += f"\n 🚨 매수 중단 중 (재개: {cl.get('resume_time','?')})"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_regime(data: dict) -> str:
|
||||||
|
regime = data.get("regime", "?")
|
||||||
|
msi = data.get("msi", {})
|
||||||
|
params = data.get("recommended_params", {})
|
||||||
|
ens = params.get("ensemble", {})
|
||||||
|
data_source = data.get("data_source", "ipc")
|
||||||
|
source_note = " <i>(IPC 데이터 없음 — 기본값 기반)</i>\n" if data_source == "default" else ""
|
||||||
|
|
||||||
|
regime_emoji = {
|
||||||
|
"BULL_EXTREME": "🔥", "BULL_STRONG": "📈",
|
||||||
|
"NORMAL": "➡️", "BEAR_WEAK": "📉", "BEAR_STRONG": "🚨"
|
||||||
|
}.get(regime, "❓")
|
||||||
|
status_emoji = {"SAFE": "✅", "CAUTION": "⚠️", "DANGER": "🚨"}.get(msi.get("status", ""), "❓")
|
||||||
|
|
||||||
|
flags = msi.get("flags", {})
|
||||||
|
flag_lines = "\n".join(f" {v}" for v in flags.values())
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>📊 시장 레짐 분석</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"{source_note}"
|
||||||
|
f"<b>레짐:</b> {regime_emoji} {regime}\n"
|
||||||
|
f"<b>MSI:</b> {status_emoji} {msi.get('score','?')}/{msi.get('max','?')} ({msi.get('status','?')})\n\n"
|
||||||
|
f"<b>지표 현황:</b>\n{flag_lines}\n\n"
|
||||||
|
f"<b>권고 파라미터:</b>\n"
|
||||||
|
f" buy_threshold: <code>{params.get('buy_threshold','?')}</code>\n"
|
||||||
|
f" max_position: <code>{params.get('max_position_ratio','?')}</code>\n"
|
||||||
|
f" sl_atr_mult: <code>{params.get('sl_atr_multiplier','?')}</code>\n\n"
|
||||||
|
f"<b>앙상블 권고:</b>\n"
|
||||||
|
f" tech: <code>{ens.get('tech','?')}</code> "
|
||||||
|
f"lstm: <code>{ens.get('lstm','?')}</code> "
|
||||||
|
f"sent: <code>{ens.get('sentiment','?')}</code>\n"
|
||||||
|
f"<i>다음 점검: {params.get('next_check_days','?')}일 후</i>"
|
||||||
|
)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_model_health(data: dict) -> str:
|
||||||
|
models = data.get("models", {})
|
||||||
|
missing = data.get("missing_models", [])
|
||||||
|
|
||||||
|
grade_emoji = {"HEALTHY": "🟢", "WARNING": "🟡", "DEGRADED": "🟠",
|
||||||
|
"CRITICAL": "🔴", "MISSING": "⚫"}
|
||||||
|
grade_counts = {}
|
||||||
|
for info in models.values():
|
||||||
|
g = info.get("grade", "?")
|
||||||
|
grade_counts[g] = grade_counts.get(g, 0) + 1
|
||||||
|
|
||||||
|
# 우선순위 높은 종목 상위 5개
|
||||||
|
critical = [(t, i) for t, i in models.items() if i.get("grade") in ("CRITICAL", "DEGRADED")]
|
||||||
|
critical.sort(key=lambda x: {"CRITICAL": 0, "DEGRADED": 1}.get(x[1].get("grade"), 9))
|
||||||
|
|
||||||
|
summary_lines = "\n".join(
|
||||||
|
f" {grade_emoji.get(g,'?')} {g}: {cnt}개"
|
||||||
|
for g, cnt in grade_counts.items()
|
||||||
|
)
|
||||||
|
critical_lines = ""
|
||||||
|
for t, info in critical[:5]:
|
||||||
|
critical_lines += f"\n {grade_emoji.get(info['grade'],'?')} {t}: {info.get('reason','?')}"
|
||||||
|
|
||||||
|
missing_str = ""
|
||||||
|
if missing:
|
||||||
|
missing_str = f"\n\n<b>모델 없는 감시종목:</b>\n " + ", ".join(missing[:5])
|
||||||
|
if len(missing) > 5:
|
||||||
|
missing_str += f" 외 {len(missing)-5}개"
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>🤖 LSTM 모델 건강도</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"<b>체크포인트 {len(models)}개:</b>\n"
|
||||||
|
f"{summary_lines}"
|
||||||
|
)
|
||||||
|
if critical_lines:
|
||||||
|
msg += f"\n\n<b>조치 필요:</b>{critical_lines}"
|
||||||
|
msg += missing_str
|
||||||
|
if not critical and not missing:
|
||||||
|
msg += "\n\n✅ 모든 모델 정상"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_weights(data: dict) -> str:
|
||||||
|
current = data.get("current_global", {})
|
||||||
|
optimal = data.get("optimal_global", {})
|
||||||
|
health = data.get("ema_health", {})
|
||||||
|
contribs = data.get("signal_contributions", {})
|
||||||
|
|
||||||
|
issues = "\n".join(f" {i}" for i in health.get("issues", []))
|
||||||
|
health_status = "✅" if health.get("status") == "OK" else "⚠️"
|
||||||
|
|
||||||
|
contrib_lines = ""
|
||||||
|
for sig, c in contribs.items():
|
||||||
|
if c.get("total_trades", 0) > 0:
|
||||||
|
acc = c.get("accuracy", 0)
|
||||||
|
contrib_lines += f"\n {sig}: 정확도 {acc:.1%} ({c['total_trades']}거래)"
|
||||||
|
|
||||||
|
delta_lines = ""
|
||||||
|
for sig in ["tech", "lstm", "sentiment"]:
|
||||||
|
cur = current.get(sig, 0)
|
||||||
|
opt = optimal.get(sig, cur)
|
||||||
|
diff = round(opt - cur, 3)
|
||||||
|
arrow = "↑" if diff > 0 else ("↓" if diff < 0 else "→")
|
||||||
|
delta_lines += f"\n {sig:12s}: {cur} {arrow} <b>{opt}</b>"
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>⚖️ 앙상블 가중치</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"<b>EMA 학습 상태:</b> {health_status}\n{issues}\n"
|
||||||
|
)
|
||||||
|
if contrib_lines:
|
||||||
|
msg += f"\n<b>신호 기여도:</b>{contrib_lines}\n"
|
||||||
|
msg += f"\n<b>권고 조정:</b>{delta_lines}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_postmortem(data: dict) -> str:
|
||||||
|
stats = data.get("basic_stats", {})
|
||||||
|
combos = data.get("signal_combinations", {})
|
||||||
|
suggestions = data.get("parameter_suggestions", {})
|
||||||
|
days = data.get("days", 30)
|
||||||
|
|
||||||
|
wr = stats.get("win_rate", 0)
|
||||||
|
pr = stats.get("profit_ratio", 0)
|
||||||
|
wr_emoji = "✅" if wr >= 55 else ("⚠️" if wr >= 50 else "🔴")
|
||||||
|
pr_emoji = "✅" if pr >= 2.0 else ("⚠️" if pr >= 1.5 else "🔴")
|
||||||
|
|
||||||
|
best_combos = list(combos.items())[:2]
|
||||||
|
worst_combos = list(combos.items())[-2:]
|
||||||
|
|
||||||
|
combo_lines = ""
|
||||||
|
for k, v in best_combos:
|
||||||
|
combo_lines += f"\n ✅ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||||
|
for k, v in worst_combos:
|
||||||
|
if v["win_rate"] < 50:
|
||||||
|
combo_lines += f"\n ⚠️ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||||
|
|
||||||
|
suggest_lines = ""
|
||||||
|
for param, s in suggestions.items():
|
||||||
|
suggest_lines += f"\n {param}: {s.get('current','?')} → <b>{s.get('recommended','?')}</b>"
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>📊 매매 사후분석</b> (최근 {days}일)\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"<b>총 거래:</b> {stats.get('total',0)}건 "
|
||||||
|
f"승률: {wr_emoji} <code>{wr}%</code>\n"
|
||||||
|
f"<b>손익비:</b> {pr_emoji} <code>{pr}</code> "
|
||||||
|
f"Sharpe: <code>{stats.get('sharpe',0)}</code>\n"
|
||||||
|
f"평균 수익: <code>+{stats.get('avg_win_pct',0)}%</code> "
|
||||||
|
f"평균 손실: <code>-{stats.get('avg_loss_pct',0)}%</code>"
|
||||||
|
)
|
||||||
|
if combo_lines:
|
||||||
|
msg += f"\n\n<b>신호 조합:</b>{combo_lines}"
|
||||||
|
if suggest_lines:
|
||||||
|
msg += f"\n\n<b>파라미터 권고:</b>{suggest_lines}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_watchlist(data: dict) -> str:
|
||||||
|
scored = data.get("scored", [])
|
||||||
|
current = data.get("current_watchlist", [])
|
||||||
|
r_min, r_max = data.get("recommended_range", (8, 15))
|
||||||
|
|
||||||
|
to_add = [s for s in scored if s.get("action") == "편입"]
|
||||||
|
to_remove = [s for s in scored if s.get("action") == "제거"]
|
||||||
|
to_keep = [s for s in scored if s.get("action") == "유지" and s.get("in_watchlist")]
|
||||||
|
to_keep.sort(key=lambda x: x.get("total_score", 0), reverse=True)
|
||||||
|
|
||||||
|
add_lines = ""
|
||||||
|
for s in to_add[:5]:
|
||||||
|
wr = f" ({s['win_rate']:.0%})" if s.get("win_rate") else ""
|
||||||
|
add_lines += f"\n ✅ {s['ticker']} {s['total_score']}점 — {s.get('theme','?')}{wr}"
|
||||||
|
|
||||||
|
remove_lines = ""
|
||||||
|
for s in to_remove:
|
||||||
|
remove_lines += f"\n ✕ {s['ticker']} {s['total_score']}점"
|
||||||
|
|
||||||
|
keep_lines = ""
|
||||||
|
for s in to_keep[:3]:
|
||||||
|
keep_lines += f"\n • {s['ticker']} {s['total_score']}점"
|
||||||
|
|
||||||
|
final = len(current) - len(to_remove) + len(to_add)
|
||||||
|
size_ok = "✅" if r_min <= final <= r_max else "⚠️"
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"<b>📋 Watchlist 분석</b>\n"
|
||||||
|
f"━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
f"현재 {len(current)}종목 → 최종 {final}종목 {size_ok}\n"
|
||||||
|
f"권고 규모: {r_min}~{r_max}종목"
|
||||||
|
)
|
||||||
|
if add_lines:
|
||||||
|
msg += f"\n\n<b>편입 추천:</b>{add_lines}"
|
||||||
|
if remove_lines:
|
||||||
|
msg += f"\n\n<b>제거 추천:</b>{remove_lines}"
|
||||||
|
if keep_lines:
|
||||||
|
msg += f"\n\n<b>상위 유지 종목:</b>{keep_lines}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
# 공개 API — 텔레그램 핸들러에서 호출
|
||||||
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _to_chunks(text: str, limit: int = 3800) -> List[str]:
|
||||||
|
"""메시지가 Telegram 4096자 제한을 초과하면 청크로 분할"""
|
||||||
|
if len(text) <= limit:
|
||||||
|
return [text]
|
||||||
|
chunks = []
|
||||||
|
while text:
|
||||||
|
chunks.append(text[:limit])
|
||||||
|
text = text[limit:]
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
async def run_syshealth() -> List[str]:
|
||||||
|
script = _skill_script("bot-system-health-diagnostics", "health_checker.py")
|
||||||
|
r = await _run_script(script, timeout=30)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 시스템 헬스 실행 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_syshealth(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_risk() -> List[str]:
|
||||||
|
script = _skill_script("auto-trade-risk-manager", "risk_dashboard.py")
|
||||||
|
r = await _run_script(script, timeout=30)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 리스크 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_risk(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_regime() -> List[str]:
|
||||||
|
script = _skill_script("korean-market-regime-detector", "regime_calculator.py")
|
||||||
|
r = await _run_script(script, timeout=60)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 레짐 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_regime(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_model_health() -> List[str]:
|
||||||
|
script = _skill_script("lstm-model-health-monitor", "model_health_report.py")
|
||||||
|
r = await _run_script(script, timeout=60)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 모델 건강도 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_model_health(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_weights() -> List[str]:
|
||||||
|
script = _skill_script("ensemble-weight-optimizer", "weight_optimizer.py")
|
||||||
|
r = await _run_script(script, timeout=30)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 가중치 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_weights(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_postmortem(days: int = 30) -> List[str]:
|
||||||
|
script = _skill_script("trade-post-mortem-analyzer", "post_mortem_report.py")
|
||||||
|
r = await _run_script(script, extra_args=["--days", str(days)], timeout=30)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ 매매 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_postmortem(r["json_data"]))
|
||||||
|
if not r["output"].strip():
|
||||||
|
return [f"<b>📊 매매 사후분석</b> (최근 {days}일)\n━━━━━━━━━━━━━━━━━━\n<i>분석 대상 매매 기록이 없습니다.</i>"]
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
|
|
||||||
|
|
||||||
|
async def run_watchlist_check(candidates: Optional[List[str]] = None) -> List[str]:
|
||||||
|
script = _skill_script("watchlist-intelligence-curator", "watchlist_scorer.py")
|
||||||
|
extra = []
|
||||||
|
if candidates:
|
||||||
|
extra = ["--candidates"] + candidates
|
||||||
|
r = await _run_script(script, extra_args=extra, timeout=30)
|
||||||
|
if not r["ok"]:
|
||||||
|
return [f"⚠️ Watchlist 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||||
|
if r["json_data"]:
|
||||||
|
return _to_chunks(_fmt_watchlist(r["json_data"]))
|
||||||
|
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from modules.services.ollama import OllamaManager
|
from modules.services.llm_client import get_llm_client
|
||||||
from modules.analysis.technical import TechnicalAnalyzer
|
from modules.analysis.technical import TechnicalAnalyzer
|
||||||
from modules.analysis.deep_learning import ModelRegistry
|
from modules.analysis.deep_learning import ModelRegistry
|
||||||
|
from modules.analysis.market_regime import MarketRegimeDetector
|
||||||
|
from modules.analysis.ai_council import get_council
|
||||||
|
from modules.analysis.ensemble import get_ensemble
|
||||||
|
from modules.config import Config
|
||||||
|
|
||||||
# [최적화] 워커 프로세스별 전역 변수 (Ollama 캐싱)
|
# AI Council 마지막 호출 시각 캐시 (종목별, 과다 호출 방지)
|
||||||
_ollama_manager = None
|
_council_last_call: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def get_predictor(ticker=None):
|
def get_predictor(ticker=None):
|
||||||
@@ -16,24 +21,23 @@ def get_predictor(ticker=None):
|
|||||||
|
|
||||||
|
|
||||||
def get_ollama():
|
def get_ollama():
|
||||||
"""워커 프로세스 내에서 OllamaManager 인스턴스를 싱글톤으로 관리
|
"""LLMClient 싱글톤 반환 (Gemini 우선, Ollama 폴백)"""
|
||||||
- 종목마다 새 인스턴스를 만들면 Ollama에 동시 요청이 폭주해 데드락 발생"""
|
return get_llm_client()
|
||||||
global _ollama_manager
|
|
||||||
if _ollama_manager is None:
|
|
||||||
_ollama_manager = OllamaManager()
|
|
||||||
return _ollama_manager
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
|
def calculate_position_size(total_capital, current_price, volatility, score, ai_confidence,
|
||||||
max_per_stock=3000000):
|
max_per_stock=3000000, ticker=None):
|
||||||
"""
|
"""
|
||||||
[v2.0] 변동성 기반 포지션 사이징 (Modified Kelly Criterion)
|
[v3.1] Modified Kelly Criterion 기반 포지션 사이징
|
||||||
|
|
||||||
핵심 원칙:
|
핵심 원칙:
|
||||||
1. 변동성이 높으면 → 적은 수량 (리스크 관리)
|
1. Kelly Fraction: f* = (p*b - q) / b (과거 실전 승률 + 손익비 기반)
|
||||||
2. 확신도(score)가 높으면 → 많은 수량 (기회 포착)
|
- 데이터 부족 시 보수적 기본값 8% 사용
|
||||||
3. AI 신뢰도가 높으면 → 가산 비중
|
- Half-Kelly 적용으로 변동성 과대추정 보완
|
||||||
4. 절대 한 종목에 전체 자산의 15% 이상 투자하지 않음
|
2. 변동성 조절: ATR 기반 변동성에 따라 Kelly 비중 추가 조절
|
||||||
|
3. 확신도 조절: 앙상블 score에 따른 최종 배수
|
||||||
|
4. AI 신뢰도 가산: LSTM confidence 기반 (상한 0.80 반영)
|
||||||
|
5. 상한: min(종목당 최대, 자산의 20%, 실제 자산)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: 매수 수량 (0이면 매수 안 함)
|
int: 매수 수량 (0이면 매수 안 함)
|
||||||
@@ -41,10 +45,12 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
|
|||||||
if current_price <= 0 or total_capital <= 0:
|
if current_price <= 0 or total_capital <= 0:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 1. 기본 투자금 (전체 자산의 10%)
|
# 1. Kelly Fraction 기반 기본 투자 비중
|
||||||
base_invest = total_capital * 0.10
|
ensemble = get_ensemble()
|
||||||
|
kelly_f = ensemble.get_kelly_fraction(ticker=ticker, half_kelly=True)
|
||||||
|
base_invest = total_capital * kelly_f
|
||||||
|
|
||||||
# 2. 변동성 조절 계수 (변동성 높을수록 투자금 감소)
|
# 2. 변동성 조절 계수 (ATR% 기반, 변동성 높을수록 축소)
|
||||||
if volatility <= 1.0:
|
if volatility <= 1.0:
|
||||||
vol_factor = 1.2
|
vol_factor = 1.2
|
||||||
elif volatility <= 2.0:
|
elif volatility <= 2.0:
|
||||||
@@ -56,7 +62,7 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
|
|||||||
else:
|
else:
|
||||||
vol_factor = 0.3
|
vol_factor = 0.3
|
||||||
|
|
||||||
# 3. 확신도 조절 계수
|
# 3. 앙상블 확신도 조절 계수 (score 기반)
|
||||||
if score >= 0.85:
|
if score >= 0.85:
|
||||||
conf_factor = 2.0
|
conf_factor = 2.0
|
||||||
elif score >= 0.75:
|
elif score >= 0.75:
|
||||||
@@ -66,35 +72,43 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_
|
|||||||
else:
|
else:
|
||||||
conf_factor = 0.5
|
conf_factor = 0.5
|
||||||
|
|
||||||
# 4. AI 신뢰도 가산
|
# 4. AI 신뢰도 가산 (LSTM confidence 상한 0.80 반영)
|
||||||
ai_bonus = 1.0
|
ai_bonus = 1.0
|
||||||
if ai_confidence >= 0.85:
|
if ai_confidence >= 0.75:
|
||||||
ai_bonus = 1.3
|
ai_bonus = 1.2
|
||||||
elif ai_confidence >= 0.7:
|
elif ai_confidence >= 0.65:
|
||||||
ai_bonus = 1.1
|
ai_bonus = 1.1
|
||||||
|
|
||||||
# 5. 최종 투자금 계산
|
# 5. 최종 투자금 계산
|
||||||
invest_amount = base_invest * vol_factor * conf_factor * ai_bonus
|
invest_amount = base_invest * vol_factor * conf_factor * ai_bonus
|
||||||
|
|
||||||
invest_amount = min(invest_amount, max_per_stock)
|
invest_amount = min(invest_amount, max_per_stock) # 종목당 최대
|
||||||
invest_amount = min(invest_amount, total_capital * 0.15)
|
invest_amount = min(invest_amount, total_capital * 0.20) # 자산 20% 상한
|
||||||
invest_amount = min(invest_amount, total_capital)
|
invest_amount = min(invest_amount, total_capital)
|
||||||
|
|
||||||
qty = int(invest_amount / current_price)
|
qty = int(invest_amount / current_price)
|
||||||
|
kelly_pct = invest_amount / total_capital * 100 if total_capital > 0 else 0
|
||||||
|
print(f" [Kelly] f={kelly_f:.2%} invest={invest_amount:,.0f}won ({kelly_pct:.1f}%) qty={qty}")
|
||||||
return max(0, qty)
|
return max(0, qty)
|
||||||
|
|
||||||
|
|
||||||
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
||||||
macro_status=None, holding_info=None):
|
macro_status=None, holding_info=None, total_capital=None):
|
||||||
"""
|
"""
|
||||||
[v3.0] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
|
[v3.1] 종목 분석 + 매매 판단 (ProcessPoolExecutor에서 실행)
|
||||||
|
|
||||||
[v3.0 개선사항]
|
[v3.1 개선사항]
|
||||||
1. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
|
1. AdaptiveEnsemble 연동: 하드코딩 가중치 → 학습 기반 동적 가중치
|
||||||
2. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
|
2. Kelly Criterion 기반 포지션 사이징 (calculate_position_size)
|
||||||
3. 강화된 LLM 프롬프트 (거시경제 상태, 볼린저밴드, 거래량 급증, 보유 수익률)
|
3. 파일 mtime 동기화: 메인 프로세스의 record_trade 결과를 워커에 반영
|
||||||
|
[v3.0 기능 유지]
|
||||||
|
4. OHLCV 전체 수신 (실제 고가/저가/거래량 사용)
|
||||||
|
5. 종목별 ModelRegistry (가중치 덮어쓰기 방지)
|
||||||
|
6. 강화된 LLM 프롬프트
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# [v3.1] 메인 프로세스가 갱신한 앙상블 가중치 파일 감지 → 재로드
|
||||||
|
get_ensemble().reload_if_stale()
|
||||||
# OHLCV 데이터 분리 (하위호환: list 형태도 허용)
|
# OHLCV 데이터 분리 (하위호환: list 형태도 허용)
|
||||||
if isinstance(ohlcv_data, dict):
|
if isinstance(ohlcv_data, dict):
|
||||||
prices = ohlcv_data.get('close', [])
|
prices = ohlcv_data.get('close', [])
|
||||||
@@ -184,10 +198,18 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|||||||
for day in investor_trend:
|
for day in investor_trend:
|
||||||
frgn_net_buy += day['foreigner']
|
frgn_net_buy += day['foreigner']
|
||||||
orgn_net_buy += day['institutional']
|
orgn_net_buy += day['institutional']
|
||||||
|
|
||||||
|
# 연속 매수일 수: 가장 최근부터 역순으로 연속된 양수 일수만 카운트
|
||||||
|
for day in reversed(investor_trend):
|
||||||
if day['foreigner'] > 0:
|
if day['foreigner'] > 0:
|
||||||
consecutive_frgn_buy += 1
|
consecutive_frgn_buy += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
for day in reversed(investor_trend):
|
||||||
if day['institutional'] > 0:
|
if day['institutional'] > 0:
|
||||||
consecutive_orgn_buy += 1
|
consecutive_orgn_buy += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
if frgn_net_buy > 0:
|
if frgn_net_buy > 0:
|
||||||
investor_score += 0.03
|
investor_score += 0.03
|
||||||
@@ -253,47 +275,82 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|||||||
except Exception:
|
except Exception:
|
||||||
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
|
print(f" ⚠️ AI response parse failed, using neutral (0.5)")
|
||||||
|
|
||||||
# ===== 7. 통합 점수 (동적 가중치 v2.0) =====
|
# ===== 7. 통합 점수 (AdaptiveEnsemble v3.1) =====
|
||||||
|
# 하드코딩 가중치 → 학습 기반 동적 가중치 (과거 매매 결과 반영)
|
||||||
adx_val = ma_info.get('adx', 20)
|
adx_val = ma_info.get('adx', 20)
|
||||||
|
|
||||||
if ai_confidence >= 0.85 and adx_val >= 25:
|
ensemble = get_ensemble()
|
||||||
w_tech, w_news, w_ai = 0.15, 0.15, 0.70
|
weights = ensemble.get_weights(
|
||||||
print(f" 🤖 [Ultra High Confidence + Strong Trend] AI Weight 70%")
|
ticker=ticker,
|
||||||
elif ai_confidence >= 0.85:
|
adx=adx_val,
|
||||||
w_tech, w_news, w_ai = 0.20, 0.20, 0.60
|
macro_state=macro_state,
|
||||||
print(f" 🤖 [High Confidence] AI Weight 60%")
|
ai_confidence=ai_confidence
|
||||||
elif adx_val >= 30:
|
)
|
||||||
w_tech, w_news, w_ai = 0.50, 0.20, 0.30
|
print(f" [Ensemble] tech={weights.tech:.2f} news={weights.sentiment:.2f} "
|
||||||
print(f" 📊 [Very Strong Trend ADX={adx_val:.0f}] Tech Weight 50%")
|
f"lstm={weights.lstm:.2f} (adx={adx_val:.0f} conf={ai_confidence:.2f})")
|
||||||
elif adx_val < 20:
|
|
||||||
w_tech, w_news, w_ai = 0.30, 0.40, 0.30
|
|
||||||
print(f" 📰 [Sideways ADX={adx_val:.0f}] News Weight 40%")
|
|
||||||
else:
|
|
||||||
w_tech, w_news, w_ai = 0.35, 0.30, 0.35
|
|
||||||
|
|
||||||
total_score = (w_tech * tech_score) + (w_news * sentiment_score) + (w_ai * lstm_score)
|
total_score = ensemble.compute_ensemble_score(
|
||||||
|
tech_score=tech_score,
|
||||||
|
sentiment_score=sentiment_score,
|
||||||
|
lstm_score=lstm_score,
|
||||||
|
investor_score=investor_score,
|
||||||
|
weights=weights
|
||||||
|
)
|
||||||
|
|
||||||
total_score += min(investor_score, 0.15)
|
# ===== 7.5. 시장 레짐 감지 (코스피 수준 기반) =====
|
||||||
total_score = min(total_score, 1.0)
|
kospi_price = 0.0
|
||||||
|
kospi_change_val = 0.0
|
||||||
|
regime_analysis = None
|
||||||
|
if macro_status:
|
||||||
|
kospi_info = macro_status.get('indicators', {}).get('KOSPI', {})
|
||||||
|
kospi_price = float(kospi_info.get('price', 0) or 0)
|
||||||
|
kospi_change_val = float(kospi_info.get('change', 0) or 0)
|
||||||
|
|
||||||
|
if Config.MARKET_REGIME_ENABLED and kospi_price > 0:
|
||||||
|
regime_analysis = MarketRegimeDetector.detect(kospi_price, kospi_change_val)
|
||||||
|
print(
|
||||||
|
f" 📈 [Regime] {MarketRegimeDetector.get_regime_label(kospi_price)} "
|
||||||
|
f"risk={regime_analysis.risk_level} "
|
||||||
|
f"buy_adj={regime_analysis.buy_threshold_adj:+.2f} "
|
||||||
|
f"pos=x{regime_analysis.position_size_adj:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
# ===== 8. 시장 상황별 동적 임계값 =====
|
# ===== 8. 시장 상황별 동적 임계값 =====
|
||||||
buy_threshold = 0.60
|
buy_threshold = 0.60
|
||||||
sell_threshold = 0.30
|
sell_threshold = 0.30
|
||||||
|
danger_force_sell = False # DANGER 긴급 매도 플래그
|
||||||
|
|
||||||
if macro_status:
|
if macro_status:
|
||||||
if macro_state == 'DANGER':
|
if macro_state == 'DANGER':
|
||||||
buy_threshold = 999.0
|
buy_threshold = 999.0
|
||||||
sell_threshold = 0.45
|
sell_threshold = 0.35 # 이전 0.45에서 하향 (더 적극적 손절)
|
||||||
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold raised to 0.45")
|
print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold lowered to 0.35")
|
||||||
|
# 보유 중이고 손실이면 즉시 매도 플래그
|
||||||
|
if holding_info and holding_info.get('qty', 0) > 0:
|
||||||
|
hy = holding_info.get('yield', 0.0)
|
||||||
|
if hy < -3.0:
|
||||||
|
danger_force_sell = True
|
||||||
|
print(f" 🚨 [DANGER + Loss {hy:.1f}%] Emergency Sell Triggered")
|
||||||
elif macro_state == 'CAUTION':
|
elif macro_state == 'CAUTION':
|
||||||
buy_threshold = 0.72
|
buy_threshold = 0.72
|
||||||
sell_threshold = 0.38
|
sell_threshold = 0.38
|
||||||
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
|
print(f" ⚠️ [CAUTION Market] Buy threshold raised to 0.72")
|
||||||
|
|
||||||
|
# 레짐 기반 임계값 추가 조정 (거시경제 판단 이후 적용)
|
||||||
|
if regime_analysis and macro_state != 'DANGER':
|
||||||
|
buy_threshold = round(
|
||||||
|
max(0.55, buy_threshold + regime_analysis.buy_threshold_adj), 3
|
||||||
|
)
|
||||||
|
|
||||||
# ===== 9. 매매 결정 =====
|
# ===== 9. 매매 결정 =====
|
||||||
decision = "HOLD"
|
decision = "HOLD"
|
||||||
decision_reason = ""
|
decision_reason = ""
|
||||||
|
|
||||||
|
# DANGER 긴급 매도 (손실 보유종목)
|
||||||
|
if danger_force_sell:
|
||||||
|
decision = "SELL"
|
||||||
|
decision_reason = f"Emergency DANGER Market + Loss ({holding_info.get('yield', 0.0):.1f}%)"
|
||||||
|
|
||||||
if holding_info:
|
if holding_info:
|
||||||
holding_yield = holding_info.get('yield', 0.0)
|
holding_yield = holding_info.get('yield', 0.0)
|
||||||
holding_qty = holding_info.get('qty', 0)
|
holding_qty = holding_info.get('qty', 0)
|
||||||
@@ -333,7 +390,7 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|||||||
if tech_score >= 0.75 and lstm_score >= 0.6 and sentiment_score >= 0.6:
|
if tech_score >= 0.75 and lstm_score >= 0.6 and sentiment_score >= 0.6:
|
||||||
strong_signal = True
|
strong_signal = True
|
||||||
strong_reason = "Triple Confirmation (Tech+AI+News)"
|
strong_reason = "Triple Confirmation (Tech+AI+News)"
|
||||||
elif lstm_score >= 0.80 and ai_confidence >= 0.85 and adx_val >= 25:
|
elif lstm_score >= 0.78 and ai_confidence >= 0.75 and adx_val >= 25:
|
||||||
strong_signal = True
|
strong_signal = True
|
||||||
strong_reason = f"High Confidence AI + Strong Trend (ADX={adx_val:.0f})"
|
strong_reason = f"High Confidence AI + Strong Trend (ADX={adx_val:.0f})"
|
||||||
elif investor_score >= 0.10 and tech_score >= 0.60 and total_score >= 0.60:
|
elif investor_score >= 0.10 and tech_score >= 0.60 and total_score >= 0.60:
|
||||||
@@ -352,24 +409,115 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|||||||
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
|
decision_reason = f"Score {total_score:.2f} >= threshold {buy_threshold:.2f}"
|
||||||
|
|
||||||
# ===== 10. 포지션 사이징 =====
|
# ===== 10. 포지션 사이징 =====
|
||||||
|
# total_capital: 호출 측에서 실제 잔고 전달 (없으면 보수적 기본값 5M)
|
||||||
|
_capital = total_capital if (total_capital and total_capital > 0) else 5_000_000
|
||||||
suggested_qty = 0
|
suggested_qty = 0
|
||||||
if decision == "BUY":
|
if decision == "BUY":
|
||||||
suggested_qty = calculate_position_size(
|
suggested_qty = calculate_position_size(
|
||||||
total_capital=10000000,
|
total_capital=_capital,
|
||||||
current_price=current_price,
|
current_price=current_price,
|
||||||
volatility=volatility,
|
volatility=volatility,
|
||||||
score=total_score,
|
score=total_score,
|
||||||
ai_confidence=ai_confidence
|
ai_confidence=ai_confidence,
|
||||||
|
ticker=ticker
|
||||||
)
|
)
|
||||||
if suggested_qty == 0:
|
if suggested_qty == 0:
|
||||||
decision = "HOLD"
|
decision = "HOLD"
|
||||||
decision_reason = "Position size too small"
|
decision_reason = "Position size too small"
|
||||||
|
|
||||||
|
# 레짐 기반 포지션 크기 조정 (이미 계산된 수량에 배수 적용)
|
||||||
|
if regime_analysis and suggested_qty > 0:
|
||||||
|
adjusted_qty = int(suggested_qty * regime_analysis.position_size_adj)
|
||||||
|
if adjusted_qty != suggested_qty:
|
||||||
|
print(f" 📐 [Regime] 포지션 조정: {suggested_qty} → {adjusted_qty}주 "
|
||||||
|
f"(x{regime_analysis.position_size_adj:.2f})")
|
||||||
|
suggested_qty = max(0, adjusted_qty)
|
||||||
|
if suggested_qty == 0:
|
||||||
|
decision = "HOLD"
|
||||||
|
decision_reason = "Regime position size adjustment → 0"
|
||||||
|
|
||||||
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} "
|
print(f" └─ Scores: Tech={tech_score:.2f} News={sentiment_score:.2f} "
|
||||||
f"LSTM={lstm_score:.2f} Inv={investor_score:.2f} → "
|
f"LSTM={lstm_score:.2f} Inv={investor_score:.2f} → "
|
||||||
f"Total={total_score:.2f} [{decision}]"
|
f"Total={total_score:.2f} [{decision}]"
|
||||||
f"{f' ({decision_reason})' if decision_reason else ''}")
|
f"{f' ({decision_reason})' if decision_reason else ''}")
|
||||||
|
|
||||||
|
# ===== 11. AI 전문가 회의 (선택적, Config.AI_COUNCIL_ENABLED) =====
|
||||||
|
council_decision = None
|
||||||
|
if Config.AI_COUNCIL_ENABLED:
|
||||||
|
now = time.time()
|
||||||
|
last_call = _council_last_call.get(ticker, 0)
|
||||||
|
if now - last_call >= Config.AI_COUNCIL_MIN_INTERVAL:
|
||||||
|
_council_last_call[ticker] = now
|
||||||
|
council_data = {
|
||||||
|
"current_price": current_price,
|
||||||
|
"kospi_price": kospi_price,
|
||||||
|
"macro_state": macro_state,
|
||||||
|
"tech_score": tech_score,
|
||||||
|
"rsi": rsi,
|
||||||
|
"adx": adx_val,
|
||||||
|
"volatility": volatility,
|
||||||
|
"bb_zone": bb_zone,
|
||||||
|
"mtf_alignment": ma_info.get('mtf_alignment', 'N/A'),
|
||||||
|
"lstm_predicted": (
|
||||||
|
pred_result.get('predicted', current_price)
|
||||||
|
if pred_result else current_price
|
||||||
|
),
|
||||||
|
"lstm_change_rate": (
|
||||||
|
pred_result.get('change_rate', 0) if pred_result else 0
|
||||||
|
),
|
||||||
|
"ai_confidence": ai_confidence,
|
||||||
|
"lstm_score": lstm_score,
|
||||||
|
"sentiment_score": sentiment_score,
|
||||||
|
"investor_score": investor_score,
|
||||||
|
"frgn_net_buy": frgn_net_buy,
|
||||||
|
"consecutive_frgn_buy": consecutive_frgn_buy,
|
||||||
|
"is_holding": (
|
||||||
|
holding_info.get('qty', 0) > 0 if holding_info else False
|
||||||
|
),
|
||||||
|
"holding_yield": (
|
||||||
|
holding_info.get('yield', 0.0) if holding_info else 0.0
|
||||||
|
),
|
||||||
|
"total_score": total_score,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
council = get_council(get_ollama())
|
||||||
|
council_decision = council.convene(
|
||||||
|
ticker, council_data,
|
||||||
|
regime_analysis=regime_analysis,
|
||||||
|
fast_mode=Config.AI_COUNCIL_FAST_MODE,
|
||||||
|
)
|
||||||
|
# 모델 교체 권고 경고 출력
|
||||||
|
if council_decision.model_replacement_recommended:
|
||||||
|
print(
|
||||||
|
f" ⚠️ [Council] 모델 교체 권고: "
|
||||||
|
f"{council_decision.recommended_model}"
|
||||||
|
)
|
||||||
|
# 회의 결정이 기존 결정과 다르고 신뢰도 높으면 우선 적용
|
||||||
|
if council_decision.confidence >= 0.75:
|
||||||
|
council_final = council_decision.final_decision.upper()
|
||||||
|
if council_final != decision:
|
||||||
|
print(
|
||||||
|
f" 🔄 [Council Override] {decision} → {council_final} "
|
||||||
|
f"(conf={council_decision.confidence:.2f})"
|
||||||
|
)
|
||||||
|
decision = council_final
|
||||||
|
decision_reason = (
|
||||||
|
f"AI Council ({council_decision.confidence:.0%}): "
|
||||||
|
f"{council_decision.majority_reasoning[:80]}"
|
||||||
|
)
|
||||||
|
# BUY로 전환된 경우 수량 재계산
|
||||||
|
if decision == "BUY" and suggested_qty == 0:
|
||||||
|
suggested_qty = calculate_position_size(
|
||||||
|
total_capital=_capital,
|
||||||
|
current_price=current_price,
|
||||||
|
volatility=volatility,
|
||||||
|
score=council_decision.confidence,
|
||||||
|
ai_confidence=ai_confidence,
|
||||||
|
ticker=ticker,
|
||||||
|
)
|
||||||
|
except Exception as _ce:
|
||||||
|
print(f" [Council] 회의 오류: {_ce}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ticker": ticker,
|
"ticker": ticker,
|
||||||
"score": total_score,
|
"score": total_score,
|
||||||
@@ -387,7 +535,24 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None,
|
|||||||
"sl_tp": sl_tp,
|
"sl_tp": sl_tp,
|
||||||
"suggested_qty": suggested_qty,
|
"suggested_qty": suggested_qty,
|
||||||
"ai_confidence": ai_confidence,
|
"ai_confidence": ai_confidence,
|
||||||
"ai_reason": ai_reason
|
"ai_reason": ai_reason,
|
||||||
|
"regime": {
|
||||||
|
"kospi_level": kospi_price,
|
||||||
|
"regime": regime_analysis.regime.value if regime_analysis else "unknown",
|
||||||
|
"description": regime_analysis.description if regime_analysis else "",
|
||||||
|
"risk_level": regime_analysis.risk_level if regime_analysis else "LOW",
|
||||||
|
"model_recommendation": (
|
||||||
|
regime_analysis.model_recommendation if regime_analysis else ""
|
||||||
|
),
|
||||||
|
} if regime_analysis else None,
|
||||||
|
"council": {
|
||||||
|
"final": council_decision.final_decision,
|
||||||
|
"confidence": council_decision.confidence,
|
||||||
|
"model_health": council_decision.model_health_score,
|
||||||
|
"replace_recommended": council_decision.model_replacement_recommended,
|
||||||
|
"recommended_model": council_decision.recommended_model,
|
||||||
|
"summary": council_decision.council_summary,
|
||||||
|
} if council_decision else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
213
modules/utils/market_calendar.py
Normal file
213
modules/utils/market_calendar.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
KRX (한국거래소) 시장 캘린더
|
||||||
|
장 운영: 평일 09:00~15:30 KST (공휴일 제외)
|
||||||
|
|
||||||
|
우선순위:
|
||||||
|
1. exchange_calendars 라이브러리 (pip install exchange-calendars) → 음력 자동 계산
|
||||||
|
2. 하드코딩 폴백 (2024~2026 공휴일 내장)
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
KST = ZoneInfo("Asia/Seoul")
|
||||||
|
MARKET_OPEN = datetime.time(9, 0)
|
||||||
|
MARKET_CLOSE = datetime.time(15, 30)
|
||||||
|
|
||||||
|
# ── KRX 공휴일 하드코딩 (exchange_calendars 미설치 시 폴백) ──────────────────
|
||||||
|
# 출처: KRX 공식 휴장일 공고 (2024~2026)
|
||||||
|
STATIC_HOLIDAYS: frozenset[datetime.date] = frozenset({
|
||||||
|
# 2024
|
||||||
|
datetime.date(2024, 1, 1), # 신정
|
||||||
|
datetime.date(2024, 2, 9), # 설날 연휴
|
||||||
|
datetime.date(2024, 2, 12), # 대체공휴일
|
||||||
|
datetime.date(2024, 3, 1), # 삼일절
|
||||||
|
datetime.date(2024, 4, 10), # 국회의원선거
|
||||||
|
datetime.date(2024, 5, 5), # 어린이날
|
||||||
|
datetime.date(2024, 5, 6), # 대체공휴일
|
||||||
|
datetime.date(2024, 5, 15), # 부처님오신날
|
||||||
|
datetime.date(2024, 6, 6), # 현충일
|
||||||
|
datetime.date(2024, 8, 15), # 광복절
|
||||||
|
datetime.date(2024, 9, 16), # 추석 연휴
|
||||||
|
datetime.date(2024, 9, 17), # 추석
|
||||||
|
datetime.date(2024, 9, 18), # 추석 연휴
|
||||||
|
datetime.date(2024, 10, 3), # 개천절
|
||||||
|
datetime.date(2024, 10, 9), # 한글날
|
||||||
|
datetime.date(2024, 12, 25), # 성탄절
|
||||||
|
datetime.date(2024, 12, 31), # 연말 휴장
|
||||||
|
# 2025
|
||||||
|
datetime.date(2025, 1, 1), # 신정
|
||||||
|
datetime.date(2025, 1, 28), # 설날 연휴
|
||||||
|
datetime.date(2025, 1, 29), # 설날
|
||||||
|
datetime.date(2025, 1, 30), # 설날 연휴
|
||||||
|
datetime.date(2025, 3, 1), # 삼일절
|
||||||
|
datetime.date(2025, 3, 3), # 대체공휴일
|
||||||
|
datetime.date(2025, 5, 5), # 어린이날
|
||||||
|
datetime.date(2025, 5, 6), # 대체공휴일
|
||||||
|
datetime.date(2025, 6, 6), # 현충일
|
||||||
|
datetime.date(2025, 8, 15), # 광복절
|
||||||
|
datetime.date(2025, 10, 2), # 대체공휴일
|
||||||
|
datetime.date(2025, 10, 3), # 개천절
|
||||||
|
datetime.date(2025, 10, 6), # 추석 연휴
|
||||||
|
datetime.date(2025, 10, 7), # 추석
|
||||||
|
datetime.date(2025, 10, 8), # 추석 연휴
|
||||||
|
datetime.date(2025, 10, 9), # 한글날
|
||||||
|
datetime.date(2025, 12, 25), # 성탄절
|
||||||
|
datetime.date(2025, 12, 31), # 연말 휴장
|
||||||
|
# 2026
|
||||||
|
datetime.date(2026, 1, 1), # 신정
|
||||||
|
datetime.date(2026, 2, 16), # 설날 연휴
|
||||||
|
datetime.date(2026, 2, 17), # 설날
|
||||||
|
datetime.date(2026, 2, 18), # 설날 연휴
|
||||||
|
datetime.date(2026, 3, 1), # 삼일절
|
||||||
|
datetime.date(2026, 3, 2), # 대체공휴일
|
||||||
|
datetime.date(2026, 5, 5), # 어린이날
|
||||||
|
datetime.date(2026, 5, 24), # 부처님오신날
|
||||||
|
datetime.date(2026, 6, 6), # 현충일
|
||||||
|
datetime.date(2026, 8, 14), # 대체공휴일
|
||||||
|
datetime.date(2026, 8, 15), # 광복절
|
||||||
|
datetime.date(2026, 9, 24), # 추석 연휴
|
||||||
|
datetime.date(2026, 9, 25), # 추석
|
||||||
|
datetime.date(2026, 10, 3), # 개천절
|
||||||
|
datetime.date(2026, 10, 9), # 한글날
|
||||||
|
datetime.date(2026, 12, 25), # 성탄절
|
||||||
|
datetime.date(2026, 12, 31), # 연말 휴장
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class KRXCalendar:
|
||||||
|
"""
|
||||||
|
KRX 시장 캘린더
|
||||||
|
|
||||||
|
>>> cal = KRXCalendar()
|
||||||
|
>>> cal.is_trading_day(datetime.date(2026, 1, 1)) # 신정
|
||||||
|
False
|
||||||
|
>>> cal.is_trading_day(datetime.date(2026, 1, 2)) # 평일
|
||||||
|
True
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._ec_cal = None
|
||||||
|
try:
|
||||||
|
import exchange_calendars as ec
|
||||||
|
self._ec_cal = ec.get_calendar("XKRX")
|
||||||
|
print("[KRXCalendar] exchange_calendars 로드 성공 (정확한 음력 공휴일 사용)")
|
||||||
|
except ImportError:
|
||||||
|
print("[KRXCalendar] exchange_calendars 미설치 → 하드코딩 폴백 (pip install exchange-calendars 권장)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[KRXCalendar] exchange_calendars 로드 실패: {e} → 폴백 사용")
|
||||||
|
|
||||||
|
# ── 날짜 판별 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_trading_day(self, date: datetime.date | None = None) -> bool:
|
||||||
|
"""주어진 날짜가 KRX 거래일인지 확인 (기본: 오늘 KST)"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.datetime.now(KST).date()
|
||||||
|
if date.weekday() >= 5: # 토(5), 일(6)
|
||||||
|
return False
|
||||||
|
if self._ec_cal:
|
||||||
|
try:
|
||||||
|
return self._ec_cal.is_session(date.isoformat())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return date not in STATIC_HOLIDAYS
|
||||||
|
|
||||||
|
def now_kst(self) -> datetime.datetime:
|
||||||
|
"""현재 KST 시각"""
|
||||||
|
return datetime.datetime.now(KST)
|
||||||
|
|
||||||
|
def is_market_open(self) -> bool:
|
||||||
|
"""현재 KST 기준 장 중 여부 (09:00 ≤ time < 15:30)"""
|
||||||
|
now = self.now_kst()
|
||||||
|
if not self.is_trading_day(now.date()):
|
||||||
|
return False
|
||||||
|
return MARKET_OPEN <= now.time() < MARKET_CLOSE
|
||||||
|
|
||||||
|
def is_pre_market(self) -> bool:
|
||||||
|
"""장 시작 전 (당일 거래일이고 09:00 이전)"""
|
||||||
|
now = self.now_kst()
|
||||||
|
return self.is_trading_day(now.date()) and now.time() < MARKET_OPEN
|
||||||
|
|
||||||
|
def is_post_market(self) -> bool:
|
||||||
|
"""장 마감 후 (당일 거래일이고 15:30 이후)"""
|
||||||
|
now = self.now_kst()
|
||||||
|
return self.is_trading_day(now.date()) and now.time() >= MARKET_CLOSE
|
||||||
|
|
||||||
|
# ── 다음 장 시각 계산 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def next_trading_open(self) -> datetime.datetime:
|
||||||
|
"""
|
||||||
|
다음 장 시작 시각 (KST)
|
||||||
|
- 오늘이 거래일이고 아직 09:00 이전 → 오늘 09:00 반환
|
||||||
|
- 그 외 → 다음 거래일 09:00 반환
|
||||||
|
"""
|
||||||
|
now = self.now_kst()
|
||||||
|
date = now.date()
|
||||||
|
if self.is_trading_day(date) and now.time() < MARKET_OPEN:
|
||||||
|
return datetime.datetime.combine(date, MARKET_OPEN, tzinfo=KST)
|
||||||
|
# 다음 거래일 탐색 (최대 14일)
|
||||||
|
next_date = date + datetime.timedelta(days=1)
|
||||||
|
for _ in range(14):
|
||||||
|
if self.is_trading_day(next_date):
|
||||||
|
return datetime.datetime.combine(next_date, MARKET_OPEN, tzinfo=KST)
|
||||||
|
next_date += datetime.timedelta(days=1)
|
||||||
|
raise RuntimeError("14일 이내에 거래일을 찾지 못했습니다.")
|
||||||
|
|
||||||
|
def today_close(self) -> datetime.datetime | None:
|
||||||
|
"""오늘 장 종료 시각. 오늘이 거래일이 아니면 None."""
|
||||||
|
now = self.now_kst()
|
||||||
|
if not self.is_trading_day(now.date()):
|
||||||
|
return None
|
||||||
|
return datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||||
|
|
||||||
|
# ── 잔여 시간 계산 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def seconds_to_open(self) -> float:
|
||||||
|
"""장 시작까지 남은 초 (이미 장 중이거나 장 마감 후면 0)"""
|
||||||
|
if self.is_market_open():
|
||||||
|
return 0.0
|
||||||
|
try:
|
||||||
|
return max(0.0, (self.next_trading_open() - self.now_kst()).total_seconds())
|
||||||
|
except RuntimeError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def seconds_to_close(self) -> float:
|
||||||
|
"""장 종료까지 남은 초 (장 외 시간이면 0)"""
|
||||||
|
now = self.now_kst()
|
||||||
|
if not self.is_trading_day(now.date()):
|
||||||
|
return 0.0
|
||||||
|
close_dt = datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||||
|
return max(0.0, (close_dt - now).total_seconds())
|
||||||
|
|
||||||
|
def minutes_to_close(self) -> float:
|
||||||
|
return self.seconds_to_close() / 60
|
||||||
|
|
||||||
|
# ── 상태 요약 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def status_summary(self) -> str:
|
||||||
|
"""현재 시장 상태 요약 문자열 (로그/알림용)"""
|
||||||
|
now = self.now_kst()
|
||||||
|
today = now.date()
|
||||||
|
if not self.is_trading_day(today):
|
||||||
|
try:
|
||||||
|
nxt = self.next_trading_open()
|
||||||
|
return f"휴장 | 다음 거래일: {nxt.strftime('%m/%d(%a) %H:%M')}"
|
||||||
|
except Exception:
|
||||||
|
return "휴장"
|
||||||
|
if self.is_market_open():
|
||||||
|
mins = int(self.minutes_to_close())
|
||||||
|
return f"장 중 | 마감까지 {mins}분"
|
||||||
|
if now.time() < MARKET_OPEN:
|
||||||
|
secs = self.seconds_to_open()
|
||||||
|
return f"장 시작 전 | 개장까지 {int(secs / 60)}분"
|
||||||
|
return "장 마감"
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤 (프로세스 내 공유)
|
||||||
|
_calendar: KRXCalendar | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_calendar() -> KRXCalendar:
|
||||||
|
global _calendar
|
||||||
|
if _calendar is None:
|
||||||
|
_calendar = KRXCalendar()
|
||||||
|
return _calendar
|
||||||
@@ -6,11 +6,16 @@
|
|||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import datetime
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
from multiprocessing.shared_memory import SharedMemory
|
from multiprocessing.shared_memory import SharedMemory
|
||||||
|
|
||||||
from modules.config import Config
|
from modules.config import Config
|
||||||
|
|
||||||
|
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
|
||||||
|
_EOD_DATE_FILE = Path("data") / ".eod_date"
|
||||||
|
|
||||||
|
|
||||||
class ProcessTracker:
|
class ProcessTracker:
|
||||||
"""메모리 기반 프로세스 추적기"""
|
"""메모리 기반 프로세스 추적기"""
|
||||||
@@ -136,6 +141,17 @@ class ProcessWatchdog:
|
|||||||
entry = self._watched.get(name)
|
entry = self._watched.get(name)
|
||||||
return entry['process'] if entry else None
|
return entry['process'] if entry else None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_eod_today() -> bool:
|
||||||
|
"""오늘 EOD 마커 파일이 존재하면 True (장 마감 셧다운 → 재시작 차단)"""
|
||||||
|
try:
|
||||||
|
if not _EOD_DATE_FILE.exists():
|
||||||
|
return False
|
||||||
|
eod_date = datetime.date.fromisoformat(_EOD_DATE_FILE.read_text().strip())
|
||||||
|
return eod_date >= datetime.date.today()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _watchdog_loop(self):
|
def _watchdog_loop(self):
|
||||||
"""주기적으로 자식 프로세스 상태 확인"""
|
"""주기적으로 자식 프로세스 상태 확인"""
|
||||||
import multiprocessing
|
import multiprocessing
|
||||||
@@ -150,10 +166,15 @@ class ProcessWatchdog:
|
|||||||
if proc.is_alive():
|
if proc.is_alive():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 프로세스가 죽었음
|
# 프로세스가 종료됨
|
||||||
exit_code = proc.exitcode
|
exit_code = proc.exitcode
|
||||||
restart_count = entry['restart_count']
|
restart_count = entry['restart_count']
|
||||||
|
|
||||||
|
# [EOD 차단] 오늘 장 마감 셧다운이면 재시작하지 않음
|
||||||
|
if ProcessWatchdog.is_eod_today():
|
||||||
|
print(f"[Watchdog] {name}: EOD 셧다운 감지 — 재시작 건너뜀.")
|
||||||
|
continue
|
||||||
|
|
||||||
if restart_count >= Config.MAX_RESTART_COUNT:
|
if restart_count >= Config.MAX_RESTART_COUNT:
|
||||||
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
|
print(f"[Watchdog] {name} crashed (exit={exit_code}). "
|
||||||
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")
|
f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")
|
||||||
|
|||||||
Reference in New Issue
Block a user