From 0aebca7ff08f6af3f1c4ae1f7031a5b111139813 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 29 Mar 2026 05:21:23 +0900 Subject: [PATCH] =?UTF-8?q?v3.1=20=EA=B3=BC=EB=A7=A4=EC=88=98=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80,=20=EC=95=99=EC=83=81=EB=B8=94=20=ED=95=99=EC=8A=B5,?= =?UTF-8?q?=20KRX=20=EC=BA=98=EB=A6=B0=EB=8D=94=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EC=9E=A5=EC=A4=91=20=EC=A0=84=EC=9A=A9=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [잔고 관리] - _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 --- CLAUDE.md | 696 ++++++++++++++++++ daily_launcher.py | 155 ++++ main_server.py | 29 +- modules/analysis/ai_council.py | 445 +++++++++++ modules/analysis/deep_learning.py | 45 +- modules/analysis/ensemble.py | 358 ++++++--- modules/analysis/market_regime.py | 279 +++++++ modules/analysis/model_validator.py | 348 +++++++++ modules/bot.py | 267 ++++++- modules/config.py | 25 + modules/services/kis.py | 47 +- modules/services/llm_client.py | 199 +++++ modules/services/telegram_bot/server.py | 149 +++- modules/services/telegram_bot/skill_runner.py | 463 ++++++++++++ modules/strategy/process.py | 275 +++++-- modules/utils/market_calendar.py | 213 ++++++ modules/utils/process_tracker.py | 23 +- 17 files changed, 3816 insertions(+), 200 deletions(-) create mode 100644 CLAUDE.md create mode 100644 daily_launcher.py create mode 100644 modules/analysis/ai_council.py create mode 100644 modules/analysis/market_regime.py create mode 100644 modules/analysis/model_validator.py create mode 100644 modules/services/llm_client.py create mode 100644 modules/services/telegram_bot/skill_runner.py create mode 100644 modules/utils/market_calendar.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8fd00a --- /dev/null +++ b/CLAUDE.md @@ -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 ` | 서버 쉘 명령어 직접 실행 | 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()`로 자동 반영 diff --git a/daily_launcher.py b/daily_launcher.py new file mode 100644 index 0000000..dc5e1a0 --- /dev/null +++ b/daily_launcher.py @@ -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() diff --git a/main_server.py b/main_server.py index 82f2962..015a5d0 100644 --- a/main_server.py +++ b/main_server.py @@ -1,3 +1,6 @@ +import os +import signal +import threading import uvicorn import multiprocessing from fastapi import FastAPI, Request @@ -21,11 +24,11 @@ news_collector = 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") bot = AutoTradingBot(ipc_lock=ipc_lock, command_queue=command_queue, - shutdown_event=shutdown_event) + shutdown_event=shutdown_event, eod_event=eod_event) bot.loop() @@ -53,11 +56,12 @@ async def lifespan(app: FastAPI): ipc_lock = multiprocessing.Lock() command_queue = multiprocessing.Queue() shutdown_event = multiprocessing.Event() + eod_event = multiprocessing.Event() # [v3.1] EOD 셧다운 시그널 print("[Server] Starting AI Trading Bot & Telegram Bot...") # 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) bot_process = multiprocessing.Process( @@ -77,6 +81,25 @@ async def lifespan(app: FastAPI): 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 # [Shutdown] diff --git a/modules/analysis/ai_council.py b/modules/analysis/ai_council.py new file mode 100644 index 0000000..547c5e9 --- /dev/null +++ b/modules/analysis/ai_council.py @@ -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 diff --git a/modules/analysis/deep_learning.py b/modules/analysis/deep_learning.py index 8df2f6d..be33d44 100644 --- a/modules/analysis/deep_learning.py +++ b/modules/analysis/deep_learning.py @@ -4,6 +4,7 @@ import pickle import torch import torch.nn as nn import numpy as np +import pandas as pd from collections import OrderedDict 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) n = len(close) - if len(open_) != n: open_ = close.copy() - if len(high) != n: high = close.copy() - if len(low) != n: low = close.copy() + _degraded = [] + if len(open_) != n: open_ = close.copy(); _degraded.append('open') + 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: - 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: - volume_norm = np.zeros(n) + volume_norm = np.full(n, 0.2) # 데이터 없으면 중립값 rsi = _compute_rsi(close, period=14) rsi_norm = rsi / 100.0 # 0~1 정규화 @@ -375,8 +382,10 @@ class PricePredictor: change_rate = ((predicted_price - current_price) / current_price) * 100 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 '?'}: 쿨다운 중 → 캐시 예측 사용 " - f"({predicted_price:.0f} / {change_rate:+.2f}%)") + f"({predicted_price:.0f} / {change_rate:+.2f}% / conf={cached_conf:.2f})") return { "current": current_price, "predicted": float(predicted_price), @@ -384,7 +393,7 @@ class PricePredictor: "trend": trend, "loss": cached_loss, "val_loss": cached_loss, - "confidence": 0.62, + "confidence": round(cached_conf, 2), "epochs": 0, "device": str(self.device), "lr": self.optimizer.param_groups[0]['lr'], @@ -578,24 +587,28 @@ class PricePredictor: trend = "UP" if predicted_price > current_price else "DOWN" 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) if overfit_ratio < 0.5: - overfit_penalty = 0.7 - elif overfit_ratio > 2.0: - overfit_penalty = 0.8 + overfit_penalty = 0.65 # 심각한 언더피팅 + elif overfit_ratio > 2.5: + overfit_penalty = 0.75 # 오버피팅 else: overfit_penalty = 1.0 + # 에포크 수 기반 수렴 판단 epoch_factor = 1.0 if actual_epochs < 10: - epoch_factor = 0.6 + epoch_factor = 0.55 # 너무 이른 수렴 → 불신뢰 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 { "current": current_price, diff --git a/modules/analysis/ensemble.py b/modules/analysis/ensemble.py index 12e02c0..836b701 100644 --- a/modules/analysis/ensemble.py +++ b/modules/analysis/ensemble.py @@ -1,14 +1,17 @@ """ -앙상블 예측 모듈 (Phase 3-2) +앙상블 예측 모듈 (Phase 3-3) - LSTM + 기술지표 + LLM 감성 → 적응형 가중치 - 과거 매매 결과 기반 가중치 자동 조정 +- Kelly Criterion 기반 포지션 비중 계산 - process.py의 하드코딩된 w_tech/w_news/w_ai 대체 +- 파일 mtime 기반 cross-process 동기화 (워커 ↔ 메인 프로세스) """ import os import json +import time import numpy as np -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Dict, Optional from modules.config import Config @@ -21,12 +24,61 @@ class SignalWeights: sentiment: float = 0.30 lstm: float = 0.35 + # 각 신호의 허용 범위 + MIN_WEIGHT = 0.10 + MAX_WEIGHT = 0.65 + def normalize(self): - total = self.tech + self.sentiment + self.lstm - if total > 0: - self.tech /= total - self.sentiment /= total - self.lstm /= total + """ + 경계 보존 정규화 (합=1, MIN≤각값≤MAX 동시 보장) + + 단순 1/2차 정규화는 경계 위반을 반복 유발하므로 + 반복 배분 알고리즘(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 def to_dict(self): @@ -45,9 +97,11 @@ class AdaptiveEnsemble: 핵심 로직: 1. 종목별 최근 N 매매의 결과를 추적 - 2. 어떤 신호가 정확했는지 소급 평가 + 2. 어떤 신호가 정확했는지 소급 평가 (크기 가중 정확도) 3. 정확도가 높은 신호의 가중치를 점진적으로 증가 4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리 + 5. Kelly Criterion 기반 최적 포지션 비중 제공 + 6. 파일 mtime 기반 cross-process 동기화 (워커 프로세스 갱신) """ def __init__(self, history_file=None, max_history=50): @@ -55,17 +109,23 @@ class AdaptiveEnsemble: self.history_file = history_file or os.path.join( 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] = {} - # {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger" + # {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger" | "default" self._context_weights: Dict[str, SignalWeights] = { "strong_trend": SignalWeights(tech=0.50, sentiment=0.20, 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), - "default": SignalWeights(tech=0.35, sentiment=0.30, lstm=0.35), + "sideways": SignalWeights(tech=0.30, sentiment=0.40, 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), } + self._load_mtime: float = 0.0 # 마지막 파일 로드 시각 self._load() + # ────────────────────────────────────────────── + # 파일 I/O + # ────────────────────────────────────────────── + def _load(self): if os.path.exists(self.history_file): try: @@ -75,6 +135,7 @@ class AdaptiveEnsemble: weights_raw = data.get("weights", {}) for ctx, w in weights_raw.items(): self._context_weights[ctx] = SignalWeights.from_dict(w) + self._load_mtime = os.path.getmtime(self.history_file) except Exception as e: print(f"[Ensemble] Load failed: {e}") @@ -86,9 +147,29 @@ class AdaptiveEnsemble: } with open(self.history_file, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) + self._load_mtime = os.path.getmtime(self.history_file) except Exception as 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: """현재 시장 컨텍스트 결정""" if macro_state == "DANGER": @@ -105,85 +186,45 @@ class AdaptiveEnsemble: """ 종목 + 시장 컨텍스트에 맞는 가중치 반환 - 1. 기본: 컨텍스트별 기준 가중치 + 1. 컨텍스트별 기준 가중치 선택 2. AI 신뢰도 높으면 lstm 가중치 보정 - 3. 종목별 학습 결과 반영 + 3. 종목별 학습 결과 반영 (크기 가중 정확도 사용) """ context = self.get_context(adx, macro_state) base = self._context_weights.get(context, self._context_weights["default"]) - # 적응형 조정: 해당 종목의 과거 성과 반영 ticker_history = self._trade_history.get(ticker, []) adjusted = SignalWeights(tech=base.tech, sentiment=base.sentiment, lstm=base.lstm) if len(ticker_history) >= 5: - # 최근 5회 신호별 정확도 평가 recent = ticker_history[-10:] - tech_acc = self._accuracy([h["tech_score"] for h in recent], - [h["outcome"] for h in recent]) - news_acc = self._accuracy([h["sentiment_score"] for h in recent], - [h["outcome"] for h in recent]) - lstm_acc = self._accuracy([h["lstm_score"] for h in recent], - [h["outcome"] for h in recent]) + # _accuracy_weighted: 방향 일치 + 수익 크기 가중 반영 (단순 binary X) + tech_acc = self._accuracy_weighted( + [h.get("tech_score", 0.5) for h in recent], + [h["outcome"] for h in recent]) + news_acc = self._accuracy_weighted( + [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 - adjusted.tech = max(0.1, min(0.6, base.tech + alpha * (tech_acc - 0.5))) - adjusted.sentiment = max(0.1, min(0.6, base.sentiment + alpha * (news_acc - 0.5))) - adjusted.lstm = max(0.1, min(0.6, base.lstm + alpha * (lstm_acc - 0.5))) + alpha = 0.05 # 미세 조정폭 (±0.1 범위) + adjusted.tech = max(0.10, min(0.60, base.tech + alpha * (tech_acc - 0.5))) + adjusted.sentiment = max(0.10, min(0.60, base.sentiment + alpha * (news_acc - 0.5))) + adjusted.lstm = max(0.10, min(0.60, base.lstm + alpha * (lstm_acc - 0.5))) - # AI 신뢰도 보정 - if ai_confidence >= 0.85: - adjusted.lstm = min(0.70, adjusted.lstm * 1.3) + # AI 신뢰도 보정 (LSTM confidence 상한 0.80 기준 조정) + if ai_confidence >= 0.75: + adjusted.lstm = min(0.65, adjusted.lstm * 1.25) 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() - 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, lstm_score: float, investor_score: float = 0.0, @@ -205,25 +246,170 @@ class AdaptiveEnsemble: total += min(investor_score, 0.15) 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 - def _accuracy(scores: list, outcomes: list) -> float: - """신호와 결과의 상관도 계산 (0.5 = 무관, 1.0 = 완전 일치)""" + def _accuracy_weighted(scores: list, outcomes: list) -> float: + """ + 신호-결과 크기 가중 정확도 (0.0~1.0, 0.5=무관) + + - 단순 방향 일치(0/1)가 아닌 수익률 절댓값으로 가중 + - 큰 손실 예측 실패는 작은 이익 예측 성공보다 강하게 패널티 + """ if len(scores) < 3: return 0.5 - # 신호가 높을 때 수익, 낮을 때 손실이면 정확 - correct = sum( - 1 for s, o in zip(scores, outcomes) - if (s >= 0.5 and o > 0) or (s < 0.5 and o <= 0) - ) - return correct / len(scores) + + total_weight = 0.0 + weighted_correct = 0.0 + + for s, o in zip(scores, outcomes): + 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 def get_ensemble() -> AdaptiveEnsemble: - """워커 프로세스 내 싱글톤 앙상블 관리자""" + """프로세스 내 싱글톤 앙상블 관리자 반환 (워커/메인 각각 독립 인스턴스)""" global _ensemble_instance if _ensemble_instance is None: _ensemble_instance = AdaptiveEnsemble() diff --git a/modules/analysis/market_regime.py b/modules/analysis/market_regime.py new file mode 100644 index 0000000..38c181a --- /dev/null +++ b/modules/analysis/market_regime.py @@ -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})" diff --git a/modules/analysis/model_validator.py b/modules/analysis/model_validator.py new file mode 100644 index 0000000..b40e510 --- /dev/null +++ b/modules/analysis/model_validator.py @@ -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 diff --git a/modules/bot.py b/modules/bot.py index e2825a1..91f3edf 100644 --- a/modules/bot.py +++ b/modules/bot.py @@ -4,7 +4,7 @@ import json import time from concurrent.futures import ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool -from datetime import datetime +from datetime import datetime, timedelta from modules.config import Config 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.performance_db import PerformanceDB from modules.strategy.process import analyze_stock_process, calculate_position_size +from modules.analysis.ensemble import get_ensemble try: from theme_manager import ThemeManager @@ -43,7 +44,7 @@ class AutoTradingBot: 5. 최고가 추적 (트레일링 스탑용) 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. 서비스 초기화 self.kis = KISClient() self.news = AsyncNewsCollector() @@ -70,8 +71,27 @@ class AutoTradingBot: # [v2.0] 최근 매크로 상태 캐싱 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. 프로세스 관리 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) try: @@ -159,6 +179,86 @@ class AutoTradingBot: except Exception as 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): """주간 성과 평가 실행 후 텔레그램으로 전송.""" try: @@ -376,6 +476,11 @@ class AutoTradingBot: self.watchlist_updated_today = False # 전일 최고가 초기화 (보유하지 않는 종목) 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분 간격) self.monitor.check_health() @@ -395,9 +500,19 @@ class AutoTradingBot: if (now.weekday() == 4 and now.hour == 15 and 35 <= now.minute <= 45 and not self.weekly_eval_sent): 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() - print("[Bot] Market Closed. Waiting...") + if not self._eod_shutdown_done: + print("[Bot] Market Closed. Waiting...") return # [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단 @@ -438,7 +553,31 @@ class AutoTradingBot: analysis_tasks = [] 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 + 투자자 동향 배치 조회 tickers_list = list(target_dict.keys()) @@ -455,6 +594,9 @@ class AutoTradingBot: ohlcv_batch = {} investor_batch = {} + # [v3.1] 사이클당 매수 횟수 제한 + buys_this_cycle = 0 + try: for ticker, name in target_dict.items(): # OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback) @@ -483,7 +625,8 @@ class AutoTradingBot: future = self.executor.submit( 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) # 결과 처리 @@ -504,31 +647,41 @@ class AutoTradingBot: print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}") 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']) if current_price <= 0: continue - # [v2.0] 포지션 사이징 (동적 수량) - qty = calculate_position_size( - total_capital=total_eval if total_eval > 0 else tracking_deposit, - current_price=current_price, - volatility=res.get('volatility', 2.0), - score=res['score'], - ai_confidence=res.get('ai_confidence', 0.5) - ) + # [v3.1] 워커에서 Kelly Criterion으로 계산한 수량 직접 사용 + # (중복 계산 제거 — 워커가 total_eval 기준으로 이미 계산 완료) + qty = res.get('suggested_qty', 0) if qty <= 0: print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})") continue required_amount = current_price * qty - # 예수금 확인 + # 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액) if tracking_deposit < required_amount: - # 수량 줄여서 재시도 qty = int(tracking_deposit / current_price) if qty <= 0: print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): " - f"필요 {required_amount:,.0f} > 잔고 {tracking_deposit:,.0f}") + f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}") continue required_amount = current_price * qty @@ -574,12 +727,24 @@ class AutoTradingBot: ) 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._save_peak_prices() - # ===== 매도 처리 (v2.0 - 분석 기반 매도) ===== + # ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) ===== elif res['decision'] == "SELL" and ticker in current_holdings: h = current_holdings[ticker] qty = int(h.get('qty', 0)) @@ -611,6 +776,40 @@ class AutoTradingBot: # 성과 DB 매도 결과 기록 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"⛔ [매수 일시 중단] 당일 손절 " + 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: del self.peak_prices[ticker] @@ -637,12 +836,40 @@ class AutoTradingBot: print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초") 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( - "🚀 [Bot Started v3.0]\n" + "🚀 [Bot Started v3.1]\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"✅ 장 상태: {self._calendar.status_summary()}\n" "✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징") # 최고가 데이터 로드 diff --git a/modules/config.py b/modules/config.py index 8c57bbf..b692047 100644 --- a/modules/config.py +++ b/modules/config.py @@ -18,6 +18,12 @@ class Config: OLLAMA_NUM_CTX = int(os.getenv("OLLAMA_NUM_CTX", "4096")) # 8192→4096 (2배 속도) OLLAMA_NUM_PREDICT = int(os.getenv("OLLAMA_NUM_PREDICT", "200")) # 응답 토큰 제한 OLLAMA_NUM_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 한국투자증권 KIS_ENV_TYPE = os.getenv("KIS_ENV_TYPE", "virtual").lower() @@ -41,6 +47,9 @@ class Config: # 5. 매매 설정 (상수) 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. 데이터 경로 DATA_DIR = os.path.join(BASE_DIR, "data") @@ -80,6 +89,22 @@ class Config: CPU_CIRCUIT_BREAKER_THRESHOLD = 92 # CPU% 이상 시 분석 스킵 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 def validate(): """필수 설정 검증""" diff --git a/modules/services/kis.py b/modules/services/kis.py index e4faa6c..54a4a57 100644 --- a/modules/services/kis.py +++ b/modules/services/kis.py @@ -4,6 +4,11 @@ import time import os from datetime import datetime, timedelta +try: + import aiohttp +except ImportError: + aiohttp = None + from modules.config import Config class KISClient: @@ -120,7 +125,7 @@ class KISClient: try: print(f"🔑 [KIS] 토큰 발급 요청: {url}") - res = requests.post(url, json=payload) + res = requests.post(url, json=payload, timeout=Config.HTTP_TIMEOUT) res.raise_for_status() data = res.json() @@ -164,7 +169,7 @@ class KISClient: "appsecret": self.app_secret } 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"] except Exception as e: print(f"❌ Hash Key 생성 실패: {e}") @@ -185,10 +190,12 @@ class KISClient: try: if method == "GET": - res = requests.get(url, headers=headers, params=params) + res = requests.get(url, headers=headers, params=params, + timeout=Config.HTTP_TIMEOUT) 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 확인) is_token_error = False try: @@ -200,18 +207,20 @@ class KISClient: is_token_error = True except: pass - + if is_token_error: print("🔄 [KIS] Token expired (caught). Refreshing...") self.ensure_token(force=True) headers = self._get_headers(tr_id) if use_hash and data and "hashkey" in headers: pass # Hash 재활용 - + if method == "GET": - res = requests.get(url, headers=headers, params=params) + res = requests.get(url, headers=headers, params=params, + timeout=Config.HTTP_TIMEOUT) 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() return res.json() @@ -266,7 +275,8 @@ class KISClient: return { "holdings": holdings, "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: return {"error": str(e)} @@ -321,7 +331,7 @@ class KISClient: try: 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() data = res.json() @@ -348,7 +358,8 @@ class KISClient: } 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() data = res.json() if data['rt_cd'] != '0': @@ -564,12 +575,13 @@ class KISClient: } 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() data = res.json() if data['rt_cd'] != '0': return [] - + results = [] for item in data['output'][:limit]: # 코드는 shtn_iscd, 이름은 hts_kor_isnm @@ -664,7 +676,8 @@ class KISClient: } 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() data = res.json() if data['rt_cd'] != '0': @@ -699,7 +712,9 @@ class KISAsyncClient: async def _async_get(self, session, url, headers, params): """비동기 GET 요청""" 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() except Exception as e: print(f"[KIS Async] Request failed: {e}") diff --git a/modules/services/llm_client.py b/modules/services/llm_client.py new file mode 100644 index 0000000..5baa186 --- /dev/null +++ b/modules/services/llm_client.py @@ -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 diff --git a/modules/services/telegram_bot/server.py b/modules/services/telegram_bot/server.py index 5953c1c..e9750e9 100644 --- a/modules/services/telegram_bot/server.py +++ b/modules/services/telegram_bot/server.py @@ -62,6 +62,14 @@ class TelegramBotServer: "/system - PC 리소스(CPU/GPU) 상태\n" "/ai - AI 모델 학습 상태 조회\n" "/evaluate - 즉시 성과 평가 보고서 생성\n\n" + "[AI 진단 스킬]\n" + "/syshealth - 시스템 종합 건강 진단\n" + "/risk - 리스크 대시보드 (MDD, 연속손절)\n" + "/regime - 코스피 시장 레짐 감지\n" + "/model_health - LSTM 모델 건강 체크\n" + "/weights - 앙상블 가중치 분석\n" + "/postmortem [일수] - 매매 사후 분석 (기본 30일)\n" + "/watchlist_check - 감시 종목 스코어링\n\n" "[관리 명령어]\n" "/restart - 메인 봇 재시작 요청\n" "/exec 명령어 - 원격 명령어 실행\n" @@ -222,7 +230,11 @@ class TelegramBotServer: volume = int(v.get('volume', 0)) if price == 0: - msg += f"⚫ {k}: 데이터 없음 (장 마감 후)\n\n" + # 장 마감 후: prev_close(전일 종가)라도 표시 + if prev_close > 0: + msg += f"⚫ {k}: {prev_close:,.2f} (전일 종가 기준, 장 마감)\n\n" + else: + msg += f"⚫ {k}: 데이터 없음 (장 마감 후)\n\n" continue if change > 0: @@ -303,9 +315,18 @@ class TelegramBotServer: from modules.config import Config 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 = "AI Model Status\n" - msg += f"* LLM Engine: Ollama ({Config.OLLAMA_MODEL})\n" - msg += f"* Device: {gpu.get('name', 'GPU')}\n" + msg += f"* LLM Engine: {llm_primary}\n" + if llm_fallback: + msg += f"* Fallback: {llm_fallback}\n" + msg += f"* LSTM Device: {gpu.get('name', 'GPU')}\n" if gpu: msg += f"* GPU Load: {gpu.get('load', 0)}%\n" @@ -417,6 +438,121 @@ class TelegramBotServer: logging.error(f"[Command] /evaluate error: {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): handlers = [ ("start", self.start_command), @@ -428,6 +564,13 @@ class TelegramBotServer: ("system", self.system_command), ("ai", self.ai_status_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), ("stop", self.stop_command), ("exec", self.exec_command) diff --git a/modules/services/telegram_bot/skill_runner.py b/modules/services/telegram_bot/skill_runner.py new file mode 100644 index 0000000..89438d1 --- /dev/null +++ b/modules/services/telegram_bot/skill_runner.py @@ -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... (일부 생략)" + + +def _escape_html(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +# ───────────────────────────────────────────── +# 스킬별 포맷터 +# ───────────────────────────────────────────── + +def _fmt_syshealth(data: dict) -> str: + ipc = data.get("ipc", {}) + gpu = data.get("gpu", {}) + token = data.get("kis_token", {}) + procs = data.get("processes", {}) + + ipc_status = ipc.get("status", "?") + ipc_emoji = {"FRESH": "✅", "NORMAL": "✅", "STALE": "⚠️", + "EXPIRED": "🔴", "EMPTY": "⚠️", "ERROR": "🔴"}.get(ipc_status, "❓") + age = ipc.get("age_seconds") + age_str = f"{age}초 전" if age is not None else "알 수 없음" + + api_str = "✅ 실행 중" if procs.get("api_running") else "🔴 오프라인" + token_str = "✅ 유효" if token.get("status") == "VALID" else f"🔴 {token.get('status','?')}" + token_env = token.get("env", "?") + + vram = gpu.get("vram_used_gb") + vram_str = f"{vram}GB / {gpu.get('vram_total_gb', 16)}GB" if vram else "측정 불가" + cuda_str = "✅" if gpu.get("cuda_available") else "❌" + + # 로그 에러 집계 + logs = data.get("logs", {}) + all_errors = {} + for ld in logs.values(): + for k, v in ld.get("errors", {}).items(): + all_errors[k] = all_errors.get(k, 0) + v + err_lines = "\n".join( + f" ⚠️ {k}: {v}회" for k, v in sorted(all_errors.items(), key=lambda x: x[1], reverse=True) + ) or " ✅ 없음" + + balance = ipc.get("balance") + balance_str = f"\n 잔고: {int(balance):,}원" if balance else "" + wl_count = ipc.get("watchlist_count", 0) + + msg = ( + f"🔧 시스템 헬스 진단\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"API 서버: {api_str}\n" + f"IPC 상태: {ipc_emoji} {ipc_status} ({age_str})" + f"{balance_str}\n" + f" 감시종목: {wl_count}개\n" + f"GPU/CUDA: {cuda_str} VRAM: {vram_str}\n" + f"KIS 토큰: {token_str} ({token_env})\n\n" + f"로그 에러 (최근):\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"🛡️ 리스크 대시보드\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"총 자산: {int(cap):,}원\n\n" + f"MDD: {mdd_emoji} {mdd_val:.1f}% ({mdd.get('level','?')})\n" + f" 최고점: {int(mdd.get('peak',0) or 0):,}원 ({mdd.get('peak_days_ago','?')}일 전)\n" + f" 복구 필요: +{mdd.get('recovery_needed',0):.1f}%\n\n" + f"일일 손실한도: {dl_emoji} {dl_ratio:.0f}% 소진\n" + f" 한도: {int(dl.get('limit',0) or 0):,}원 " + f"사용: {int(dl.get('used',0) or 0):,}원\n\n" + f"연속 손절: {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 = " (IPC 데이터 없음 — 기본값 기반)\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"📊 시장 레짐 분석\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"{source_note}" + f"레짐: {regime_emoji} {regime}\n" + f"MSI: {status_emoji} {msi.get('score','?')}/{msi.get('max','?')} ({msi.get('status','?')})\n\n" + f"지표 현황:\n{flag_lines}\n\n" + f"권고 파라미터:\n" + f" buy_threshold: {params.get('buy_threshold','?')}\n" + f" max_position: {params.get('max_position_ratio','?')}\n" + f" sl_atr_mult: {params.get('sl_atr_multiplier','?')}\n\n" + f"앙상블 권고:\n" + f" tech: {ens.get('tech','?')} " + f"lstm: {ens.get('lstm','?')} " + f"sent: {ens.get('sentiment','?')}\n" + f"다음 점검: {params.get('next_check_days','?')}일 후" + ) + 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모델 없는 감시종목:\n " + ", ".join(missing[:5]) + if len(missing) > 5: + missing_str += f" 외 {len(missing)-5}개" + + msg = ( + f"🤖 LSTM 모델 건강도\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"체크포인트 {len(models)}개:\n" + f"{summary_lines}" + ) + if critical_lines: + msg += f"\n\n조치 필요:{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} {opt}" + + msg = ( + f"⚖️ 앙상블 가중치\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"EMA 학습 상태: {health_status}\n{issues}\n" + ) + if contrib_lines: + msg += f"\n신호 기여도:{contrib_lines}\n" + msg += f"\n권고 조정:{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','?')} → {s.get('recommended','?')}" + + msg = ( + f"📊 매매 사후분석 (최근 {days}일)\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"총 거래: {stats.get('total',0)}건 " + f"승률: {wr_emoji} {wr}%\n" + f"손익비: {pr_emoji} {pr} " + f"Sharpe: {stats.get('sharpe',0)}\n" + f"평균 수익: +{stats.get('avg_win_pct',0)}% " + f"평균 손실: -{stats.get('avg_loss_pct',0)}%" + ) + if combo_lines: + msg += f"\n\n신호 조합:{combo_lines}" + if suggest_lines: + msg += f"\n\n파라미터 권고:{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"📋 Watchlist 분석\n" + f"━━━━━━━━━━━━━━━━━━\n" + f"현재 {len(current)}종목 → 최종 {final}종목 {size_ok}\n" + f"권고 규모: {r_min}~{r_max}종목" + ) + if add_lines: + msg += f"\n\n편입 추천:{add_lines}" + if remove_lines: + msg += f"\n\n제거 추천:{remove_lines}" + if keep_lines: + msg += f"\n\n상위 유지 종목:{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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_syshealth(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_risk(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_regime(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_model_health(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_weights(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_postmortem(r["json_data"])) + if not r["output"].strip(): + return [f"📊 매매 사후분석 (최근 {days}일)\n━━━━━━━━━━━━━━━━━━\n분석 대상 매매 기록이 없습니다."] + return _to_chunks(f"
{_escape_html(r['output'])}
") + + +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{_escape_html(r['output'])}"] + if r["json_data"]: + return _to_chunks(_fmt_watchlist(r["json_data"])) + return _to_chunks(f"
{_escape_html(r['output'])}
") diff --git a/modules/strategy/process.py b/modules/strategy/process.py index 217944a..31ddd49 100644 --- a/modules/strategy/process.py +++ b/modules/strategy/process.py @@ -1,12 +1,17 @@ import os import json +import time 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.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 캐싱) -_ollama_manager = None +# AI Council 마지막 호출 시각 캐시 (종목별, 과다 호출 방지) +_council_last_call: dict = {} def get_predictor(ticker=None): @@ -16,24 +21,23 @@ def get_predictor(ticker=None): def get_ollama(): - """워커 프로세스 내에서 OllamaManager 인스턴스를 싱글톤으로 관리 - - 종목마다 새 인스턴스를 만들면 Ollama에 동시 요청이 폭주해 데드락 발생""" - global _ollama_manager - if _ollama_manager is None: - _ollama_manager = OllamaManager() - return _ollama_manager + """LLMClient 싱글톤 반환 (Gemini 우선, Ollama 폴백)""" + return get_llm_client() 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. 변동성이 높으면 → 적은 수량 (리스크 관리) - 2. 확신도(score)가 높으면 → 많은 수량 (기회 포착) - 3. AI 신뢰도가 높으면 → 가산 비중 - 4. 절대 한 종목에 전체 자산의 15% 이상 투자하지 않음 + 1. Kelly Fraction: f* = (p*b - q) / b (과거 실전 승률 + 손익비 기반) + - 데이터 부족 시 보수적 기본값 8% 사용 + - Half-Kelly 적용으로 변동성 과대추정 보완 + 2. 변동성 조절: ATR 기반 변동성에 따라 Kelly 비중 추가 조절 + 3. 확신도 조절: 앙상블 score에 따른 최종 배수 + 4. AI 신뢰도 가산: LSTM confidence 기반 (상한 0.80 반영) + 5. 상한: min(종목당 최대, 자산의 20%, 실제 자산) Returns: 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: return 0 - # 1. 기본 투자금 (전체 자산의 10%) - base_invest = total_capital * 0.10 + # 1. Kelly Fraction 기반 기본 투자 비중 + 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: vol_factor = 1.2 elif volatility <= 2.0: @@ -56,7 +62,7 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_ else: vol_factor = 0.3 - # 3. 확신도 조절 계수 + # 3. 앙상블 확신도 조절 계수 (score 기반) if score >= 0.85: conf_factor = 2.0 elif score >= 0.75: @@ -66,35 +72,43 @@ def calculate_position_size(total_capital, current_price, volatility, score, ai_ else: conf_factor = 0.5 - # 4. AI 신뢰도 가산 + # 4. AI 신뢰도 가산 (LSTM confidence 상한 0.80 반영) ai_bonus = 1.0 - if ai_confidence >= 0.85: - ai_bonus = 1.3 - elif ai_confidence >= 0.7: + if ai_confidence >= 0.75: + ai_bonus = 1.2 + elif ai_confidence >= 0.65: ai_bonus = 1.1 # 5. 최종 투자금 계산 invest_amount = base_invest * vol_factor * conf_factor * ai_bonus - invest_amount = min(invest_amount, max_per_stock) - invest_amount = min(invest_amount, total_capital * 0.15) + invest_amount = min(invest_amount, max_per_stock) # 종목당 최대 + invest_amount = min(invest_amount, total_capital * 0.20) # 자산 20% 상한 invest_amount = min(invest_amount, total_capital) 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) 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 개선사항] - 1. OHLCV 전체 수신 (실제 고가/저가/거래량 사용) - 2. 종목별 ModelRegistry (가중치 덮어쓰기 방지) - 3. 강화된 LLM 프롬프트 (거시경제 상태, 볼린저밴드, 거래량 급증, 보유 수익률) + [v3.1 개선사항] + 1. AdaptiveEnsemble 연동: 하드코딩 가중치 → 학습 기반 동적 가중치 + 2. Kelly Criterion 기반 포지션 사이징 (calculate_position_size) + 3. 파일 mtime 동기화: 메인 프로세스의 record_trade 결과를 워커에 반영 + [v3.0 기능 유지] + 4. OHLCV 전체 수신 (실제 고가/저가/거래량 사용) + 5. 종목별 ModelRegistry (가중치 덮어쓰기 방지) + 6. 강화된 LLM 프롬프트 """ try: + # [v3.1] 메인 프로세스가 갱신한 앙상블 가중치 파일 감지 → 재로드 + get_ensemble().reload_if_stale() # OHLCV 데이터 분리 (하위호환: list 형태도 허용) if isinstance(ohlcv_data, dict): 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: frgn_net_buy += day['foreigner'] orgn_net_buy += day['institutional'] + + # 연속 매수일 수: 가장 최근부터 역순으로 연속된 양수 일수만 카운트 + for day in reversed(investor_trend): if day['foreigner'] > 0: consecutive_frgn_buy += 1 + else: + break + for day in reversed(investor_trend): if day['institutional'] > 0: consecutive_orgn_buy += 1 + else: + break if frgn_net_buy > 0: investor_score += 0.03 @@ -253,47 +275,82 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None, except Exception: print(f" ⚠️ AI response parse failed, using neutral (0.5)") - # ===== 7. 통합 점수 (동적 가중치 v2.0) ===== + # ===== 7. 통합 점수 (AdaptiveEnsemble v3.1) ===== + # 하드코딩 가중치 → 학습 기반 동적 가중치 (과거 매매 결과 반영) adx_val = ma_info.get('adx', 20) - if ai_confidence >= 0.85 and adx_val >= 25: - w_tech, w_news, w_ai = 0.15, 0.15, 0.70 - print(f" 🤖 [Ultra High Confidence + Strong Trend] AI Weight 70%") - elif ai_confidence >= 0.85: - w_tech, w_news, w_ai = 0.20, 0.20, 0.60 - print(f" 🤖 [High Confidence] AI Weight 60%") - elif adx_val >= 30: - w_tech, w_news, w_ai = 0.50, 0.20, 0.30 - print(f" 📊 [Very Strong Trend ADX={adx_val:.0f}] Tech Weight 50%") - 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 + ensemble = get_ensemble() + weights = ensemble.get_weights( + ticker=ticker, + adx=adx_val, + macro_state=macro_state, + ai_confidence=ai_confidence + ) + print(f" [Ensemble] tech={weights.tech:.2f} news={weights.sentiment:.2f} " + f"lstm={weights.lstm:.2f} (adx={adx_val:.0f} conf={ai_confidence:.2f})") - 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) - total_score = min(total_score, 1.0) + # ===== 7.5. 시장 레짐 감지 (코스피 수준 기반) ===== + 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. 시장 상황별 동적 임계값 ===== buy_threshold = 0.60 sell_threshold = 0.30 + danger_force_sell = False # DANGER 긴급 매도 플래그 if macro_status: if macro_state == 'DANGER': buy_threshold = 999.0 - sell_threshold = 0.45 - print(f" 🚨 [DANGER Market] Buy BLOCKED, Sell threshold raised to 0.45") + sell_threshold = 0.35 # 이전 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': buy_threshold = 0.72 sell_threshold = 0.38 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. 매매 결정 ===== decision = "HOLD" 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: holding_yield = holding_info.get('yield', 0.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: strong_signal = True 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_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: @@ -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}" # ===== 10. 포지션 사이징 ===== + # total_capital: 호출 측에서 실제 잔고 전달 (없으면 보수적 기본값 5M) + _capital = total_capital if (total_capital and total_capital > 0) else 5_000_000 suggested_qty = 0 if decision == "BUY": suggested_qty = calculate_position_size( - total_capital=10000000, + total_capital=_capital, current_price=current_price, volatility=volatility, score=total_score, - ai_confidence=ai_confidence + ai_confidence=ai_confidence, + ticker=ticker ) if suggested_qty == 0: decision = "HOLD" 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} " f"LSTM={lstm_score:.2f} Inv={investor_score:.2f} → " f"Total={total_score:.2f} [{decision}]" 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 { "ticker": ticker, "score": total_score, @@ -387,7 +535,24 @@ def analyze_stock_process(ticker, ohlcv_data, news_items, investor_trend=None, "sl_tp": sl_tp, "suggested_qty": suggested_qty, "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: diff --git a/modules/utils/market_calendar.py b/modules/utils/market_calendar.py new file mode 100644 index 0000000..207a533 --- /dev/null +++ b/modules/utils/market_calendar.py @@ -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 diff --git a/modules/utils/process_tracker.py b/modules/utils/process_tracker.py index 4724b2d..5984ca3 100644 --- a/modules/utils/process_tracker.py +++ b/modules/utils/process_tracker.py @@ -6,11 +6,16 @@ """ import os import time +import datetime import threading +from pathlib import Path from multiprocessing.shared_memory import SharedMemory from modules.config import Config +# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용 +_EOD_DATE_FILE = Path("data") / ".eod_date" + class ProcessTracker: """메모리 기반 프로세스 추적기""" @@ -136,6 +141,17 @@ class ProcessWatchdog: entry = self._watched.get(name) 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): """주기적으로 자식 프로세스 상태 확인""" import multiprocessing @@ -150,10 +166,15 @@ class ProcessWatchdog: if proc.is_alive(): continue - # 프로세스가 죽었음 + # 프로세스가 종료됨 exit_code = proc.exitcode restart_count = entry['restart_count'] + # [EOD 차단] 오늘 장 마감 셧다운이면 재시작하지 않음 + if ProcessWatchdog.is_eod_today(): + print(f"[Watchdog] {name}: EOD 셧다운 감지 — 재시작 건너뜀.") + continue + if restart_count >= Config.MAX_RESTART_COUNT: print(f"[Watchdog] {name} crashed (exit={exit_code}). " f"Max restarts ({Config.MAX_RESTART_COUNT}) reached. Giving up.")