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:
2026-03-29 05:21:23 +09:00
parent 760d1906ed
commit 0aebca7ff0
17 changed files with 3816 additions and 200 deletions

696
CLAUDE.md Normal file
View 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
View 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()

View File

@@ -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]

View 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

View File

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

View File

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

View 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})"

View 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

View File

@@ -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"
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징") "✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
# 최고가 데이터 로드 # 최고가 데이터 로드

View File

@@ -19,6 +19,12 @@ class Config:
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():
"""필수 설정 검증""" """필수 설정 검증"""

View File

@@ -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,9 +190,11 @@ 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
@@ -209,9 +216,11 @@ class KISClient:
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,7 +575,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':
@@ -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}")

View 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

View File

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

View 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# ─────────────────────────────────────────────
# 스킬별 포맷터
# ─────────────────────────────────────────────
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>")

View File

@@ -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:

View 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

View File

@@ -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.")