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