Compare commits
99 Commits
760d1906ed
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ad141a2887 | |||
| 6774067505 | |||
| c451f5313b | |||
| 9241b5cd90 | |||
| 8bfc8e153f | |||
| 232aa52adb | |||
| d2f7030446 | |||
| 43ee610780 | |||
| f79c5c26df | |||
| 7108e5e4f5 | |||
| 1e6638a64b | |||
| 32308bede6 | |||
| ac6409605c | |||
| e4d02b8059 | |||
| 94a034ef38 | |||
| 2a11d05f4a | |||
| c2e77a7310 | |||
| bea27a75cf | |||
| 39adfc5fc5 | |||
| 1a848faac4 | |||
| cb70226f42 | |||
| de24bae984 | |||
| 0e6c893b4e | |||
| fb80973e38 | |||
| 31b0e7dbc4 | |||
| 6169f48eb8 | |||
| 27a6df6cff | |||
| 803fdb6278 | |||
| 77e21b54e6 | |||
| 4d0c89ce79 | |||
| 4b60ab34c3 | |||
| 53a0657027 | |||
| 91f01d126b | |||
| 0702cf052f | |||
| 8aa3f1c3b2 | |||
| 4db0551d33 | |||
| 4d837fdd31 | |||
| 2567a6f10b | |||
| 17ed1943f1 | |||
| 8d246b5b32 | |||
| b4bec9d51b | |||
| f32792e4a9 | |||
| f152545d3b | |||
| bf3d6ee694 | |||
| 44bc065796 | |||
| 9127616669 | |||
| 900f45c2ff | |||
| eb34cbc0f7 | |||
| 0de09613d2 | |||
| a5274a4fa7 | |||
| 4e72f8ca2e | |||
| 44c6811352 | |||
| 9eef2c5015 | |||
| b05e5714e3 | |||
| c8793cc3cf | |||
| 11e73f6960 | |||
| f1fc3e1102 | |||
| e0e56090ee | |||
| e0269bae39 | |||
| bee0add9dd | |||
| 1adf91a19b | |||
| 26ef660c75 | |||
| 139e4e3382 | |||
| bb03cc4525 | |||
| 71ef959310 | |||
| 2aa9f48ea3 | |||
| cc6310d72f | |||
| e574074ca8 | |||
| b9def06993 | |||
| 05ab2846bb | |||
| 760f914d3b | |||
| 8eefe9d79d | |||
| 91de16675b | |||
| 44888d6ede | |||
| 9e5fecb369 | |||
| 28f9c8c3a6 | |||
| c5a88fab66 | |||
| 7056cf2fa6 | |||
| 4ac7da8670 | |||
| b690900cfc | |||
| d85512d036 | |||
| 3ebe95ba29 | |||
| 163c9fb690 | |||
| 27bf360b01 | |||
| eafa73edb1 | |||
| 68eb7b073c | |||
| 8342d38935 | |||
| e47947fb69 | |||
| 94c684bab8 | |||
| 1a6d9fcb39 | |||
| 6cb5085118 | |||
| fdabc69004 | |||
| 90235497ae | |||
| 8469bf7ffa | |||
| 8a2fac03a6 | |||
| ad2c65c2b2 | |||
| 7ea1a21487 | |||
| 42b91d03cf | |||
| 0aebca7ff0 |
29
.gitignore
vendored
29
.gitignore
vendored
@@ -47,13 +47,38 @@ daily_trade_history.json
|
||||
watchlist.json
|
||||
bot_ipc.json
|
||||
|
||||
# Test
|
||||
# Test (top-level only; ai_trade/tests tracked separately)
|
||||
tests/
|
||||
tests/*
|
||||
!ai_trade/tests/
|
||||
!ai_trade/tests/**
|
||||
|
||||
# System
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# stock
|
||||
KIS_SETUP.md
|
||||
KIS_SETUP.md
|
||||
# Claude Code subagent state
|
||||
.claude/
|
||||
|
||||
# Signal V2 runtime data
|
||||
ai_trade/data/*.db
|
||||
ai_trade/data/*.db-*
|
||||
|
||||
# Plan-B-Insta services 예외 (코드는 추적, .env는 무시 유지)
|
||||
!services/
|
||||
!services/**/
|
||||
!services/**/*.py
|
||||
!services/**/Dockerfile
|
||||
!services/**/requirements.txt
|
||||
!services/**/.env.example
|
||||
!services/**/*.j2
|
||||
!services/**/*.html
|
||||
!services/**/*.css
|
||||
!services/**/.gitkeep
|
||||
!services/**/pytest.ini
|
||||
!services/docker-compose.yml
|
||||
# 단 실 .env는 무시 유지
|
||||
services/**/.env
|
||||
services/.env
|
||||
|
||||
277
CHECK_POINT.md
Normal file
277
CHECK_POINT.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# web-ai CHECK_POINT
|
||||
|
||||
> Windows AI Server (192.168.45.59), AMD 9800X3D + RTX 5070 Ti (16GB VRAM).
|
||||
> V1(LSTM 레거시) + V2(Chronos-2 signal pipeline) 이중 구조.
|
||||
> 2026-05-18 작성.
|
||||
|
||||
## 🚀 2026-05-18 박재오 7 결정 — Windows 컴퓨팅 노드 신설 (1주 작업)
|
||||
|
||||
박재오 결정 7건 완료. 상세 가이드: `Obsidian Vault/raw/2026-05-18-Windows-NAS-아키텍처-7결정-통합.md`
|
||||
|
||||
### 결정 6 — 옵션 4 하이브리드 운영
|
||||
|
||||
```
|
||||
[Windows AI Server]
|
||||
|
||||
🔵 Native Python (NSSM 자동 시작, HIGH priority)
|
||||
├─ ai_trade (트레이딩 :8001) ⭐ 절대 우선
|
||||
├─ Ollama qwen3:14b (:11435)
|
||||
└─ MusicGen (:8765)
|
||||
|
||||
🟢 WSL2 + Docker Engine (Docker Desktop X, 라이선스·메모리 ↓)
|
||||
├─ insta-render (:18710) ⭐ NEW
|
||||
├─ music-render (:8771) ⭐ NEW
|
||||
├─ video-render (:18712) ⭐ NEW (외부 API 게이트웨이)
|
||||
└─ task-watcher (옵션 d 작업 감지)
|
||||
```
|
||||
|
||||
### Day 2 — WSL2 + Docker Engine 설치 ⭐ (2시간)
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
wsl --install -d Ubuntu-22.04
|
||||
# 재부팅 후 Ubuntu 초기 설정
|
||||
|
||||
wsl -d Ubuntu-22.04
|
||||
# 안에서 Docker Engine 설치
|
||||
sudo apt update && sudo apt install -y ca-certificates curl gnupg
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list
|
||||
sudo apt update && sudo apt install -y docker-ce docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
sudo systemctl enable docker
|
||||
sudo systemctl start docker
|
||||
|
||||
# Tailscale (NAS와 같은 LAN 확인)
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
sudo tailscale up
|
||||
```
|
||||
|
||||
- [ ] WSL2 + Ubuntu 22.04 설치
|
||||
- [ ] Docker Engine 설치 (Desktop X)
|
||||
- [ ] Tailscale 가입
|
||||
|
||||
### Day 3~4 — insta-render 컨테이너 신설 ⭐ (4시간)
|
||||
|
||||
**디렉토리**: `C:\Users\jaeoh\Desktop\workspace\web-ai-services\insta-render\`
|
||||
|
||||
**Dockerfile**: Playwright + Python 3.12 (raw 파일에 풀 코드)
|
||||
|
||||
**main.py**: Redis 큐 worker + Playwright Browser pool + Semaphore(1)
|
||||
|
||||
- [ ] 디렉토리 생성 + Dockerfile + main.py + requirements.txt
|
||||
- [ ] `docker compose up -d insta-render`
|
||||
- [ ] 테스트: NAS Redis에서 작업 push → Windows에서 처리 확인
|
||||
|
||||
### Day 6 — NSSM 자동 시작 ⭐ (1시간)
|
||||
|
||||
**NSSM 다운로드**: https://nssm.cc/download
|
||||
|
||||
```powershell
|
||||
# 관리자 PowerShell
|
||||
|
||||
# 트레이딩 자동 시작 (HIGH priority — 결정 b)
|
||||
nssm install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001"
|
||||
nssm set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade"
|
||||
nssm set ai_trade Priority HIGH_PRIORITY_CLASS
|
||||
nssm set ai_trade AppStartup AUTO
|
||||
|
||||
# WSL2 + Docker 자동 시작
|
||||
nssm install wsl_docker "wsl" "-d Ubuntu-22.04 -- sudo service docker start && cd /workspace/web-ai-services && docker compose up -d"
|
||||
nssm set wsl_docker AppStartup AUTO
|
||||
|
||||
# 시작
|
||||
nssm start ai_trade
|
||||
nssm start wsl_docker
|
||||
```
|
||||
|
||||
- [ ] NSSM 다운로드 + 압축 해제 + PATH 추가
|
||||
- [ ] ai_trade service 등록 (HIGH priority)
|
||||
- [ ] wsl_docker service 등록
|
||||
- [ ] 재부팅 후 자동 시작 확인
|
||||
|
||||
### Day 7 — 작업 감지 (옵션 d) — task-watcher ⭐ (2시간)
|
||||
|
||||
박재오 작업 중 → Redis `queue:paused` 플래그 → 워커 일시정지. 트레이딩은 영향 X.
|
||||
|
||||
선택지:
|
||||
- **A. 자동 (Python pynput + PowerShell API)** — 마우스·게임 process 감지, 자동 토글
|
||||
- **B. 수동 토글** — 박재오님이 작업 시작 시 `redis-cli SET queue:paused 1`, 종료 시 `DEL`
|
||||
- **C. NAS frontend에 토글 UI 1개** — 클릭 한 번
|
||||
|
||||
→ 시작은 **B 수동 토글** (구현 0, 즉시 가능), 나중에 A 자동화로 진화.
|
||||
|
||||
- [ ] B 수동 토글 명령어 확인
|
||||
- [ ] 또는 A Python pynput 자동 감지 구현 (선택, 2시간)
|
||||
|
||||
### Day 5 — music-render 컨테이너 (선택 — MusicGen 패턴 정착)
|
||||
|
||||
기존 NAS music-lab → Windows MusicGen 호출 패턴 이미 운영 중. 표준화만:
|
||||
- [ ] Redis 큐 사용으로 전환 (HTTP 직접 호출 X)
|
||||
- [ ] Browser pool 같은 패턴 적용 (Suno + MusicGen 동시 1개)
|
||||
|
||||
### Day 5 — video-render 컨테이너 (선택 — 영상 생성 결정 4)
|
||||
|
||||
외부 영상 API 6개 게이트웨이 (Runway·Sora·Veo·Pika·Kling·Luma):
|
||||
- [ ] 박재오 자금·품질 판단 후 1~2개 가입
|
||||
- [ ] `.env`에 API 키 추가
|
||||
- [ ] video-render Docker 컨테이너 신설
|
||||
|
||||
---
|
||||
|
||||
## 🔥 2026-05-18 추가 — NAS API 부하 진짜 원인 발견
|
||||
|
||||
박재오 발견: 5건 + 중기 2건 적용 후 **web-ai V1+V2 4 process 종료가 NAS CPU 가장 큰 즉시 감소**.
|
||||
→ 진짜 병목은 **web-ai → NAS stock(:18500) 인바운드 API 호출 빈도**.
|
||||
|
||||
상세: `Obsidian Vault/raw/2026-05-18-NAS-Window-AI-API-부하-해결방안.md`
|
||||
|
||||
### 🔴 추가 즉시 작업 (50분으로 70% 부담 감소)
|
||||
|
||||
#### A. 캐시 TTL 대폭 증가 ⭐⭐⭐ (10분)
|
||||
**파일**: `ai_trade/stock_client.py`
|
||||
```python
|
||||
# 변경 전 → 변경 후
|
||||
PORTFOLIO_TTL = 60 → 180 # 3분
|
||||
NEWS_TTL = 300 → 600 # 10분
|
||||
SCREENER_TTL = 60 → 300 # 5분
|
||||
```
|
||||
- [ ] 3 TTL 상수 증가
|
||||
- 효과: 분당 12 호출 → 3~4 호출 (70% 감소)
|
||||
|
||||
#### B. V1·V2 단일화 결정 ⭐⭐ (10분 결정)
|
||||
- 동시 운영 시 NAS API 부담 2배 + KIS rate limit 충돌
|
||||
- **권장**: V1 폐기 + V2 단독 (Phase 4 자산 활용)
|
||||
- 또는 V2 임시 종료 + V1 유지 → Phase 5 진입 시 V1 폐기
|
||||
- [ ] 박재오 결정
|
||||
- [ ] 선택 안 된 쪽 `legacy/` 또는 `.disabled`
|
||||
- [ ] start.bat 한 쪽만
|
||||
|
||||
#### C. (NAS 측) stock TTLCache — web-backend CHECK_POINT.md #13 참고
|
||||
- 박재오가 web-backend/stock에서 별도 적용
|
||||
|
||||
## 🟢 현재 상태
|
||||
|
||||
- **V2 Phase 4 완료** (5/17, 56/56 tests pass, main push)
|
||||
- Chronos-2 zero-shot 1일 수익률 + 분봉 모멘텀 5단계
|
||||
- 09:00~15:30 매 1분 / 16:00 일봉 추론
|
||||
- Sell-first 우선순위 (stop_loss · anomaly · take_profit) + 매수 hard gate
|
||||
- Confidence = chronos×0.5 + momentum×0.3 + screener×0.2
|
||||
|
||||
---
|
||||
|
||||
## 🔴 즉시 (이번 주)
|
||||
|
||||
### 1. V1 vs V2 KIS rate limit 충돌 해결
|
||||
- **현재**: V1 + V2 동시 실행 시 KIS EGW00201 (초당 2회 제한) 충돌
|
||||
- **임시 해결**: V2 종료 상태 (현재 V1만 운영 중)
|
||||
- **결정 필요**: V1 deprecation 시점 (Phase 6)
|
||||
- [ ] 박재오 결정 — V1 폐기 일자 (예: 5/20 / 5/31)
|
||||
- [ ] Phase 5 진입 전 V1 정리
|
||||
|
||||
### 2. `.venv` 한글 경로 문제 해결
|
||||
- 가상환경 한글 경로 깨짐 → 시스템 Python 사용 강제
|
||||
- 다른 개발 머신 협업 시 문제
|
||||
- [ ] `.venv`를 영문 경로로 이전 또는 시스템 Python으로 통일
|
||||
- [ ] start.bat에 경로 명시
|
||||
|
||||
### 3. `state.signals` consumer-drain protocol 정의
|
||||
- Phase 5 prereq
|
||||
- 신호가 누적되기만 하고 소비 로직 미정의
|
||||
- [ ] consumer (예: agent-office /signal endpoint)가 처리한 신호 marking
|
||||
- [ ] 24h 만료 dedup 외에 *처리됨* 상태 추가
|
||||
|
||||
---
|
||||
|
||||
## 🟡 중기 (1~2주, Phase 5)
|
||||
|
||||
### 4. agent-office `/signal` 엔드포인트 통합
|
||||
- web-ai V2가 매수/매도 신호 생성 → agent-office로 push
|
||||
- agent-office가 텔레그램 발송 + 사용자 결정 대기
|
||||
- [ ] V2에서 agent-office HTTP POST 호출 추가
|
||||
- [ ] payload: ticker, action, confidence, reasoning, chronos_quantile
|
||||
|
||||
### 5. Ollama Qwen3 14B 통합 (Windows 로컬)
|
||||
- 신호 *해석* 레이어 — 단순 규칙 결과 → LLM 자연어 설명
|
||||
- 9.3GB VRAM 사용 (Chronos 7GB와 동시 가능, 15.5GB)
|
||||
- [ ] Ollama Windows 설치 (이미 실행 중인지 확인)
|
||||
- [ ] `state.signals` 큐에서 신호 pop → Qwen3 prompt → 결과 add
|
||||
- [ ] 텔레그램 전송 시 LLM 해석 텍스트 포함
|
||||
|
||||
### 6. 이중 텔레그램 전송
|
||||
- 현재: V1만 텔레그램 발송 (Telegram Bot + KIS 자동주문)
|
||||
- Phase 5: V2도 별도 chat_id로 발송 (박재오 본인용 + 검증용)
|
||||
- [ ] V2 텔레그램 chat_id 환경변수 (`TELEGRAM_V2_CHAT_ID`)
|
||||
- [ ] V1·V2 메시지 톤 차별화 (V1 = 자동주문 / V2 = 신호 알림)
|
||||
|
||||
### 7. holidays.json 자동 동기화
|
||||
- 현재: NAS에서 수동 copy
|
||||
- 한국 휴장일 누락 시 V2 폴링 실수 (휴장일에도 KIS 호출)
|
||||
- [ ] NAS realestate-lab 또는 별도 컨테이너에서 휴장일 자동 발급
|
||||
- [ ] V2가 부팅 시·매일 00:00에 GET 갱신
|
||||
|
||||
---
|
||||
|
||||
## 🟢 장기 (1개월+, Phase 6+)
|
||||
|
||||
### 8. V1 완전 deprecation
|
||||
- LSTM 7-feature 모델 + main_server.py 폐기
|
||||
- 모든 자동매매 V2로 통일
|
||||
- [ ] V1 종료 일자 박재오 결정
|
||||
- [ ] V1 코드 `legacy/` 폴더로 이동 (read-only)
|
||||
|
||||
### 9. Chronos-2 모델 미세조정 검토
|
||||
- 현재 zero-shot. 한국 주식 데이터로 미세조정 시 정확도 ↑ 가능?
|
||||
- 박재오 자체 학습 데이터 (KIS 1년치) → finetune
|
||||
- [ ] 데이터 수집·전처리
|
||||
- [ ] LoRA 또는 full finetune 결정
|
||||
|
||||
### 10. KIS WebSocket 실 운영 검증
|
||||
- 현재 코드는 있으나 검증 부족
|
||||
- 실시간 호가가 1분 폴링보다 빠른 신호 확보 가능
|
||||
- [ ] 1주일 운영 후 latency·드롭 측정
|
||||
|
||||
---
|
||||
|
||||
## ✅ 최근 완료 (참고)
|
||||
|
||||
- 2026-05-17: emit/skip 로깅 추가 (`2aa9f48`)
|
||||
- 2026-05-17: signal_generator poll_loop 통합 — Phase 4 완료 (`cc6310d`)
|
||||
- 2026-05-16: 코드 리뷰 수정 — sell-first 순서, anomaly 테스트 (`e574074`)
|
||||
- 2026-05-16: signal_generator 초안 — 9개 unit test (`b9def06`)
|
||||
- 2026-05-15: Foundation — 6개 env 임계값 + state.signals 필드 (`05ab284`)
|
||||
- 2026-05-14: FP32 강제 — Chronos FP16 overflow 회피 (`760f914`)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 운영 커맨드 (Windows PowerShell)
|
||||
|
||||
```powershell
|
||||
# V2 시작
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade
|
||||
.\start.bat
|
||||
|
||||
# 테스트 실행 (56 tests)
|
||||
pytest tests/ -v
|
||||
|
||||
# 로그 확인 (V2 실시간)
|
||||
Get-Content logs/ai_trade.log -Wait
|
||||
|
||||
# Chronos 메모리 사용 확인
|
||||
nvidia-smi
|
||||
|
||||
# KIS API 헬스 (REST)
|
||||
curl http://localhost:8001/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고
|
||||
|
||||
- 위키: [[자산-주식-자동매매]] (V3.1 → V2 Phase 4 정정 필요)
|
||||
- NAS stock 연동: `192.168.45.54:18500` (X-WebAI-Key 인증)
|
||||
- CLAUDE.md (본 디렉토리 루트): Phase 진행도 + 환경변수 명세
|
||||
|
||||
## 변경 이력
|
||||
|
||||
- 2026-05-18: 페이지 신설. 즉시 3건 (V1/V2 충돌, .venv 한글, consumer protocol) + 중기 4건 (Phase 5) + 장기 3건 (V1 deprecation·Chronos finetune·WebSocket).
|
||||
139
CLAUDE.md
Normal file
139
CLAUDE.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# web-ai — Workspace 가이드
|
||||
|
||||
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti 16GB) 의 두 신호 파이프라인.
|
||||
**Confidence Signal Pipeline V2 의 Windows-side 구현체** (NAS stock 백엔드와 HTTP 연동).
|
||||
|
||||
상위 워크스페이스 컨텍스트는 `../CLAUDE.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
| 경로 | 역할 | 포트 | 상태 |
|
||||
|------|------|------|------|
|
||||
| `signal_v1/` | ⚠️ **DEPRECATED 2026-05-19** — 레거시 LSTM 봇. 사용 안 함. `legacy/signal_v1/`로 이동 완료 (2026-05-19) | `:8000` | **OFF** |
|
||||
| `ai_trade/` | 자동매매 메인 (구 `signal_v2` 2026-05-19 rename) — Chronos-bolt + 분봉 모멘텀 + KIS WebSocket + 신호 생성 | `:8001` | **Phase 4 완료 (2026-05-17)**, Phase 5 대기 |
|
||||
| `legacy/start_v1.bat` | (deprecated) V1 진입점 — root `start.bat`에서 이동됨. 자동 실행 차단 | — | **OFF** |
|
||||
| `ai_trade/start.bat` | 자동매매 진입점 | — | `ai_trade/main.py` uvicorn 실행 |
|
||||
| `services/` | (예정) NAS↔Windows 분산 worker — insta-render·music-render·video-render·task-watcher | 18710~ | **Plan-B-Insta 작업 중** |
|
||||
| `.env` | 환경변수 (`KIS_REAL_*`, `TELEGRAM_*`, `STOCK_API_URL`, `WEBAI_API_KEY`, `LOG_LEVEL`) | — | |
|
||||
| `requirements.txt` | 공용 의존성 | — | torch, chronos-forecasting, fastapi, httpx, websockets 등 |
|
||||
|
||||
`.venv` 는 **구조적으로 깨짐**: `pyvenv.cfg` 가 한글 사용자 경로(`C:\Users\박재오\...`) 를 포함하여 콘솔 코드페이지가 roundtrip 못함. 테스트는 시스템 Python 으로 실행: `C:\Users\jaeoh\AppData\Local\Programs\Python\Python312\python.exe -m pytest ai_trade/tests -q`.
|
||||
|
||||
---
|
||||
|
||||
## 서버 시작 방식
|
||||
|
||||
### V1 (⚠️ DEPRECATED — 운영 X)
|
||||
2026-05-19부터 자동 시작 차단. `legacy/start_v1.bat`에 보존 (참고용만).
|
||||
별도 backtest 등 1회성 시 필요 시 박재오 직접 `legacy/start_v1.bat` 실행.
|
||||
|
||||
### ai_trade 단독 (smoke/검증)
|
||||
```bat
|
||||
cd C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade
|
||||
.\start.bat
|
||||
```
|
||||
기대 로그: `Uvicorn running on http://0.0.0.0:8001`, `poll_loop started`, `[KIS] minute bars ... OK`, `[Chronos] predicted N tickers`, `signal emit XXXXXX buy conf=0.xxx`.
|
||||
|
||||
휴장일/장 외 시간엔 `poll_loop` 만 idle. `Application startup complete` 만 보이면 정상.
|
||||
|
||||
### V1 + V2 동시 실행 — **권장 안 함**
|
||||
**KIS app_key 초당 2회 한도 (EGW00201)** 충돌. V1 cycle + V2 분봉 cron 이 같은 KIS app_key 로 동시 호출하면 rate limit. 채택 해결책: V2 임시 종료 (Phase 3a 결정), Phase 6 V1 deprecation 시 자연 해소. 별도 app_key 발급은 옵션 B.
|
||||
|
||||
---
|
||||
|
||||
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||
|
||||
| Phase | 내용 | 상태 |
|
||||
|-------|------|------|
|
||||
| 0 | Architecture & contract spec | ✅ Chronos-2 + Qwen3 14B 채택 |
|
||||
| 1 | stock 백엔드 WebAI API 보강 (NAS) | ✅ 102/102 tests, 운영 배포 |
|
||||
| 1.5 | V1 → `signal_v1/` rename | ✅ V1 정상 기동 |
|
||||
| 2 | ai_trade pull worker + signal API client + scheduler | ✅ 19/19 tests, `:8001` 기동 |
|
||||
| 3a | KIS REST 분봉 + WebSocket 호가 + NXT 스케줄 | ✅ 33/33 tests |
|
||||
| 3b | Chronos-bolt-base 추론 + 5분봉 모멘텀 분류기 | ✅ 45/45 tests, 실 KIS+Chronos chain 검증 |
|
||||
| 4 | Signal Generator (매수/매도 룰) + pull_worker 통합 + 로깅 | ✅ **2026-05-17 완료, 56/56 tests, push 완료** |
|
||||
| 5 | agent-office `/signal` + Ollama Qwen3 14B + 이중 텔레그램 | ⏳ 2주 예상 |
|
||||
| 6 | signal_v1 deprecation | ⏳ 1주 |
|
||||
| 7 | 운영 모니터링 + 4주 IC 검증 | ⏳ 1주 + 4주 |
|
||||
|
||||
상세 spec/plan: `../web-ui/docs/superpowers/specs/` 및 `../web-ui/docs/superpowers/plans/` (web-ui repo 안에 보관됨 — V2 자체 코드와 분리 보관).
|
||||
|
||||
---
|
||||
|
||||
## ai_trade 디렉토리 내부
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `main.py` | FastAPI app + lifespan (StockClient + KISClient + KISWebSocket + ChronosPredictor + SignalDedup 초기화). poll_loop task 생성 |
|
||||
| `config.py` | Settings dataclass — 환경변수 로드. Phase 4 추가 6 필드: `stop_loss_pct`, `take_profit_pct`, `chronos_spread_threshold`, `asking_bid_ratio_threshold`, `confidence_threshold`, `min_momentum_for_buy` |
|
||||
| `state.py` | PollState (process-wide singleton) — portfolio, screener_preview, news_sentiment, chronos_predictions, minute_bars, asking_price, **signals** (Phase 4) |
|
||||
| `stock_client.py` | NAS stock 백엔드 pull (X-WebAI-Key + 메모리 cache 60s/300s/60s + retry) |
|
||||
| `kis_client.py` | KIS REST 분봉/호가 — V1 토큰 read-only 공유 (mtime cache) + 초당 2회 throttle + 지수 backoff |
|
||||
| `kis_websocket.py` | KIS WebSocket H0STASP0 호가 + approval_key + 재연결 (1→2→4→max 30s) |
|
||||
| `chronos_predictor.py` | `amazon/chronos-bolt-base` zero-shot quantile (FP32 강제 — FP16 overflow 회피) |
|
||||
| `minute_momentum.py` | 5분봉 → strong_up/weak_up/neutral/weak_down/strong_down 5단계 분류 |
|
||||
| `signal_generator.py` | **Phase 4 — 매수/매도 룰 엔진**. `generate_signals(state, dedup, settings)` 진입. sell-first → buy 순서. 신호 emit/skip INFO/DEBUG 로그 |
|
||||
| `pull_worker.py` | asyncio cron — 장전 5분 / 장중 1분 / 장후 5분 / NXT / dead zone skip. cycle 끝에 `generate_signals` 호출 |
|
||||
| `scheduler.py` | polling window 판정 (KST 캘린더 + 휴장일) |
|
||||
| `rate_limit.py` | 초당 N회 token bucket |
|
||||
| `dedup.py` | SignalDedup SQLite WAL — `(ticker, action)` PK 24h |
|
||||
| `tests/` | 56 tests (pytest + respx HTTP mock + monkeypatch) |
|
||||
| `data/` | dedup.db (SQLite WAL) + `holidays.json` (NAS stock 에서 manual copy) |
|
||||
| `start.bat` | V2 진입 |
|
||||
|
||||
---
|
||||
|
||||
## 신호 룰 요약 (Phase 4)
|
||||
|
||||
### 매수 (screener Top-N + portfolio, sell 신호 받은 종목은 skip)
|
||||
모두 충족:
|
||||
1. `chronos.median > 0`
|
||||
2. **`chronos.q90 - chronos.q10 < 0.6`** (absolute spread — 2026-05-17 spec amend, 기존 relative formula 가 zero-shot median≈0 빈번에서 모든 신호 거부)
|
||||
3. `minute_momentum == strong_up` (env 로 조정 가능)
|
||||
4. `asking_price.bid_ratio >= 0.6`
|
||||
|
||||
종합 confidence = `chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2`. `> 0.7` 시 emit.
|
||||
|
||||
### 매도 (portfolio only, 우선순위 stop_loss → anomaly → take_profit)
|
||||
- **stop_loss**: `pnl_pct < -7%` 즉시 (confidence=1.0)
|
||||
- **anomaly**: `chronos.median < -1%` + `strong_down` + `bid_ratio < 0.4` + 종합 conf > 0.7
|
||||
- **take_profit**: `pnl_pct > 15%` 검토 (confidence=0.6)
|
||||
|
||||
---
|
||||
|
||||
## 알려진 함정 / Phase 7 백로그
|
||||
|
||||
1. **KIS rate limit (EGW00201)** — V1+V2 동시 실행 시 충돌. Phase 6 자연 해소
|
||||
2. **`.venv` 한글 경로 깨짐** — 시스템 Python 사용
|
||||
3. **Chronos FP16 overflow** — 한국 주가 5만+ 시 inf. FP32 강제 (`chronos_predictor.py:39-41`)
|
||||
4. **`predict_quantiles` positional `inputs`** — ChronosBolt API 새 변경. `try/except TypeError` fallback 처리됨
|
||||
5. **`state.signals` consumer-drain protocol 미정의** — Phase 5 prereq. dict 무한 누적 위험 (실제로는 bounded by unique ticker count)
|
||||
6. **integration test 가 poll_loop 실제 호출 안 함** — `test_pull_worker.py:test_poll_loop_calls_generate_signals_after_cycle` 가 `generate_signals` 직접 호출. Phase 7 hardening 시 mock-iteration 으로 강화
|
||||
7. **KIS WebSocket URL `ws://ops.koreainvestment.com:21000/31000`** — 첫 운영 시 실제 KIS API docs 와 대조 필요
|
||||
8. **`_parse_asking_price` 필드 인덱스** — 마지막 2 필드 가정. 실 운영 raw 메시지 캡처 후 매핑 검증 필요
|
||||
9. **`holidays.json` 자동 동기화 부재** — NAS stock 의 `holidays.json` 을 수동 copy
|
||||
10. **schema rename** — Phase 0 §5.2 의 `lstm_pred_*`, `news_top[]` 는 `chronos_pred_*`, `news_reason(string)` 으로 변경됨. Phase 5 prompt 작성 시 반영
|
||||
11. **6개 env 필드가 `.env` 에 미기재** — 기본값으로 동작 가능하나 discoverability 위해 `.env.example` 또는 commented block 추가 권장
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계 (Phase 5 진입 시 brainstorming 주제)
|
||||
|
||||
- `state.signals` consumer 패턴: pop vs leave + Phase 5 자체 dedup
|
||||
- agent-office 의 `/signal` endpoint 설계 — POST 페이로드 schema
|
||||
- Ollama Qwen3 14B Q4 로컬 호출 — 타임아웃, retry, VRAM 공존 (Chronos + Qwen3 동시 메모리 9.3GB / 15.5GB 가용)
|
||||
- 이중 텔레그램 (본인 풀 / 아내 lite) — context augmentation 단일 호출에서 양쪽 메시지 생성
|
||||
- LLM 비용: ₩0 목표 유지 (로컬)
|
||||
|
||||
---
|
||||
|
||||
## 양쪽 디렉토리 (web-ui ↔ web-ai) 작업 시 주의
|
||||
|
||||
- **코드**: ai_trade 는 web-ai/, spec/plan/메모리는 web-ui/
|
||||
- **커밋**: `web-ai` 와 `web-ui` 는 **별도 Gitea 저장소**. 각각 경로에서만 `git add/commit/push`
|
||||
- **메모리**: Claude Code 의 auto-memory 는 디렉토리별 격리. 핵심 reference 는 양쪽에 미러됨 (`./memory-mirror/` 또는 `~/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ai/memory/`)
|
||||
- **spec amendment 발생 시**: 코드는 `web-ai` 에 commit, spec 갱신은 `web-ui/docs/superpowers/specs/` 에 commit (Phase 4 spread formula 변경 사례 = web-ui commit `534ded5`)
|
||||
|
||||
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조 (있다면).
|
||||
269
README.md
269
README.md
@@ -1,104 +1,211 @@
|
||||
# 🤖 AI Automated Trading System (Windows Server Edition)
|
||||
# web-ai
|
||||
|
||||
이 프로젝트는 **Python, PyTorch (Deep Learning), Ollama (LLM)**을 활용하여 한국 주식 시장(KIS)에서 자동으로 매매를 수행하는 고성능 AI 트레이딩 봇입니다.
|
||||
**ProcessPoolExecutor 기반의 병렬 처리**와 **독립된 텔레그램 봇 프로세스**를 통해 높은 안정성과 응답 속도를 보장합니다.
|
||||
Windows AI 머신(AMD 9800X3D + RTX 5070 Ti 16GB)에서 동작하는 두 영역의 서비스:
|
||||
|
||||
## 🚀 Key Features
|
||||
1. **ai_trade** — Confidence Signal Pipeline V2. NAS stock 백엔드와 KIS Open API를 결합해 매수/매도 신호를 생성하는 FastAPI 워커.
|
||||
2. **services** — NAS↔Windows 분산 렌더링 워커(인스타 카드 / 음악 / 영상 / 이미지) + task-watcher.
|
||||
|
||||
* **Multi-Process Architecture**: 메인 서버, 트레이딩 봇, 텔레그램 봇이 각각 독립된 프로세스로 실행되어 상호 간섭을 최소화합니다.
|
||||
* **Advanced AI Analysis**: **RTX 5070 Ti (16GB VRAM)** 하드웨어 가속을 활용한 **Attention-LSTM** 모델이 주가를 예측합니다.
|
||||
* **Process Management System**:
|
||||
* **Zombie Killer**: 서버 시작 시 이전에 종료되지 않은 좀비 프로세스를 자동으로 감지하고 제거합니다.
|
||||
* **PID Tracking**: 실행 중인 모든 프로세스의 ID를 `pids.txt` 파일에 실시간으로 기록하여 식별을 돕습니다.
|
||||
* **Reliable Telegram Bot**:
|
||||
* **HTML Parsing**: 마크다운 에러를 방지하기 위해 안정적인 HTML 포맷을 사용하여 메시지를 전송합니다.
|
||||
* **Interactive Commands**: `/status`, `/portfolio`, `/exec` 등 다양한 명령어로 봇을 실시간 제어할 수 있습니다.
|
||||
* **Auto-Recovery**: `ProcessPoolExecutor`의 워커 프로세스가 충돌(OOM 등)할 경우 자동으로 감지하고 재시작합니다.
|
||||
상위 워크스페이스 컨텍스트는 `../CLAUDE.md`, 본 디렉토리 상세는 `CLAUDE.md`, 운영 체크포인트는 `CHECK_POINT.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture & Directory Structure
|
||||
## 디렉토리 구조
|
||||
|
||||
```plaintext
|
||||
/
|
||||
├── main_server.py # [Entry Point] 프로세스 매니저 및 FastAPI 서버
|
||||
├── pids.txt # [Runtime] 실행 중인 프로세스 ID 목록 (자동 관리)
|
||||
├── modules/
|
||||
│ ├── bot.py # [Core] 메인 트레이딩 봇 (스케줄러 & 상태 머신)
|
||||
│ ├── config.py # [Config] 환경 변수 및 상수 관리
|
||||
│ ├── analysis/ # [Brain] AI 분석 모듈
|
||||
│ │ ├── deep_learning.py # PyTorch 기반 Attention-LSTM 모델
|
||||
│ │ ├── technical.py # RSI, 볼린저밴드 등 보조지표 계산
|
||||
│ │ └── macro.py # 거시경제(환율, 유가, 지수) 분석
|
||||
│ ├── services/ # [I/O] 외부 서비스 연동
|
||||
│ │ ├── kis.py # 한국투자증권 API (Throttling 적용)
|
||||
│ │ ├── telegram_bot/ # [Independent] 독립 프로세스 텔레그램 봇
|
||||
│ │ ├── news.py # 네이버 뉴스 크롤링
|
||||
│ │ └── ollama.py # Local LLM (Llama 3) 인터페이스
|
||||
│ ├── strategy/ # [Logic] 매수/매도 의사결정 프로세스
|
||||
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
|
||||
│ └── utils/ # [Util] 유틸리티
|
||||
│ ├── process_tracker.py # PID 추적 및 좀비 프로세스 정리
|
||||
│ ├── ipc.py # 프로세스 간 통신 (IPC)
|
||||
│ └── monitor.py # 시스템 리소스 모니터링
|
||||
└── ...
|
||||
| 경로 | 역할 | 포트 |
|
||||
|------|------|------|
|
||||
| `ai_trade/` | 자동매매 메인. Chronos-bolt(또는 Chronos-2) + 분봉 모멘텀 + KIS WebSocket 호가 + 매수/매도 신호 생성기. | `:8001` |
|
||||
| `services/_shared/` | 4개 render worker 공통 모듈 (`ReliableQueue` — BLMOVE + ack/fail + recovery). | — |
|
||||
| `services/insta-render/` | Instagram 카드 Playwright 렌더 워커. NAS Redis `queue:insta-render` 소비. | `:18710` |
|
||||
| `services/music-render/` | Suno + MusicGen 음악 생성 워커. `queue:music-render` 소비. | `:18711` |
|
||||
| `services/video-render/` | sora / veo / kling / seedance 4 provider 영상 생성 게이트웨이. `queue:video-render` 소비. | `:18712` |
|
||||
| `services/image-render/` | gpt_image / nano_banana / flux(ComfyUI 로컬) 3 provider. `queue:image-render` 소비. | `:18714` |
|
||||
| `services/task-watcher/` | 박재오 작업 시간대에 `queue:paused` 토글 → 워커 일시 정지. | `:18713` |
|
||||
| `legacy/signal_v1/` | ⚠ **DEPRECATED** (2026-05-19). LSTM 봇. 자동 실행 차단됨. | OFF |
|
||||
|
||||
---
|
||||
|
||||
## ai_trade — Confidence Signal Pipeline V2
|
||||
|
||||
NAS stock 백엔드(`:18500`)에서 portfolio / news_sentiment / screener를 pull하고, KIS REST/WebSocket으로 분봉·호가를 보강한 뒤 Chronos 예측과 5분봉 모멘텀 분류로 매수/매도 신호를 생성한다.
|
||||
|
||||
### 매수 (screener Top-N + portfolio)
|
||||
|
||||
모두 충족 시 confidence 계산 → threshold 초과 시 emit:
|
||||
|
||||
1. `chronos.median > 0`
|
||||
2. `chronos.q90 - chronos.q10 < 0.6` (absolute spread)
|
||||
3. `minute_momentum == strong_up`
|
||||
4. `asking_price.bid_ratio >= 0.6`
|
||||
|
||||
종합 confidence = `chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2`. `> 0.7` 시 emit.
|
||||
|
||||
### 매도 (portfolio only, 우선순위 stop_loss → anomaly → take_profit)
|
||||
|
||||
- **stop_loss**: `pnl_pct < -7%` 즉시 (confidence=1.0)
|
||||
- **anomaly**: `chronos.median < -1%` + `strong_down` + `bid_ratio < 0.4` + 종합 conf > 0.7
|
||||
- **take_profit**: `pnl_pct > 15%` 검토 (confidence=0.6)
|
||||
|
||||
### 핵심 파일
|
||||
|
||||
| 파일 | 책임 |
|
||||
|------|------|
|
||||
| `main.py` | FastAPI app + lifespan (의존성 wiring) + poll_loop task 생성 |
|
||||
| `config.py` | `Settings` dataclass — 환경변수 로드 |
|
||||
| `state.py` | `PollState` (process-wide singleton) — portfolio·screener·signals 등 + `get_active_signals` / `purge_expired_signals` |
|
||||
| `stock_client.py` | NAS stock 백엔드 pull (X-WebAI-Key + 메모리 캐시) |
|
||||
| `kis_client.py` | KIS REST 분봉/호가 + asyncio.Lock 직렬화 + 지수 backoff |
|
||||
| `kis_websocket.py` | KIS WebSocket 호가 + approval_key + 재연결 |
|
||||
| `chronos_predictor.py` | HuggingFace Chronos zero-shot 분위수 예측 (FP32 강제) |
|
||||
| `minute_momentum.py` | 5분봉 → strong_up / weak_up / neutral / weak_down / strong_down |
|
||||
| `signal_generator.py` | 매수/매도 룰 엔진. cycle_id + expires_at 부착 |
|
||||
| `pull_worker.py` | asyncio cron — 시간대별 분기 + post-close 트리거 + signal 생성 + expired purge |
|
||||
| `scheduler.py` | 폴링 윈도우 판정 (KST 캘린더 + 휴장일) |
|
||||
| `rate_limit.py` | 초당 N회 token bucket + `SignalDedup` SQLite WAL |
|
||||
|
||||
### 시작
|
||||
|
||||
```bat
|
||||
cd ai_trade
|
||||
start.bat
|
||||
```
|
||||
|
||||
→ `Uvicorn running on http://0.0.0.0:8001`, `poll_loop started`.
|
||||
|
||||
휴장일/장 외 시간엔 poll_loop만 idle.
|
||||
|
||||
### 헬스 / 로그
|
||||
|
||||
```powershell
|
||||
curl http://localhost:8001/health
|
||||
Get-Content logs\ai_trade.log -Wait
|
||||
nvidia-smi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Learning Structure (Deep Learning)
|
||||
## services — NAS↔Windows 분산 워커
|
||||
|
||||
본 시스템은 단순한 알고리즘 매매를 넘어, **Deep Learning**을 통해 시장의 패턴을 실시간으로 학습합니다.
|
||||
NAS측 lab 서비스(insta-lab / music-lab / video-lab / image-render NAS측)가 `queue:<worker>-render` 에 LPUSH로 작업을 enqueue. Windows worker가 BLMOVE로 atomic dequeue 후 처리, 완료 시 NAS internal webhook으로 결과 통지.
|
||||
|
||||
### 1. Model: Attention-LSTM (High Capacity)
|
||||
* **Architecture**: LSTM(Long Short-Term Memory) + **Attention Mechanism**
|
||||
* **Input**: 최근 60일(약 3개월)간의 주가(종가) 시계열 데이터
|
||||
* **Core Logic**:
|
||||
* **Feature Extraction**: 4-Layer Stacked LSTM (Hidden Size: 512)이 시계열 특징 추출.
|
||||
* **Attention Layer**: 과거 60일 중 현재 예측에 가장 중요한 시점에 가중치를 부여.
|
||||
* **Adaptive Training**: 종목별로 매일 실시간 학습(Online Learning)을 수행하여 최신 트렌드 반영.
|
||||
### 신뢰성 패턴 (`_shared.ReliableQueue`)
|
||||
|
||||
### 2. Hardware Acceleration (RTX 5070 Ti)
|
||||
* **CUDA Optimization**: PyTorch를 통해 GPU 가속 활성화.
|
||||
* **Specs**: Batch Size 64, Epochs 200, Precision FP32.
|
||||
* 서버 시작 시 `High Performance Mode`가 자동으로 감지 및 활성화됩니다.
|
||||
* **OOM Protection**: GPU 메모리 보호를 위해 병렬 워커 수를 2개로 제한하고, 워커 충돌 시 자동 재시작합니다.
|
||||
- **dequeue**: `BLMOVE main → processing:<queue>:<worker_id>` (atomic).
|
||||
- **ack**: `LREM processing 1 raw` (성공).
|
||||
- **fail**: `LREM processing` → `attempts++` 후 main 재큐 또는 `max_attempts` 도달 시 `dead_letter:<queue>` 이동.
|
||||
- **recover**: startup 시 자신의 processing list orphan을 main queue로 (attempts 증가).
|
||||
|
||||
---
|
||||
### 시작 (NAS, WSL2 Docker)
|
||||
|
||||
## 🛠️ Usage & Troubleshooting
|
||||
|
||||
### 1. Installation
|
||||
```bash
|
||||
# Clone & Install
|
||||
git clone <repository-url>
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start Server
|
||||
python main_server.py
|
||||
cd services
|
||||
docker compose up -d insta-render music-render video-render image-render task-watcher
|
||||
```
|
||||
|
||||
### 2. Process Management (`pids.txt`)
|
||||
서버가 실행되면 `pids.txt` 파일에 현재 실행 중인 프로세스 목록이 기록됩니다.
|
||||
```text
|
||||
58360: Main Server (Uvicorn Worker)
|
||||
72028: Trading Bot Main
|
||||
66488: Telegram Bot Standalone
|
||||
16372: Trading Bot Worker
|
||||
...
|
||||
```
|
||||
* **CPU 사용량이 비정상적으로 높을 때**: 작업 관리자나 `Get-Process python`으로 확인한 PID가 `pids.txt`에 없다면 **좀비 프로세스**입니다.
|
||||
* **자동 정리**: `main_server.py`를 다시 실행하면 시작 시 자동으로 좀비 프로세스를 찾아 종료합니다.
|
||||
build context는 `services/` 루트. 각 Dockerfile은 `_shared` 모듈을 함께 COPY하고 `PYTHONPATH=/app`.
|
||||
|
||||
### 3. Telegram Commands
|
||||
* `/start`: 봇 시작 및 명령어 안내
|
||||
* `/status`: 현재 봇 상태, 시장 지수, AI 모델 상태 조회
|
||||
* `/portfolio`: 현재 보유 종목 및 수익률 조회
|
||||
* `/system`: CPU/GPU 사용량 및 프로세스 상태 확인
|
||||
* `/restart`: 봇 프로세스 재시작 (업데이트 반영 시 유용)
|
||||
* `/stop`: 봇 종료
|
||||
### 운영 조작
|
||||
|
||||
```bash
|
||||
# 워커 일시 정지 / 재개
|
||||
redis-cli -h 192.168.45.54 SET queue:paused 1
|
||||
redis-cli -h 192.168.45.54 DEL queue:paused
|
||||
|
||||
# 큐 / dead-letter 점검
|
||||
redis-cli -h 192.168.45.54 LLEN queue:insta-render
|
||||
redis-cli -h 192.168.45.54 LLEN dead_letter:queue:insta-render
|
||||
redis-cli -h 192.168.45.54 KEYS 'processing:*'
|
||||
```
|
||||
|
||||
### 환경 변수
|
||||
|
||||
| 변수 | 용도 |
|
||||
|------|------|
|
||||
| `REDIS_URL` | NAS Redis (`redis://192.168.45.54:6379`) |
|
||||
| `NAS_BASE_URL` | NAS 대상 서비스 URL (insta-lab `:18700`, music-lab `:18600`, video-lab `:18801`, image-render NAS측 `:18802`) |
|
||||
| `INTERNAL_API_KEY` | NAS internal webhook 인증 |
|
||||
| `WORKER_ID` | (권장) `<service>-prod-1` 등 영속 ID. hostname 기반 default는 컨테이너 재기동 시 바뀌어 orphan 추적 불가 |
|
||||
| `OPENAI_API_KEY` / `GEMINI_API_KEY` / `KLING_*` / `SEEDANCE_API_KEY` / `SUNO_API_KEY` | 각 provider 인증 |
|
||||
| `COMFYUI_URL` | image-render FLUX 로컬 ComfyUI (`http://host.docker.internal:8188`) |
|
||||
| `FLUX_BLOCK_TRADING_HOURS` | `1` 이면 장중(09:00~15:30) FLUX 차단 (Chronos GPU 보호) |
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
본 소프트웨어는 투자를 보조하는 도구이며, 투자의 결과에 대한 책임은 전적으로 사용자에게 있습니다. AI의 예측은 100% 정확하지 않으며, 시장 상황에 따라 손실이 발생할 수 있습니다. 모의투자 환경에서 충분한 테스트 후 사용하시기 바랍니다.
|
||||
## 환경 변수 (ai_trade)
|
||||
|
||||
| 변수 | 기본 | 설명 |
|
||||
|------|------|------|
|
||||
| `STOCK_API_URL` | (필수) | NAS stock 백엔드 base URL |
|
||||
| `WEBAI_API_KEY` | (필수) | stock 백엔드 호출 시 X-WebAI-Key |
|
||||
| `SIGNAL_V2_PORT` | `8001` | uvicorn 포트 |
|
||||
| `KIS_ENV_TYPE` | `virtual` | `virtual` / `real` |
|
||||
| `KIS_REAL_APP_KEY` / `KIS_REAL_APP_SECRET` / `KIS_REAL_ACCOUNT` | — | KIS 실계좌 |
|
||||
| `KIS_VIRTUAL_APP_KEY` / `KIS_VIRTUAL_APP_SECRET` / `KIS_VIRTUAL_ACCOUNT` | — | KIS 모의계좌 |
|
||||
| `V1_TOKEN_PATH` | `legacy/signal_v1/data/kis_token.json` | KIS 토큰 파일 (V1 토큰 read-only 공유) |
|
||||
| `CHRONOS_MODEL` | `amazon/chronos-2` | Chronos 모델 ID |
|
||||
| `STOP_LOSS_PCT` | `-0.07` | 손절 임계 |
|
||||
| `TAKE_PROFIT_PCT` | `0.15` | 익절 임계 |
|
||||
| `CHRONOS_SPREAD_THRESHOLD` | `0.6` | 매수 hard gate spread 상한 |
|
||||
| `ASKING_BID_RATIO_THRESHOLD` | `0.6` | 매수 hard gate 호가 비율 |
|
||||
| `CONFIDENCE_THRESHOLD` | `0.7` | 매수 종합 confidence 하한 |
|
||||
| `MIN_MOMENTUM_FOR_BUY` | `strong_up` | 매수 hard gate 모멘텀 단계 |
|
||||
| `SIGNAL_TTL_SECONDS` | `300` | emit signal expires_at TTL |
|
||||
|
||||
`.env` 는 web-ai 루트 (이 디렉토리)에 둔다. **절대 커밋 금지.**
|
||||
|
||||
---
|
||||
|
||||
## 테스트
|
||||
|
||||
```bash
|
||||
# ai_trade
|
||||
python -m pytest ai_trade/tests -q
|
||||
|
||||
# services/_shared 공통 모듈
|
||||
cd services/_shared && python -m pytest tests/ -q
|
||||
|
||||
# 각 worker
|
||||
cd services/insta-render && python -m pytest tests/ -q
|
||||
cd services/music-render && python -m pytest tests/ -q
|
||||
cd services/video-render && python -m pytest tests/ -q
|
||||
cd services/image-render && python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
**`.venv` 한글 사용자 경로 깨짐**으로 시스템 Python(`C:\Users\jaeoh\AppData\Local\Programs\Python\Python312\python.exe`) 사용 권장. 또는 `py -3.12 -m pytest …`.
|
||||
|
||||
---
|
||||
|
||||
## 알려진 함정
|
||||
|
||||
1. **KIS rate limit (EGW00201)** — V1+V2 동시 실행 시 충돌. V1은 `legacy/`로 격리. ai_trade는 `asyncio.Lock`으로 throttle 직렬화 (`kis_client.py`).
|
||||
2. **`.venv` 한글 경로** — 시스템 Python 사용.
|
||||
3. **Chronos FP16 overflow** — 한국 주가 5만원+ 시 inf. FP32 강제됨.
|
||||
4. **post-close 트리거** — 상태기반(`last_post_close_date`)으로 변경됨. 16:00 이후 + 오늘 미실행이면 trigger.
|
||||
5. **services worker_id** — env로 명시 권장. hostname 기반 default는 컨테이너 재기동 시 바뀌어 orphan 분실 위험.
|
||||
6. **dead-letter 누적** — `redis-cli LLEN dead_letter:*` 정기 점검 필요.
|
||||
7. **Dockerfile build context** — `services/` 루트 (각 worker 디렉토리 아님). compose 변경 동반.
|
||||
|
||||
---
|
||||
|
||||
## Phase 진행 상태 (Confidence Signal Pipeline V2)
|
||||
|
||||
| Phase | 내용 | 상태 |
|
||||
|-------|------|------|
|
||||
| 0 | Architecture & contract spec | ✅ |
|
||||
| 1 | stock 백엔드 WebAI API 보강 (NAS) | ✅ |
|
||||
| 1.5 | V1 → `signal_v1/` rename → `legacy/` 격리 | ✅ |
|
||||
| 2 | ai_trade pull worker + signal API client + scheduler | ✅ |
|
||||
| 3a | KIS REST 분봉 + WebSocket 호가 + NXT 스케줄 | ✅ |
|
||||
| 3b | Chronos-bolt-base 추론 + 5분봉 모멘텀 분류기 | ✅ |
|
||||
| 4 | Signal Generator + 로깅 | ✅ |
|
||||
| 4.5 | 코드 리뷰 F1-F6 hotfix (토큰 경로 / throttle Lock / post-close 상태기반 / Chronos abs / state.signals lifecycle / render queue 신뢰성) | ✅ |
|
||||
| 5 | agent-office `/signal` + Ollama Qwen3 14B + 이중 텔레그램 | ⏳ |
|
||||
| 6 | signal_v1 deprecation (legacy 완료, 아카이브만 남음) | 일부 ✅ |
|
||||
| 7 | 운영 모니터링 + 4주 IC 검증 | ⏳ |
|
||||
|
||||
상세 spec/plan은 `../web-ui/docs/superpowers/specs/` / `../web-ui/docs/superpowers/plans/` (별도 repo).
|
||||
|
||||
---
|
||||
|
||||
## 라이선스 / 사용
|
||||
|
||||
비공개. 박재오 개인 웹 플랫폼.
|
||||
|
||||
0
ai_trade/__init__.py
Normal file
0
ai_trade/__init__.py
Normal file
136
ai_trade/chronos_predictor.py
Normal file
136
ai_trade/chronos_predictor.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Chronos-2 zero-shot forecaster wrapper."""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
# F4: signal_generator hard gate와 동일한 absolute spread threshold.
|
||||
# zero-shot median≈0에서 conf가 0으로 폭락하던 relative 산식 (spread/abs(median)) 대체.
|
||||
_SPREAD_THRESHOLD = 0.6
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChronosPrediction:
|
||||
median: float
|
||||
q10: float
|
||||
q90: float
|
||||
conf: float
|
||||
as_of: str
|
||||
|
||||
|
||||
class ChronosPredictor:
|
||||
"""HuggingFace Chronos-2 zero-shot forecaster."""
|
||||
|
||||
def __init__(self, model_name: str = "amazon/chronos-2", device: str | None = None):
|
||||
# BaseChronosPipeline auto-detects model variant (Chronos / ChronosBolt / Chronos-2)
|
||||
# and returns the appropriate sub-pipeline. ChronosPipeline only supports legacy T5.
|
||||
import torch
|
||||
try:
|
||||
from chronos import BaseChronosPipeline
|
||||
pipeline_cls = BaseChronosPipeline
|
||||
except ImportError:
|
||||
from chronos import ChronosPipeline
|
||||
pipeline_cls = ChronosPipeline
|
||||
|
||||
self._device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
||||
# Always use float32 — Korean stock prices (e.g. 280,000원) exceed FP16 max (~65,504)
|
||||
# causing inf in quantile output. FP32 is safe for typical price magnitudes.
|
||||
dtype = torch.float32
|
||||
logger.info("Loading Chronos pipeline: %s on %s (cls=%s)",
|
||||
model_name, self._device, pipeline_cls.__name__)
|
||||
# Try `dtype` (newer API) first, fall back to `torch_dtype` (older)
|
||||
try:
|
||||
self._pipeline = pipeline_cls.from_pretrained(
|
||||
model_name, device_map=self._device, dtype=dtype,
|
||||
)
|
||||
except TypeError:
|
||||
self._pipeline = pipeline_cls.from_pretrained(
|
||||
model_name, device_map=self._device, torch_dtype=dtype,
|
||||
)
|
||||
logger.info("Chronos pipeline loaded.")
|
||||
|
||||
def predict_batch(
|
||||
self,
|
||||
daily_ohlcv_dict: dict[str, list[dict]],
|
||||
prediction_length: int = 1,
|
||||
num_samples: int = 100,
|
||||
) -> dict[str, ChronosPrediction]:
|
||||
"""종목별 1-day return 분포 예측.
|
||||
|
||||
ChronosBolt / Chronos-2 등 신모델은 predict_quantiles 사용 (deterministic).
|
||||
Legacy ChronosPipeline (T5) 는 sample-based predict.
|
||||
"""
|
||||
import torch
|
||||
|
||||
tickers = list(daily_ohlcv_dict.keys())
|
||||
if not tickers:
|
||||
return {}
|
||||
|
||||
contexts = [
|
||||
torch.tensor([bar["close"] for bar in daily_ohlcv_dict[t]], dtype=torch.float32)
|
||||
for t in tickers
|
||||
]
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
results: dict[str, ChronosPrediction] = {}
|
||||
|
||||
# Modern API: predict_quantiles (ChronosBolt / Chronos-2)
|
||||
if hasattr(self._pipeline, "predict_quantiles"):
|
||||
quantile_levels = [0.1, 0.5, 0.9]
|
||||
# ChronosBolt API: positional `inputs` (first arg). Older variants use `context`.
|
||||
try:
|
||||
quantiles_tensor, _ = self._pipeline.predict_quantiles(
|
||||
contexts,
|
||||
prediction_length=prediction_length,
|
||||
quantile_levels=quantile_levels,
|
||||
)
|
||||
except TypeError:
|
||||
quantiles_tensor, _ = self._pipeline.predict_quantiles(
|
||||
context=contexts,
|
||||
prediction_length=prediction_length,
|
||||
quantile_levels=quantile_levels,
|
||||
)
|
||||
quantiles_np = (
|
||||
quantiles_tensor.cpu().numpy()
|
||||
if hasattr(quantiles_tensor, "cpu")
|
||||
else np.asarray(quantiles_tensor)
|
||||
)
|
||||
# shape: [num_series, prediction_length, 3]
|
||||
for i, ticker in enumerate(tickers):
|
||||
q10_price, q50_price, q90_price = quantiles_np[i, 0, :]
|
||||
last_close = daily_ohlcv_dict[ticker][-1]["close"]
|
||||
median = float((q50_price - last_close) / last_close)
|
||||
q10 = float((q10_price - last_close) / last_close)
|
||||
q90 = float((q90_price - last_close) / last_close)
|
||||
spread = q90 - q10 # F4: absolute spread
|
||||
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||
results[ticker] = ChronosPrediction(
|
||||
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
|
||||
)
|
||||
return results
|
||||
|
||||
# Legacy API: sample-based predict (ChronosPipeline T5)
|
||||
forecasts = self._pipeline.predict(
|
||||
context=contexts,
|
||||
prediction_length=prediction_length,
|
||||
num_samples=num_samples,
|
||||
)
|
||||
forecasts_np = forecasts.numpy() if hasattr(forecasts, "numpy") else np.asarray(forecasts)
|
||||
for i, ticker in enumerate(tickers):
|
||||
samples = forecasts_np[i, :, 0]
|
||||
last_close = daily_ohlcv_dict[ticker][-1]["close"]
|
||||
returns = (samples - last_close) / last_close
|
||||
median = float(np.quantile(returns, 0.5))
|
||||
q10 = float(np.quantile(returns, 0.1))
|
||||
q90 = float(np.quantile(returns, 0.9))
|
||||
spread = q90 - q10 # F4: absolute spread
|
||||
conf = float(max(0.0, min(1.0, 1.0 - spread / _SPREAD_THRESHOLD)))
|
||||
results[ticker] = ChronosPrediction(
|
||||
median=median, q10=q10, q90=q90, conf=conf, as_of=now_iso,
|
||||
)
|
||||
return results
|
||||
78
ai_trade/config.py
Normal file
78
ai_trade/config.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Signal V2 환경변수 로딩."""
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
stock_api_url: str = field(
|
||||
default_factory=lambda: os.getenv("STOCK_API_URL", "").rstrip("/")
|
||||
)
|
||||
webai_api_key: str = field(
|
||||
default_factory=lambda: os.getenv("WEBAI_API_KEY", "").strip()
|
||||
)
|
||||
port: int = field(default_factory=lambda: int(os.getenv("SIGNAL_V2_PORT", "8001")))
|
||||
db_path: Path = field(
|
||||
default_factory=lambda: Path(__file__).parent / "data" / "ai_trade.db"
|
||||
)
|
||||
# KIS — V1 호환 패턴 (KIS_ENV_TYPE virtual/real)
|
||||
kis_env_type: str = field(default_factory=lambda: os.getenv("KIS_ENV_TYPE", "virtual").lower())
|
||||
kis_real_app_key: str = field(default_factory=lambda: os.getenv("KIS_REAL_APP_KEY", "").strip())
|
||||
kis_real_app_secret: str = field(default_factory=lambda: os.getenv("KIS_REAL_APP_SECRET", "").strip())
|
||||
kis_real_account: str = field(default_factory=lambda: os.getenv("KIS_REAL_ACCOUNT", "").strip())
|
||||
kis_virtual_app_key: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_APP_KEY", "").strip())
|
||||
kis_virtual_app_secret: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_APP_SECRET", "").strip())
|
||||
kis_virtual_account: str = field(default_factory=lambda: os.getenv("KIS_VIRTUAL_ACCOUNT", "").strip())
|
||||
v1_token_path: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.getenv("V1_TOKEN_PATH",
|
||||
str(Path(__file__).parent.parent / "legacy" / "signal_v1" / "data" / "kis_token.json"))
|
||||
)
|
||||
)
|
||||
chronos_model: str = field(default_factory=lambda: os.getenv("CHRONOS_MODEL", "amazon/chronos-2"))
|
||||
stop_loss_pct: float = field(
|
||||
default_factory=lambda: float(os.getenv("STOP_LOSS_PCT", "-0.07"))
|
||||
)
|
||||
take_profit_pct: float = field(
|
||||
default_factory=lambda: float(os.getenv("TAKE_PROFIT_PCT", "0.15"))
|
||||
)
|
||||
chronos_spread_threshold: float = field(
|
||||
default_factory=lambda: float(os.getenv("CHRONOS_SPREAD_THRESHOLD", "0.6"))
|
||||
)
|
||||
asking_bid_ratio_threshold: float = field(
|
||||
default_factory=lambda: float(os.getenv("ASKING_BID_RATIO_THRESHOLD", "0.6"))
|
||||
)
|
||||
confidence_threshold: float = field(
|
||||
default_factory=lambda: float(os.getenv("CONFIDENCE_THRESHOLD", "0.7"))
|
||||
)
|
||||
min_momentum_for_buy: str = field(
|
||||
default_factory=lambda: os.getenv("MIN_MOMENTUM_FOR_BUY", "strong_up")
|
||||
)
|
||||
signal_ttl_seconds: int = field(
|
||||
default_factory=lambda: int(os.getenv("SIGNAL_TTL_SECONDS", "300"))
|
||||
)
|
||||
|
||||
@property
|
||||
def kis_is_virtual(self) -> bool:
|
||||
return self.kis_env_type != "real"
|
||||
|
||||
@property
|
||||
def kis_app_key(self) -> str:
|
||||
return self.kis_real_app_key if self.kis_env_type == "real" else self.kis_virtual_app_key
|
||||
|
||||
@property
|
||||
def kis_app_secret(self) -> str:
|
||||
return self.kis_real_app_secret if self.kis_env_type == "real" else self.kis_virtual_app_secret
|
||||
|
||||
@property
|
||||
def kis_account(self) -> str:
|
||||
return self.kis_real_account if self.kis_env_type == "real" else self.kis_virtual_account
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
0
ai_trade/data/.gitkeep
Normal file
0
ai_trade/data/.gitkeep
Normal file
18
ai_trade/holidays.json
Normal file
18
ai_trade/holidays.json
Normal file
@@ -0,0 +1,18 @@
|
||||
[
|
||||
"2026-01-01",
|
||||
"2026-01-28",
|
||||
"2026-01-29",
|
||||
"2026-01-30",
|
||||
"2026-03-01",
|
||||
"2026-05-05",
|
||||
"2026-05-25",
|
||||
"2026-06-06",
|
||||
"2026-08-15",
|
||||
"2026-09-24",
|
||||
"2026-09-25",
|
||||
"2026-09-26",
|
||||
"2026-10-03",
|
||||
"2026-10-09",
|
||||
"2026-12-25",
|
||||
"2026-12-31"
|
||||
]
|
||||
197
ai_trade/kis_client.py
Normal file
197
ai_trade/kis_client.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""KIS REST API client — 분봉 + 호가. V1 토큰 read-only 공유."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
_MAX_ATTEMPTS = 3
|
||||
_THROTTLE_INTERVAL = 0.5 # 초당 2회 제한
|
||||
|
||||
|
||||
class KISClient:
|
||||
"""KIS REST (분봉 + 호가). V1 토큰 파일 read-only."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_key: str, app_secret: str, account: str, is_virtual: bool,
|
||||
v1_token_path: Path,
|
||||
timeout: float = 10.0,
|
||||
):
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._account = account
|
||||
self._is_virtual = is_virtual
|
||||
self._v1_token_path = Path(v1_token_path)
|
||||
self._base_url = (
|
||||
"https://openapivts.koreainvestment.com:29443" if is_virtual
|
||||
else "https://openapi.koreainvestment.com:9443"
|
||||
)
|
||||
self._client = httpx.AsyncClient(timeout=timeout)
|
||||
self._token_cache: tuple[str, float] | None = None # (token, file_mtime)
|
||||
self._last_throttle_at = 0.0
|
||||
self._throttle_lock = asyncio.Lock()
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def _read_v1_token(self) -> str:
|
||||
if not self._v1_token_path.exists():
|
||||
raise RuntimeError(f"V1 token file missing: {self._v1_token_path}")
|
||||
mtime = self._v1_token_path.stat().st_mtime
|
||||
if self._token_cache and self._token_cache[1] == mtime:
|
||||
return self._token_cache[0]
|
||||
data = json.loads(self._v1_token_path.read_text(encoding="utf-8"))
|
||||
token = data.get("access_token", "")
|
||||
if not token:
|
||||
raise RuntimeError("V1 token file has no access_token")
|
||||
self._token_cache = (token, mtime)
|
||||
return token
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
# F2: Lock으로 직렬화. 없으면 asyncio.gather 동시 호출 시 race로
|
||||
# 같은 elapsed 계산 후 동시에 깨어나 KIS 초당 2회(EGW00201) 위반.
|
||||
async with self._throttle_lock:
|
||||
elapsed = time.monotonic() - self._last_throttle_at
|
||||
if elapsed < _THROTTLE_INTERVAL:
|
||||
await asyncio.sleep(_THROTTLE_INTERVAL - elapsed)
|
||||
self._last_throttle_at = time.monotonic()
|
||||
|
||||
def _common_headers(self, tr_id: str) -> dict[str, str]:
|
||||
token = self._read_v1_token()
|
||||
return {
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": self._app_key,
|
||||
"appsecret": self._app_secret,
|
||||
"tr_id": tr_id,
|
||||
"custtype": "P",
|
||||
}
|
||||
|
||||
async def _request_with_retry(
|
||||
self, method: str, path: str, tr_id: str, **kwargs,
|
||||
) -> dict:
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = self._common_headers(tr_id)
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
await self._throttle()
|
||||
try:
|
||||
response = await self._client.request(
|
||||
method, url, headers=headers, **kwargs
|
||||
)
|
||||
if response.status_code == 429:
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
await asyncio.sleep(2**attempt)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.TimeoutException:
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
await asyncio.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
raise RuntimeError("retry exhausted")
|
||||
|
||||
async def get_minute_ohlcv(self, ticker: str) -> list[dict]:
|
||||
"""현재 시점 직전 30개 1분봉 OHLCV (TR_ID FHKST03010200)."""
|
||||
path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
params = {
|
||||
"FID_ETC_CLS_CODE": "",
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_HOUR_1": datetime.now(KST).strftime("%H%M%S"),
|
||||
"FID_PW_DATA_INCU_YN": "N",
|
||||
}
|
||||
raw = await self._request_with_retry(
|
||||
"GET", path, tr_id="FHKST03010200", params=params,
|
||||
)
|
||||
output2 = raw.get("output2", [])
|
||||
bars = []
|
||||
for row in output2:
|
||||
try:
|
||||
date = row["stck_bsop_date"]
|
||||
hhmmss = row["stck_cntg_hour"]
|
||||
dt = datetime.strptime(f"{date} {hhmmss}", "%Y%m%d %H%M%S").replace(tzinfo=KST)
|
||||
bars.append({
|
||||
"datetime": dt.isoformat(),
|
||||
"open": int(row["stck_oprc"]),
|
||||
"high": int(row["stck_hgpr"]),
|
||||
"low": int(row["stck_lwpr"]),
|
||||
"close": int(row["stck_prpr"]),
|
||||
"volume": int(row["cntg_vol"]),
|
||||
})
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.warning("skip malformed bar for %s: %r", ticker, e)
|
||||
# KIS returns descending; reverse to ascending (most recent last)
|
||||
bars.reverse()
|
||||
return bars
|
||||
|
||||
async def get_asking_price(self, ticker: str) -> dict:
|
||||
"""현재 호가 + 매수/매도 잔량 (TR_ID FHKST01010200)."""
|
||||
path = "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
}
|
||||
raw = await self._request_with_retry(
|
||||
"GET", path, tr_id="FHKST01010200", params=params,
|
||||
)
|
||||
output1 = raw.get("output1", {})
|
||||
bid_total = int(output1.get("total_bidp_rsqn", 0))
|
||||
ask_total = int(output1.get("total_askp_rsqn", 0))
|
||||
total = bid_total + ask_total
|
||||
bid_ratio = bid_total / total if total > 0 else 0.0
|
||||
current_price = int(output1.get("stck_prpr", 0))
|
||||
return {
|
||||
"bid_total": bid_total,
|
||||
"ask_total": ask_total,
|
||||
"bid_ratio": bid_ratio,
|
||||
"current_price": current_price,
|
||||
"as_of": datetime.now(KST).isoformat(),
|
||||
}
|
||||
|
||||
async def get_daily_ohlcv(self, ticker: str, days: int = 60) -> list[dict]:
|
||||
"""KRX 일봉 OHLCV (TR_ID FHKST03010100).
|
||||
|
||||
Returns: [{"datetime", "open", "high", "low", "close", "volume"}, ...]
|
||||
시간 오름차순.
|
||||
"""
|
||||
path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
today = datetime.now(KST).strftime("%Y%m%d")
|
||||
start_date = (datetime.now(KST) - timedelta(days=days * 2)).strftime("%Y%m%d")
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": ticker,
|
||||
"FID_INPUT_DATE_1": start_date,
|
||||
"FID_INPUT_DATE_2": today,
|
||||
"FID_PERIOD_DIV_CODE": "D",
|
||||
"FID_ORG_ADJ_PRC": "1",
|
||||
}
|
||||
raw = await self._request_with_retry(
|
||||
"GET", path, tr_id="FHKST03010100", params=params,
|
||||
)
|
||||
output2 = raw.get("output2", [])
|
||||
bars = []
|
||||
for row in output2:
|
||||
try:
|
||||
date = row["stck_bsop_date"]
|
||||
bars.append({
|
||||
"datetime": f"{date[:4]}-{date[4:6]}-{date[6:]}",
|
||||
"open": int(row["stck_oprc"]),
|
||||
"high": int(row["stck_hgpr"]),
|
||||
"low": int(row["stck_lwpr"]),
|
||||
"close": int(row["stck_clpr"]),
|
||||
"volume": int(row["acml_vol"]),
|
||||
})
|
||||
except (KeyError, ValueError):
|
||||
continue
|
||||
bars.reverse()
|
||||
return bars[-days:]
|
||||
186
ai_trade/kis_websocket.py
Normal file
186
ai_trade/kis_websocket.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""KIS WebSocket — approval_key + 실시간 호가 구독."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Callable
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
import websockets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
# KIS 호가 메시지 필드 인덱스 (운영 환경 검증 필요)
|
||||
# H0STASP0 응답: ticker | time | current_price | ... | ask_total | bid_total
|
||||
# 본 spec/plan 의 가정: 마지막 2개 필드가 ask_total / bid_total
|
||||
_ASKING_TICKER_IDX = 0
|
||||
_ASKING_TIME_IDX = 1
|
||||
_ASKING_CURRENT_PRICE_IDX = 2
|
||||
_ASKING_TOTAL_ASK_IDX = -2
|
||||
_ASKING_TOTAL_BID_IDX = -1
|
||||
|
||||
|
||||
class KISWebSocket:
|
||||
"""KIS WebSocket client. approval_key 발급 + 호가 실시간."""
|
||||
|
||||
def __init__(self, app_key: str, app_secret: str, is_virtual: bool):
|
||||
self._app_key = app_key
|
||||
self._app_secret = app_secret
|
||||
self._is_virtual = is_virtual
|
||||
self._base_rest = (
|
||||
"https://openapivts.koreainvestment.com:29443" if is_virtual
|
||||
else "https://openapi.koreainvestment.com:9443"
|
||||
)
|
||||
self._ws_url = (
|
||||
"ws://ops.koreainvestment.com:31000" if is_virtual
|
||||
else "ws://ops.koreainvestment.com:21000"
|
||||
)
|
||||
self._approval_key: str | None = None
|
||||
self._ws = None
|
||||
self._subscriptions: set[str] = set()
|
||||
self._on_asking_price: Callable[[str, dict], None] | None = None
|
||||
self._recv_task: asyncio.Task | None = None
|
||||
self._shutdown = asyncio.Event()
|
||||
|
||||
async def _fetch_approval_key(self) -> str:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{self._base_rest}/oauth2/Approval",
|
||||
json={
|
||||
"grant_type": "client_credentials",
|
||||
"appkey": self._app_key,
|
||||
"secretkey": self._app_secret,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
self._approval_key = data["approval_key"]
|
||||
return self._approval_key
|
||||
|
||||
async def _connect(self):
|
||||
return await websockets.connect(self._ws_url)
|
||||
|
||||
async def _connect_with_backoff(self):
|
||||
"""연결 시도 with exponential backoff (1s → 2s → 4s → max 30s)."""
|
||||
for attempt in range(10):
|
||||
try:
|
||||
ws = await self._connect()
|
||||
return ws
|
||||
except Exception as e:
|
||||
wait = min(2**attempt, 30)
|
||||
logger.warning(
|
||||
"KIS WebSocket connect failed (attempt %d): %r — retrying in %ds",
|
||||
attempt + 1, e, wait,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
raise RuntimeError("KIS WebSocket connect exhausted retries")
|
||||
|
||||
async def start(
|
||||
self, tickers: list[str],
|
||||
on_asking_price: Callable[[str, dict], None],
|
||||
) -> None:
|
||||
if self._approval_key is None:
|
||||
await self._fetch_approval_key()
|
||||
self._on_asking_price = on_asking_price
|
||||
self._ws = await self._connect_with_backoff()
|
||||
for ticker in tickers:
|
||||
await self.subscribe(ticker)
|
||||
self._recv_task = asyncio.create_task(self._receive_loop())
|
||||
|
||||
async def subscribe(self, ticker: str) -> None:
|
||||
if self._ws is None or self._approval_key is None:
|
||||
raise RuntimeError("KIS WebSocket not started")
|
||||
msg = json.dumps({
|
||||
"header": {
|
||||
"approval_key": self._approval_key,
|
||||
"custtype": "P",
|
||||
"tr_type": "1",
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
"body": {
|
||||
"input": {"tr_id": "H0STASP0", "tr_key": ticker},
|
||||
},
|
||||
})
|
||||
await self._ws.send(msg)
|
||||
self._subscriptions.add(ticker)
|
||||
|
||||
async def unsubscribe(self, ticker: str) -> None:
|
||||
if self._ws is None or self._approval_key is None:
|
||||
return
|
||||
msg = json.dumps({
|
||||
"header": {
|
||||
"approval_key": self._approval_key,
|
||||
"custtype": "P",
|
||||
"tr_type": "2",
|
||||
"content-type": "utf-8",
|
||||
},
|
||||
"body": {
|
||||
"input": {"tr_id": "H0STASP0", "tr_key": ticker},
|
||||
},
|
||||
})
|
||||
await self._ws.send(msg)
|
||||
self._subscriptions.discard(ticker)
|
||||
|
||||
async def close(self) -> None:
|
||||
self._shutdown.set()
|
||||
if self._recv_task is not None:
|
||||
self._recv_task.cancel()
|
||||
try:
|
||||
await self._recv_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self._ws is not None:
|
||||
await self._ws.close()
|
||||
|
||||
async def _receive_loop(self) -> None:
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
raw = await self._ws.recv()
|
||||
except websockets.ConnectionClosed:
|
||||
logger.warning("KIS WebSocket closed — reconnecting")
|
||||
self._ws = await self._connect_with_backoff()
|
||||
for ticker in list(self._subscriptions):
|
||||
await self.subscribe(ticker)
|
||||
continue
|
||||
if not isinstance(raw, str):
|
||||
continue
|
||||
parsed = self._parse_asking_price(raw)
|
||||
if parsed is not None and self._on_asking_price is not None:
|
||||
ticker, data = parsed
|
||||
try:
|
||||
self._on_asking_price(ticker, data)
|
||||
except Exception:
|
||||
logger.exception("on_asking_price callback failed")
|
||||
|
||||
def _parse_asking_price(self, raw: str) -> tuple[str, dict] | None:
|
||||
"""KIS H0STASP0 raw → (ticker, asking_price dict).
|
||||
|
||||
Raw format: '0|H0STASP0|<count>|<data>' where data = '^'-joined fields.
|
||||
Field indices (운영 검증 필요): 마지막 2개 가정 (ask, bid).
|
||||
"""
|
||||
try:
|
||||
parts = raw.split("|")
|
||||
if len(parts) < 4 or parts[1] != "H0STASP0":
|
||||
return None
|
||||
fields = parts[3].split("^")
|
||||
ticker = fields[_ASKING_TICKER_IDX]
|
||||
current_price_str = fields[_ASKING_CURRENT_PRICE_IDX]
|
||||
current_price = int(current_price_str) if current_price_str.lstrip("-").isdigit() else 0
|
||||
ask_str = fields[_ASKING_TOTAL_ASK_IDX]
|
||||
bid_str = fields[_ASKING_TOTAL_BID_IDX]
|
||||
ask_total = int(ask_str) if ask_str.lstrip("-").isdigit() else 0
|
||||
bid_total = int(bid_str) if bid_str.lstrip("-").isdigit() else 0
|
||||
total = bid_total + ask_total
|
||||
return ticker, {
|
||||
"bid_total": bid_total,
|
||||
"ask_total": ask_total,
|
||||
"bid_ratio": bid_total / total if total > 0 else 0.0,
|
||||
"current_price": current_price,
|
||||
"as_of": datetime.now(KST).isoformat(),
|
||||
}
|
||||
except (IndexError, ValueError) as e:
|
||||
logger.warning("parse_asking_price failed: %r", e)
|
||||
return None
|
||||
125
ai_trade/main.py
Normal file
125
ai_trade/main.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""FastAPI app — Signal V2 Pull Worker."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from ai_trade import state as state_mod
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
from ai_trade.config import get_settings
|
||||
from ai_trade.kis_client import KISClient
|
||||
from ai_trade.kis_websocket import KISWebSocket
|
||||
from ai_trade.pull_worker import poll_loop, make_asking_price_callback
|
||||
from ai_trade.rate_limit import SignalDedup
|
||||
from ai_trade.stock_client import StockClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AppContext:
|
||||
client: StockClient | None = None
|
||||
dedup: SignalDedup | None = None
|
||||
shutdown: asyncio.Event | None = None
|
||||
poll_task: asyncio.Task | None = None
|
||||
kis_client: KISClient | None = None
|
||||
kis_ws: KISWebSocket | None = None
|
||||
chronos: ChronosPredictor | None = None
|
||||
|
||||
|
||||
_ctx = AppContext()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
if not settings.webai_api_key:
|
||||
logger.warning(
|
||||
"WEBAI_API_KEY not configured — stock API calls will fail with 401"
|
||||
)
|
||||
if not settings.kis_app_key:
|
||||
logger.warning(
|
||||
"KIS app_key not configured (KIS_ENV_TYPE=%s, KIS_%s_APP_KEY missing) — KIS REST/WebSocket disabled",
|
||||
settings.kis_env_type, settings.kis_env_type.upper()
|
||||
)
|
||||
|
||||
_ctx.client = StockClient(settings.stock_api_url, settings.webai_api_key)
|
||||
_ctx.dedup = SignalDedup(settings.db_path)
|
||||
_ctx.shutdown = asyncio.Event()
|
||||
|
||||
# KIS only if app_key configured
|
||||
if settings.kis_app_key:
|
||||
_ctx.kis_client = KISClient(
|
||||
app_key=settings.kis_app_key,
|
||||
app_secret=settings.kis_app_secret,
|
||||
account=settings.kis_account,
|
||||
is_virtual=settings.kis_is_virtual,
|
||||
v1_token_path=settings.v1_token_path,
|
||||
)
|
||||
_ctx.kis_ws = KISWebSocket(
|
||||
app_key=settings.kis_app_key,
|
||||
app_secret=settings.kis_app_secret,
|
||||
is_virtual=settings.kis_is_virtual,
|
||||
)
|
||||
# Subscribe portfolio holdings (if any)
|
||||
try:
|
||||
portfolio = await _ctx.client.get_portfolio()
|
||||
tickers = [h["ticker"] for h in portfolio.get("holdings", []) if "ticker" in h]
|
||||
cb = make_asking_price_callback(state_mod.state)
|
||||
await _ctx.kis_ws.start(tickers, cb)
|
||||
except Exception:
|
||||
logger.exception("KIS WebSocket startup failed — continuing without realtime asking_price")
|
||||
|
||||
# Load Chronos (heavy: ~1GB model download first time)
|
||||
try:
|
||||
_ctx.chronos = ChronosPredictor(model_name=settings.chronos_model)
|
||||
except Exception:
|
||||
logger.exception("ChronosPredictor load failed — continuing without chronos predictions")
|
||||
|
||||
_ctx.poll_task = asyncio.create_task(
|
||||
poll_loop(
|
||||
_ctx.client, state_mod.state, _ctx.shutdown,
|
||||
kis_client=_ctx.kis_client,
|
||||
chronos=_ctx.chronos,
|
||||
dedup=_ctx.dedup,
|
||||
settings=settings,
|
||||
)
|
||||
)
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
if _ctx.shutdown is not None:
|
||||
_ctx.shutdown.set()
|
||||
if _ctx.poll_task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(_ctx.poll_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
_ctx.poll_task.cancel()
|
||||
try:
|
||||
await _ctx.poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if _ctx.kis_ws is not None:
|
||||
await _ctx.kis_ws.close()
|
||||
if _ctx.kis_client is not None:
|
||||
await _ctx.kis_client.close()
|
||||
if _ctx.client is not None:
|
||||
await _ctx.client.close()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Signal V2 Pull Worker", version="0.1.0", lifespan=lifespan
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
settings = get_settings()
|
||||
return {
|
||||
"status": "online",
|
||||
"stock_api_url": settings.stock_api_url,
|
||||
"last_poll": state_mod.state.last_updated,
|
||||
"cache_size": _ctx.client.cache_size() if _ctx.client is not None else 0,
|
||||
}
|
||||
69
ai_trade/momentum_classifier.py
Normal file
69
ai_trade/momentum_classifier.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""분봉 OHLCV → 5-level 모멘텀 분류."""
|
||||
from __future__ import annotations
|
||||
from collections import deque
|
||||
|
||||
# 분류 카테고리
|
||||
STRONG_UP = "strong_up"
|
||||
WEAK_UP = "weak_up"
|
||||
NEUTRAL = "neutral"
|
||||
WEAK_DOWN = "weak_down"
|
||||
STRONG_DOWN = "strong_down"
|
||||
|
||||
_BARS_PER_5MIN = 5
|
||||
_LOOKBACK_5MIN_BARS = 5
|
||||
_VOLUME_AVG_WINDOW = 12 # 60분 = 5분봉 12개
|
||||
|
||||
|
||||
def aggregate_1min_to_5min(minute_bars: list[dict]) -> list[dict]:
|
||||
"""1분봉 N개 → 5분봉 floor(N/5) 개. 시간 오름차순.
|
||||
|
||||
각 5분봉: open=첫 1분봉 open, high=max, low=min, close=마지막 close, volume=sum.
|
||||
"""
|
||||
bars_5min = []
|
||||
chunks = len(minute_bars) // _BARS_PER_5MIN
|
||||
for i in range(chunks):
|
||||
chunk = minute_bars[i * _BARS_PER_5MIN : (i + 1) * _BARS_PER_5MIN]
|
||||
bars_5min.append({
|
||||
"datetime": chunk[0]["datetime"],
|
||||
"open": chunk[0]["open"],
|
||||
"high": max(b["high"] for b in chunk),
|
||||
"low": min(b["low"] for b in chunk),
|
||||
"close": chunk[-1]["close"],
|
||||
"volume": sum(b["volume"] for b in chunk),
|
||||
})
|
||||
return bars_5min
|
||||
|
||||
|
||||
def classify_minute_momentum(minute_bars: deque) -> str:
|
||||
"""1분봉 deque → 5-level 모멘텀 분류.
|
||||
|
||||
Returns: STRONG_UP / WEAK_UP / NEUTRAL / WEAK_DOWN / STRONG_DOWN
|
||||
"""
|
||||
minute_list = list(minute_bars)
|
||||
if len(minute_list) < _BARS_PER_5MIN * _LOOKBACK_5MIN_BARS:
|
||||
return NEUTRAL # 데이터 부족
|
||||
|
||||
bars_5min = aggregate_1min_to_5min(minute_list)
|
||||
if len(bars_5min) < _LOOKBACK_5MIN_BARS:
|
||||
return NEUTRAL
|
||||
|
||||
recent = bars_5min[-_LOOKBACK_5MIN_BARS:]
|
||||
up_count = sum(1 for b in recent if b["close"] > b["open"])
|
||||
|
||||
# 거래량 multiplier: recent 5 avg vs 60분 avg
|
||||
recent_vol_avg = sum(b["volume"] for b in recent) / len(recent)
|
||||
long_window = bars_5min[-_VOLUME_AVG_WINDOW:]
|
||||
long_vol_avg = sum(b["volume"] for b in long_window) / len(long_window)
|
||||
vol_mult = recent_vol_avg / long_vol_avg if long_vol_avg > 0 else 1.0
|
||||
|
||||
# 5-level 분류
|
||||
if up_count == 5 and vol_mult >= 1.5:
|
||||
return STRONG_UP
|
||||
elif up_count >= 3 and vol_mult >= 1.0:
|
||||
return WEAK_UP
|
||||
elif up_count == 0 and vol_mult >= 1.5:
|
||||
return STRONG_DOWN
|
||||
elif up_count <= 2 and vol_mult < 1.0:
|
||||
return WEAK_DOWN
|
||||
else:
|
||||
return NEUTRAL
|
||||
203
ai_trade/pull_worker.py
Normal file
203
ai_trade/pull_worker.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Polling loop — async cron + state update."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
from ai_trade.kis_client import KISClient
|
||||
from ai_trade.scheduler import (
|
||||
KST, _is_market_day, _is_polling_window, _next_interval, _is_post_close_trigger,
|
||||
)
|
||||
from ai_trade.state import PollState
|
||||
from ai_trade.stock_client import StockClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def poll_loop(
|
||||
client: StockClient, state: PollState, shutdown: asyncio.Event,
|
||||
kis_client: KISClient | None = None,
|
||||
chronos=None,
|
||||
dedup=None,
|
||||
settings=None,
|
||||
) -> None:
|
||||
"""FastAPI lifespan 에서 asyncio.create_task 로 시작."""
|
||||
logger.info("poll_loop started")
|
||||
last_post_close_date = None # F3: state-based post-close trigger
|
||||
while not shutdown.is_set():
|
||||
now = datetime.now(KST)
|
||||
if _is_market_day(now) and _is_polling_window(now):
|
||||
try:
|
||||
await _run_polling_cycle(client, state, kis_client=kis_client)
|
||||
except Exception:
|
||||
logger.exception("poll cycle failed")
|
||||
# Minute momentum 갱신 (매 cycle)
|
||||
try:
|
||||
update_minute_momentum_for_all(state)
|
||||
except Exception:
|
||||
logger.exception("minute momentum update failed")
|
||||
# Post-close trigger (F3: 상태기반 — 16:00 이후 + 오늘 미실행)
|
||||
if (
|
||||
_is_post_close_trigger(now, last_post_close_date)
|
||||
and chronos is not None and kis_client is not None
|
||||
):
|
||||
try:
|
||||
await _run_post_close_cycle(kis_client, chronos, state)
|
||||
last_post_close_date = now.date()
|
||||
except Exception:
|
||||
logger.exception("post-close cycle failed")
|
||||
# Phase 4: generate signals
|
||||
if dedup is not None and settings is not None:
|
||||
try:
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
generate_signals(state, dedup, settings)
|
||||
except Exception:
|
||||
logger.exception("generate_signals failed")
|
||||
# F5: cycle 끝에 expired signal purge (consumer 미사용 케이스 보호)
|
||||
try:
|
||||
state.purge_expired_signals(datetime.now(KST))
|
||||
except Exception:
|
||||
logger.exception("purge_expired_signals failed")
|
||||
interval = _next_interval(now)
|
||||
try:
|
||||
await asyncio.wait_for(shutdown.wait(), timeout=interval)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
logger.info("poll_loop ended")
|
||||
|
||||
|
||||
async def _run_polling_cycle(
|
||||
client: StockClient, state: PollState,
|
||||
kis_client: KISClient | None = None,
|
||||
) -> None:
|
||||
"""기존 3 endpoint (stock) + KIS 분봉 fetch."""
|
||||
portfolio, sentiment, screener = await asyncio.gather(
|
||||
client.get_portfolio(),
|
||||
client.get_news_sentiment(),
|
||||
client.run_screener_preview(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
|
||||
for name, result in (
|
||||
("portfolio", portfolio),
|
||||
("news_sentiment", sentiment),
|
||||
("screener_preview", screener),
|
||||
):
|
||||
if isinstance(result, dict):
|
||||
setattr(state, name, result)
|
||||
state.last_updated[name] = now_iso
|
||||
state.fetch_errors[name] = 0
|
||||
else:
|
||||
state.fetch_errors[name] = state.fetch_errors.get(name, 0) + 1
|
||||
logger.warning("fetch %s failed: %r", name, result)
|
||||
|
||||
# KIS 분봉 + 호가 (kis_client 주어졌을 때만)
|
||||
if kis_client is not None:
|
||||
try:
|
||||
await _run_kis_minute_cycle(kis_client, state)
|
||||
except Exception:
|
||||
logger.exception("kis minute cycle failed")
|
||||
|
||||
|
||||
async def _run_kis_minute_cycle(kis_client: KISClient, state: PollState) -> None:
|
||||
"""KIS 분봉 + 호가 fetch + state 갱신.
|
||||
|
||||
- 분봉: portfolio + screener Top-N union 종목 모두
|
||||
- 호가 (REST): screener-only 종목 (portfolio 는 WebSocket 으로 들어옴)
|
||||
"""
|
||||
portfolio_tickers = _portfolio_tickers(state)
|
||||
screener_tickers = _screener_tickers(state)
|
||||
all_tickers = list(set(portfolio_tickers) | set(screener_tickers))
|
||||
|
||||
# 분봉 fetch (병렬)
|
||||
minute_results = await asyncio.gather(*[
|
||||
kis_client.get_minute_ohlcv(t) for t in all_tickers
|
||||
], return_exceptions=True)
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
for ticker, result in zip(all_tickers, minute_results):
|
||||
if isinstance(result, list):
|
||||
buf = state.minute_bars.setdefault(ticker, deque(maxlen=60))
|
||||
buf.extend(result)
|
||||
state.last_updated[f"minute_bars/{ticker}"] = now_iso
|
||||
else:
|
||||
state.fetch_errors[f"minute_bars/{ticker}"] = (
|
||||
state.fetch_errors.get(f"minute_bars/{ticker}", 0) + 1
|
||||
)
|
||||
|
||||
# 호가 fetch (REST) — screener-only
|
||||
screener_only = list(set(screener_tickers) - set(portfolio_tickers))
|
||||
asking_results = await asyncio.gather(*[
|
||||
kis_client.get_asking_price(t) for t in screener_only
|
||||
], return_exceptions=True)
|
||||
for ticker, result in zip(screener_only, asking_results):
|
||||
if isinstance(result, dict):
|
||||
state.asking_price[ticker] = result
|
||||
state.last_updated[f"asking_price/{ticker}"] = now_iso
|
||||
|
||||
|
||||
def make_asking_price_callback(state: PollState):
|
||||
"""KIS WebSocket on_asking_price callback factory."""
|
||||
def _cb(ticker: str, data: dict) -> None:
|
||||
state.asking_price[ticker] = data
|
||||
state.last_updated[f"asking_price/{ticker}"] = datetime.now(KST).isoformat()
|
||||
return _cb
|
||||
|
||||
|
||||
def _portfolio_tickers(state: PollState) -> list[str]:
|
||||
if state.portfolio is None:
|
||||
return []
|
||||
return [h["ticker"] for h in state.portfolio.get("holdings", []) if "ticker" in h]
|
||||
|
||||
|
||||
def _screener_tickers(state: PollState) -> list[str]:
|
||||
if state.screener_preview is None:
|
||||
return []
|
||||
return [i["ticker"] for i in state.screener_preview.get("items", []) if "ticker" in i]
|
||||
|
||||
|
||||
async def _run_post_close_cycle(kis_client, chronos, state) -> None:
|
||||
"""16:00 KST 종가 후 1회: daily fetch + chronos predict."""
|
||||
tickers = list(set(_portfolio_tickers(state)) | set(_screener_tickers(state)))
|
||||
if not tickers:
|
||||
return
|
||||
|
||||
daily_results = await asyncio.gather(*[
|
||||
kis_client.get_daily_ohlcv(t, days=60) for t in tickers
|
||||
], return_exceptions=True)
|
||||
daily_dict = {}
|
||||
for ticker, result in zip(tickers, daily_results):
|
||||
if isinstance(result, list) and len(result) >= 30:
|
||||
daily_dict[ticker] = result
|
||||
state.daily_ohlcv[ticker] = result
|
||||
elif isinstance(result, Exception):
|
||||
state.fetch_errors[f"daily_ohlcv/{ticker}"] = (
|
||||
state.fetch_errors.get(f"daily_ohlcv/{ticker}", 0) + 1
|
||||
)
|
||||
|
||||
if daily_dict and chronos is not None:
|
||||
try:
|
||||
predictions = chronos.predict_batch(daily_dict)
|
||||
except Exception:
|
||||
logger.exception("chronos predict_batch failed")
|
||||
return
|
||||
for ticker, pred in predictions.items():
|
||||
state.chronos_predictions[ticker] = {
|
||||
"median": pred.median,
|
||||
"q10": pred.q10,
|
||||
"q90": pred.q90,
|
||||
"conf": pred.conf,
|
||||
"as_of": pred.as_of,
|
||||
}
|
||||
state.last_updated[f"chronos/{ticker}"] = pred.as_of
|
||||
|
||||
|
||||
def update_minute_momentum_for_all(state) -> None:
|
||||
"""매 분봉 cycle 후 호출 — 모든 종목 모멘텀 갱신."""
|
||||
from ai_trade.momentum_classifier import classify_minute_momentum
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
for ticker, bars in state.minute_bars.items():
|
||||
state.minute_momentum[ticker] = classify_minute_momentum(bars)
|
||||
state.last_updated[f"momentum/{ticker}"] = now_iso
|
||||
3
ai_trade/pytest.ini
Normal file
3
ai_trade/pytest.ini
Normal file
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
73
ai_trade/rate_limit.py
Normal file
73
ai_trade/rate_limit.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""SignalDedup — SQLite-backed 24h duplicate signal blocker."""
|
||||
from __future__ import annotations
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
"""Test seam — overridable via monkeypatch."""
|
||||
return datetime.now(KST).isoformat()
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS signal_dedup (
|
||||
ticker TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
last_sent TEXT NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
PRIMARY KEY (ticker, action)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_signal_dedup_last_sent
|
||||
ON signal_dedup(last_sent);
|
||||
"""
|
||||
|
||||
|
||||
class SignalDedup:
|
||||
"""24h dedup interface. WAL + busy_timeout=120000."""
|
||||
|
||||
def __init__(self, db_path: Path):
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
@contextmanager
|
||||
def _conn(self):
|
||||
conn = sqlite3.connect(self._db_path, timeout=120.0)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=120000")
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
conn.commit()
|
||||
|
||||
def is_recent(self, ticker: str, action: str, within_hours: int = 24) -> bool:
|
||||
threshold_dt = datetime.fromisoformat(_now_iso()) - timedelta(hours=within_hours)
|
||||
threshold_iso = threshold_dt.isoformat()
|
||||
with self._conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT last_sent FROM signal_dedup WHERE ticker = ? AND action = ?",
|
||||
(ticker, action),
|
||||
).fetchone()
|
||||
return row is not None and row[0] >= threshold_iso
|
||||
|
||||
def record(self, ticker: str, action: str, confidence: float) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO signal_dedup (ticker, action, last_sent, confidence)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT (ticker, action) DO UPDATE
|
||||
SET last_sent = excluded.last_sent,
|
||||
confidence = excluded.confidence""",
|
||||
(ticker, action, _now_iso(), confidence),
|
||||
)
|
||||
conn.commit()
|
||||
108
ai_trade/scheduler.py
Normal file
108
ai_trade/scheduler.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Polling scheduler — 시간대별 분기 + 휴장일 처리."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta, time
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
_HOLIDAYS_PATH = Path(__file__).parent / "holidays.json"
|
||||
_HOLIDAYS: set[str] = set(json.loads(_HOLIDAYS_PATH.read_text(encoding="utf-8")))
|
||||
|
||||
# Market windows (정규장)
|
||||
_PRE_OPEN = time(7, 0)
|
||||
_OPEN = time(9, 0)
|
||||
_CLOSE = time(15, 30)
|
||||
_POST_END = time(20, 0)
|
||||
|
||||
# NXT windows (시간외)
|
||||
_NXT_PRE_END = time(23, 30)
|
||||
_NXT_POST_OPEN = time(4, 30)
|
||||
# 23:30 - 04:30 (dead zone) skip
|
||||
|
||||
|
||||
def _is_market_day(now: datetime) -> bool:
|
||||
"""평일 + 휴장일 아닌 날."""
|
||||
if now.weekday() >= 5: # Sat/Sun
|
||||
return False
|
||||
return now.strftime("%Y-%m-%d") not in _HOLIDAYS
|
||||
|
||||
|
||||
def _is_polling_window(now: datetime) -> bool:
|
||||
"""폴링 윈도우: 07:00-23:30 + 04:30-07:00."""
|
||||
t = now.time()
|
||||
return (
|
||||
(_PRE_OPEN <= t < _NXT_PRE_END)
|
||||
or (_NXT_POST_OPEN <= t < _PRE_OPEN)
|
||||
)
|
||||
|
||||
|
||||
def _next_interval(now: datetime) -> float:
|
||||
"""다음 폴링까지 sleep 초수."""
|
||||
if not _is_market_day(now):
|
||||
return _seconds_until_next_market_open(now)
|
||||
|
||||
t = now.time()
|
||||
if _PRE_OPEN <= t < _OPEN:
|
||||
return 300.0 # 장전 5분
|
||||
elif _OPEN <= t < _CLOSE:
|
||||
return 60.0 # 장중 1분
|
||||
elif _CLOSE <= t < _POST_END:
|
||||
return 300.0 # 장후 5분
|
||||
elif _POST_END <= t < _NXT_PRE_END:
|
||||
return 300.0 # NXT 야간 5분
|
||||
elif _NXT_POST_OPEN <= t < _PRE_OPEN:
|
||||
return 300.0 # NXT 새벽 5분
|
||||
else:
|
||||
# Dead zone (23:30-04:30) — wait until next 04:30
|
||||
return _seconds_until_nxt_or_market_open(now)
|
||||
|
||||
|
||||
def _seconds_until_nxt_or_market_open(now: datetime) -> float:
|
||||
"""다음 04:30 (NXT 새벽 start) 까지 초수. 휴장일은 다음 영업일 07:00."""
|
||||
candidate = now.replace(hour=4, minute=30, second=0, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
for _ in range(14):
|
||||
if _is_market_day(candidate):
|
||||
return (candidate - now).total_seconds()
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
logger.warning("could not find next market day within 14 days")
|
||||
return 86400.0
|
||||
|
||||
|
||||
def _is_post_close_trigger(now: datetime, last_post_close_date) -> bool:
|
||||
"""F3 — 16:00 KST 이후 오늘 아직 post-close cycle 안 돌렸으면 True (상태기반).
|
||||
|
||||
이전엔 16:00:00-16:00:59 1분 윈도우라 5분 sleep + 비결정적 cycle 시작시각
|
||||
조합으로 영영 못 잡는 경우 발생 (예: cycle이 15:31에 시작되면 16:01에 깸).
|
||||
|
||||
Args:
|
||||
now: 현재 KST datetime.
|
||||
last_post_close_date: 마지막 post-close 실행 영업일 date (None=미실행).
|
||||
"""
|
||||
if not _is_market_day(now):
|
||||
return False
|
||||
if now.time() < time(16, 0):
|
||||
return False
|
||||
return last_post_close_date != now.date()
|
||||
|
||||
|
||||
def _seconds_until_next_market_open(now: datetime) -> float:
|
||||
"""다음 영업일의 07:00 KST 까지 초수 (휴장일/주말용)."""
|
||||
candidate = now.replace(hour=7, minute=0, second=0, microsecond=0)
|
||||
if candidate <= now:
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
for _ in range(14): # safety bound (max 2 weeks of holidays)
|
||||
if _is_market_day(candidate):
|
||||
return (candidate - now).total_seconds()
|
||||
candidate += timedelta(days=1)
|
||||
|
||||
logger.warning("could not find next market day within 14 days")
|
||||
return 86400.0
|
||||
245
ai_trade/signal_generator.py
Normal file
245
ai_trade/signal_generator.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Phase 4 — 매수/매도 신호 생성.
|
||||
|
||||
순수 함수 generate_signals(state, dedup, settings). state 를 mutate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
MOMENTUM_SCORES = {
|
||||
"strong_up": 1.0,
|
||||
"weak_up": 0.7,
|
||||
"neutral": 0.5,
|
||||
"weak_down": 0.3,
|
||||
"strong_down": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def generate_signals(state, dedup, settings) -> None:
|
||||
"""Phase 4 entry — state-mutating. F5: cycle_id += 1 (호출마다, emit 여부 무관).
|
||||
|
||||
Evaluation order: sell first (priority), then buy. A ticker receiving a sell
|
||||
signal in this cycle is excluded from buy evaluation to avoid silent overwrite.
|
||||
"""
|
||||
state.signal_cycle_id += 1
|
||||
_evaluate_sell_signals(state, dedup, settings)
|
||||
_evaluate_buy_signals(state, dedup, settings)
|
||||
|
||||
|
||||
# ----- 매수 -----
|
||||
|
||||
def _evaluate_buy_signals(state, dedup, settings) -> None:
|
||||
candidates = _buy_candidates(state)
|
||||
for ticker, name, rank in candidates:
|
||||
existing = state.signals.get(ticker)
|
||||
if existing is not None and existing.get("action") == "sell":
|
||||
logger.debug("buy %s skipped: same-cycle sell precedence", ticker)
|
||||
continue
|
||||
if not _check_buy_hard_gate(state, ticker, settings):
|
||||
logger.debug("buy %s skipped: hard gate failed", ticker)
|
||||
continue
|
||||
confidence = _compute_buy_confidence(state, ticker, rank)
|
||||
if confidence <= settings.confidence_threshold:
|
||||
logger.debug("buy %s skipped: confidence %.3f <= %.3f",
|
||||
ticker, confidence, settings.confidence_threshold)
|
||||
continue
|
||||
if dedup.is_recent(ticker, "buy", within_hours=24):
|
||||
logger.debug("buy %s skipped: dedup 24h", ticker)
|
||||
continue
|
||||
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence, settings)
|
||||
dedup.record(ticker, "buy", confidence=confidence)
|
||||
logger.info("signal emit %s buy conf=%.3f rank=%s cycle=%d",
|
||||
ticker, confidence, rank, state.signal_cycle_id)
|
||||
|
||||
|
||||
def _buy_candidates(state) -> list[tuple[str, str, int | None]]:
|
||||
"""screener Top-N (rank 1..N) + portfolio (rank=None)."""
|
||||
candidates: list[tuple[str, str, int | None]] = []
|
||||
seen: set[str] = set()
|
||||
if state.screener_preview is not None:
|
||||
for i, item in enumerate(state.screener_preview.get("items", [])):
|
||||
ticker = item.get("ticker")
|
||||
if not ticker or ticker in seen:
|
||||
continue
|
||||
seen.add(ticker)
|
||||
name = item.get("name", ticker)
|
||||
candidates.append((ticker, name, i + 1))
|
||||
if state.portfolio is not None:
|
||||
for h in state.portfolio.get("holdings", []):
|
||||
ticker = h.get("ticker")
|
||||
if not ticker or ticker in seen:
|
||||
continue
|
||||
seen.add(ticker)
|
||||
candidates.append((ticker, h.get("name", ticker), None))
|
||||
return candidates
|
||||
|
||||
|
||||
def _check_buy_hard_gate(state, ticker: str, settings) -> bool:
|
||||
pred = state.chronos_predictions.get(ticker)
|
||||
if pred is None or pred.get("median", 0) <= 0:
|
||||
return False
|
||||
spread = pred.get("q90", 0) - pred.get("q10", 0)
|
||||
if spread >= settings.chronos_spread_threshold:
|
||||
return False
|
||||
momentum = state.minute_momentum.get(ticker)
|
||||
if momentum != settings.min_momentum_for_buy:
|
||||
return False
|
||||
ap = state.asking_price.get(ticker)
|
||||
if ap is None or ap.get("bid_ratio", 0) < settings.asking_bid_ratio_threshold:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float:
|
||||
pred = state.chronos_predictions[ticker]
|
||||
chronos_conf = pred["conf"]
|
||||
minute_score = MOMENTUM_SCORES.get(state.minute_momentum.get(ticker, "neutral"), 0.5)
|
||||
screener_norm = max(0.0, 1 - (rank - 1) / 20) if rank is not None else 0.0
|
||||
return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
|
||||
|
||||
|
||||
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float, settings) -> dict:
|
||||
ap = state.asking_price[ticker]
|
||||
as_of_dt = datetime.now(KST)
|
||||
ttl = getattr(settings, "signal_ttl_seconds", 300)
|
||||
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"action": "buy",
|
||||
"confidence_webai": confidence,
|
||||
"current_price": ap["current_price"],
|
||||
"avg_price": None,
|
||||
"pnl_pct": None,
|
||||
"context": _build_context(state, ticker, rank),
|
||||
"as_of": as_of_dt.isoformat(),
|
||||
"cycle_id": state.signal_cycle_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
# ----- 매도 -----
|
||||
|
||||
def _evaluate_sell_signals(state, dedup, settings) -> None:
|
||||
if state.portfolio is None:
|
||||
return
|
||||
for holding in state.portfolio.get("holdings", []):
|
||||
ticker = holding.get("ticker")
|
||||
if not ticker:
|
||||
continue
|
||||
sell = _try_stop_loss(state, holding, settings)
|
||||
if sell is None:
|
||||
sell = _try_anomaly(state, holding, settings)
|
||||
if sell is None:
|
||||
sell = _try_take_profit(state, holding, settings)
|
||||
if sell is None:
|
||||
continue
|
||||
if dedup.is_recent(ticker, "sell", within_hours=24):
|
||||
logger.debug("sell %s skipped: dedup 24h", ticker)
|
||||
continue
|
||||
state.signals[ticker] = sell
|
||||
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
|
||||
logger.info("signal emit %s sell conf=%.3f reason=%s cycle=%d",
|
||||
ticker, sell["confidence_webai"],
|
||||
sell.get("context", {}).get("sell_reason"),
|
||||
state.signal_cycle_id)
|
||||
|
||||
|
||||
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
|
||||
pnl = holding.get("pnl_pct")
|
||||
if pnl is None or pnl >= settings.stop_loss_pct:
|
||||
return None
|
||||
return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss", settings=settings)
|
||||
|
||||
|
||||
def _try_take_profit(state, holding: dict, settings) -> dict | None:
|
||||
pnl = holding.get("pnl_pct")
|
||||
if pnl is None or pnl <= settings.take_profit_pct:
|
||||
return None
|
||||
return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit", settings=settings)
|
||||
|
||||
|
||||
def _try_anomaly(state, holding: dict, settings) -> dict | None:
|
||||
ticker = holding["ticker"]
|
||||
pred = state.chronos_predictions.get(ticker)
|
||||
if pred is None or pred["median"] >= -0.01:
|
||||
return None
|
||||
momentum = state.minute_momentum.get(ticker)
|
||||
if momentum != "strong_down":
|
||||
return None
|
||||
ap = state.asking_price.get(ticker)
|
||||
if ap is None:
|
||||
return None
|
||||
if ap["bid_ratio"] > (1 - settings.asking_bid_ratio_threshold):
|
||||
return None
|
||||
minute_score = 1.0 - MOMENTUM_SCORES.get(momentum, 0.5)
|
||||
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
|
||||
if confidence <= settings.confidence_threshold:
|
||||
return None
|
||||
return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly", settings=settings)
|
||||
|
||||
|
||||
def _build_sell_signal(state, holding: dict, confidence: float, reason: str, settings=None) -> dict:
|
||||
ticker = holding["ticker"]
|
||||
as_of_dt = datetime.now(KST)
|
||||
ttl = getattr(settings, "signal_ttl_seconds", 300) if settings else 300
|
||||
expires_at = (as_of_dt + timedelta(seconds=ttl)).isoformat()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"name": holding.get("name", ticker),
|
||||
"action": "sell",
|
||||
"confidence_webai": confidence,
|
||||
"current_price": holding.get("current_price"),
|
||||
"avg_price": holding.get("avg_price"),
|
||||
"pnl_pct": holding.get("pnl_pct"),
|
||||
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
|
||||
"as_of": as_of_dt.isoformat(),
|
||||
"cycle_id": state.signal_cycle_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
|
||||
|
||||
# ----- Context -----
|
||||
|
||||
def _build_context(state, ticker: str, rank: int | None, sell_reason: str | None = None) -> dict:
|
||||
pred = state.chronos_predictions.get(ticker) or {}
|
||||
ap = state.asking_price.get(ticker) or {}
|
||||
news_item = _find_news_sentiment(state, ticker)
|
||||
screener_scores = _find_screener_scores(state, ticker)
|
||||
context: dict = {
|
||||
"chronos_pred_1d": pred.get("median"),
|
||||
"chronos_pred_conf": pred.get("conf"),
|
||||
"chronos_q10": pred.get("q10"),
|
||||
"chronos_q90": pred.get("q90"),
|
||||
"screener_rank": rank,
|
||||
"screener_scores": screener_scores,
|
||||
"minute_momentum": state.minute_momentum.get(ticker),
|
||||
"asking_bid_ratio": ap.get("bid_ratio"),
|
||||
"news_sentiment": news_item.get("score") if news_item else None,
|
||||
"news_reason": news_item.get("reason") if news_item else None,
|
||||
}
|
||||
if sell_reason is not None:
|
||||
context["sell_reason"] = sell_reason
|
||||
return context
|
||||
|
||||
|
||||
def _find_news_sentiment(state, ticker: str) -> dict | None:
|
||||
if state.news_sentiment is None:
|
||||
return None
|
||||
for item in state.news_sentiment.get("items", []):
|
||||
if item.get("ticker") == ticker:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
def _find_screener_scores(state, ticker: str) -> dict | None:
|
||||
if state.screener_preview is None:
|
||||
return None
|
||||
for item in state.screener_preview.get("items", []):
|
||||
if item.get("ticker") == ticker:
|
||||
return item.get("scores")
|
||||
return None
|
||||
3
ai_trade/start.bat
Normal file
3
ai_trade/start.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0\.."
|
||||
python -m uvicorn ai_trade.main:app --host 0.0.0.0 --port 8001
|
||||
59
ai_trade/state.py
Normal file
59
ai_trade/state.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""PollState — process-wide singleton."""
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class PollState:
|
||||
portfolio: dict | None = None
|
||||
news_sentiment: dict | None = None
|
||||
screener_preview: dict | None = None
|
||||
minute_bars: dict[str, deque] = field(default_factory=dict)
|
||||
asking_price: dict[str, dict] = field(default_factory=dict)
|
||||
# Phase 3b additions
|
||||
daily_ohlcv: dict[str, list[dict]] = field(default_factory=dict)
|
||||
chronos_predictions: dict[str, dict] = field(default_factory=dict)
|
||||
minute_momentum: dict[str, str] = field(default_factory=dict)
|
||||
signals: dict[str, dict] = field(default_factory=dict)
|
||||
# F5 lifecycle
|
||||
signal_cycle_id: int = 0
|
||||
last_updated: dict[str, str] = field(default_factory=dict)
|
||||
fetch_errors: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def get_active_signals(self, now: datetime) -> list[dict]:
|
||||
"""expires_at > now 인 신호만 반환. expires_at 없거나 파싱 실패는 expired 취급."""
|
||||
active: list[dict] = []
|
||||
for sig in self.signals.values():
|
||||
expires_at = sig.get("expires_at")
|
||||
if not expires_at:
|
||||
continue
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at)
|
||||
except ValueError:
|
||||
continue
|
||||
if exp_dt > now:
|
||||
active.append(sig)
|
||||
return active
|
||||
|
||||
def purge_expired_signals(self, now: datetime) -> int:
|
||||
"""만료된 signal 제거. expires_at 없거나 파싱 실패도 제거. 제거 개수 반환."""
|
||||
to_drop = []
|
||||
for ticker, sig in self.signals.items():
|
||||
expires_at = sig.get("expires_at")
|
||||
if not expires_at:
|
||||
to_drop.append(ticker)
|
||||
continue
|
||||
try:
|
||||
exp_dt = datetime.fromisoformat(expires_at)
|
||||
except ValueError:
|
||||
to_drop.append(ticker)
|
||||
continue
|
||||
if exp_dt <= now:
|
||||
to_drop.append(ticker)
|
||||
for t in to_drop:
|
||||
del self.signals[t]
|
||||
return len(to_drop)
|
||||
|
||||
|
||||
state = PollState()
|
||||
129
ai_trade/stock_client.py
Normal file
129
ai_trade/stock_client.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Stock API HTTP client — async httpx + retry + memory cache."""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache TTL by endpoint (seconds).
|
||||
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||
_TTL = {
|
||||
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||
}
|
||||
|
||||
# Retry policy
|
||||
_MAX_ATTEMPTS = 3
|
||||
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
class StockClient:
|
||||
"""stock API wrapper. Async httpx + self-retry + memory cache."""
|
||||
|
||||
def __init__(self, base_url: str, api_key: str, timeout: float = 10.0):
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._api_key = api_key
|
||||
self._client = httpx.AsyncClient(timeout=timeout)
|
||||
# cache: key → (data, timestamp_monotonic)
|
||||
self._cache: dict[str, tuple[Any, float]] = {}
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
def cache_size(self) -> int:
|
||||
"""Number of cached endpoint responses (public surface for /health)."""
|
||||
return len(self._cache)
|
||||
|
||||
async def get_portfolio(self) -> dict:
|
||||
return await self._cached_request(
|
||||
"portfolio", "GET", "/api/webai/portfolio"
|
||||
)
|
||||
|
||||
async def get_news_sentiment(self, date: str | None = None) -> dict:
|
||||
path = "/api/webai/news-sentiment"
|
||||
if date is not None:
|
||||
path += f"?date={date}"
|
||||
cache_key = f"news-sentiment:{date or 'latest'}"
|
||||
return await self._cached_request(
|
||||
cache_key, "GET", path, _ttl_key="news-sentiment"
|
||||
)
|
||||
|
||||
async def run_screener_preview(
|
||||
self, weights: dict | None = None, top_n: int = 20
|
||||
) -> dict:
|
||||
body = {"mode": "preview", "top_n": top_n}
|
||||
if weights is not None:
|
||||
body["weights"] = weights
|
||||
return await self._cached_request(
|
||||
"screener-preview",
|
||||
"POST",
|
||||
"/api/stock/screener/run",
|
||||
json=body,
|
||||
_ttl_key="screener-preview",
|
||||
)
|
||||
|
||||
async def _cached_request(
|
||||
self,
|
||||
cache_key: str,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
_ttl_key: str | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
ttl_key = _ttl_key or cache_key
|
||||
ttl = _TTL.get(ttl_key, 60.0)
|
||||
# Fresh cache hit?
|
||||
if cache_key in self._cache:
|
||||
data, ts = self._cache[cache_key]
|
||||
if time.monotonic() - ts < ttl:
|
||||
return data
|
||||
|
||||
# Fetch (with retry)
|
||||
try:
|
||||
data = await self._request_with_retry(method, path, **kwargs)
|
||||
self._cache[cache_key] = (data, time.monotonic())
|
||||
return data
|
||||
except httpx.HTTPError:
|
||||
# Stale fallback: serve old cached value if exists
|
||||
if cache_key in self._cache:
|
||||
stale_data, stale_ts = self._cache[cache_key]
|
||||
age = time.monotonic() - stale_ts
|
||||
logger.warning(
|
||||
"serving stale cache for %s (age=%.1fs)", cache_key, age
|
||||
)
|
||||
return stale_data
|
||||
raise
|
||||
|
||||
async def _request_with_retry(self, method: str, path: str, **kwargs) -> dict:
|
||||
url = f"{self._base_url}{path}"
|
||||
headers = self._auth_headers()
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
response = await self._client.request(
|
||||
method, url, headers=headers, **kwargs
|
||||
)
|
||||
if response.status_code in _RETRY_STATUSES:
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
await asyncio.sleep(2**attempt)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.TimeoutException:
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
await asyncio.sleep(2**attempt)
|
||||
continue
|
||||
raise
|
||||
except httpx.HTTPStatusError:
|
||||
raise
|
||||
# Unreachable: every iteration either returns or raises
|
||||
raise RuntimeError("_request_with_retry exhausted loop without raising")
|
||||
|
||||
def _auth_headers(self) -> dict[str, str]:
|
||||
return {"X-WebAI-Key": self._api_key}
|
||||
0
ai_trade/tests/__init__.py
Normal file
0
ai_trade/tests/__init__.py
Normal file
BIN
ai_trade/tests/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
ai_trade/tests/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
ai_trade/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc
Normal file
BIN
ai_trade/tests/__pycache__/conftest.cpython-312-pytest-9.0.2.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
18
ai_trade/tests/conftest.py
Normal file
18
ai_trade/tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Pytest fixtures for ai_trade tests."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dedup_db(tmp_path) -> Path:
|
||||
"""SQLite 단위 테스트용 임시 DB path."""
|
||||
return tmp_path / "test_ai_trade.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_stock_api():
|
||||
"""respx 로 stock API mock. base_url 은 테스트마다 임의."""
|
||||
with respx.mock(base_url="https://test.stock.local", assert_all_called=False) as mock:
|
||||
yield mock
|
||||
143
ai_trade/tests/test_chronos_predictor.py
Normal file
143
ai_trade/tests/test_chronos_predictor.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tests for ChronosPredictor (model mock)."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pipeline():
|
||||
"""Mock BaseChronosPipeline.from_pretrained returning a mock pipeline object."""
|
||||
with patch("chronos.BaseChronosPipeline") as cls:
|
||||
cls.__name__ = "BaseChronosPipeline"
|
||||
instance = MagicMock()
|
||||
# ChronosBolt API: predict_quantiles returns (quantiles_tensor, mean_tensor)
|
||||
# Modern (predict_quantiles) branch will be used since hasattr(MagicMock, "predict_quantiles") is True.
|
||||
cls.from_pretrained.return_value = instance
|
||||
yield instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_torch_cpu():
|
||||
with patch("torch.cuda.is_available", return_value=False):
|
||||
yield
|
||||
|
||||
|
||||
def _daily_ohlcv(close_seq):
|
||||
return [{"datetime": f"2026-05-{i+1:02d}", "open": c, "high": c, "low": c,
|
||||
"close": c, "volume": 1000} for i, c in enumerate(close_seq)]
|
||||
|
||||
|
||||
def _mk_quantiles_tensor(q10_price: float, q50_price: float, q90_price: float):
|
||||
"""Helper: build predict_quantiles return tensor shape [1, 1, 3]."""
|
||||
import torch
|
||||
return torch.tensor([[[q10_price, q50_price, q90_price]]], dtype=torch.float32)
|
||||
|
||||
|
||||
def test_predict_batch_returns_prediction_dict(mock_pipeline, mock_torch_cpu):
|
||||
"""mock predict_quantiles → dict[ticker, ChronosPrediction]. last_close=100, q50=102 → median≈+2%."""
|
||||
quantiles = _mk_quantiles_tensor(101.5, 102.0, 102.5) # narrow around 102
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor, ChronosPrediction
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
assert "005930" in result
|
||||
pred = result["005930"]
|
||||
assert isinstance(pred, ChronosPrediction)
|
||||
assert abs(pred.median - 0.02) < 0.001
|
||||
|
||||
|
||||
def test_conf_high_when_distribution_narrow(mock_pipeline, mock_torch_cpu):
|
||||
"""좁은 distribution (q90-q10 작음, median 0 아님) → conf ≈ 1."""
|
||||
# last_close=100, q10=101.99, q50=102.00, q90=102.01
|
||||
# returns: q10=0.0199, q50=0.02, q90=0.0201
|
||||
# spread = (0.0201 - 0.0199) / max(0.02, 0.001) = 0.0002/0.02 = 0.01 → conf = 1 - 0.005 = 0.995
|
||||
quantiles = _mk_quantiles_tensor(101.99, 102.0, 102.01)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
assert result["005930"].conf > 0.8
|
||||
|
||||
|
||||
def test_conf_low_when_distribution_wide(mock_pipeline, mock_torch_cpu):
|
||||
"""넓은 distribution → conf ≈ 0."""
|
||||
# last_close=100, q10=70, q50=100, q90=130
|
||||
# returns: q10=-0.3, q50=0.0, q90=0.3
|
||||
# spread = (0.3 - (-0.3)) / max(0.0, 0.001) = 0.6 / 0.001 = 600 → conf = max(0, 1 - 300) = 0
|
||||
quantiles = _mk_quantiles_tensor(70.0, 100.0, 130.0)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
assert result["005930"].conf < 0.3
|
||||
|
||||
|
||||
def test_return_computed_from_price_relative_to_last_close(mock_pipeline, mock_torch_cpu):
|
||||
"""price 예측 → last_close 대비 return 변환. last_close=100, q50=110 → return ≈ +10%."""
|
||||
quantiles = _mk_quantiles_tensor(109.0, 110.0, 111.0)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
# last close = 100
|
||||
daily = {"005930": _daily_ohlcv(list(range(41, 101)))} # last = 100
|
||||
result = predictor.predict_batch(daily)
|
||||
assert abs(result["005930"].median - 0.10) < 0.001
|
||||
|
||||
|
||||
# ----- F4: absolute spread 기반 confidence -----
|
||||
|
||||
def test_confidence_high_when_spread_near_zero(mock_pipeline, mock_torch_cpu):
|
||||
"""F4 — median≈0 + spread≈0 일 때 conf≈1 (현 relative 산식의 회귀 케이스).
|
||||
|
||||
한국 주가 100000원, q10=q50=q90=100000 → median=0, spread=0.
|
||||
Relative 산식 (spread/abs(median))은 0/0.001 보호선이라 spread=0이면 conf=1로
|
||||
동작하지만, median≈0 + 미세 spread(예 1원) 케이스에서 폭증 → conf=0.
|
||||
Absolute 산식은 그런 폭증 없음.
|
||||
"""
|
||||
quantiles = _mk_quantiles_tensor(100000.0, 100000.0, 100000.0)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100000] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
assert result["005930"].conf > 0.95, (
|
||||
f"median≈0 + spread≈0인데 conf={result['005930'].conf} (F4 회귀)"
|
||||
)
|
||||
|
||||
|
||||
def test_confidence_half_at_spread_03(mock_pipeline, mock_torch_cpu):
|
||||
"""F4 — spread 0.30일 때 conf ≈ 0.5 (1 - 0.3/0.6)."""
|
||||
# q10=85000 → -0.15, q90=115000 → 0.15, q50=100000 → 0.0
|
||||
# spread = 0.30, conf = 1 - 0.30/0.60 = 0.50
|
||||
quantiles = _mk_quantiles_tensor(85000.0, 100000.0, 115000.0)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100000] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
conf = result["005930"].conf
|
||||
assert 0.45 < conf < 0.55, f"spread=0.30에서 conf={conf} (expected ≈0.5)"
|
||||
|
||||
|
||||
def test_confidence_zero_at_threshold_spread(mock_pipeline, mock_torch_cpu):
|
||||
"""F4 — spread가 _SPREAD_THRESHOLD(0.6)이면 conf=0."""
|
||||
quantiles = _mk_quantiles_tensor(70000.0, 100000.0, 130000.0)
|
||||
mock_pipeline.predict_quantiles.return_value = (quantiles, None)
|
||||
|
||||
from ai_trade.chronos_predictor import ChronosPredictor
|
||||
predictor = ChronosPredictor(model_name="mock-model")
|
||||
daily = {"005930": _daily_ohlcv([100000] * 60)}
|
||||
result = predictor.predict_batch(daily)
|
||||
assert result["005930"].conf < 0.05, (
|
||||
f"spread=threshold에서 conf={result['005930'].conf} (expected ≈0)"
|
||||
)
|
||||
22
ai_trade/tests/test_config_token_path.py
Normal file
22
ai_trade/tests/test_config_token_path.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""F1 — V1_TOKEN_PATH default가 legacy/signal_v1/ 경유인지 검증."""
|
||||
from pathlib import Path
|
||||
|
||||
from ai_trade.config import Settings
|
||||
|
||||
|
||||
def test_v1_token_default_path_uses_legacy_dir(monkeypatch):
|
||||
"""env에 V1_TOKEN_PATH 없으면 legacy/signal_v1/data/kis_token.json"""
|
||||
monkeypatch.delenv("V1_TOKEN_PATH", raising=False)
|
||||
settings = Settings()
|
||||
expected_suffix = Path("legacy") / "signal_v1" / "data" / "kis_token.json"
|
||||
assert str(settings.v1_token_path).endswith(str(expected_suffix)), (
|
||||
f"expected default to end with {expected_suffix}, got {settings.v1_token_path}"
|
||||
)
|
||||
|
||||
|
||||
def test_v1_token_env_override_wins(monkeypatch, tmp_path):
|
||||
"""env로 명시한 경로가 default를 덮어씀."""
|
||||
custom = tmp_path / "custom_token.json"
|
||||
monkeypatch.setenv("V1_TOKEN_PATH", str(custom))
|
||||
settings = Settings()
|
||||
assert settings.v1_token_path == custom
|
||||
190
ai_trade/tests/test_kis_client.py
Normal file
190
ai_trade/tests/test_kis_client.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for KISClient (REST)."""
|
||||
import asyncio
|
||||
import json
|
||||
import time as time_module
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ai_trade.kis_client import KISClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_v1_token(tmp_path):
|
||||
"""V1 토큰 파일 fixture."""
|
||||
token_file = tmp_path / "kis_token.json"
|
||||
token_file.write_text(json.dumps({
|
||||
"access_token": "test-kis-token-abc123",
|
||||
"token_expired": "2099-12-31 23:59:59",
|
||||
}))
|
||||
return token_file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kis_client_factory(fake_v1_token):
|
||||
def _make():
|
||||
return KISClient(
|
||||
app_key="test-app-key",
|
||||
app_secret="test-app-secret",
|
||||
account="50000000-01",
|
||||
is_virtual=True,
|
||||
v1_token_path=fake_v1_token,
|
||||
)
|
||||
return _make
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_get_minute_ohlcv_normal_returns_30_bars(kis_client_factory):
|
||||
"""정상 200 → 30개 분봉 list 반환."""
|
||||
sample_output2 = [
|
||||
{
|
||||
"stck_bsop_date": "20260518",
|
||||
"stck_cntg_hour": f"09{m:02d}00",
|
||||
"stck_oprc": "78000", "stck_hgpr": "78500",
|
||||
"stck_lwpr": "77800", "stck_prpr": "78300",
|
||||
"cntg_vol": "12345",
|
||||
}
|
||||
for m in range(30) # 9:00-9:29 = 30 bars
|
||||
]
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
).mock(
|
||||
return_value=httpx.Response(200, json={"output2": sample_output2})
|
||||
)
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
bars = await client.get_minute_ohlcv("005930")
|
||||
assert len(bars) == 30
|
||||
assert bars[0]["close"] == 78300
|
||||
assert "datetime" in bars[0]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_get_minute_ohlcv_429_retry_then_success(kis_client_factory, monkeypatch):
|
||||
"""429 → exponential backoff → 200."""
|
||||
sleep_calls = []
|
||||
async def fake_sleep(s): sleep_calls.append(s)
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
).mock(side_effect=[
|
||||
httpx.Response(429, text="rate limit"),
|
||||
httpx.Response(200, json={"output2": []}),
|
||||
])
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
result = await client.get_minute_ohlcv("005930")
|
||||
assert result == []
|
||||
assert 1 in sleep_calls
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_get_minute_ohlcv_uses_v1_token(kis_client_factory, fake_v1_token):
|
||||
"""KIS 호출 헤더에 V1 토큰 파일의 access_token 사용."""
|
||||
route = respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
).mock(return_value=httpx.Response(200, json={"output2": []}))
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
await client.get_minute_ohlcv("005930")
|
||||
assert route.called
|
||||
req = route.calls.last.request
|
||||
# check authorization header contains the V1 token
|
||||
auth = req.headers.get("authorization", "")
|
||||
assert "test-kis-token-abc123" in auth
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_get_asking_price_computes_bid_ratio(kis_client_factory):
|
||||
"""호가 응답 → bid_total/(bid+ask) bid_ratio 계산."""
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn"
|
||||
).mock(return_value=httpx.Response(200, json={
|
||||
"output1": {
|
||||
"total_bidp_rsqn": "600",
|
||||
"total_askp_rsqn": "400",
|
||||
"stck_prpr": "78500",
|
||||
}
|
||||
}))
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
data = await client.get_asking_price("005930")
|
||||
assert data["bid_total"] == 600
|
||||
assert data["ask_total"] == 400
|
||||
assert abs(data["bid_ratio"] - 0.6) < 1e-9
|
||||
assert data["current_price"] == 78500
|
||||
assert "as_of" in data
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_get_daily_ohlcv_returns_60_bars(kis_client_factory):
|
||||
"""KIS daily endpoint returns 60 ascending bars after parsing."""
|
||||
# Build 60 KIS-format daily bars (descending dates as KIS does)
|
||||
sample_output2 = []
|
||||
for i in range(60):
|
||||
# Generate a fake date 60 days ago, descending
|
||||
day = 60 - i
|
||||
sample_output2.append({
|
||||
"stck_bsop_date": f"2026{(((day-1)//30)+1):02d}{(((day-1)%30)+1):02d}",
|
||||
"stck_oprc": "78000", "stck_hgpr": "78500",
|
||||
"stck_lwpr": "77800", "stck_clpr": str(78000 + i),
|
||||
"acml_vol": "12345",
|
||||
})
|
||||
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||
).mock(return_value=httpx.Response(200, json={"output2": sample_output2}))
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
bars = await client.get_daily_ohlcv("005930", days=60)
|
||||
# KIS returns descending; client reverses to ascending
|
||||
assert len(bars) == 60
|
||||
# Ascending order: first item has smaller datetime than last
|
||||
assert bars[0]["datetime"] < bars[-1]["datetime"]
|
||||
assert isinstance(bars[0]["open"], int)
|
||||
assert isinstance(bars[0]["close"], int)
|
||||
assert "datetime" in bars[0]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_throttle_serializes_concurrent_gather(kis_client_factory):
|
||||
"""F2 — 5개 동시 요청이 asyncio.gather로 들어와도 0.5초 간격으로 직렬화.
|
||||
|
||||
초당 2회 = 0.5초 간격. 5개 요청 시 최소 (5-1)*0.5 = 2.0초.
|
||||
Lock 없으면 race condition으로 거의 동시에 나가 0.5초대로 끝남.
|
||||
"""
|
||||
sample = {"output2": []}
|
||||
respx.get(
|
||||
"https://openapivts.koreainvestment.com:29443"
|
||||
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
|
||||
).mock(return_value=httpx.Response(200, json=sample))
|
||||
|
||||
client = kis_client_factory()
|
||||
try:
|
||||
start = time_module.monotonic()
|
||||
await asyncio.gather(*[client.get_minute_ohlcv(f"00593{i}") for i in range(5)])
|
||||
elapsed = time_module.monotonic() - start
|
||||
# 5 throttle = 최소 (5-1)*0.5 = 2.0s, tolerance 0.3s
|
||||
assert elapsed >= 1.7, (
|
||||
f"throttle race condition: 5 concurrent calls took only {elapsed:.2f}s, "
|
||||
f"expected >=1.7s (0.5s * 4 inter-call gaps)"
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
94
ai_trade/tests/test_kis_websocket.py
Normal file
94
ai_trade/tests/test_kis_websocket.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Tests for KISWebSocket."""
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ai_trade.kis_websocket import KISWebSocket
|
||||
|
||||
|
||||
BASE_REST = "https://openapivts.koreainvestment.com:29443"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_fetch_approval_key_via_oauth_endpoint():
|
||||
"""POST /oauth2/Approval → approval_key 추출."""
|
||||
respx.post(f"{BASE_REST}/oauth2/Approval").mock(
|
||||
return_value=httpx.Response(200, json={"approval_key": "test-approval-key-xyz"})
|
||||
)
|
||||
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
|
||||
key = await ws._fetch_approval_key()
|
||||
assert key == "test-approval-key-xyz"
|
||||
assert ws._approval_key == "test-approval-key-xyz"
|
||||
|
||||
|
||||
async def test_subscribe_sends_h0stasp0_message():
|
||||
"""subscribe() → WebSocket 으로 H0STASP0 구독 메시지 전송."""
|
||||
sent_messages = []
|
||||
mock_ws = AsyncMock()
|
||||
mock_ws.send = AsyncMock(side_effect=lambda m: sent_messages.append(m))
|
||||
|
||||
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
|
||||
ws._approval_key = "test-key"
|
||||
ws._ws = mock_ws
|
||||
await ws.subscribe("005930")
|
||||
assert ws._subscriptions == {"005930"}
|
||||
assert len(sent_messages) == 1
|
||||
msg = json.loads(sent_messages[0])
|
||||
assert msg["header"]["tr_type"] == "1" # subscribe
|
||||
assert msg["body"]["input"]["tr_id"] == "H0STASP0"
|
||||
assert msg["body"]["input"]["tr_key"] == "005930"
|
||||
|
||||
|
||||
def test_parse_asking_price_extracts_bid_ask_totals():
|
||||
"""KIS raw '0|H0STASP0|001|...' → (ticker, dict).
|
||||
|
||||
KIS 호가 메시지 형식 — KIS 공식 spec 의 정확한 필드 인덱스 운영 검증 필요.
|
||||
본 테스트는 implementer 의 _parse_asking_price 구현 인덱스에 맞춰서 sample 작성.
|
||||
"""
|
||||
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
|
||||
# Build a sample raw message — implementer 가 _ASKING_TOTAL_BID/ASK 인덱스에
|
||||
# 맞춰서 필드 배치하면 됨. 예: 마지막 2개 필드를 bid_total / ask_total 로.
|
||||
fields = ["005930", "091500", "78500"] # ticker, time, current_price
|
||||
fields.extend(["0"] * 40) # padding (KIS 의 실 필드 수 ~50개)
|
||||
fields.append("400") # ask_total
|
||||
fields.append("600") # bid_total
|
||||
raw = f"0|H0STASP0|001|{'^'.join(fields)}"
|
||||
|
||||
result = ws._parse_asking_price(raw)
|
||||
assert result is not None, "parse_asking_price returned None"
|
||||
ticker, data = result
|
||||
assert ticker == "005930"
|
||||
assert "bid_total" in data
|
||||
assert "ask_total" in data
|
||||
assert "bid_ratio" in data
|
||||
assert "current_price" in data
|
||||
# bid_total=600, ask_total=400, bid_ratio=0.6
|
||||
assert data["bid_total"] == 600
|
||||
assert data["ask_total"] == 400
|
||||
assert abs(data["bid_ratio"] - 0.6) < 1e-9
|
||||
|
||||
|
||||
async def test_reconnect_on_disconnect_with_backoff(monkeypatch):
|
||||
"""연결 끊김 → exponential backoff retry. _connect_with_backoff() 검증."""
|
||||
sleep_calls = []
|
||||
async def fake_sleep(s): sleep_calls.append(s)
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
ws = KISWebSocket(app_key="k", app_secret="s", is_virtual=True)
|
||||
# Mock _connect to fail twice then succeed
|
||||
call_count = [0]
|
||||
async def fake_connect():
|
||||
call_count[0] += 1
|
||||
if call_count[0] < 3:
|
||||
raise ConnectionError("fake disconnect")
|
||||
return AsyncMock()
|
||||
monkeypatch.setattr(ws, "_connect", fake_connect)
|
||||
|
||||
result = await ws._connect_with_backoff()
|
||||
assert call_count[0] == 3 # 2 fails + 1 success
|
||||
# exponential 1s, 2s
|
||||
assert sleep_calls[:2] == [1, 2]
|
||||
62
ai_trade/tests/test_main.py
Normal file
62
ai_trade/tests/test_main.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Tests for FastAPI main app."""
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_health_endpoint_returns_status_online(monkeypatch):
|
||||
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||
# Reload modules so they pick up the new env
|
||||
import importlib
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
from ai_trade import main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
with TestClient(main_mod.app) as client:
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "online"
|
||||
assert body["stock_api_url"] == "https://test.stock.local"
|
||||
|
||||
|
||||
def test_startup_warns_if_webai_api_key_missing(monkeypatch, caplog):
|
||||
# Use setenv with empty string + no-op load_dotenv to defeat .env re-read on reload
|
||||
monkeypatch.setattr("ai_trade.config.load_dotenv", lambda *a, **k: None)
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "")
|
||||
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
|
||||
import importlib
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
# After reload, load_dotenv reference is fresh — re-patch
|
||||
monkeypatch.setattr("ai_trade.config.load_dotenv", lambda *a, **k: None)
|
||||
from ai_trade import main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
with caplog.at_level(logging.WARNING, logger="ai_trade.main"):
|
||||
with TestClient(main_mod.app) as client:
|
||||
client.get("/health")
|
||||
assert any("WEBAI_API_KEY" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_startup_warns_if_kis_app_key_missing(monkeypatch, caplog):
|
||||
"""KIS app_key 미설정 시 startup WARNING (KIS 호출 disabled) — V1 패턴."""
|
||||
monkeypatch.setattr("ai_trade.config.load_dotenv", lambda *a, **k: None)
|
||||
monkeypatch.setenv("STOCK_API_URL", "https://test.stock.local")
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||
# V1 pattern: kis_env_type=virtual, both virtual keys empty
|
||||
monkeypatch.setenv("KIS_ENV_TYPE", "virtual")
|
||||
monkeypatch.setenv("KIS_VIRTUAL_APP_KEY", "")
|
||||
monkeypatch.setenv("KIS_REAL_APP_KEY", "")
|
||||
|
||||
import importlib
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
monkeypatch.setattr("ai_trade.config.load_dotenv", lambda *a, **k: None)
|
||||
from ai_trade import main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
with caplog.at_level(logging.WARNING, logger="ai_trade.main"):
|
||||
with TestClient(main_mod.app) as client:
|
||||
client.get("/health")
|
||||
assert any("KIS" in rec.message and "app_key" in rec.message.lower() for rec in caplog.records)
|
||||
92
ai_trade/tests/test_momentum_classifier.py
Normal file
92
ai_trade/tests/test_momentum_classifier.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""Tests for minute momentum classifier."""
|
||||
from collections import deque
|
||||
|
||||
from ai_trade.momentum_classifier import (
|
||||
aggregate_1min_to_5min, classify_minute_momentum,
|
||||
STRONG_UP, WEAK_UP, NEUTRAL, WEAK_DOWN, STRONG_DOWN,
|
||||
)
|
||||
|
||||
|
||||
def _bar(open_, high, low, close, volume):
|
||||
return {
|
||||
"datetime": "2026-05-18T09:00:00+09:00",
|
||||
"open": open_, "high": high, "low": low, "close": close, "volume": volume,
|
||||
}
|
||||
|
||||
|
||||
def _make_chunks(num_chunks_up: int, num_chunks_total: int, base_vol: int = 1000):
|
||||
"""num_chunks_total 개의 5-bar 청크. num_chunks_up 청크는 양봉, 나머지는 음봉.
|
||||
각 청크는 5개 1분봉. 거래량 = base_vol per bar.
|
||||
"""
|
||||
bars = []
|
||||
for i in range(num_chunks_total):
|
||||
is_up = i < num_chunks_up
|
||||
o, c = (100, 110) if is_up else (110, 100)
|
||||
for j in range(5):
|
||||
bars.append(_bar(o, max(o, c) + 5, min(o, c) - 5, c, base_vol))
|
||||
return bars
|
||||
|
||||
|
||||
def test_strong_up_5_consecutive_green_with_high_volume():
|
||||
"""직전 5개 5분봉 모두 양봉 + 거래량 1.5x → STRONG_UP."""
|
||||
# 60분 (12 5분봉) 데이터: 7 normal + 5 high-vol up
|
||||
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
|
||||
recent = _make_chunks(num_chunks_up=5, num_chunks_total=5, base_vol=2500)
|
||||
minute_bars = deque(older + recent, maxlen=60)
|
||||
assert classify_minute_momentum(minute_bars) == STRONG_UP
|
||||
|
||||
|
||||
def test_weak_up_3of5_green_normal_volume():
|
||||
"""직전 5개 5분봉 중 3-4개 양봉 + 거래량 ≥ 1.0x → WEAK_UP."""
|
||||
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
|
||||
# 5 chunks: 3 up + 2 down, normal vol
|
||||
recent_up = _make_chunks(num_chunks_up=3, num_chunks_total=3, base_vol=1000)
|
||||
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=2, base_vol=1000)
|
||||
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
|
||||
assert classify_minute_momentum(minute_bars) == WEAK_UP
|
||||
|
||||
|
||||
def test_neutral_mixed():
|
||||
"""up_count=2, vol normal → NEUTRAL (rule 미해당)."""
|
||||
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
|
||||
recent_up = _make_chunks(num_chunks_up=2, num_chunks_total=2, base_vol=1000)
|
||||
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=3, base_vol=1000)
|
||||
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
|
||||
# up_count=2, vol_mult=1.0 → 어느 분기 조건도 만족 안 함 → NEUTRAL
|
||||
assert classify_minute_momentum(minute_bars) == NEUTRAL
|
||||
|
||||
|
||||
def test_weak_down_low_green_low_volume():
|
||||
"""up_count <= 2 + vol < 1.0 → WEAK_DOWN."""
|
||||
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
|
||||
recent_up = _make_chunks(num_chunks_up=1, num_chunks_total=1, base_vol=500)
|
||||
recent_down = _make_chunks(num_chunks_up=0, num_chunks_total=4, base_vol=500)
|
||||
minute_bars = deque(older + recent_up + recent_down, maxlen=60)
|
||||
# recent 5 chunks avg vol = 500, long 12 avg ≈ (7*1000 + 5*500) / 12 ≈ 791 → vol_mult ≈ 0.63
|
||||
assert classify_minute_momentum(minute_bars) == WEAK_DOWN
|
||||
|
||||
|
||||
def test_strong_down_5_consecutive_red_high_volume():
|
||||
"""직전 5개 5분봉 모두 음봉 + 거래량 1.5x → STRONG_DOWN."""
|
||||
older = _make_chunks(num_chunks_up=3, num_chunks_total=7, base_vol=1000)
|
||||
recent = _make_chunks(num_chunks_up=0, num_chunks_total=5, base_vol=2500)
|
||||
minute_bars = deque(older + recent, maxlen=60)
|
||||
assert classify_minute_momentum(minute_bars) == STRONG_DOWN
|
||||
|
||||
|
||||
def test_aggregate_1min_to_5min_correctness():
|
||||
"""5 1분봉 → 1개 5분봉 — open/close/high/low/volume 정확."""
|
||||
bars = [
|
||||
_bar(100, 105, 99, 102, 1000),
|
||||
_bar(102, 108, 101, 107, 1500),
|
||||
_bar(107, 110, 105, 106, 800),
|
||||
_bar(106, 109, 104, 108, 1200),
|
||||
_bar(108, 112, 107, 111, 900),
|
||||
]
|
||||
result = aggregate_1min_to_5min(bars)
|
||||
assert len(result) == 1
|
||||
assert result[0]["open"] == 100 # 첫 bar
|
||||
assert result[0]["close"] == 111 # 마지막 bar
|
||||
assert result[0]["high"] == 112 # max
|
||||
assert result[0]["low"] == 99 # min
|
||||
assert result[0]["volume"] == 5400 # sum
|
||||
241
ai_trade/tests/test_pull_worker.py
Normal file
241
ai_trade/tests/test_pull_worker.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Tests for pull_worker (Phase 3a additions)."""
|
||||
from collections import deque
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_trade.state import PollState
|
||||
|
||||
|
||||
async def test_minute_polling_cycle_updates_state_minute_bars():
|
||||
"""KIS REST mock 의 분봉 데이터가 state.minute_bars[ticker] deque 에 들어간다."""
|
||||
from ai_trade.pull_worker import _run_kis_minute_cycle
|
||||
|
||||
state = PollState()
|
||||
state.portfolio = {"holdings": [{"ticker": "005930"}, {"ticker": "000660"}]}
|
||||
state.screener_preview = {
|
||||
"items": [{"ticker": "005930"}, {"ticker": "035720"}]
|
||||
}
|
||||
|
||||
kis_client_mock = MagicMock()
|
||||
kis_client_mock.get_minute_ohlcv = AsyncMock(side_effect=[
|
||||
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 78000,
|
||||
"high": 78500, "low": 77900, "close": 78300, "volume": 12345}],
|
||||
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 180000,
|
||||
"high": 181000, "low": 179800, "close": 180500, "volume": 5000}],
|
||||
[{"datetime": "2026-05-18T09:00:00+09:00", "open": 51000,
|
||||
"high": 51200, "low": 50800, "close": 51100, "volume": 8000}],
|
||||
])
|
||||
kis_client_mock.get_asking_price = AsyncMock(return_value={
|
||||
"bid_total": 600, "ask_total": 400, "bid_ratio": 0.6,
|
||||
"current_price": 51100, "as_of": "2026-05-18T09:00:30+09:00",
|
||||
})
|
||||
|
||||
await _run_kis_minute_cycle(kis_client_mock, state)
|
||||
|
||||
# 3 unique tickers (005930, 000660, 035720)
|
||||
assert "005930" in state.minute_bars
|
||||
assert "000660" in state.minute_bars
|
||||
assert "035720" in state.minute_bars
|
||||
assert len(state.minute_bars["005930"]) >= 1
|
||||
# asking_price 만 screener-only ticker (035720) 에 들어가야 함
|
||||
# (portfolio = 005930, 000660 는 WebSocket 으로 들어옴)
|
||||
assert "035720" in state.asking_price
|
||||
|
||||
|
||||
def test_websocket_message_updates_state_asking_price():
|
||||
"""WebSocket callback factory → state.asking_price 갱신."""
|
||||
from ai_trade.pull_worker import make_asking_price_callback
|
||||
|
||||
state = PollState()
|
||||
cb = make_asking_price_callback(state)
|
||||
cb("005930", {"bid_total": 1000, "ask_total": 800, "bid_ratio": 0.555,
|
||||
"current_price": 78500, "as_of": "2026-05-18T10:00:00+09:00"})
|
||||
assert state.asking_price["005930"]["bid_total"] == 1000
|
||||
assert "asking_price/005930" in state.last_updated
|
||||
|
||||
|
||||
async def test_post_close_cycle_updates_chronos_predictions():
|
||||
"""mock kis + mock chronos → state.chronos_predictions + state.daily_ohlcv 갱신."""
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from ai_trade.pull_worker import _run_post_close_cycle
|
||||
from ai_trade.chronos_predictor import ChronosPrediction
|
||||
from ai_trade.state import PollState
|
||||
|
||||
state = PollState()
|
||||
state.portfolio = {"holdings": [{"ticker": "005930"}]}
|
||||
state.screener_preview = {"items": [{"ticker": "000660"}]}
|
||||
|
||||
kis_mock = MagicMock()
|
||||
daily_005930 = [{"datetime": f"2026-05-{i+1:02d}", "open": 100, "high": 105,
|
||||
"low": 95, "close": 100 + i, "volume": 1000} for i in range(60)]
|
||||
daily_000660 = [{"datetime": f"2026-05-{i+1:02d}", "open": 200, "high": 210,
|
||||
"low": 190, "close": 200 + i, "volume": 2000} for i in range(60)]
|
||||
# _run_post_close_cycle iterates tickers and calls get_daily_ohlcv per ticker.
|
||||
# Order depends on set() so use side_effect mapping if possible, otherwise list.
|
||||
async def fake_daily(ticker, days=60):
|
||||
if ticker == "005930":
|
||||
return daily_005930
|
||||
if ticker == "000660":
|
||||
return daily_000660
|
||||
return []
|
||||
kis_mock.get_daily_ohlcv = AsyncMock(side_effect=fake_daily)
|
||||
|
||||
chronos_mock = MagicMock()
|
||||
chronos_mock.predict_batch = MagicMock(return_value={
|
||||
"005930": ChronosPrediction(0.02, -0.01, 0.04, 0.85, "2026-05-18T16:00:00+09:00"),
|
||||
"000660": ChronosPrediction(0.03, -0.02, 0.06, 0.75, "2026-05-18T16:00:00+09:00"),
|
||||
})
|
||||
|
||||
await _run_post_close_cycle(kis_mock, chronos_mock, state)
|
||||
|
||||
assert "005930" in state.chronos_predictions
|
||||
assert "000660" in state.chronos_predictions
|
||||
assert state.chronos_predictions["005930"]["median"] == 0.02
|
||||
assert state.chronos_predictions["005930"]["conf"] == 0.85
|
||||
assert "005930" in state.daily_ohlcv
|
||||
assert "chronos/005930" in state.last_updated
|
||||
|
||||
|
||||
def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
|
||||
"""Phase 4: generate_signals 가 cycle 후 state.signals 를 갱신한다."""
|
||||
from unittest.mock import MagicMock
|
||||
from ai_trade.state import PollState
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
|
||||
state = PollState()
|
||||
state.portfolio = {"holdings": [{
|
||||
"ticker": "005930", "name": "삼성전자",
|
||||
"avg_price": 75000, "current_price": 69000,
|
||||
"pnl_pct": -0.08, "profit_rate": -8.0,
|
||||
"quantity": 100, "broker": "키움",
|
||||
}]}
|
||||
state.screener_preview = {"items": []}
|
||||
|
||||
dedup = MagicMock()
|
||||
dedup.is_recent.return_value = False
|
||||
|
||||
settings = MagicMock()
|
||||
settings.stop_loss_pct = -0.07
|
||||
settings.take_profit_pct = 0.15
|
||||
settings.chronos_spread_threshold = 0.6
|
||||
settings.asking_bid_ratio_threshold = 0.6
|
||||
settings.confidence_threshold = 0.7
|
||||
settings.min_momentum_for_buy = "strong_up"
|
||||
settings.signal_ttl_seconds = 300
|
||||
|
||||
generate_signals(state, dedup, settings)
|
||||
|
||||
assert "005930" in state.signals
|
||||
assert state.signals["005930"]["action"] == "sell"
|
||||
assert state.signals["005930"]["confidence_webai"] == 1.0
|
||||
dedup.record.assert_called_with("005930", "sell", confidence=1.0)
|
||||
|
||||
|
||||
async def test_post_close_fires_at_1601_when_not_yet_today(monkeypatch):
|
||||
"""F3 — 16:01에 깬 cycle도 오늘 post_close 안 돌렸으면 호출됨 (회귀 방지)."""
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
import asyncio as _asyncio
|
||||
|
||||
from ai_trade import pull_worker
|
||||
|
||||
_kst = _ZI("Asia/Seoul")
|
||||
now_at_1601 = _dt(2026, 5, 18, 16, 1, tzinfo=_kst)
|
||||
|
||||
class FrozenDateTime:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return now_at_1601
|
||||
|
||||
monkeypatch.setattr(pull_worker, "datetime", FrozenDateTime)
|
||||
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
|
||||
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
|
||||
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
|
||||
post_close = AsyncMock()
|
||||
monkeypatch.setattr(pull_worker, "_run_post_close_cycle", post_close)
|
||||
|
||||
state = MagicMock()
|
||||
chronos = MagicMock()
|
||||
kis = MagicMock()
|
||||
shutdown = _asyncio.Event()
|
||||
|
||||
async def _stop_soon():
|
||||
await _asyncio.sleep(0.05)
|
||||
shutdown.set()
|
||||
|
||||
_asyncio.create_task(_stop_soon())
|
||||
await pull_worker.poll_loop(
|
||||
client=MagicMock(),
|
||||
state=state,
|
||||
shutdown=shutdown,
|
||||
kis_client=kis,
|
||||
chronos=chronos,
|
||||
dedup=None,
|
||||
settings=None,
|
||||
)
|
||||
|
||||
assert post_close.await_count >= 1, "post-close가 16:01에 호출되지 않음 (F3 회귀)"
|
||||
|
||||
|
||||
async def test_poll_loop_purges_expired_signals(monkeypatch):
|
||||
"""F5 — 매 cycle 끝에 expired signal이 제거됨."""
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo as _ZI
|
||||
import asyncio as _asyncio
|
||||
|
||||
from ai_trade import pull_worker
|
||||
from ai_trade.state import PollState
|
||||
|
||||
_kst = _ZI("Asia/Seoul")
|
||||
now = _dt(2026, 5, 18, 10, 0, tzinfo=_kst)
|
||||
|
||||
class FrozenDT:
|
||||
@staticmethod
|
||||
def now(tz=None):
|
||||
return now
|
||||
|
||||
state = PollState()
|
||||
state.signals = {
|
||||
"OLD": {
|
||||
"ticker": "OLD",
|
||||
"expires_at": _dt(2026, 5, 18, 9, 0, tzinfo=_kst).isoformat(),
|
||||
"cycle_id": 1,
|
||||
},
|
||||
"FRESH": {
|
||||
"ticker": "FRESH",
|
||||
"expires_at": _dt(2026, 5, 18, 10, 30, tzinfo=_kst).isoformat(),
|
||||
"cycle_id": 1,
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(pull_worker, "datetime", FrozenDT)
|
||||
monkeypatch.setattr(pull_worker, "_is_market_day", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_is_polling_window", lambda n: True)
|
||||
monkeypatch.setattr(pull_worker, "_next_interval", lambda n: 0.01)
|
||||
monkeypatch.setattr(pull_worker, "_run_polling_cycle", AsyncMock())
|
||||
monkeypatch.setattr(pull_worker, "update_minute_momentum_for_all", lambda s: None)
|
||||
monkeypatch.setattr(pull_worker, "_is_post_close_trigger", lambda *a, **k: False)
|
||||
|
||||
shutdown = _asyncio.Event()
|
||||
|
||||
async def stop_soon():
|
||||
await _asyncio.sleep(0.05)
|
||||
shutdown.set()
|
||||
|
||||
_asyncio.create_task(stop_soon())
|
||||
|
||||
await pull_worker.poll_loop(
|
||||
client=MagicMock(),
|
||||
state=state,
|
||||
shutdown=shutdown,
|
||||
kis_client=MagicMock(),
|
||||
chronos=MagicMock(),
|
||||
dedup=None,
|
||||
settings=None,
|
||||
)
|
||||
|
||||
assert "OLD" not in state.signals
|
||||
assert "FRESH" in state.signals
|
||||
34
ai_trade/tests/test_rate_limit.py
Normal file
34
ai_trade/tests/test_rate_limit.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Tests for SignalDedup."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ai_trade.rate_limit import SignalDedup
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def test_is_recent_returns_false_for_new_ticker_action(tmp_dedup_db):
|
||||
dedup = SignalDedup(tmp_dedup_db)
|
||||
assert dedup.is_recent("005930", "buy") is False
|
||||
|
||||
|
||||
def test_is_recent_returns_true_within_24h(tmp_dedup_db):
|
||||
dedup = SignalDedup(tmp_dedup_db)
|
||||
dedup.record("005930", "buy", confidence=0.82)
|
||||
assert dedup.is_recent("005930", "buy") is True
|
||||
|
||||
|
||||
def test_is_recent_returns_false_after_24h(tmp_dedup_db, monkeypatch):
|
||||
dedup = SignalDedup(tmp_dedup_db)
|
||||
# Record with a timestamp 25 hours ago
|
||||
now = datetime.now(KST)
|
||||
fake_now = now - timedelta(hours=25)
|
||||
monkeypatch.setattr(
|
||||
"ai_trade.rate_limit._now_iso", lambda: fake_now.isoformat()
|
||||
)
|
||||
dedup.record("005930", "buy", confidence=0.82)
|
||||
# Reset to real now for is_recent check
|
||||
monkeypatch.setattr(
|
||||
"ai_trade.rate_limit._now_iso", lambda: now.isoformat()
|
||||
)
|
||||
assert dedup.is_recent("005930", "buy", within_hours=24) is False
|
||||
119
ai_trade/tests/test_scheduler.py
Normal file
119
ai_trade/tests/test_scheduler.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Tests for scheduler interval logic."""
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_trade.scheduler import _next_interval, _is_market_day, KST
|
||||
|
||||
|
||||
def _kst(year, month, day, hour, minute=0):
|
||||
return datetime(year, month, day, hour, minute, tzinfo=KST)
|
||||
|
||||
|
||||
def test_next_interval_pre_market_5min():
|
||||
now = _kst(2026, 5, 18, 8, 30) # Monday 08:30
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_market_open_1min():
|
||||
now = _kst(2026, 5, 18, 10, 0) # Monday 10:00
|
||||
assert _next_interval(now) == 60
|
||||
|
||||
|
||||
def test_next_interval_post_market_5min():
|
||||
now = _kst(2026, 5, 18, 17, 0) # Monday 17:00
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_overnight_skip_to_next_morning():
|
||||
now = _kst(2026, 5, 18, 2, 30) # Monday 02:30 (dead zone, not NXT window)
|
||||
interval = _next_interval(now)
|
||||
# Dead zone 23:30-04:30 → next 04:30 is ~2h away
|
||||
assert 2 * 3600 - 60 < interval < 2 * 3600 + 60
|
||||
|
||||
|
||||
def test_next_interval_holiday_skip():
|
||||
# 2026-05-05 어린이날 (Tuesday holiday)
|
||||
now = _kst(2026, 5, 5, 10, 0)
|
||||
assert _is_market_day(now) is False
|
||||
interval = _next_interval(now)
|
||||
# Next: 2026-05-06 (Wed) 07:00, ~21h away
|
||||
assert 20 * 3600 < interval < 22 * 3600
|
||||
|
||||
|
||||
def test_next_interval_at_market_open_boundary():
|
||||
"""09:00:00 정확 second → 60초 (market 구간 진입)."""
|
||||
now = _kst(2026, 5, 18, 9, 0) # Monday 09:00:00
|
||||
assert _next_interval(now) == 60
|
||||
|
||||
|
||||
def test_next_interval_at_market_close_boundary():
|
||||
"""15:30:00 정확 second → 300초 (post-market 구간 진입)."""
|
||||
now = _kst(2026, 5, 18, 15, 30) # Monday 15:30:00
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_at_polling_window_end_boundary():
|
||||
"""23:30:00 정확 second → dead zone skip (다음 04:30 까지)."""
|
||||
now = _kst(2026, 5, 18, 23, 30) # Monday 23:30:00 (NXT_PRE_END boundary)
|
||||
interval = _next_interval(now)
|
||||
# Dead zone 23:30-04:30 → next 04:30 is ~5h away
|
||||
assert 5 * 3600 - 60 < interval < 5 * 3600 + 60
|
||||
|
||||
|
||||
def test_next_interval_nxt_evening_5min():
|
||||
"""22:00 평일 (NXT 야간) → 300 (5분)."""
|
||||
now = _kst(2026, 5, 18, 22, 0)
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_nxt_dawn_5min():
|
||||
"""05:30 평일 (NXT 새벽) → 300 (5분)."""
|
||||
now = _kst(2026, 5, 18, 5, 30)
|
||||
assert _next_interval(now) == 300
|
||||
|
||||
|
||||
def test_next_interval_dead_zone_skip():
|
||||
"""02:00 평일 (dead zone 23:30-04:30) → 다음 04:30 까지 (~9000s)."""
|
||||
now = _kst(2026, 5, 18, 2, 0)
|
||||
interval = _next_interval(now)
|
||||
# 02:00 → 04:30 = 2.5h = 9000s
|
||||
assert 9000 - 60 < interval < 9000 + 60
|
||||
|
||||
|
||||
# ----- F3 post-close 상태기반 트리거 -----
|
||||
|
||||
from datetime import date as _date # noqa: E402
|
||||
from ai_trade.scheduler import _is_post_close_trigger # noqa: E402
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_at_1601_if_not_yet_today():
|
||||
"""F3 — 16:01에 깬 cycle도 오늘 아직 안 돌렸으면 trigger."""
|
||||
now = _kst(2026, 5, 18, 16, 1)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_if_already_today():
|
||||
"""F3 — 이미 오늘 돌렸으면 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 16, 5)
|
||||
today = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=today) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_before_1600():
|
||||
"""F3 — 16:00 전에는 trigger 안 함."""
|
||||
now = _kst(2026, 5, 18, 15, 59)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
|
||||
|
||||
def test_post_close_trigger_fires_next_day_after_reset():
|
||||
"""F3 — 다음 영업일이 되면 다시 trigger."""
|
||||
now = _kst(2026, 5, 19, 16, 0)
|
||||
yesterday = _date(2026, 5, 18)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=yesterday) is True
|
||||
|
||||
|
||||
def test_post_close_trigger_skips_on_holiday():
|
||||
"""F3 — 휴장일에는 trigger 안 함 (2026-05-05 어린이날)."""
|
||||
now = _kst(2026, 5, 5, 16, 30)
|
||||
assert _is_post_close_trigger(now, last_post_close_date=None) is False
|
||||
218
ai_trade/tests/test_signal_generator.py
Normal file
218
ai_trade/tests/test_signal_generator.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""Tests for signal_generator."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from ai_trade.signal_generator import generate_signals
|
||||
from ai_trade.state import PollState
|
||||
|
||||
|
||||
def _settings(**overrides):
|
||||
"""Build a Settings-like object for tests (avoid env)."""
|
||||
defaults = dict(
|
||||
stop_loss_pct=-0.07,
|
||||
take_profit_pct=0.15,
|
||||
chronos_spread_threshold=0.6,
|
||||
asking_bid_ratio_threshold=0.6,
|
||||
confidence_threshold=0.7,
|
||||
min_momentum_for_buy="strong_up",
|
||||
signal_ttl_seconds=300,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
m = MagicMock()
|
||||
for k, v in defaults.items():
|
||||
setattr(m, k, v)
|
||||
return m
|
||||
|
||||
|
||||
def _make_state_with_buy_candidate(
|
||||
ticker="005930", name="삼성전자",
|
||||
chronos_median=0.02, chronos_q10=-0.01, chronos_q90=0.04, chronos_conf=0.85,
|
||||
momentum="strong_up", bid_ratio=0.7, current_price=78500,
|
||||
):
|
||||
state = PollState()
|
||||
state.screener_preview = {"items": [{"ticker": ticker, "name": name}]}
|
||||
state.chronos_predictions[ticker] = {
|
||||
"median": chronos_median, "q10": chronos_q10, "q90": chronos_q90,
|
||||
"conf": chronos_conf, "as_of": "2026-05-17T16:00:00+09:00",
|
||||
}
|
||||
state.minute_momentum[ticker] = momentum
|
||||
state.asking_price[ticker] = {
|
||||
"bid_total": int(bid_ratio * 1000),
|
||||
"ask_total": int((1 - bid_ratio) * 1000),
|
||||
"bid_ratio": bid_ratio,
|
||||
"current_price": current_price,
|
||||
"as_of": "2026-05-17T16:00:01+09:00",
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
def _make_state_with_holding(
|
||||
ticker="005930", name="삼성전자",
|
||||
pnl_pct=0.0, avg_price=75000, current_price=75000,
|
||||
):
|
||||
state = PollState()
|
||||
state.portfolio = {"holdings": [{
|
||||
"ticker": ticker, "name": name,
|
||||
"avg_price": avg_price, "current_price": current_price,
|
||||
"pnl_pct": pnl_pct, "profit_rate": pnl_pct * 100,
|
||||
"quantity": 100, "broker": "키움",
|
||||
}]}
|
||||
state.screener_preview = {"items": []}
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dedup_mock():
|
||||
d = MagicMock()
|
||||
d.is_recent.return_value = False
|
||||
return d
|
||||
|
||||
|
||||
def test_buy_signal_when_all_conditions_pass_and_confidence_high(dedup_mock):
|
||||
state = _make_state_with_buy_candidate()
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" in state.signals
|
||||
sig = state.signals["005930"]
|
||||
assert sig["action"] == "buy"
|
||||
assert sig["confidence_webai"] > 0.7
|
||||
dedup_mock.record.assert_called()
|
||||
|
||||
|
||||
def test_silent_when_chronos_median_negative(dedup_mock):
|
||||
state = _make_state_with_buy_candidate(chronos_median=-0.01)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" not in state.signals
|
||||
|
||||
|
||||
def test_silent_when_distribution_spread_too_wide(dedup_mock):
|
||||
# spread = q90 - q10 = 0.5 - (-0.5) = 1.0 > 0.6 → hard gate fails
|
||||
state = _make_state_with_buy_candidate(
|
||||
chronos_median=0.001, chronos_q10=-0.5, chronos_q90=0.5,
|
||||
)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" not in state.signals
|
||||
|
||||
|
||||
def test_silent_when_momentum_not_strong_up(dedup_mock):
|
||||
state = _make_state_with_buy_candidate(momentum="weak_up")
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" not in state.signals
|
||||
|
||||
|
||||
def test_silent_when_bid_ratio_below_threshold(dedup_mock):
|
||||
state = _make_state_with_buy_candidate(bid_ratio=0.5)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" not in state.signals
|
||||
|
||||
|
||||
def test_silent_when_confidence_below_threshold(dedup_mock):
|
||||
# chronos_conf low + rank=20 → confidence < 0.7
|
||||
state = _make_state_with_buy_candidate(chronos_conf=0.3)
|
||||
# add 19 fake items to push 005930 rank to 20
|
||||
state.screener_preview["items"] = (
|
||||
[{"ticker": f"FAKE{i:03d}"} for i in range(19)]
|
||||
+ [{"ticker": "005930", "name": "삼성전자"}]
|
||||
)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
# confidence_webai = 0.3*0.5 + 1.0*0.3 + 0.05*0.2 = 0.46 < 0.7
|
||||
assert "005930" not in state.signals
|
||||
|
||||
|
||||
def test_sell_signal_when_stop_loss_triggered(dedup_mock):
|
||||
state = _make_state_with_holding(pnl_pct=-0.08, current_price=69000, avg_price=75000)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" in state.signals
|
||||
sig = state.signals["005930"]
|
||||
assert sig["action"] == "sell"
|
||||
assert sig["confidence_webai"] == 1.0
|
||||
assert sig["pnl_pct"] == -0.08
|
||||
|
||||
|
||||
def test_sell_signal_when_take_profit_triggered(dedup_mock):
|
||||
state = _make_state_with_holding(pnl_pct=0.16, current_price=87000, avg_price=75000)
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" in state.signals
|
||||
sig = state.signals["005930"]
|
||||
assert sig["action"] == "sell"
|
||||
assert sig["confidence_webai"] == 0.6
|
||||
|
||||
|
||||
def test_silent_when_dedup_recently_sent(dedup_mock):
|
||||
state = _make_state_with_buy_candidate()
|
||||
dedup_mock.is_recent.return_value = True
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert "005930" not in state.signals
|
||||
dedup_mock.record.assert_not_called()
|
||||
|
||||
|
||||
def test_sell_signal_triggers_on_anomaly_path(dedup_mock):
|
||||
"""Anomaly sell: median < -1%, momentum strong_down, low bid_ratio, confidence > threshold."""
|
||||
state = PollState()
|
||||
state.portfolio = {"holdings": [{
|
||||
"ticker": "005930", "name": "삼성전자",
|
||||
"avg_price": 75000, "current_price": 70000,
|
||||
"pnl_pct": -0.067, # within stop_loss tolerance (default -0.07): NOT triggering stop_loss
|
||||
"quantity": 100, "broker": "키움",
|
||||
}]}
|
||||
state.screener_preview = {"items": []}
|
||||
state.chronos_predictions["005930"] = {
|
||||
"median": -0.025, "q10": -0.05, "q90": 0.005, "conf": 0.85,
|
||||
}
|
||||
state.minute_momentum["005930"] = "strong_down"
|
||||
state.asking_price["005930"] = {"current_price": 70000, "bid_ratio": 0.30}
|
||||
# bid_ratio 0.30 < (1 - 0.6) = 0.4 → anomaly bid_ratio gate passes
|
||||
# confidence = 0.85*0.5 + 1.0*0.3 + 1.0*0.2 = 0.425 + 0.3 + 0.2 = 0.925 > 0.7
|
||||
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
|
||||
assert "005930" in state.signals
|
||||
sig = state.signals["005930"]
|
||||
assert sig["action"] == "sell"
|
||||
assert sig["context"]["sell_reason"] == "anomaly"
|
||||
assert sig["confidence_webai"] > 0.7
|
||||
|
||||
|
||||
# ----- F5: cycle_id + expires_at 부착 -----
|
||||
|
||||
def test_emit_attaches_cycle_id_and_expires_at(dedup_mock):
|
||||
"""F5 — emit signal에 cycle_id (state.signal_cycle_id) + expires_at 부착."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
_kst = ZoneInfo("Asia/Seoul")
|
||||
|
||||
state = _make_state_with_buy_candidate()
|
||||
before = datetime.now(_kst)
|
||||
generate_signals(state, dedup_mock, _settings(signal_ttl_seconds=300))
|
||||
after = datetime.now(_kst)
|
||||
|
||||
sig = state.signals["005930"]
|
||||
assert sig["cycle_id"] == 1
|
||||
assert "expires_at" in sig
|
||||
exp_dt = datetime.fromisoformat(sig["expires_at"])
|
||||
assert before + timedelta(seconds=295) < exp_dt < after + timedelta(seconds=305)
|
||||
|
||||
|
||||
def test_cycle_id_increments_each_call(dedup_mock):
|
||||
"""F5 — generate_signals 호출마다 cycle_id += 1 (emit 여부 무관)."""
|
||||
state = _make_state_with_buy_candidate()
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert state.signal_cycle_id == 1
|
||||
# 2번째 호출 — dedup이 막아도 cycle_id는 증가
|
||||
dedup_mock.is_recent.return_value = True
|
||||
generate_signals(state, dedup_mock, _settings())
|
||||
assert state.signal_cycle_id == 2
|
||||
|
||||
|
||||
def test_sell_signal_also_carries_cycle_id_and_expires_at(dedup_mock):
|
||||
"""F5 — sell signal도 동일하게 부착."""
|
||||
from datetime import datetime
|
||||
state = _make_state_with_holding(pnl_pct=-0.08, current_price=68000)
|
||||
generate_signals(state, dedup_mock, _settings(signal_ttl_seconds=120))
|
||||
|
||||
assert "005930" in state.signals
|
||||
sig = state.signals["005930"]
|
||||
assert sig["action"] == "sell"
|
||||
assert sig["cycle_id"] == 1
|
||||
# parse expires_at as ISO — must succeed
|
||||
datetime.fromisoformat(sig["expires_at"])
|
||||
66
ai_trade/tests/test_state_signals_lifecycle.py
Normal file
66
ai_trade/tests/test_state_signals_lifecycle.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""F5 — state.signals lifecycle (expires_at + cycle_id)."""
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ai_trade.state import PollState
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
|
||||
def test_initial_signal_cycle_id_is_zero():
|
||||
state = PollState()
|
||||
assert state.signal_cycle_id == 0
|
||||
|
||||
|
||||
def test_get_active_signals_excludes_expired():
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
future = (now + timedelta(seconds=300)).isoformat()
|
||||
past = (now - timedelta(seconds=60)).isoformat()
|
||||
state.signals = {
|
||||
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1, "action": "buy"},
|
||||
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1, "action": "buy"},
|
||||
}
|
||||
active = state.get_active_signals(now)
|
||||
tickers = [s["ticker"] for s in active]
|
||||
assert "A" in tickers
|
||||
assert "B" not in tickers
|
||||
|
||||
|
||||
def test_get_active_signals_treats_missing_expires_as_expired():
|
||||
"""expires_at 없는 legacy 신호는 expired로 간주."""
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
state.signals = {"C": {"ticker": "C", "action": "buy"}}
|
||||
assert state.get_active_signals(now) == []
|
||||
|
||||
|
||||
def test_purge_expired_signals_removes_expired():
|
||||
state = PollState()
|
||||
now = datetime(2026, 5, 25, 10, 0, tzinfo=KST)
|
||||
future = (now + timedelta(seconds=300)).isoformat()
|
||||
past = (now - timedelta(seconds=60)).isoformat()
|
||||
state.signals = {
|
||||
"A": {"ticker": "A", "expires_at": future, "cycle_id": 1},
|
||||
"B": {"ticker": "B", "expires_at": past, "cycle_id": 1},
|
||||
}
|
||||
removed = state.purge_expired_signals(now)
|
||||
assert "A" in state.signals
|
||||
assert "B" not in state.signals
|
||||
assert removed == 1
|
||||
|
||||
|
||||
# ----- SIGNAL_TTL_SECONDS env -----
|
||||
|
||||
def test_signal_ttl_seconds_default(monkeypatch):
|
||||
monkeypatch.delenv("SIGNAL_TTL_SECONDS", raising=False)
|
||||
from ai_trade.config import Settings
|
||||
s = Settings()
|
||||
assert s.signal_ttl_seconds == 300
|
||||
|
||||
|
||||
def test_signal_ttl_seconds_env_override(monkeypatch):
|
||||
monkeypatch.setenv("SIGNAL_TTL_SECONDS", "60")
|
||||
from ai_trade.config import Settings
|
||||
s = Settings()
|
||||
assert s.signal_ttl_seconds == 60
|
||||
168
ai_trade/tests/test_stock_client.py
Normal file
168
ai_trade/tests/test_stock_client.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""Tests for stock_client.StockClient."""
|
||||
import asyncio
|
||||
import logging
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from ai_trade.stock_client import StockClient
|
||||
|
||||
|
||||
BASE_URL = "https://test.stock.local"
|
||||
API_KEY = "test-secret"
|
||||
|
||||
|
||||
async def test_get_portfolio_normal_returns_dict_with_pnl_pct(mock_stock_api):
|
||||
"""정상 200 응답 + cache 저장."""
|
||||
mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"holdings": [{"ticker": "005930", "pnl_pct": 0.047}],
|
||||
"cash": [],
|
||||
"summary": {},
|
||||
},
|
||||
)
|
||||
)
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
result = await client.get_portfolio()
|
||||
assert result["holdings"][0]["pnl_pct"] == 0.047
|
||||
# Cache populated
|
||||
assert len(client._cache) >= 1
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_get_portfolio_uses_cache_within_ttl(mock_stock_api):
|
||||
"""180s TTL 내 두번째 호출 = mock 콜 1회."""
|
||||
route = mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
)
|
||||
)
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
await client.get_portfolio()
|
||||
await client.get_portfolio() # second call within TTL
|
||||
assert route.call_count == 1
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_get_portfolio_refetches_after_ttl_expiry(mock_stock_api, monkeypatch):
|
||||
"""TTL 만료 후 재호출 = mock 콜 2회. time.monotonic 모킹."""
|
||||
route = mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
)
|
||||
)
|
||||
# Fake clock: starts at 0, jumps past portfolio TTL (180s) between calls
|
||||
fake_time = [0.0]
|
||||
monkeypatch.setattr(
|
||||
"ai_trade.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
)
|
||||
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
await client.get_portfolio()
|
||||
fake_time[0] = 181.0 # 180s TTL 만료
|
||||
await client.get_portfolio()
|
||||
assert route.call_count == 2
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_get_portfolio_retries_3_times_on_timeout(mock_stock_api, monkeypatch):
|
||||
"""timeout 2번 + 200 1번 → 최종 성공. exponential sleep 호출 검증."""
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(s):
|
||||
sleep_calls.append(s)
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
side_effect=[
|
||||
httpx.TimeoutException("timeout 1"),
|
||||
httpx.TimeoutException("timeout 2"),
|
||||
httpx.Response(
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
),
|
||||
]
|
||||
)
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
result = await client.get_portfolio()
|
||||
assert result["holdings"] == []
|
||||
assert sleep_calls == [1, 2] # exponential 1s, 2s
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_get_portfolio_429_triggers_backoff(mock_stock_api, monkeypatch):
|
||||
"""429 → 1s backoff → 200."""
|
||||
sleep_calls = []
|
||||
|
||||
async def fake_sleep(s):
|
||||
sleep_calls.append(s)
|
||||
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
side_effect=[
|
||||
httpx.Response(429, text="rate limit"),
|
||||
httpx.Response(
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
),
|
||||
]
|
||||
)
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
result = await client.get_portfolio()
|
||||
assert result["holdings"] == []
|
||||
assert sleep_calls == [1]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_get_portfolio_falls_back_to_stale_on_all_failures(
|
||||
mock_stock_api, monkeypatch, caplog
|
||||
):
|
||||
"""cache 에 이전 성공 응답 + 모든 retry 5xx → stale 반환 + logger.warning."""
|
||||
# No-op sleep for fast test
|
||||
async def fake_sleep(s):
|
||||
return None
|
||||
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
||||
|
||||
# Patch time.monotonic BEFORE first call so cached timestamp uses fake clock
|
||||
fake_time = [0.0]
|
||||
monkeypatch.setattr(
|
||||
"ai_trade.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
)
|
||||
|
||||
# First call succeeds
|
||||
route1 = mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"holdings": [{"ticker": "005930"}], "cash": [], "summary": {}},
|
||||
)
|
||||
)
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
first = await client.get_portfolio()
|
||||
assert first["holdings"][0]["ticker"] == "005930"
|
||||
|
||||
# Advance fake clock past TTL (180s) so cache is stale
|
||||
fake_time[0] = 181.0
|
||||
|
||||
# Now mock to return 500s persistently
|
||||
route1.mock(return_value=httpx.Response(500, text="server error"))
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="ai_trade.stock_client"):
|
||||
result = await client.get_portfolio()
|
||||
assert result["holdings"][0]["ticker"] == "005930" # stale data returned
|
||||
assert any(
|
||||
"stale" in rec.message.lower() for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
await client.close()
|
||||
18
ai_trade/tests/test_stock_client_ttl.py
Normal file
18
ai_trade/tests/test_stock_client_ttl.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# tests/test_stock_client_ttl.py
|
||||
"""SP-A1 회귀 — _TTL이 NAS 부담 완화를 위한 값으로 설정되어 있어야 함."""
|
||||
from ai_trade.stock_client import _TTL
|
||||
|
||||
|
||||
def test_portfolio_ttl_is_180s():
|
||||
"""portfolio TTL은 180초 이상 (3분 폴링에서 1회 fetch가 3 폴링 커버)."""
|
||||
assert _TTL["portfolio"] >= 180.0
|
||||
|
||||
|
||||
def test_news_sentiment_ttl_is_600s():
|
||||
"""news-sentiment TTL은 600초 이상 (10분, 뉴스 sentiment는 자주 안 바뀜)."""
|
||||
assert _TTL["news-sentiment"] >= 600.0
|
||||
|
||||
|
||||
def test_screener_preview_ttl_is_300s():
|
||||
"""screener-preview TTL은 300초 이상 (5분, Top-20은 분 단위로 거의 안 바뀜)."""
|
||||
assert _TTL["screener-preview"] >= 300.0
|
||||
696
legacy/signal_v1/CLAUDE.md
Normal file
696
legacy/signal_v1/CLAUDE.md
Normal file
@@ -0,0 +1,696 @@
|
||||
# 🤖 AI Trading Bot — 프로젝트 설계 문서 (CLAUDE.md)
|
||||
|
||||
> **최종 갱신**: 2026-03-19
|
||||
> **런타임**: Windows (Python 3.x, PyTorch CUDA, FastAPI, Ollama)
|
||||
> **하드웨어**: AMD 9800X3D + RTX 5070 Ti (16 GB VRAM)
|
||||
|
||||
---
|
||||
|
||||
## 1. 시스템 아키텍처 개요
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ main_server.py │
|
||||
│ FastAPI (uvicorn, port 8000) — 프로세스 매니저 & REST API 서버 │
|
||||
│ ┌──────────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
|
||||
│ │ Trading Bot │ │ Telegram Bot │ │ ProcessWatchdog │ │
|
||||
│ │ (Process #1) │ │ (Process #2) │ │ (Daemon Thread) │ │
|
||||
│ └──────┬───────┘ └────────┬────────┘ └──────────┬───────────┘ │
|
||||
│ │ │ │ │
|
||||
│ └─── Shared Memory (IPC) ───┘ Health Check / Restart │
|
||||
│ + Command Queue │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1.1 멀티 프로세스 구성
|
||||
|
||||
| 프로세스 | 역할 | 진입점 |
|
||||
|---------|------|--------|
|
||||
| **Main Server (Uvicorn)** | FastAPI REST API 서버, 프로세스 오케스트레이터 | `main_server.py` |
|
||||
| **Trading Bot** | 자동매매 메인 루프 (스케줄러, 분석, 주문) | `modules/bot.py` → `AutoTradingBot.loop()` |
|
||||
| **Telegram Bot** | 사용자 인터랙션 (명령어 처리, 알림) | `modules/services/telegram_bot/runner.py` |
|
||||
| **ProcessWatchdog** | 자식 프로세스 헬스체크 & 자동 재시작 (30초 간격) | `modules/utils/process_tracker.py` |
|
||||
|
||||
### 1.2 프로세스 간 통신 (IPC)
|
||||
|
||||
```
|
||||
┌─────────────┐ SharedMemory (128KB) ┌──────────────┐
|
||||
│ Trading Bot │ ─── write_status() ───────► │ Telegram Bot │
|
||||
│ │ ◄── read_status() ──────── │ │
|
||||
│ │ │ │
|
||||
│ │ multiprocessing.Queue │ │
|
||||
│ │ ◄── send_command() ──────── │ │
|
||||
│ │ (텔레그램 → 봇 명령) │ │
|
||||
└─────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
- **SharedMemory** (`web_ai_bot_ipc`, 128KB): 메인 봇이 상태 데이터(잔고, GPU, 매크로 지표 등)를 JSON으로 기록, 텔레그램 봇이 읽기
|
||||
- **Command Queue** (`multiprocessing.Queue`): 텔레그램 → 메인 봇 양방향 명령 채널 (`restart`, `evaluate` 등)
|
||||
- **Lock** (`multiprocessing.Lock`): SharedMemory 동시 접근 보호
|
||||
- **IPC Staleness**: 600초 (10분 이상 오래된 데이터는 무시)
|
||||
|
||||
### 1.3 서버 생명주기 (Lifespan)
|
||||
|
||||
```python
|
||||
# main_server.py > lifespan()
|
||||
1. Config.validate() # 환경변수 검증
|
||||
2. ProcessTracker.check_and_kill_zombies() # 좀비 프로세스 정리
|
||||
3. 전역 객체 초기화 (OllamaManager, KISClient, NewsCollector)
|
||||
4. Shared Resources 생성 (Lock, Queue, Event)
|
||||
5. Trading Bot 프로세스 생성 & 시작
|
||||
6. Telegram Bot 프로세스 생성 & 시작
|
||||
7. ProcessWatchdog 시작 (30초 간격 헬스체크)
|
||||
8. → yield (서버 정상 운영)
|
||||
9. [종료] shutdown_event 설정 → 자식 종료 → SharedMemory 해제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 디렉토리 구조
|
||||
|
||||
```
|
||||
web-ai/
|
||||
├── main_server.py # [Entry Point] FastAPI + 프로세스 매니저
|
||||
├── warmup_and_restart.py # LSTM 사전학습 + 봇 자동 시작 스크립트
|
||||
├── watchlist_manager.py # 뉴스 기반 일일 Watchlist 자동 업데이트
|
||||
├── backtester.py # 전략 백테스팅 CLI
|
||||
├── theme_manager.py # 종목별 테마/섹터 관리
|
||||
├── .env # 환경변수 (KIS, Telegram, Ollama 등)
|
||||
│
|
||||
├── modules/
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py # [Config] 환경변수 & 상수 정의
|
||||
│ ├── bot.py # [Core] AutoTradingBot (상태 머신 & 스케줄러)
|
||||
│ │
|
||||
│ ├── analysis/ # [AI Brain] 분석 엔진
|
||||
│ │ ├── deep_learning.py # Attention-LSTM (7D 피처, PyTorch GPU)
|
||||
│ │ ├── technical.py # 기술적 지표 (RSI, MACD, BB, ADX, OBV...)
|
||||
│ │ ├── macro.py # 거시경제 분석 (KOSPI/KOSDAQ/MSI)
|
||||
│ │ ├── ensemble.py # 적응형 앙상블 (3신호 가중치 자동조정)
|
||||
│ │ ├── evaluator.py # 주간 성과 평가 + LLM 전문가 패널
|
||||
│ │ └── backtest.py # 백테스팅 프레임워크 (Sharpe, MDD 등)
|
||||
│ │
|
||||
│ ├── strategy/ # [Decision] 매매 의사결정
|
||||
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
|
||||
│ │
|
||||
│ ├── services/ # [I/O] 외부 서비스 연동
|
||||
│ │ ├── kis.py # 한국투자증권 REST API (동기 + 비동기)
|
||||
│ │ ├── ollama.py # Ollama LLM 인터페이스 (GPU 충돌 방지)
|
||||
│ │ ├── news.py # Google News RSS 크롤링 (동기 + 비동기)
|
||||
│ │ ├── telegram.py # 텔레그램 메시지 발송 (Fire-and-forget)
|
||||
│ │ └── telegram_bot/
|
||||
│ │ ├── server.py # 텔레그램 봇 서버 (명령어 핸들러)
|
||||
│ │ └── runner.py # 텔레그램 봇 독립 프로세스 실행기
|
||||
│ │
|
||||
│ └── utils/ # [Util] 유틸리티
|
||||
│ ├── ipc.py # SharedMemory + Command Queue IPC
|
||||
│ ├── process_tracker.py # PID 추적 & 좀비 정리 & Watchdog
|
||||
│ ├── monitor.py # CPU/GPU/RAM 서킷 브레이커
|
||||
│ └── performance_db.py # 일별 스냅샷 & 매매 기록 영구 저장
|
||||
│
|
||||
├── data/ # [Runtime Data]
|
||||
│ ├── watchlist.json # 현재 감시 종목 리스트
|
||||
│ ├── daily_trade_history.json # 일일 매매 기록
|
||||
│ ├── kis_token.json # KIS OAuth 토큰 캐시
|
||||
│ ├── peak_prices.json # 트레일링 스탑용 최고가
|
||||
│ ├── ensemble_history.json # AdaptiveEnsemble 가중치 + 매매 히스토리 (종목별)
|
||||
│ ├── models/ # LSTM 체크포인트 (종목별 .pt 파일)
|
||||
│ └── performance/ # 성과 데이터 (daily_snapshots, trade_records)
|
||||
│
|
||||
└── tests/ # 테스트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 모듈 상세
|
||||
|
||||
### 3.1 AutoTradingBot (`modules/bot.py`)
|
||||
|
||||
**메인 트레이딩 루프** — 장 시작(09:00) ~ 장 마감(15:30) 사이에 자동 실행
|
||||
|
||||
```
|
||||
[v3.1 주요 기능]
|
||||
├── ATR 기반 동적 손절/익절 + 트레일링 스탑
|
||||
├── Kelly Criterion 포지션 사이징 (실전 승률·손익비 기반, Half-Kelly)
|
||||
├── AdaptiveEnsemble 연동 (매도 후 가중치 자동 학습)
|
||||
├── 당일 누적 매수 추적 (_today_buy_total) - KIS T+2 미차감 보완
|
||||
├── 사이클당 최대 매수 종목 수 제한 (MAX_BUY_PER_CYCLE)
|
||||
├── ProcessPoolExecutor 병렬 분석 (워커 1개, OOM 대응 자동 재시작)
|
||||
├── 일별 자산 스냅샷 (09:05~09:15)
|
||||
├── 주간 성과 평가 (월요일 아침)
|
||||
├── CPU 서킷 브레이커 연동
|
||||
└── IPC Command Queue 폴링 (텔레그램 명령 처리)
|
||||
```
|
||||
|
||||
**잔고 추적 로직 (v3.1 — 과매수 방지)**:
|
||||
```
|
||||
KIS get_balance() → raw_deposit (dnca_tot_amt)
|
||||
↓
|
||||
max_daily_buy = raw_deposit × MAX_DAILY_BUY_RATIO (80%)
|
||||
tracking_deposit = max_daily_buy - effective_today_buy
|
||||
↑
|
||||
max(kis_today_buy, self._today_buy_total)
|
||||
(KIS thdt_buy_amt vs 로컬 누적 중 큰 값)
|
||||
```
|
||||
- `_today_buy_total`: 인스턴스 변수, 사이클 간 유지 (09:00 리셋)
|
||||
- `_buy_scores`: BUY 시 신호 점수 저장 → SELL 시 `record_trade()` 전달
|
||||
|
||||
**run_cycle() 흐름**:
|
||||
1. 시스템 헬스 체크 (CPU/GPU/RAM)
|
||||
2. 거시경제 분석 (KOSPI/KOSDAQ/MSI)
|
||||
3. 위험 상태별 분기 (SAFE/CAUTION/DANGER)
|
||||
4. Watchlist 종목 OHLCV 수집 (KIS 비동기 배치)
|
||||
5. 잔고 조회 + 당일 누적 매수 차감 → 실제 가용 예수금 계산
|
||||
6. `ProcessPoolExecutor`로 종목 병렬 분석 (Kelly Criterion + Ensemble 가중치)
|
||||
7. 앙상블 점수 기반 매수/매도 판단 (사이클당 MAX_BUY_PER_CYCLE 제한)
|
||||
8. 주문 실행 & 결과 텔레그램 알림
|
||||
9. 매도 시 `record_trade()` → Ensemble 가중치 학습
|
||||
10. IPC 상태 갱신
|
||||
|
||||
### 3.2 AI 분석 파이프라인
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ analyze_stock_ │
|
||||
│ process() │
|
||||
│ (strategy/process)│
|
||||
└─────────┬───────────┘
|
||||
│
|
||||
┌─────────────────────┼────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌───────────────┐ ┌────────────────┐ ┌─────────────────┐
|
||||
│ Technical │ │ Deep Learning │ │ LLM (Ollama) │
|
||||
│ Analyzer │ │ LSTM │ │ Sentiment │
|
||||
│ (기술적 지표) │ │ (주가 예측) │ │ (뉴스 감성분석) │
|
||||
├───────────────┤ ├────────────────┤ ├─────────────────┤
|
||||
│ RSI 25% │ │ Attention-LSTM │ │ qwen2.5:7b │
|
||||
│ 이격도 15% │ │ 4L×512H │ │ JSON 포맷 요청 │
|
||||
│ MACD 15% │ │ 7차원 피처 │ │ 뉴스+지표 통합 │
|
||||
│ Stochastic 5% │ │ 60일 시퀀스 │ │ 감성+신뢰도 │
|
||||
│ BB 15% │ │ GPU 가속 │ │ │
|
||||
│ ADX 15% │ │ 종목별 모델 │ │ │
|
||||
│ MTF 10% │ │ (ModelRegistry)│ │ │
|
||||
│ OBV ±보너스 │ │ │ │ │
|
||||
└───────┬───────┘ └───────┬────────┘ └───────┬─────────┘
|
||||
│ │ │
|
||||
└──────────┬────────┘ │
|
||||
▼ │
|
||||
┌─────────────────┐ │
|
||||
│ AdaptiveEnsemble│ ◄───────────────────┘
|
||||
│ (학습형 가중치) │
|
||||
├─────────────────┤
|
||||
│ get_weights() │ ← 과거 매매 결과 반영
|
||||
│ (ADX+macro+conf)│ 크기 가중 정확도 기준
|
||||
│ 경계: 0.10~0.65 │ Water-Filling 정규화
|
||||
│ Kelly Fraction │ ← 승률·손익비 기반
|
||||
└────────┬────────┘
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ 매수/매도/홀드 │
|
||||
│ 최종 판단 │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
#### 3.2.1 Deep Learning — Attention-LSTM (`analysis/deep_learning.py`)
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| **아키텍처** | 4-Layer Stacked LSTM + Attention + FC |
|
||||
| **Hidden Size** | 512 |
|
||||
| **Input Features** | 7 (close, open, high, low, volume_norm, rsi_14, macd_hist) |
|
||||
| **시퀀스 길이** | 60일 |
|
||||
| **학습 에포크** | 최대 200 (Early Stopping patience=15) |
|
||||
| **빠른 재학습** | 30 에포크 (체크포인트 존재 시) |
|
||||
| **쿨다운** | 1200초 (20분, 동일 종목 재학습 방지) |
|
||||
| **ModelRegistry** | LRU 방식, 최대 5개 모델 동시 적재 |
|
||||
| **체크포인트** | `data/models/{ticker}_v3.pt` |
|
||||
| **GPU 관리** | LSTM 학습 시 Ollama 자동 언로드/리로드 |
|
||||
|
||||
#### 3.2.2 기술적 분석 (`analysis/technical.py`)
|
||||
|
||||
`TechnicalAnalyzer.get_technical_score()` → 0.0 ~ 1.0 통합 점수
|
||||
|
||||
| 지표 | 비중 | 설명 |
|
||||
|------|------|------|
|
||||
| RSI (14일) | 25% | Wilder 방식, 30 이하 과매도/70 이상 과매수 |
|
||||
| 이동평균 이격도 | 15% | 20일 MA 대비 현재가 위치 |
|
||||
| MACD | 15% | 12/26/9, 히스토그램 방향 |
|
||||
| Stochastic | 5% | Fast %K/%D (14/3/3) |
|
||||
| Bollinger Bands | 15% | 20일/2σ, %B 위치 + 밴드폭 |
|
||||
| ADX | 15% | 추세 강도 (>25 강한 추세) |
|
||||
| Multi-Timeframe | 10% | 5일/20일/60일 추세 일관성 |
|
||||
| OBV | ±0.1 보너스 | 거래량 기반 매집/분산 감지 |
|
||||
|
||||
추가 기능:
|
||||
- `calculate_atr()` → ATR 기반 동적 손절/익절
|
||||
- `calculate_dynamic_sl_tp()` → 변동성 적응형 SL/TP
|
||||
- `calculate_obv()` → 스마트 머니 다이버전스 감지
|
||||
|
||||
#### 3.2.3 거시경제 분석 (`analysis/macro.py`)
|
||||
|
||||
```python
|
||||
MacroAnalyzer.get_macro_status(kis_client) → {
|
||||
"status": "SAFE" | "CAUTION" | "DANGER",
|
||||
"risk_score": int,
|
||||
"indicators": {
|
||||
"KOSPI": {"price", "change", "high", "low", "prev_close", "volume"},
|
||||
"KOSDAQ": {"price", "change", ...},
|
||||
"KOSPI200":{"price", "change", ...},
|
||||
"MSI": float # Market Stress Index (0~100)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **SAFE** (risk_score < 1): 정상 매매
|
||||
- **CAUTION** (1 ≤ risk_score < 3): 매수 규모 축소
|
||||
- **DANGER** (risk_score ≥ 3): 매수 중단, 보유분만 관리
|
||||
|
||||
#### 3.2.4 앙상블 (`analysis/ensemble.py`)
|
||||
|
||||
`AdaptiveEnsemble` — 과거 매매 결과 기반 가중치 자동 조정 + Kelly Criterion:
|
||||
|
||||
**가중치 학습 흐름**:
|
||||
```
|
||||
BUY 체결 → bot._buy_scores[ticker] = {tech, sentiment, lstm} 저장
|
||||
SELL 체결 → ensemble.record_trade(ticker, ..., outcome_pct=yld)
|
||||
→ _update_weights() → EMA(alpha=0.10) 가중치 점진 조정
|
||||
→ _save() → data/ensemble_history.json
|
||||
워커 프로세스 → reload_if_stale() → 파일 mtime 감지 시 재로드
|
||||
```
|
||||
|
||||
**주요 메서드**:
|
||||
- `get_weights(ticker, adx, macro_state, ai_confidence)` → `SignalWeights`
|
||||
- 시장 컨텍스트 (strong_trend/sideways/danger/default) 별 기본 가중치
|
||||
- 종목별 최근 10거래 크기 가중 정확도 반영
|
||||
- ai_confidence >= 0.75 → LSTM 가중치 +25% (confidence 상한 0.80 반영)
|
||||
- `get_kelly_fraction(ticker, half_kelly=True)` → 0.03~0.25 범위 투자 비중
|
||||
- f* = (p·b - q) / b (p=승률, b=손익비)
|
||||
- 거래 데이터 < 10건 → 보수적 기본값 8%
|
||||
- Half-Kelly 적용으로 변동성 과대추정 보완
|
||||
- `compute_ensemble_score(tech, sentiment, lstm, investor, weights)` → 통합 점수
|
||||
- `reload_if_stale()` → 파일 mtime 기반 cross-process 동기화
|
||||
|
||||
**`SignalWeights.normalize()` — Water-Filling 알고리즘**:
|
||||
- 경계(0.10~0.65) 위반 시 해당 값을 경계에 고정, 나머지에 잔여 비중 비례 배분
|
||||
- 2차 정규화(합=1 보장)와 경계 클램핑이 상충하는 문제 해결
|
||||
- 영구 저장: `data/ensemble_history.json` (가중치 + 매매 히스토리 통합)
|
||||
|
||||
#### 3.2.5 성과 평가 (`analysis/evaluator.py`)
|
||||
|
||||
`PerformanceEvaluator.generate_weekly_report()`:
|
||||
- 핵심 지표: 총수익률, Sharpe Ratio, MDD, 승률, 평균손익비, KOSPI 상관도
|
||||
- S/A/B/C/D/F 등급 산출
|
||||
- **5명 전문가 LLM 패널** (Ollama): 각각 다른 관점으로 평가
|
||||
- HTML 포맷 텔레그램 주간 보고서 자동 생성
|
||||
|
||||
---
|
||||
|
||||
## 4. 외부 서비스 연동
|
||||
|
||||
### 4.1 한국투자증권 KIS API (`services/kis.py`)
|
||||
|
||||
#### 인증
|
||||
|
||||
```python
|
||||
KISClient.ensure_token()
|
||||
# OAuth 2.0 → access_token 발급 → data/kis_token.json에 캐시
|
||||
# 토큰 만료 시 자동 갱신 (_request_api에서 처리)
|
||||
```
|
||||
|
||||
| 설정 | 모의투자 | 실전투자 |
|
||||
|------|---------|---------|
|
||||
| Base URL | `openapivts.koreainvestment.com:29443` | `openapi.koreainvestment.com:9443` |
|
||||
| 환경변수 | `KIS_VIRTUAL_APP_KEY/SECRET/ACCOUNT` | `KIS_REAL_APP_KEY/SECRET/ACCOUNT` |
|
||||
| 전환 | `.env` → `KIS_ENV_TYPE=virtual` | `.env` → `KIS_ENV_TYPE=real` |
|
||||
|
||||
#### API 스로틀링
|
||||
|
||||
- 초당 2회 제한 (`_throttle()` — 0.5초 딜레이)
|
||||
- 토큰 만료 시 자동 갱신 (403 → retry with new token)
|
||||
|
||||
#### 주요 API 엔드포인트 매핑
|
||||
|
||||
| 기능 | KISClient 메서드 | KIS TR_ID |
|
||||
|------|-----------------|-----------|
|
||||
| 잔고 조회 | `get_balance()` → `{holdings, total_eval, deposit, today_buy_amt}` | `VTTC8434R` (모의) / `TTTC8434R` (실전) |
|
||||
| 주문 (매수/매도) | `order()` | `VTTC0802U` / `VTTC0801U` (모의) |
|
||||
| 현재가 조회 | `get_current_price()` | `FHKST01010100` |
|
||||
| 일봉 OHLCV | `get_daily_ohlcv()` → `_get_daily_ohlcv_by_range()` | `FHKST03010100` |
|
||||
| 일봉 종가 | `get_daily_price()` → `_get_daily_price_by_range()` | `FHKST03010100` |
|
||||
| 거래량 순위 | `get_volume_rank()` | `FHPST01710000` |
|
||||
| 지수 현재가 | `get_current_index()` | `FHPUP02100000` |
|
||||
| 지수 일봉 | `get_daily_index_price()` | `FHKUP03500100` |
|
||||
| 투자자 동향 | `get_investor_trend()` | `FHKST01010900` |
|
||||
| Hash Key | `get_hash_key()` | - |
|
||||
|
||||
#### 비동기 클라이언트 (`KISAsyncClient`)
|
||||
|
||||
`aiohttp` 기반 — 다중 종목 동시 수집용:
|
||||
- `get_daily_price_batch()` — 여러 종목 일봉 병렬 수집
|
||||
- `get_daily_ohlcv_batch()` — 여러 종목 OHLCV 병렬 수집
|
||||
- `get_investor_trends_batch()` — 여러 종목 투자자 동향 병렬 수집
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Ollama LLM (`services/ollama.py`)
|
||||
|
||||
| 설정 | 값 |
|
||||
|------|-----|
|
||||
| **모델** | `qwen2.5:7b-instruct-q4_K_M` (VRAM ~4GB) |
|
||||
| **API URL** | `http://localhost:11434` |
|
||||
| **Context Window** | 4096 토큰 |
|
||||
| **Max Output** | 200 토큰 |
|
||||
| **Temperature** | 0.1 (결정론적, JSON 안정성) |
|
||||
| **Keep Alive** | 5분 (비활성 시 자동 언로드) |
|
||||
| **Timeout** | 90초 |
|
||||
| **CPU Threads** | 8 (9800X3D 최적화) |
|
||||
| **응답 포맷** | JSON (format: "json") |
|
||||
|
||||
**GPU 충돌 방지**:
|
||||
- LSTM 학습 중 → Ollama 추론 최대 60초 대기
|
||||
- VRAM > 12GB → 모델 즉시 언로드 (`keep_alive=0`)
|
||||
- LSTM 학습 전 → Ollama 자동 언로드, 학습 후 → 자동 리로드
|
||||
|
||||
---
|
||||
|
||||
### 4.3 뉴스 수집 (`services/news.py`)
|
||||
|
||||
- **소스**: Google News RSS (`news.google.com/rss/search`)
|
||||
- **동기**: `NewsCollector.get_market_news()` — 시장 일반 뉴스 5건
|
||||
- **비동기**: `AsyncNewsCollector`
|
||||
- `get_market_news_async()` — 시장 뉴스 (5분 캐시)
|
||||
- `get_stock_news_async()` — 종목별 뉴스 (5분 캐시)
|
||||
|
||||
---
|
||||
|
||||
## 5. 웹 백엔드 서버 API (FastAPI)
|
||||
|
||||
### 5.1 서버 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| **프레임워크** | FastAPI + Uvicorn |
|
||||
| **호스트** | `0.0.0.0:8000` |
|
||||
| **NAS 백엔드** | `http://192.168.45.54:18500` (웹 프론트엔드 서버) |
|
||||
|
||||
### 5.2 API 엔드포인트
|
||||
|
||||
#### `GET /` — 서버 상태
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "online",
|
||||
"gpu_vram": 4.2,
|
||||
"service": "Windows AI Server (Refactored)"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /trade/balance` | `GET /api/trade/balance` — 잔고 조회
|
||||
|
||||
KIS API를 통해 현재 계좌 잔고(예수금, 보유종목, 평가금액) 조회.
|
||||
|
||||
```json
|
||||
{
|
||||
"total_eval": 10500000,
|
||||
"deposit": 5000000,
|
||||
"holdings": [
|
||||
{
|
||||
"ticker": "005930",
|
||||
"name": "삼성전자",
|
||||
"qty": 10,
|
||||
"avg_price": 72000,
|
||||
"current_price": 73500,
|
||||
"profit_rate": 2.08
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /trade/order` | `POST /api/trade/order` — 수동 주문
|
||||
|
||||
```json
|
||||
// Request Body
|
||||
{
|
||||
"ticker": "005930",
|
||||
"action": "BUY", // "BUY" | "SELL"
|
||||
"quantity": 10
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
"status": "executed",
|
||||
"kis_result": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
#### `POST /analyze/portfolio` | `POST /api/analyze/portfolio` — AI 포트폴리오 분석
|
||||
|
||||
현재 잔고 + 최신 뉴스를 종합하여 Ollama LLM으로 포트폴리오 분석.
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis": "... AI 분석 결과 (한국어) ..."
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 NAS 서버와의 통신 흐름
|
||||
|
||||
```
|
||||
┌──────────────┐ HTTP Request ┌────────────────────┐
|
||||
│ NAS Backend │ ─────────────────────► │ Windows AI Server │
|
||||
│ (웹 프론트) │ │ (FastAPI:8000) │
|
||||
│ :18500 │ ◄──────────────────── │ │
|
||||
│ │ JSON Response │ │
|
||||
└──────────────┘ └────────────────────┘
|
||||
|
||||
[통신 시나리오]
|
||||
1. 웹 → /api/trade/balance → 잔고 데이터 표시
|
||||
2. 웹 → /api/trade/order → 수동 매수/매도 실행
|
||||
3. 웹 → /api/analyze/portfolio → AI 분석 결과 표시
|
||||
4. 웹 → / → 서버 상태 및 GPU 정보
|
||||
```
|
||||
|
||||
- **NAS 서버** (`192.168.45.54:18500`): 웹 프론트엔드 호스팅, 사용자 인터페이스 제공
|
||||
- **Windows AI 서버** (`0.0.0.0:8000`): GPU 연산, KIS API 통신, AI 분석 처리
|
||||
- 내부 네트워크 (LAN) 통신, 외부 노출 없음
|
||||
|
||||
---
|
||||
|
||||
## 6. 텔레그램 봇 설정 & 명령어
|
||||
|
||||
### 6.1 환경변수
|
||||
|
||||
```env
|
||||
TELEGRAM_BOT_TOKEN=8546032918:AAF5GJcP92DrtpSoQdaimMIZe7bz_xtGGPo
|
||||
TELEGRAM_CHAT_ID=7388056964
|
||||
```
|
||||
|
||||
### 6.2 봇 프로세스 아키텍처
|
||||
|
||||
```
|
||||
runner.py
|
||||
└── run_telegram_bot_standalone()
|
||||
├── SharedIPC 초기화 (lock, queue, shutdown_event)
|
||||
├── TelegramBotServer 생성
|
||||
├── IPC에서 초기 데이터 로드
|
||||
├── bot_server.run() (python-telegram-bot polling)
|
||||
└── Conflict 감지 시 백오프 재시도 (최대 10회)
|
||||
```
|
||||
|
||||
- **라이브러리**: `python-telegram-bot` (Application, CommandHandler)
|
||||
- **메시지 포맷**: HTML (`parse_mode="HTML"`)
|
||||
- **동시 업데이트**: `concurrent_updates=True`
|
||||
- **로깅**: `telegram_bot.log` (파일 + 콘솔)
|
||||
|
||||
### 6.3 명령어 목록
|
||||
|
||||
| 명령어 | 설명 | 데이터 소스 |
|
||||
|--------|------|------------|
|
||||
| `/start` | 봇 시작 & 전체 명령어 안내 | - |
|
||||
| `/status` | 봇 상태, 시장 지수, AI 모델 상태 | IPC (SharedMemory) |
|
||||
| `/portfolio` | 보유 종목 & 수익률 조회 | IPC → FakeKIS.get_balance() |
|
||||
| `/watchlist` | 현재 감시 종목 리스트 | IPC → watchlist 데이터 |
|
||||
| `/update_watchlist` | Watchlist 즉시 업데이트 요청 | Command Queue → 메인 봇 |
|
||||
| `/macro` | 거시경제 분석 (KOSPI/KOSDAQ/MSI) | IPC → macro_indices |
|
||||
| `/system` | CPU/GPU/RAM 시스템 상태 | IPC → gpu_status + psutil |
|
||||
| `/ai` | AI 모델 상태 (VRAM, 학습 여부) | IPC → gpu_status |
|
||||
| `/restart` | 메인 봇 재시작 명령 | Command Queue |
|
||||
| `/stop` | 봇 종료 | shutdown_event.set() |
|
||||
| `/exec <cmd>` | 서버 쉘 명령어 직접 실행 | subprocess (10초 타임아웃) |
|
||||
| `/evaluate` | 즉시 성과 평가 보고서 생성 | PerformanceEvaluator |
|
||||
|
||||
### 6.4 TelegramMessenger (`services/telegram.py`)
|
||||
|
||||
단방향 알림 전용 (메인 봇 → 사용자):
|
||||
- **비동기 전송**: `threading.Thread(daemon=True)` — Fire-and-forget
|
||||
- **HTML 파싱**: 마크다운 에러 방지
|
||||
- 매매 실행, 서버 시작/종료, 에러 알림 등에 사용
|
||||
|
||||
### 6.5 Conflict 처리
|
||||
|
||||
텔레그램 봇 API는 동시에 하나의 polling 인스턴스만 허용:
|
||||
- `Conflict` 에러 감지 시 지수 백오프 (5s → 10s → ... → 30s)
|
||||
- 최대 10회 재시도 후 프로세스 종료
|
||||
- Watchdog가 감지하여 자동 재시작
|
||||
|
||||
---
|
||||
|
||||
## 7. 환경 설정 (`modules/config.py`)
|
||||
|
||||
### 7.1 주요 설정 상수
|
||||
|
||||
| 그룹 | 키 | 값 | 설명 |
|
||||
|------|-----|-----|------|
|
||||
| **매매** | `MAX_INVESTMENT_PER_STOCK` | 3,000,000원 | 종목당 최대 투자금 |
|
||||
| **매매** | `MAX_BUY_PER_CYCLE` | 2 | 사이클당 최대 매수 종목 수 (env: `MAX_BUY_PER_CYCLE`) |
|
||||
| **매매** | `MAX_DAILY_BUY_RATIO` | 0.80 | 예수금 대비 일일 최대 매수 비율 (env: `MAX_DAILY_BUY_RATIO`) |
|
||||
| **IPC** | `SHM_NAME` | `web_ai_bot_ipc` | SharedMemory 이름 |
|
||||
| **IPC** | `SHM_SIZE` | 131,072 (128KB) | SharedMemory 크기 |
|
||||
| **IPC** | `IPC_STALENESS` | 600초 | 데이터 유효 기간 |
|
||||
| **GPU** | `VRAM_WARNING_THRESHOLD` | 12.0 GB | VRAM 경고 임계값 |
|
||||
| **프로세스** | `WATCHDOG_INTERVAL` | 30초 | 헬스체크 간격 |
|
||||
| **프로세스** | `MAX_RESTART_COUNT` | 3 | 최대 자동 재시작 횟수 |
|
||||
| **LSTM** | `LSTM_COOLDOWN` | 1,200초 | 동일 종목 재학습 방지 |
|
||||
| **LSTM** | `LSTM_FAST_EPOCHS` | 30 | 빠른 재학습 에포크 |
|
||||
| **CPU** | `CPU_CIRCUIT_BREAKER_THRESHOLD` | 92% | 서킷 브레이커 임계값 |
|
||||
| **CPU** | `CPU_CIRCUIT_BREAKER_CONSECUTIVE` | 2회 | 연속 초과 시 발동 |
|
||||
| **Ollama** | `OLLAMA_NUM_CTX` | 4,096 | 컨텍스트 윈도우 |
|
||||
| **Ollama** | `OLLAMA_NUM_PREDICT` | 200 | 최대 출력 토큰 |
|
||||
| **Ollama** | `OLLAMA_NUM_THREAD` | 8 | CPU 스레드 수 |
|
||||
| **Network** | `HTTP_TIMEOUT` | 10초 | 기본 HTTP 요청 타임아웃 |
|
||||
|
||||
### 7.2 .env 파일 구조
|
||||
|
||||
```env
|
||||
# NAS Backend (웹 프론트엔드 서버)
|
||||
NAS_API_URL=http://192.168.45.54:18500
|
||||
|
||||
# Ollama LLM
|
||||
OLLAMA_API_URL=http://localhost:11434
|
||||
OLLAMA_MODEL=qwen2.5:7b-instruct-q4_K_M
|
||||
|
||||
# KIS API (virtual/real 전환)
|
||||
KIS_ENV_TYPE=virtual
|
||||
KIS_REAL_APP_KEY=...
|
||||
KIS_REAL_APP_SECRET=...
|
||||
KIS_REAL_ACCOUNT=XXXXXXXX-XX
|
||||
KIS_VIRTUAL_APP_KEY=...
|
||||
KIS_VIRTUAL_APP_SECRET=...
|
||||
KIS_VIRTUAL_ACCOUNT=XXXXXXXX-XX
|
||||
|
||||
# Telegram Bot
|
||||
TELEGRAM_BOT_TOKEN=...
|
||||
TELEGRAM_CHAT_ID=...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 운영 가이드
|
||||
|
||||
### 8.1 시작 방법
|
||||
|
||||
```bash
|
||||
# 일반 시작
|
||||
python main_server.py
|
||||
|
||||
# LSTM 사전학습 후 자동 시작
|
||||
python warmup_and_restart.py
|
||||
|
||||
# 텔레그램 봇만 단독 실행 (디버깅용)
|
||||
python -m modules.services.telegram_bot.runner
|
||||
```
|
||||
|
||||
### 8.2 좀비 프로세스 관리
|
||||
|
||||
- `main_server.py` 실행 시 자동으로 이전 좀비 프로세스 정리
|
||||
- `pids.txt` 기반 → 메모리 기반 PID 추적으로 전환 완료
|
||||
- 수동 확인: `Get-Process python` (PowerShell)
|
||||
|
||||
### 8.3 로그 파일
|
||||
|
||||
| 파일 | 용도 |
|
||||
|------|------|
|
||||
| `server.log` | Uvicorn 서버 로그 |
|
||||
| `telegram_bot.log` | 텔레그램 봇 로그 |
|
||||
| `warmup.log` | LSTM 사전학습 진행 로그 |
|
||||
| `bot_output.log` | 트레이딩 봇 출력 로그 |
|
||||
|
||||
### 8.4 트러블슈팅
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|------|------|------|
|
||||
| KIS 403 Forbidden | 토큰 만료 또는 Rate Limit | `data/kis_token.json` 삭제 후 재시작 |
|
||||
| Telegram Conflict | 이전 봇 프로세스 미종료 | `main_server.py` 재시작 (자동 정리) |
|
||||
| GPU OOM | LSTM + Ollama 동시 적재 | `VRAM_WARNING_THRESHOLD` 낮추기 |
|
||||
| CPU 100% 고정 | 좀비 워커 프로세스 | `main_server.py` 재시작 |
|
||||
| IPC 데이터 오래됨 | 메인 봇 크래시 | Watchdog 자동 재시작 확인, 수동 재시작 |
|
||||
| 예수금 초과 매수 | KIS 모의투자 T+2 미차감 | `MAX_DAILY_BUY_RATIO` / `MAX_BUY_PER_CYCLE` 조정 |
|
||||
| Kelly 비중이 너무 낮음 | 거래 기록 부족 (< 10건) | 초기에는 기본값 8% 사용, 거래 누적 후 자동 조정 |
|
||||
| 앙상블 가중치 갱신 안 됨 | 매도 체결 없음 또는 `_buy_scores` 누락 | 봇 재시작 전 매도 완료 확인; `data/ensemble_history.json` 확인 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 데이터 흐름 요약
|
||||
|
||||
```
|
||||
[시장 개장 전]
|
||||
WatchlistManager → 뉴스 분석 → Watchlist 갱신
|
||||
|
||||
[장중 사이클 (≈5분 간격)]
|
||||
1. SystemMonitor.check_health() → CPU/GPU 확인
|
||||
2. MacroAnalyzer.get_macro_status() → 시장 상태 판단
|
||||
3. KIS → get_balance() → raw_deposit - today_buy_total = 가용 예수금
|
||||
4. KIS → get_daily_ohlcv_batch() → OHLCV 수집
|
||||
5. ProcessPool → analyze_stock_process() × N종목
|
||||
├── ensemble.reload_if_stale() → 파일 mtime 감지 시 가중치 재로드
|
||||
├── TechnicalAnalyzer → 기술적 점수
|
||||
├── PricePredictor → LSTM 예측
|
||||
├── OllamaManager → LLM 감성 분석
|
||||
├── AdaptiveEnsemble.get_weights() → 학습된 동적 가중치
|
||||
└── calculate_position_size() → Kelly Criterion 수량 산출
|
||||
6. 매수 판단 → 예수금 확인 → KIS 주문
|
||||
├── _buy_scores[ticker] 저장 (앙상블 학습용)
|
||||
├── _today_buy_total += 매수금액
|
||||
└── buys_this_cycle++ (MAX_BUY_PER_CYCLE 제한)
|
||||
7. 매도 판단 → KIS 주문
|
||||
└── ensemble.record_trade() → 가중치 학습 + ensemble_history.json 저장
|
||||
8. SharedIPC.write_status() → 텔레그램 봇에 공유
|
||||
9. TelegramMessenger → 결과 알림
|
||||
|
||||
[장 마감 후]
|
||||
PerformanceDB.save_daily_snapshot() → 일별 자산 기록
|
||||
Evaluator → 주간 보고서 (월요일)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 버전 변경 이력
|
||||
|
||||
### v3.1 (2026-03-19) — 잔고 관리 & 앙상블 학습 완성
|
||||
|
||||
**버그 수정**:
|
||||
- `tracking_deposit` 사이클 간 초기화 문제 → `_today_buy_total` 인스턴스 변수로 누적 추적
|
||||
- KIS 모의투자 T+2 미차감으로 인한 예수금 초과 매수 방지
|
||||
- `ai_confidence >= 0.85` 임계값 버그 (LSTM confidence 상한 0.80 미반영) → 0.75로 수정
|
||||
- OHLCV 피처 누락 시 silent fallback → 경고 로그 출력
|
||||
|
||||
**신규 기능**:
|
||||
- `MAX_BUY_PER_CYCLE`: 사이클당 최대 매수 종목 수 제한 (기본 2)
|
||||
- `MAX_DAILY_BUY_RATIO`: 예수금 대비 일일 최대 매수 비율 (기본 80%)
|
||||
- `kis.get_balance()` → `today_buy_amt` 필드 추가 (`thdt_buy_amt`)
|
||||
|
||||
**앙상블 (`analysis/ensemble.py`)**:
|
||||
- `AdaptiveEnsemble`을 `process.py`에 실제 연동 (하드코딩 가중치 제거)
|
||||
- `get_kelly_fraction()`: Half-Kelly Criterion 포지션 비중 계산 추가
|
||||
- `SignalWeights.normalize()`: Water-Filling 알고리즘으로 경계 위반 문제 해결
|
||||
- `_accuracy()` 이진 지표 제거 → `_accuracy_weighted()` (크기 가중) 통일
|
||||
- `reload_if_stale()`: 파일 mtime 기반 cross-process 동기화
|
||||
|
||||
**포지션 사이징 (`strategy/process.py`)**:
|
||||
- `calculate_position_size()`: 하드코딩 10% → Kelly Criterion (과거 승률·손익비 기반)
|
||||
- `bot.py` 중복 계산 제거 → 워커의 `suggested_qty` 직접 사용
|
||||
|
||||
**앙상블 학습 루프 (`bot.py`)**:
|
||||
- BUY 체결 시 `_buy_scores[ticker]` 신호 점수 저장
|
||||
- SELL 체결 시 `ensemble.record_trade()` → `ensemble_history.json` 갱신
|
||||
- 워커 프로세스는 `reload_if_stale()`로 자동 반영
|
||||
26
legacy/signal_v1/DEPRECATED.md
Normal file
26
legacy/signal_v1/DEPRECATED.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# signal_v1 — DEPRECATED
|
||||
|
||||
> **2026-05-19부터 사용 안 함.** 신규 작업 금지. 모든 트레이딩은 `web-ai/ai_trade/` (구 `signal_v2`) 에서 진행.
|
||||
|
||||
## 폐기 사유
|
||||
|
||||
- V2 (`ai_trade`) Phase 4 완료 — Chronos-2 zero-shot 1일 수익률 + 분봉 모멘텀 + 5-state classifier + sell-first 우선순위 + 매수 hard gate가 V1 (LSTM 7-features + Gemini Flash) 보다 정확도·확장성·해석가능성에서 우위
|
||||
- V1 + V2 동시 운영 시 KIS API rate limit 충돌
|
||||
- NAS 인바운드 polling 부담 (web-ai → NAS API) 의 50% 차지
|
||||
|
||||
## 향후 처리
|
||||
|
||||
- 디렉토리를 `legacy/signal_v1/`로 이동 예정 (현재 file lock 풀린 후 처리)
|
||||
- `start.bat` 진입점은 이미 `legacy/start_v1.bat`으로 이동 → 자동 시작 차단됨
|
||||
- DSM Scheduler 등 외부 trigger에 V1 startup 등록되어 있다면 해제 필요 (박재오 확인)
|
||||
|
||||
## 활용 (필요 시)
|
||||
|
||||
- 코드 참고용 (LSTM 모델 구조, Telegram bot 인터페이스, KIS 자동주문 패턴)
|
||||
- 별도 backtest 실행은 가능 (`backtest_runner.py`) — 단 운영 자동 실행 X
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- 신 운영 가이드: `../ai_trade/`
|
||||
- web-ai 통합 가이드: `../CLAUDE.md`
|
||||
- V1 vs V2 진단: `../CHECK_POINT.md`
|
||||
104
legacy/signal_v1/README.md
Normal file
104
legacy/signal_v1/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 🤖 AI Automated Trading System (Windows Server Edition)
|
||||
|
||||
이 프로젝트는 **Python, PyTorch (Deep Learning), Ollama (LLM)**을 활용하여 한국 주식 시장(KIS)에서 자동으로 매매를 수행하는 고성능 AI 트레이딩 봇입니다.
|
||||
**ProcessPoolExecutor 기반의 병렬 처리**와 **독립된 텔레그램 봇 프로세스**를 통해 높은 안정성과 응답 속도를 보장합니다.
|
||||
|
||||
## 🚀 Key Features
|
||||
|
||||
* **Multi-Process Architecture**: 메인 서버, 트레이딩 봇, 텔레그램 봇이 각각 독립된 프로세스로 실행되어 상호 간섭을 최소화합니다.
|
||||
* **Advanced AI Analysis**: **RTX 5070 Ti (16GB VRAM)** 하드웨어 가속을 활용한 **Attention-LSTM** 모델이 주가를 예측합니다.
|
||||
* **Process Management System**:
|
||||
* **Zombie Killer**: 서버 시작 시 이전에 종료되지 않은 좀비 프로세스를 자동으로 감지하고 제거합니다.
|
||||
* **PID Tracking**: 실행 중인 모든 프로세스의 ID를 `pids.txt` 파일에 실시간으로 기록하여 식별을 돕습니다.
|
||||
* **Reliable Telegram Bot**:
|
||||
* **HTML Parsing**: 마크다운 에러를 방지하기 위해 안정적인 HTML 포맷을 사용하여 메시지를 전송합니다.
|
||||
* **Interactive Commands**: `/status`, `/portfolio`, `/exec` 등 다양한 명령어로 봇을 실시간 제어할 수 있습니다.
|
||||
* **Auto-Recovery**: `ProcessPoolExecutor`의 워커 프로세스가 충돌(OOM 등)할 경우 자동으로 감지하고 재시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ System Architecture & Directory Structure
|
||||
|
||||
```plaintext
|
||||
/
|
||||
├── main_server.py # [Entry Point] 프로세스 매니저 및 FastAPI 서버
|
||||
├── pids.txt # [Runtime] 실행 중인 프로세스 ID 목록 (자동 관리)
|
||||
├── modules/
|
||||
│ ├── bot.py # [Core] 메인 트레이딩 봇 (스케줄러 & 상태 머신)
|
||||
│ ├── config.py # [Config] 환경 변수 및 상수 관리
|
||||
│ ├── analysis/ # [Brain] AI 분석 모듈
|
||||
│ │ ├── deep_learning.py # PyTorch 기반 Attention-LSTM 모델
|
||||
│ │ ├── technical.py # RSI, 볼린저밴드 등 보조지표 계산
|
||||
│ │ └── macro.py # 거시경제(환율, 유가, 지수) 분석
|
||||
│ ├── services/ # [I/O] 외부 서비스 연동
|
||||
│ │ ├── kis.py # 한국투자증권 API (Throttling 적용)
|
||||
│ │ ├── telegram_bot/ # [Independent] 독립 프로세스 텔레그램 봇
|
||||
│ │ ├── news.py # 네이버 뉴스 크롤링
|
||||
│ │ └── ollama.py # Local LLM (Llama 3) 인터페이스
|
||||
│ ├── strategy/ # [Logic] 매수/매도 의사결정 프로세스
|
||||
│ │ └── process.py # 워커 프로세스용 분석 함수 (병렬 처리)
|
||||
│ └── utils/ # [Util] 유틸리티
|
||||
│ ├── process_tracker.py # PID 추적 및 좀비 프로세스 정리
|
||||
│ ├── ipc.py # 프로세스 간 통신 (IPC)
|
||||
│ └── monitor.py # 시스템 리소스 모니터링
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 AI Learning Structure (Deep Learning)
|
||||
|
||||
본 시스템은 단순한 알고리즘 매매를 넘어, **Deep Learning**을 통해 시장의 패턴을 실시간으로 학습합니다.
|
||||
|
||||
### 1. Model: Attention-LSTM (High Capacity)
|
||||
* **Architecture**: LSTM(Long Short-Term Memory) + **Attention Mechanism**
|
||||
* **Input**: 최근 60일(약 3개월)간의 주가(종가) 시계열 데이터
|
||||
* **Core Logic**:
|
||||
* **Feature Extraction**: 4-Layer Stacked LSTM (Hidden Size: 512)이 시계열 특징 추출.
|
||||
* **Attention Layer**: 과거 60일 중 현재 예측에 가장 중요한 시점에 가중치를 부여.
|
||||
* **Adaptive Training**: 종목별로 매일 실시간 학습(Online Learning)을 수행하여 최신 트렌드 반영.
|
||||
|
||||
### 2. Hardware Acceleration (RTX 5070 Ti)
|
||||
* **CUDA Optimization**: PyTorch를 통해 GPU 가속 활성화.
|
||||
* **Specs**: Batch Size 64, Epochs 200, Precision FP32.
|
||||
* 서버 시작 시 `High Performance Mode`가 자동으로 감지 및 활성화됩니다.
|
||||
* **OOM Protection**: GPU 메모리 보호를 위해 병렬 워커 수를 2개로 제한하고, 워커 충돌 시 자동 재시작합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Usage & Troubleshooting
|
||||
|
||||
### 1. Installation
|
||||
```bash
|
||||
# Clone & Install
|
||||
git clone <repository-url>
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Start Server
|
||||
python main_server.py
|
||||
```
|
||||
|
||||
### 2. Process Management (`pids.txt`)
|
||||
서버가 실행되면 `pids.txt` 파일에 현재 실행 중인 프로세스 목록이 기록됩니다.
|
||||
```text
|
||||
58360: Main Server (Uvicorn Worker)
|
||||
72028: Trading Bot Main
|
||||
66488: Telegram Bot Standalone
|
||||
16372: Trading Bot Worker
|
||||
...
|
||||
```
|
||||
* **CPU 사용량이 비정상적으로 높을 때**: 작업 관리자나 `Get-Process python`으로 확인한 PID가 `pids.txt`에 없다면 **좀비 프로세스**입니다.
|
||||
* **자동 정리**: `main_server.py`를 다시 실행하면 시작 시 자동으로 좀비 프로세스를 찾아 종료합니다.
|
||||
|
||||
### 3. Telegram Commands
|
||||
* `/start`: 봇 시작 및 명령어 안내
|
||||
* `/status`: 현재 봇 상태, 시장 지수, AI 모델 상태 조회
|
||||
* `/portfolio`: 현재 보유 종목 및 수익률 조회
|
||||
* `/system`: CPU/GPU 사용량 및 프로세스 상태 확인
|
||||
* `/restart`: 봇 프로세스 재시작 (업데이트 반영 시 유용)
|
||||
* `/stop`: 봇 종료
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
본 소프트웨어는 투자를 보조하는 도구이며, 투자의 결과에 대한 책임은 전적으로 사용자에게 있습니다. AI의 예측은 100% 정확하지 않으며, 시장 상황에 따라 손실이 발생할 수 있습니다. 모의투자 환경에서 충분한 테스트 후 사용하시기 바랍니다.
|
||||
179
legacy/signal_v1/backtest_runner.py
Normal file
179
legacy/signal_v1/backtest_runner.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
실제 과거 데이터 기반 전 종목 백테스트 러너 (Task B)
|
||||
|
||||
목적:
|
||||
- 현재 watchlist의 모든 종목에 대해 KIS API로 일봉 OHLCV 수집
|
||||
- v3.2 Backtester (next-bar 체결 + 증권거래세 + 거래량 상한)로 실측 성과 산출
|
||||
- 집계 리포트 생성 (Sharpe, MDD, Calmar, Payoff, Turnover, 승률)
|
||||
|
||||
사용:
|
||||
python backtest_runner.py # watchlist 전체
|
||||
python backtest_runner.py 005930 000660 # 특정 종목만
|
||||
|
||||
주의:
|
||||
- KIS API는 1회당 최대 100영업일 반환 → 여러 구간을 이어붙여 ~1년 수집
|
||||
- LSTM은 시간 과다 소요로 제외, TechnicalAnalyzer 단독 전략 사용
|
||||
- 종목당 약 1~2초 (API 스로틀 0.5초/호출 × 3구간)
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from modules.services.kis import KISClient
|
||||
from modules.analysis.technical import TechnicalAnalyzer
|
||||
from modules.analysis.backtest import Backtester
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 전략: 기술적 점수 기반 BUY/SELL
|
||||
# ──────────────────────────────────────────────
|
||||
def technical_strategy(slice_data: dict, buy_th: float = 0.65, sell_th: float = 0.35) -> str:
|
||||
closes = slice_data.get("close", [])
|
||||
volumes = slice_data.get("volume", [])
|
||||
if len(closes) < 30:
|
||||
return "HOLD"
|
||||
try:
|
||||
score, *_ = TechnicalAnalyzer.get_technical_score(
|
||||
closes[-1], closes, volumes if volumes else None
|
||||
)
|
||||
except Exception:
|
||||
return "HOLD"
|
||||
if score >= buy_th:
|
||||
return "BUY"
|
||||
if score <= sell_th:
|
||||
return "SELL"
|
||||
return "HOLD"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# KIS OHLCV 다중 구간 수집 (~1년)
|
||||
# ──────────────────────────────────────────────
|
||||
def fetch_ohlcv_long(kis: KISClient, ticker: str, days: int = 240) -> dict | None:
|
||||
"""~1년(240영업일) 일봉 OHLCV 수집. API 한계(100일)를 여러 호출로 극복."""
|
||||
try:
|
||||
# 단순화: 100일짜리 한 번 + 추가로 count=250 요청 시도
|
||||
data = kis._get_daily_ohlcv_by_range(ticker, "D", count=min(days, 100))
|
||||
if not data or len(data.get("close", [])) < 60:
|
||||
return None
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"[{ticker}] OHLCV 수집 실패: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 메인
|
||||
# ──────────────────────────────────────────────
|
||||
def main():
|
||||
argv_tickers = sys.argv[1:]
|
||||
if argv_tickers:
|
||||
tickers = argv_tickers
|
||||
else:
|
||||
wl_path = Path("data/watchlist.json")
|
||||
if not wl_path.exists():
|
||||
print("data/watchlist.json 없음")
|
||||
return
|
||||
watchlist = json.loads(wl_path.read_text(encoding="utf-8"))
|
||||
tickers = list(watchlist.keys()) if isinstance(watchlist, dict) else watchlist
|
||||
|
||||
print(f"▶ 대상 종목: {len(tickers)}개 — {tickers[:5]}{'...' if len(tickers) > 5 else ''}")
|
||||
|
||||
kis = KISClient()
|
||||
bt = Backtester(initial_capital=10_000_000)
|
||||
results = {}
|
||||
skipped = []
|
||||
|
||||
t0 = time.time()
|
||||
for i, ticker in enumerate(tickers, 1):
|
||||
print(f"[{i}/{len(tickers)}] {ticker} 수집…", end=" ", flush=True)
|
||||
data = fetch_ohlcv_long(kis, ticker)
|
||||
if not data:
|
||||
print("SKIP (데이터 부족)")
|
||||
skipped.append(ticker)
|
||||
continue
|
||||
bars = len(data["close"])
|
||||
try:
|
||||
r = bt.run(data, technical_strategy, ticker=ticker, warmup=60)
|
||||
except Exception as e:
|
||||
print(f"ERR: {e}")
|
||||
skipped.append(ticker)
|
||||
continue
|
||||
results[ticker] = r
|
||||
print(f"bars={bars} trades={r.total_trades} ret={r.total_return_pct:+.1f}% "
|
||||
f"MDD={r.max_drawdown_pct:.1f}% Sharpe={r.sharpe_ratio:.2f}")
|
||||
|
||||
elapsed = time.time() - t0
|
||||
|
||||
# ── 집계 ──
|
||||
if not results:
|
||||
print("\n집계할 결과 없음.")
|
||||
return
|
||||
|
||||
import statistics
|
||||
rets = [r.total_return_pct for r in results.values()]
|
||||
sharpes = [r.sharpe_ratio for r in results.values() if r.total_trades > 0]
|
||||
mdds = [r.max_drawdown_pct for r in results.values()]
|
||||
wins = [r.win_rate for r in results.values() if r.total_trades > 0]
|
||||
trades_total = sum(r.total_trades for r in results.values())
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"📊 백테스트 집계 — {len(results)}종목 / {elapsed:.1f}s")
|
||||
print("=" * 60)
|
||||
print(f"평균 수익률: {statistics.mean(rets):+.2f}% "
|
||||
f"(중앙 {statistics.median(rets):+.2f}%)")
|
||||
print(f"평균 MDD: {statistics.mean(mdds):.2f}%")
|
||||
if sharpes:
|
||||
print(f"평균 Sharpe: {statistics.mean(sharpes):.3f}")
|
||||
if wins:
|
||||
print(f"평균 승률: {statistics.mean(wins):.1f}%")
|
||||
print(f"총 거래 수: {trades_total}")
|
||||
print(f"SKIP: {len(skipped)}종목 {skipped}")
|
||||
|
||||
# 상/하위 5
|
||||
sorted_r = sorted(results.items(), key=lambda kv: kv[1].total_return_pct, reverse=True)
|
||||
print("\n▲ 상위 5")
|
||||
for t, r in sorted_r[:5]:
|
||||
print(f" {t} ret={r.total_return_pct:+7.2f}% "
|
||||
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
|
||||
print("\n▼ 하위 5")
|
||||
for t, r in sorted_r[-5:]:
|
||||
print(f" {t} ret={r.total_return_pct:+7.2f}% "
|
||||
f"MDD={r.max_drawdown_pct:5.2f}% trades={r.total_trades}")
|
||||
|
||||
# 리포트 파일
|
||||
report = {
|
||||
"generated_at": datetime.now().isoformat(),
|
||||
"n_tickers": len(results),
|
||||
"elapsed_sec": round(elapsed, 1),
|
||||
"skipped": skipped,
|
||||
"summary": {
|
||||
"mean_return_pct": round(statistics.mean(rets), 2),
|
||||
"median_return_pct": round(statistics.median(rets), 2),
|
||||
"mean_mdd_pct": round(statistics.mean(mdds), 2),
|
||||
"mean_sharpe": round(statistics.mean(sharpes), 3) if sharpes else None,
|
||||
"mean_win_rate": round(statistics.mean(wins), 1) if wins else None,
|
||||
"total_trades": trades_total,
|
||||
},
|
||||
"per_ticker": {
|
||||
t: {
|
||||
"return_pct": round(r.total_return_pct, 2),
|
||||
"mdd_pct": round(r.max_drawdown_pct, 2),
|
||||
"sharpe": round(r.sharpe_ratio, 3),
|
||||
"calmar": round(r.calmar_ratio, 3),
|
||||
"payoff": round(r.payoff_ratio, 3),
|
||||
"turnover": round(r.turnover_ratio, 3),
|
||||
"win_rate": round(r.win_rate, 1),
|
||||
"trades": r.total_trades,
|
||||
} for t, r in results.items()
|
||||
},
|
||||
}
|
||||
out_path = Path("data/backtest_report.json")
|
||||
out_path.parent.mkdir(exist_ok=True)
|
||||
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"\n리포트 저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import uvicorn
|
||||
import multiprocessing
|
||||
from fastapi import FastAPI, Request
|
||||
445
legacy/signal_v1/modules/analysis/ai_council.py
Normal file
445
legacy/signal_v1/modules/analysis/ai_council.py
Normal file
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
AI 전문가 회의 시스템 (Multi-Agent Council)
|
||||
- 4명의 전문가 에이전트가 독립 분석 후 의장 AI가 최종 결정
|
||||
- 코스피 레짐 기반 모델 교체 권고
|
||||
- process.py 분석 결과를 입력받아 심층 검토 수행
|
||||
|
||||
흐름:
|
||||
전문가 1~4 (각 역할별 Ollama 호출)
|
||||
↓
|
||||
의장 AI (전문가 의견 취합 + 최종 결정 + 모델 건전성 평가)
|
||||
↓
|
||||
CouncilDecision (결정 + 모델 교체 권고 + 회의록)
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Any
|
||||
|
||||
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExpertOpinion:
|
||||
"""개별 전문가 의견"""
|
||||
expert_name: str
|
||||
role: str
|
||||
decision: str # BUY / SELL / HOLD
|
||||
confidence: float # 0~1
|
||||
reasoning: str
|
||||
key_concern: str
|
||||
model_feedback: str # 현재 AI 모델 적합성 평가
|
||||
|
||||
|
||||
@dataclass
|
||||
class CouncilDecision:
|
||||
"""회의 최종 결정"""
|
||||
final_decision: str # BUY / SELL / HOLD
|
||||
consensus_score: float # 0~1 (1 = 만장일치)
|
||||
confidence: float # 0~1
|
||||
majority_reasoning: str # 주요 결정 근거
|
||||
dissenting_views: str # 소수 의견
|
||||
model_health_score: float # 0~1 (현재 모델 신뢰도)
|
||||
model_replacement_recommended: bool # 모델 교체 필요 여부
|
||||
recommended_model: str # 교체 권고 모델명
|
||||
council_summary: str # 회의 전체 요약
|
||||
expert_opinions: List[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
# 전문가 페르소나 정의
|
||||
_EXPERTS = [
|
||||
{
|
||||
"name": "기술분석가",
|
||||
"role": "technical",
|
||||
"persona": (
|
||||
"20년 경력의 코스피 전문 기술분석가. "
|
||||
"RSI, MACD, 볼린저밴드, 추세선, 거래량 분석을 주로 사용. "
|
||||
"단기 가격 모멘텀과 지지/저항 구간을 중시함."
|
||||
),
|
||||
"focus": (
|
||||
"RSI 과매수/과매도, 볼린저밴드 위치, ADX 추세 강도, "
|
||||
"거래량 급증, 멀티타임프레임 정렬 여부를 핵심 근거로 사용하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "퀀트전문가",
|
||||
"role": "quant",
|
||||
"persona": (
|
||||
"AI/ML 기반 퀀트 투자 전문가. "
|
||||
"LSTM 예측 신뢰도, 통계적 유의성, 백테스트 성과를 중시. "
|
||||
"모델의 현재 시장 환경 적합성을 항상 평가함."
|
||||
),
|
||||
"focus": (
|
||||
"LSTM 신뢰도와 예측 방향을 중심으로 분석하세요. "
|
||||
"현재 코스피 레짐에서 LSTM v3 모델이 적합한지 반드시 평가하고, "
|
||||
"더 나은 대안 모델이 있으면 구체적으로 제안하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "리스크관리자",
|
||||
"role": "risk",
|
||||
"persona": (
|
||||
"글로벌 헤지펀드 리스크 관리 전문가. "
|
||||
"포지션 사이징, 최대 낙폭(MDD), VaR, 손절 기준을 최우선으로 고려. "
|
||||
"수익보다 손실 방어를 먼저 생각함."
|
||||
),
|
||||
"focus": (
|
||||
"변동성 대비 포지션 크기 적절성, 손절 기준 타당성, "
|
||||
"현재 보유 중이라면 추가 하락 리스크를 집중 평가하세요."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "거시경제분석가",
|
||||
"role": "macro",
|
||||
"persona": (
|
||||
"글로벌 매크로 및 한국 증시 전문가. "
|
||||
"코스피 지수 수준, 원/달러 환율, 미국 금리, 외국인 수급을 중시. "
|
||||
"현재 시장이 역사적으로 어떤 위치인지 판단함."
|
||||
),
|
||||
"focus": (
|
||||
"코스피 지수 현재 수준이 역사적으로 어떤 의미인지, "
|
||||
"이 가격대에서 매수/보유가 타당한지 거시경제 관점에서 평가하세요."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _build_expert_prompt(expert: dict, ticker: str, data: dict) -> str:
|
||||
"""전문가 역할에 맞는 분석 프롬프트 생성"""
|
||||
kospi = data.get("kospi_price", 2500)
|
||||
regime_label = MarketRegimeDetector.get_regime_label(kospi)
|
||||
|
||||
base = (
|
||||
f"종목: {ticker} | 현재가: {data.get('current_price', 0):,.0f}원\n"
|
||||
f"코스피: {kospi:.0f} [{regime_label}]\n"
|
||||
f"시장상태: {data.get('macro_state', 'SAFE')}\n"
|
||||
f"---기술지표---\n"
|
||||
f"기술점수: {data.get('tech_score', 0.5):.3f} | "
|
||||
f"RSI: {data.get('rsi', 50):.1f} | ADX: {data.get('adx', 20):.1f}\n"
|
||||
f"변동성: {data.get('volatility', 2.0):.2f}% | BB위치: {data.get('bb_zone', '중간')}\n"
|
||||
f"MTF정렬: {data.get('mtf_alignment', 'N/A')}\n"
|
||||
f"---AI모델---\n"
|
||||
f"LSTM예측: {data.get('lstm_predicted', 0):,.0f}원 "
|
||||
f"(변화율: {data.get('lstm_change_rate', 0):+.2f}%)\n"
|
||||
f"LSTM신뢰도: {data.get('ai_confidence', 0.5):.2f} | "
|
||||
f"LSTM점수: {data.get('lstm_score', 0.5):.3f}\n"
|
||||
f"---수급/감성---\n"
|
||||
f"감성점수: {data.get('sentiment_score', 0.5):.3f} | "
|
||||
f"수급점수: {data.get('investor_score', 0):.3f}\n"
|
||||
f"외인순매수: {data.get('frgn_net_buy', 0):+,} "
|
||||
f"({data.get('consecutive_frgn_buy', 0)}일 연속)\n"
|
||||
f"---포지션---\n"
|
||||
f"보유중: {data.get('is_holding', False)} | "
|
||||
f"보유수익률: {data.get('holding_yield', 0):+.2f}%\n"
|
||||
f"통합점수: {data.get('total_score', 0.5):.3f}\n"
|
||||
)
|
||||
|
||||
role_addition = (
|
||||
f"\n당신은 {expert['persona']}\n"
|
||||
f"분석 초점: {expert['focus']}\n"
|
||||
)
|
||||
|
||||
output_format = (
|
||||
"\n반드시 아래 JSON 형식으로만 응답하세요 (다른 텍스트 금지):\n"
|
||||
"{\n"
|
||||
' "decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||
' "confidence": 0.0~1.0,\n'
|
||||
' "reasoning": "주요 판단 근거 (1~2문장, 한국어)",\n'
|
||||
' "key_concern": "가장 우려되는 리스크 (1문장, 한국어)",\n'
|
||||
' "model_feedback": "현재 LSTM v3 모델이 이 시장 환경에서 적합한지 평가 (1문장)"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
return base + role_addition + output_format
|
||||
|
||||
|
||||
def _build_chairman_prompt(
|
||||
ticker: str,
|
||||
opinions: List[ExpertOpinion],
|
||||
data: dict,
|
||||
regime: RegimeAnalysis,
|
||||
) -> str:
|
||||
"""의장 AI 최종 결정 프롬프트"""
|
||||
opinions_text = "\n".join([
|
||||
f"[{op.expert_name}] {op.decision} (확신도: {op.confidence:.2f})\n"
|
||||
f" 근거: {op.reasoning}\n"
|
||||
f" 우려: {op.key_concern}\n"
|
||||
f" 모델평가: {op.model_feedback}"
|
||||
for op in opinions
|
||||
])
|
||||
|
||||
votes = [op.decision for op in opinions]
|
||||
buy_n = votes.count("BUY")
|
||||
sell_n = votes.count("SELL")
|
||||
hold_n = votes.count("HOLD")
|
||||
avg_conf = sum(op.confidence for op in opinions) / max(len(opinions), 1)
|
||||
|
||||
return (
|
||||
"당신은 AI 투자 전문가 회의를 주재하는 의장입니다.\n\n"
|
||||
f"=== 종목: {ticker} ===\n"
|
||||
f"현재가: {data.get('current_price', 0):,.0f}원 | "
|
||||
f"코스피: {data.get('kospi_price', 2500):.0f}\n"
|
||||
f"시장 레짐: {regime.regime.value} ({regime.description})\n"
|
||||
f"레짐 권고: {regime.model_recommendation}\n\n"
|
||||
f"=== 전문가 의견 ===\n{opinions_text}\n\n"
|
||||
f"=== 투표: 매수 {buy_n} / 매도 {sell_n} / 보유 {hold_n} "
|
||||
f"(평균 확신도: {avg_conf:.2f}) ===\n\n"
|
||||
"당신의 임무:\n"
|
||||
"1. 4명 의견을 종합하여 최종 매매 결정\n"
|
||||
f"2. LSTM v3 모델이 코스피 {data.get('kospi_price', 2500):.0f} 레짐에서 적합한지 평가\n"
|
||||
"3. 필요 시 대안 모델 구체적으로 권고\n\n"
|
||||
"반드시 아래 JSON 형식으로만 응답하세요:\n"
|
||||
"{\n"
|
||||
' "final_decision": "BUY" 또는 "SELL" 또는 "HOLD",\n'
|
||||
' "consensus_score": 0.0~1.0,\n'
|
||||
' "confidence": 0.0~1.0,\n'
|
||||
' "majority_reasoning": "최종 결정 근거 2~3문장 (한국어)",\n'
|
||||
' "dissenting_views": "소수 의견 요약 (없으면 빈 문자열)",\n'
|
||||
' "model_health_score": 0.0~1.0,\n'
|
||||
' "model_replacement_recommended": true 또는 false,\n'
|
||||
' "recommended_model": "교체 권고 모델명 (없으면 \'현재 모델 유지\')",\n'
|
||||
' "council_summary": "회의 전체 요약 3~4문장 (한국어)"\n'
|
||||
"}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_response(raw: Optional[str]) -> Optional[dict]:
|
||||
"""LLM 응답에서 JSON 추출 (폴백 포함)"""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
import re
|
||||
match = re.search(r'\{[\s\S]*\}', raw)
|
||||
if match:
|
||||
try:
|
||||
return json.loads(match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _vote_fallback(opinions: List[ExpertOpinion]) -> CouncilDecision:
|
||||
"""의장 AI 실패 시 단순 다수결 폴백"""
|
||||
from collections import Counter
|
||||
if not opinions:
|
||||
return CouncilDecision(
|
||||
final_decision="HOLD", consensus_score=0.5, confidence=0.5,
|
||||
majority_reasoning="분석 데이터 부족", dissenting_views="",
|
||||
model_health_score=0.5, model_replacement_recommended=False,
|
||||
recommended_model="현재 모델 유지",
|
||||
council_summary="전문가 의견 수집 실패로 HOLD 처리",
|
||||
)
|
||||
|
||||
votes = [op.decision for op in opinions]
|
||||
final = Counter(votes).most_common(1)[0][0]
|
||||
avg_conf = sum(op.confidence for op in opinions) / len(opinions)
|
||||
vote_counts = Counter(votes)
|
||||
consensus = vote_counts[final] / len(votes)
|
||||
|
||||
return CouncilDecision(
|
||||
final_decision=final,
|
||||
consensus_score=round(consensus, 3),
|
||||
confidence=round(avg_conf, 3),
|
||||
majority_reasoning=f"전문가 {vote_counts[final]}/{len(votes)} 다수결 결과",
|
||||
dissenting_views="",
|
||||
model_health_score=0.5,
|
||||
model_replacement_recommended=False,
|
||||
recommended_model="현재 모델 유지",
|
||||
council_summary="의장 AI 오류 - 전문가 투표로 대체",
|
||||
expert_opinions=[
|
||||
{"name": op.expert_name, "decision": op.decision,
|
||||
"confidence": op.confidence, "reasoning": op.reasoning}
|
||||
for op in opinions
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class AICouncil:
|
||||
"""
|
||||
AI 전문가 회의 시스템
|
||||
|
||||
사용 방법:
|
||||
council = AICouncil(llm_client)
|
||||
decision = council.convene(ticker, analysis_data, regime_analysis)
|
||||
|
||||
fast_mode=True 시 전문가 생략, 의장 AI 단독 판단 (속도 약 4배 향상)
|
||||
llm_client: GeminiLLMClient 또는 OllamaManager (request_inference 인터페이스 공용)
|
||||
"""
|
||||
|
||||
def __init__(self, llm_client: Any = None):
|
||||
self._ollama = llm_client # 내부 변수명 유지 (하위호환)
|
||||
|
||||
def _get_ollama(self) -> Any:
|
||||
if self._ollama is None:
|
||||
from modules.services.llm_client import get_llm_client
|
||||
self._ollama = get_llm_client()
|
||||
return self._ollama
|
||||
|
||||
def _ask_expert(self, expert: dict, ticker: str, data: dict) -> ExpertOpinion:
|
||||
"""단일 전문가 의견 수집"""
|
||||
prompt = _build_expert_prompt(expert, ticker, data)
|
||||
raw = self._get_ollama().request_inference(prompt)
|
||||
parsed = _parse_json_response(raw)
|
||||
|
||||
if parsed:
|
||||
return ExpertOpinion(
|
||||
expert_name=expert["name"],
|
||||
role=expert["role"],
|
||||
decision=str(parsed.get("decision", "HOLD")).upper(),
|
||||
confidence=float(parsed.get("confidence", 0.5)),
|
||||
reasoning=str(parsed.get("reasoning", "")),
|
||||
key_concern=str(parsed.get("key_concern", "")),
|
||||
model_feedback=str(parsed.get("model_feedback", "")),
|
||||
)
|
||||
|
||||
# 파싱 실패 → 중립
|
||||
print(f"[Council] {expert['name']} 응답 파싱 실패 → HOLD 처리")
|
||||
return ExpertOpinion(
|
||||
expert_name=expert["name"],
|
||||
role=expert["role"],
|
||||
decision="HOLD",
|
||||
confidence=0.5,
|
||||
reasoning="응답 파싱 실패",
|
||||
key_concern="",
|
||||
model_feedback="",
|
||||
)
|
||||
|
||||
def convene(
|
||||
self,
|
||||
ticker: str,
|
||||
analysis_data: dict,
|
||||
regime_analysis: Optional[RegimeAnalysis] = None,
|
||||
fast_mode: bool = True,
|
||||
) -> CouncilDecision:
|
||||
"""
|
||||
전문가 회의 소집 및 최종 결정
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드
|
||||
analysis_data: process.py 분석 결과 딕셔너리
|
||||
regime_analysis: MarketRegimeDetector.detect() 결과
|
||||
fast_mode: True=의장 AI 단독(빠름), False=전문가 4명+의장(심층)
|
||||
|
||||
Returns:
|
||||
CouncilDecision
|
||||
"""
|
||||
# 레짐 기본값
|
||||
if regime_analysis is None:
|
||||
kospi = analysis_data.get("kospi_price", 2500)
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi)
|
||||
|
||||
expert_opinions: List[ExpertOpinion] = []
|
||||
|
||||
if not fast_mode:
|
||||
print(f"[Council] {ticker} - 전문가 회의 시작 (4명)")
|
||||
for expert in _EXPERTS:
|
||||
print(f"[Council] {expert['name']} 분석 중...")
|
||||
opinion = self._ask_expert(expert, ticker, analysis_data)
|
||||
expert_opinions.append(opinion)
|
||||
time.sleep(0.3) # Ollama 연속 요청 간격
|
||||
else:
|
||||
print(f"[Council] {ticker} - Fast mode (의장 단독)")
|
||||
|
||||
# 의장 AI 취합
|
||||
chairman_prompt = _build_chairman_prompt(
|
||||
ticker, expert_opinions, analysis_data, regime_analysis
|
||||
)
|
||||
raw_chairman = self._get_ollama().request_inference(chairman_prompt)
|
||||
parsed_chairman = _parse_json_response(raw_chairman)
|
||||
|
||||
if parsed_chairman:
|
||||
decision = CouncilDecision(
|
||||
final_decision=str(parsed_chairman.get("final_decision", "HOLD")).upper(),
|
||||
consensus_score=float(parsed_chairman.get("consensus_score", 0.5)),
|
||||
confidence=float(parsed_chairman.get("confidence", 0.5)),
|
||||
majority_reasoning=str(parsed_chairman.get("majority_reasoning", "")),
|
||||
dissenting_views=str(parsed_chairman.get("dissenting_views", "")),
|
||||
model_health_score=float(parsed_chairman.get("model_health_score", 0.7)),
|
||||
model_replacement_recommended=bool(
|
||||
parsed_chairman.get("model_replacement_recommended", False)
|
||||
),
|
||||
recommended_model=str(
|
||||
parsed_chairman.get("recommended_model", "현재 모델 유지")
|
||||
),
|
||||
council_summary=str(parsed_chairman.get("council_summary", "")),
|
||||
expert_opinions=[
|
||||
{
|
||||
"name": op.expert_name,
|
||||
"decision": op.decision,
|
||||
"confidence": op.confidence,
|
||||
"reasoning": op.reasoning,
|
||||
}
|
||||
for op in expert_opinions
|
||||
],
|
||||
)
|
||||
|
||||
status_icon = "⚠️" if decision.model_replacement_recommended else "✅"
|
||||
print(
|
||||
f"[Council] {ticker} → {decision.final_decision} "
|
||||
f"(합의율: {decision.consensus_score:.0%}, "
|
||||
f"모델건전성: {decision.model_health_score:.0%}) "
|
||||
f"{status_icon}"
|
||||
)
|
||||
if decision.model_replacement_recommended:
|
||||
print(f"[Council] 모델 교체 권고: {decision.recommended_model}")
|
||||
|
||||
return decision
|
||||
|
||||
# 의장 실패 → 투표 폴백
|
||||
print(f"[Council] {ticker} - 의장 AI 실패, 투표 폴백 사용")
|
||||
return _vote_fallback(expert_opinions)
|
||||
|
||||
def quick_validate(
|
||||
self,
|
||||
ticker: str,
|
||||
kospi_price: float,
|
||||
ai_confidence: float,
|
||||
backtest_sharpe: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
LLM 호출 없이 규칙 기반 빠른 모델 검증
|
||||
|
||||
Returns:
|
||||
{
|
||||
"regime": str,
|
||||
"model_ok": bool,
|
||||
"score": float,
|
||||
"recommendation": str,
|
||||
"should_replace": bool,
|
||||
}
|
||||
"""
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||
validation = MarketRegimeDetector.validate_model_for_regime(
|
||||
regime_analysis.regime,
|
||||
backtest_sharpe=backtest_sharpe,
|
||||
)
|
||||
|
||||
# AI 신뢰도 하락 시 추가 감점
|
||||
score = validation["confidence_score"]
|
||||
if ai_confidence < 0.4:
|
||||
score *= 0.8
|
||||
|
||||
return {
|
||||
"regime": regime_analysis.regime.value,
|
||||
"regime_description": regime_analysis.description,
|
||||
"model_ok": score >= 0.5 and not validation["should_replace"],
|
||||
"score": round(score, 3),
|
||||
"recommendation": validation["recommendation"],
|
||||
"should_replace": validation["should_replace"],
|
||||
"alternative_models": validation["alternative_models"],
|
||||
}
|
||||
|
||||
|
||||
# 전역 싱글톤
|
||||
_council_instance: Optional[AICouncil] = None
|
||||
|
||||
|
||||
def get_council(llm_client: Any = None) -> AICouncil:
|
||||
"""워커 프로세스 내 AICouncil 싱글톤 반환 (GeminiLLMClient 또는 OllamaManager 수용)"""
|
||||
global _council_instance
|
||||
if _council_instance is None:
|
||||
_council_instance = AICouncil(llm_client)
|
||||
return _council_instance
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
백테스팅 프레임워크 (Phase 3-1)
|
||||
- 과거 OHLCV 데이터로 전략 시뮬레이션
|
||||
- 성과지표: Sharpe ratio, MDD, 승률, 평균손익비
|
||||
- Phase 2 모델 변경 전후 비교 검증용
|
||||
백테스팅 프레임워크 (v3.2 — Realism 보강)
|
||||
|
||||
개선 사항 (v3.2):
|
||||
1. 다음 봉 시가 체결 옵션 (look-ahead bias 제거)
|
||||
2. 증권거래세 (매도 시 0.2%, 수수료와 별개 부과)
|
||||
3. 거래량 기반 부분 체결 (한 봉 거래량의 N% 상한)
|
||||
4. Calmar, Payoff, Turnover 지표 추가
|
||||
"""
|
||||
|
||||
import json
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Callable
|
||||
@@ -14,12 +16,12 @@ from typing import Dict, List, Optional, Callable
|
||||
@dataclass
|
||||
class Trade:
|
||||
ticker: str
|
||||
entry_date: int # 데이터 인덱스
|
||||
entry_date: int
|
||||
entry_price: float
|
||||
exit_date: int
|
||||
exit_price: float
|
||||
qty: int
|
||||
direction: str = "LONG" # LONG / SHORT
|
||||
direction: str = "LONG"
|
||||
|
||||
@property
|
||||
def pnl(self):
|
||||
@@ -44,20 +46,26 @@ class BacktestResult:
|
||||
total_trades: int
|
||||
winning_trades: int
|
||||
losing_trades: int
|
||||
calmar_ratio: float = 0.0
|
||||
payoff_ratio: float = 0.0 # 평균수익 / |평균손실|
|
||||
turnover_ratio: float = 0.0 # 총 매매대금 / 초기자본
|
||||
trades: List[Trade] = field(default_factory=list)
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
"=" * 50,
|
||||
"📊 백테스팅 결과",
|
||||
"📊 백테스팅 결과 (v3.2)",
|
||||
"=" * 50,
|
||||
f"총 수익률: {self.total_return_pct:+.2f}%",
|
||||
f"Sharpe Ratio: {self.sharpe_ratio:.3f}",
|
||||
f"Calmar Ratio: {self.calmar_ratio:.3f}",
|
||||
f"Max Drawdown: {self.max_drawdown_pct:.2f}%",
|
||||
f"승률: {self.win_rate:.1f}% ({self.winning_trades}/{self.total_trades})",
|
||||
f"평균 수익: {self.avg_win_pct:+.2f}%",
|
||||
f"평균 손실: {self.avg_loss_pct:.2f}%",
|
||||
f"손익비(PF): {self.profit_factor:.2f}",
|
||||
f"Payoff Ratio: {self.payoff_ratio:.2f}",
|
||||
f"Turnover: {self.turnover_ratio:.2f}x",
|
||||
"=" * 50,
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -65,40 +73,37 @@ class BacktestResult:
|
||||
|
||||
class Backtester:
|
||||
"""
|
||||
OHLCV 기반 전략 백테스터
|
||||
OHLCV 기반 전략 백테스터.
|
||||
|
||||
사용 예시:
|
||||
bt = Backtester(initial_capital=10_000_000)
|
||||
result = bt.run(
|
||||
ohlcv_data={"close": [...], "high": [...], "low": [...], "volume": [...]},
|
||||
strategy_fn=my_strategy,
|
||||
ticker="005930"
|
||||
)
|
||||
print(result.summary())
|
||||
체결 모델 (v3.2):
|
||||
- next_bar_open=True: 신호 발생 다음 봉 시가로 체결 (look-ahead 제거)
|
||||
- slippage: 체결가에 ±slippage_rate 적용
|
||||
- commission_rate: 매수/매도 양쪽에 부과 (증권사 수수료)
|
||||
- sell_tax_rate: 매도 시에만 부과 (증권거래세 0.2%)
|
||||
- max_volume_participation: 봉 거래량의 N% 이하로 체결 제한
|
||||
"""
|
||||
|
||||
def __init__(self, initial_capital: float = 10_000_000,
|
||||
commission_rate: float = 0.00015, # 0.015% (증권사 기본)
|
||||
slippage_rate: float = 0.001): # 0.1% 슬리피지
|
||||
def __init__(self,
|
||||
initial_capital: float = 10_000_000,
|
||||
commission_rate: float = 0.00015,
|
||||
slippage_rate: float = 0.001,
|
||||
sell_tax_rate: float = 0.002,
|
||||
next_bar_open: bool = True,
|
||||
max_volume_participation: float = 0.01):
|
||||
self.initial_capital = initial_capital
|
||||
self.commission_rate = commission_rate
|
||||
self.slippage_rate = slippage_rate
|
||||
self.sell_tax_rate = sell_tax_rate
|
||||
self.next_bar_open = next_bar_open
|
||||
self.max_volume_participation = max_volume_participation
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 단일 종목
|
||||
# ──────────────────────────────────────────────
|
||||
def run(self, ohlcv_data: Dict, strategy_fn: Callable,
|
||||
ticker: str = "UNKNOWN", warmup: int = 60) -> BacktestResult:
|
||||
"""
|
||||
단일 종목 백테스팅
|
||||
|
||||
Args:
|
||||
ohlcv_data: {'close':[], 'high':[], 'low':[], 'open':[], 'volume':[]}
|
||||
strategy_fn: (ohlcv_slice: dict) -> str ("BUY" | "SELL" | "HOLD")
|
||||
ticker: 종목 코드
|
||||
warmup: 초기 웜업 기간 (기술지표 안정화)
|
||||
|
||||
Returns:
|
||||
BacktestResult
|
||||
"""
|
||||
closes = np.array(ohlcv_data.get('close', []), dtype=float)
|
||||
opens = np.array(ohlcv_data.get('open', closes), dtype=float)
|
||||
highs = np.array(ohlcv_data.get('high', closes), dtype=float)
|
||||
lows = np.array(ohlcv_data.get('low', closes), dtype=float)
|
||||
volumes = np.array(ohlcv_data.get('volume', np.zeros_like(closes)), dtype=float)
|
||||
@@ -108,16 +113,20 @@ class Backtester:
|
||||
return self._empty_result()
|
||||
|
||||
capital = self.initial_capital
|
||||
position = 0 # 보유 수량
|
||||
position = 0
|
||||
entry_price = 0.0
|
||||
entry_idx = 0
|
||||
equity_curve = [capital]
|
||||
trades: List[Trade] = []
|
||||
total_turnover = 0.0 # 누적 매매대금
|
||||
|
||||
for i in range(warmup, n):
|
||||
# 전략 함수에 현재까지의 슬라이스 전달
|
||||
# 마지막 인덱스는 next-bar 체결 시 여유 필요
|
||||
last_signal_idx = n - 2 if self.next_bar_open else n - 1
|
||||
|
||||
for i in range(warmup, last_signal_idx + 1):
|
||||
slice_data = {
|
||||
'close': closes[:i+1].tolist(),
|
||||
'open': opens[:i+1].tolist(),
|
||||
'high': highs[:i+1].tolist(),
|
||||
'low': lows[:i+1].tolist(),
|
||||
'volume': volumes[:i+1].tolist(),
|
||||
@@ -128,43 +137,58 @@ class Backtester:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
price = closes[i]
|
||||
buy_price = price * (1 + self.slippage_rate) # 슬리피지 포함 매수가
|
||||
sell_price = price * (1 - self.slippage_rate) # 슬리피지 포함 매도가
|
||||
# 체결가 산출 — next_bar_open이면 i+1 시가, 아니면 i 종가
|
||||
fill_idx = i + 1 if self.next_bar_open and i + 1 < n else i
|
||||
base_price = opens[fill_idx] if self.next_bar_open else closes[fill_idx]
|
||||
fill_volume = volumes[fill_idx]
|
||||
|
||||
buy_price = base_price * (1 + self.slippage_rate)
|
||||
sell_price = base_price * (1 - self.slippage_rate)
|
||||
|
||||
if signal == "BUY" and position == 0:
|
||||
# 전액 투자 (수수료 포함)
|
||||
qty = int(capital / (buy_price * (1 + self.commission_rate)))
|
||||
# 전액 투자 (수수료 포함 총비용 기준)
|
||||
raw_qty = int(capital / (buy_price * (1 + self.commission_rate)))
|
||||
# 거래량 상한 — 봉 거래량의 N%까지만 체결
|
||||
vol_cap = int(fill_volume * self.max_volume_participation)
|
||||
qty = min(raw_qty, vol_cap) if vol_cap > 0 else raw_qty
|
||||
if qty > 0:
|
||||
cost = qty * buy_price * (1 + self.commission_rate)
|
||||
capital -= cost
|
||||
position = qty
|
||||
entry_price = buy_price
|
||||
entry_idx = i
|
||||
entry_idx = fill_idx
|
||||
total_turnover += qty * buy_price
|
||||
|
||||
elif signal == "SELL" and position > 0:
|
||||
proceeds = position * sell_price * (1 - self.commission_rate)
|
||||
# 매도: 수수료 + 증권거래세
|
||||
sell_cost_rate = self.commission_rate + self.sell_tax_rate
|
||||
vol_cap = int(fill_volume * self.max_volume_participation) if fill_volume > 0 else position
|
||||
exec_qty = min(position, vol_cap) if vol_cap > 0 else position
|
||||
proceeds = exec_qty * sell_price * (1 - sell_cost_rate)
|
||||
capital += proceeds
|
||||
total_turnover += exec_qty * sell_price
|
||||
trades.append(Trade(
|
||||
ticker=ticker,
|
||||
entry_date=entry_idx,
|
||||
entry_price=entry_price,
|
||||
exit_date=i,
|
||||
exit_date=fill_idx,
|
||||
exit_price=sell_price,
|
||||
qty=position
|
||||
qty=exec_qty
|
||||
))
|
||||
position = 0
|
||||
entry_price = 0.0
|
||||
position -= exec_qty
|
||||
if position == 0:
|
||||
entry_price = 0.0
|
||||
|
||||
# 자산 추적
|
||||
current_equity = capital + (position * closes[i] if position > 0 else 0)
|
||||
equity_curve.append(current_equity)
|
||||
|
||||
# 미청산 포지션 강제 종료
|
||||
# 미청산 포지션: 마지막 종가 기준 강제 청산 (수수료+세금 반영)
|
||||
if position > 0:
|
||||
last_price = closes[-1] * (1 - self.slippage_rate)
|
||||
proceeds = position * last_price * (1 - self.commission_rate)
|
||||
sell_cost_rate = self.commission_rate + self.sell_tax_rate
|
||||
proceeds = position * last_price * (1 - sell_cost_rate)
|
||||
capital += proceeds
|
||||
total_turnover += position * last_price
|
||||
trades.append(Trade(
|
||||
ticker=ticker,
|
||||
entry_date=entry_idx,
|
||||
@@ -174,45 +198,46 @@ class Backtester:
|
||||
qty=position
|
||||
))
|
||||
equity_curve[-1] = capital
|
||||
position = 0
|
||||
|
||||
return self._compute_metrics(equity_curve, trades)
|
||||
return self._compute_metrics(equity_curve, trades, total_turnover)
|
||||
|
||||
def run_multi(self, ohlcv_dict: Dict[str, Dict], strategy_fn: Callable,
|
||||
warmup: int = 60) -> Dict[str, BacktestResult]:
|
||||
"""여러 종목 백테스팅"""
|
||||
results = {}
|
||||
for ticker, ohlcv_data in ohlcv_dict.items():
|
||||
results[ticker] = self.run(ohlcv_data, strategy_fn, ticker, warmup)
|
||||
return results
|
||||
return {t: self.run(d, strategy_fn, t, warmup) for t, d in ohlcv_dict.items()}
|
||||
|
||||
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade]) -> BacktestResult:
|
||||
# ──────────────────────────────────────────────
|
||||
# 지표 계산
|
||||
# ──────────────────────────────────────────────
|
||||
def _compute_metrics(self, equity_curve: List[float], trades: List[Trade],
|
||||
total_turnover: float) -> BacktestResult:
|
||||
equity = np.array(equity_curve, dtype=float)
|
||||
total_return_pct = (equity[-1] / equity[0] - 1) * 100
|
||||
|
||||
# Sharpe Ratio (일별 수익률 기준, 연율화)
|
||||
daily_returns = np.diff(equity) / equity[:-1]
|
||||
if daily_returns.std() > 0:
|
||||
sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252)
|
||||
else:
|
||||
sharpe = 0.0
|
||||
daily_returns = np.diff(equity) / (equity[:-1] + 1e-9)
|
||||
sharpe = (daily_returns.mean() / daily_returns.std()) * np.sqrt(252) \
|
||||
if daily_returns.std() > 0 else 0.0
|
||||
|
||||
# Max Drawdown
|
||||
peak = np.maximum.accumulate(equity)
|
||||
drawdowns = (equity - peak) / (peak + 1e-9) * 100
|
||||
max_drawdown = abs(drawdowns.min())
|
||||
|
||||
# 승률 / 손익비
|
||||
wins = [t for t in trades if t.pnl_pct > 0]
|
||||
losses = [t for t in trades if t.pnl_pct <= 0]
|
||||
|
||||
win_rate = len(wins) / len(trades) * 100 if trades else 0
|
||||
avg_win = np.mean([t.pnl_pct for t in wins]) if wins else 0
|
||||
avg_loss = np.mean([t.pnl_pct for t in losses]) if losses else 0
|
||||
avg_win = float(np.mean([t.pnl_pct for t in wins])) if wins else 0.0
|
||||
avg_loss = float(np.mean([t.pnl_pct for t in losses])) if losses else 0.0
|
||||
|
||||
total_win = sum(t.pnl for t in wins)
|
||||
total_loss = abs(sum(t.pnl for t in losses))
|
||||
profit_factor = total_win / (total_loss + 1e-9)
|
||||
|
||||
# 신규 지표
|
||||
calmar = (total_return_pct / max_drawdown) if max_drawdown > 0 else 0.0
|
||||
payoff = (avg_win / abs(avg_loss)) if avg_loss != 0 else 0.0
|
||||
turnover_ratio = total_turnover / (self.initial_capital + 1e-9)
|
||||
|
||||
return BacktestResult(
|
||||
total_return_pct=round(total_return_pct, 2),
|
||||
sharpe_ratio=round(sharpe, 3),
|
||||
@@ -224,7 +249,10 @@ class Backtester:
|
||||
total_trades=len(trades),
|
||||
winning_trades=len(wins),
|
||||
losing_trades=len(losses),
|
||||
trades=trades
|
||||
calmar_ratio=round(calmar, 3),
|
||||
payoff_ratio=round(payoff, 3),
|
||||
turnover_ratio=round(turnover_ratio, 3),
|
||||
trades=trades,
|
||||
)
|
||||
|
||||
def _empty_result(self) -> BacktestResult:
|
||||
@@ -237,15 +265,6 @@ class Backtester:
|
||||
|
||||
def compare_strategies(ohlcv_data: Dict, strategies: Dict[str, Callable],
|
||||
initial_capital: float = 10_000_000) -> Dict[str, BacktestResult]:
|
||||
"""
|
||||
여러 전략 동시 비교
|
||||
|
||||
Args:
|
||||
strategies: {"전략명": strategy_fn, ...}
|
||||
|
||||
Returns:
|
||||
{"전략명": BacktestResult, ...}
|
||||
"""
|
||||
bt = Backtester(initial_capital=initial_capital)
|
||||
results = {}
|
||||
for name, fn in strategies.items():
|
||||
@@ -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,
|
||||
416
legacy/signal_v1/modules/analysis/ensemble.py
Normal file
416
legacy/signal_v1/modules/analysis/ensemble.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
앙상블 예측 모듈 (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
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalWeights:
|
||||
"""앙상블 가중치"""
|
||||
tech: float = 0.35
|
||||
sentiment: float = 0.30
|
||||
lstm: float = 0.35
|
||||
|
||||
# 각 신호의 허용 범위
|
||||
MIN_WEIGHT = 0.10
|
||||
MAX_WEIGHT = 0.65
|
||||
|
||||
def normalize(self):
|
||||
"""
|
||||
경계 보존 정규화 (합=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):
|
||||
return {"tech": self.tech, "sentiment": self.sentiment, "lstm": self.lstm}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
return cls(tech=d.get("tech", 0.35),
|
||||
sentiment=d.get("sentiment", 0.30),
|
||||
lstm=d.get("lstm", 0.35))
|
||||
|
||||
|
||||
class AdaptiveEnsemble:
|
||||
"""
|
||||
적응형 앙상블 가중치 관리자
|
||||
|
||||
핵심 로직:
|
||||
1. 종목별 최근 N 매매의 결과를 추적
|
||||
2. 어떤 신호가 정확했는지 소급 평가 (크기 가중 정확도)
|
||||
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
|
||||
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
|
||||
5. Kelly Criterion 기반 최적 포지션 비중 제공
|
||||
6. 파일 mtime 기반 cross-process 동기화 (워커 프로세스 갱신)
|
||||
"""
|
||||
|
||||
def __init__(self, history_file=None, max_history=50):
|
||||
self.max_history = max_history
|
||||
self.history_file = history_file or os.path.join(
|
||||
Config.DATA_DIR, "ensemble_history.json"
|
||||
)
|
||||
# {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" | "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),
|
||||
}
|
||||
self._load_mtime: float = 0.0 # 마지막 파일 로드 시각
|
||||
self._load()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 파일 I/O
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(self.history_file):
|
||||
try:
|
||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self._trade_history = data.get("history", {})
|
||||
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}")
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
data = {
|
||||
"history": {k: v[-self.max_history:] for k, v in self._trade_history.items()},
|
||||
"weights": {ctx: w.to_dict() for ctx, w in self._context_weights.items()}
|
||||
}
|
||||
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":
|
||||
return "danger"
|
||||
if adx >= 25:
|
||||
return "strong_trend"
|
||||
if adx < 20:
|
||||
return "sideways"
|
||||
return "default"
|
||||
|
||||
def get_weights(self, ticker: str, adx: float = 20.0,
|
||||
macro_state: str = "SAFE",
|
||||
ai_confidence: float = 0.5) -> SignalWeights:
|
||||
"""
|
||||
종목 + 시장 컨텍스트에 맞는 가중치 반환
|
||||
|
||||
1. 컨텍스트별 기준 가중치 선택
|
||||
2. AI 신뢰도 높으면 lstm 가중치 보정
|
||||
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:
|
||||
recent = ticker_history[-10:]
|
||||
# _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])
|
||||
|
||||
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 신뢰도 보정 (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.75)
|
||||
|
||||
return adjusted.normalize()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 앙상블 점수
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
def compute_ensemble_score(self, tech_score: float, sentiment_score: float,
|
||||
lstm_score: float, investor_score: float = 0.0,
|
||||
weights: Optional[SignalWeights] = None) -> float:
|
||||
"""
|
||||
앙상블 통합 점수 계산
|
||||
|
||||
Args:
|
||||
weights: 가중치 (None이면 기본값 사용)
|
||||
"""
|
||||
if weights is None:
|
||||
weights = SignalWeights()
|
||||
|
||||
total = (weights.tech * tech_score
|
||||
+ weights.sentiment * sentiment_score
|
||||
+ weights.lstm * lstm_score)
|
||||
|
||||
# 수급 가산점 (최대 +0.15)
|
||||
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_weighted(scores: list, outcomes: list) -> float:
|
||||
"""
|
||||
신호-결과 크기 가중 정확도 (0.0~1.0, 0.5=무관)
|
||||
|
||||
- 단순 방향 일치(0/1)가 아닌 수익률 절댓값으로 가중
|
||||
- 큰 손실 예측 실패는 작은 이익 예측 성공보다 강하게 패널티
|
||||
"""
|
||||
if len(scores) < 3:
|
||||
return 0.5
|
||||
|
||||
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()
|
||||
return _ensemble_instance
|
||||
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from modules.services.kis import KISClient
|
||||
|
||||
@@ -130,7 +131,7 @@ class MacroAnalyzer:
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 테스트를 위한 코드
|
||||
load_dotenv()
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent / ".env")
|
||||
|
||||
# 환경변수 로딩 및 클라이언트 초기화
|
||||
if os.getenv("KIS_ENV_TYPE") == "real":
|
||||
279
legacy/signal_v1/modules/analysis/market_regime.py
Normal file
279
legacy/signal_v1/modules/analysis/market_regime.py
Normal file
@@ -0,0 +1,279 @@
|
||||
"""
|
||||
시장 레짐 감지 모듈
|
||||
- 코스피 지수 수준에 따른 시장 레짐 분류
|
||||
- 코스피 6300 목표 수준에서의 모델 적합성 평가
|
||||
- 레짐별 전략 파라미터 자동 조정
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
class MarketRegime(Enum):
|
||||
BULL_EXTREME = "bull_extreme" # 코스피 5000+ (역사적 극고점, 6300 시나리오)
|
||||
BULL_STRONG = "bull_strong" # 코스피 3500~5000 (강한 상승장)
|
||||
BULL_NORMAL = "bull_normal" # 코스피 2500~3500 (정상 상승장)
|
||||
SIDEWAYS = "sideways" # 코스피 2000~2500 (횡보)
|
||||
BEAR_MILD = "bear_mild" # 코스피 1500~2000 (약세)
|
||||
BEAR_SEVERE = "bear_severe" # 코스피 1500 미만 (심각한 약세)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegimeAnalysis:
|
||||
"""레짐 분석 결과"""
|
||||
regime: MarketRegime
|
||||
kospi_level: float
|
||||
description: str
|
||||
recommended_strategy: str
|
||||
buy_threshold_adj: float # 매수 임계값 조정치 (+: 더 엄격, -: 완화)
|
||||
position_size_adj: float # 포지션 크기 조정 배수 (1.0 = 기본)
|
||||
lstm_weight_adj: float # LSTM 앙상블 가중치 조정 (+0.1 = 10% 증가)
|
||||
model_recommendation: str # 모델 유지/교체 권고
|
||||
risk_level: str # LOW / MEDIUM / HIGH / EXTREME
|
||||
|
||||
|
||||
class MarketRegimeDetector:
|
||||
"""
|
||||
코스피 지수 수준 기반 시장 레짐 감지기
|
||||
|
||||
코스피 6300 시나리오:
|
||||
- 현재 한국 증시 역대 최고점(2021년 3300) 대비 약 2배 수준
|
||||
- BULL_EXTREME 레짐에 해당 → LSTM 단독 의존 지양, Transformer/Mamba 검토 필요
|
||||
- 추세 추종 강화 + 고점 리스크 관리 병행
|
||||
"""
|
||||
|
||||
# 레짐별 상세 파라미터
|
||||
_REGIME_PARAMS: Dict[MarketRegime, dict] = {
|
||||
MarketRegime.BULL_EXTREME: {
|
||||
"description": "코스피 극강세장 5000+ (6300 시나리오)",
|
||||
"recommended_strategy": (
|
||||
"추세 추종 극대화, 트레일링 스탑 확대(ATR×4), "
|
||||
"고점 과열 구간으로 포지션 축소 병행"
|
||||
),
|
||||
"buy_threshold_adj": -0.04, # 강세 모멘텀 → 진입 소폭 완화
|
||||
"position_size_adj": 0.75, # 고점 리스크로 포지션 축소
|
||||
"lstm_weight_adj": -0.12, # LSTM 비중 축소 (비선형 가격 동작)
|
||||
"model_recommendation": (
|
||||
"Temporal Fusion Transformer(TFT) 또는 Mamba(SSM) 교체 권고 - "
|
||||
"LSTM은 극강세 과열 구간에서 비선형 가격 동작 포착 한계"
|
||||
),
|
||||
"risk_level": "EXTREME",
|
||||
},
|
||||
MarketRegime.BULL_STRONG: {
|
||||
"description": "코스피 강상승장 3500~5000",
|
||||
"recommended_strategy": "추세 추종, 모멘텀 강화, 손절 완화(ATR×2.5)",
|
||||
"buy_threshold_adj": -0.03,
|
||||
"position_size_adj": 1.1,
|
||||
"lstm_weight_adj": 0.05,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 성능 모니터링 유지",
|
||||
"risk_level": "MEDIUM",
|
||||
},
|
||||
MarketRegime.BULL_NORMAL: {
|
||||
"description": "코스피 정상 상승장 2500~3500",
|
||||
"recommended_strategy": "기본 전략 유지 (기술+LSTM+LLM 균형)",
|
||||
"buy_threshold_adj": 0.0,
|
||||
"position_size_adj": 1.0,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "현재 LSTM v3 최적 환경",
|
||||
"risk_level": "LOW",
|
||||
},
|
||||
MarketRegime.SIDEWAYS: {
|
||||
"description": "코스피 횡보장 2000~2500",
|
||||
"recommended_strategy": "박스권 매매, LLM 감성 비중 확대, 빠른 익절",
|
||||
"buy_threshold_adj": 0.03,
|
||||
"position_size_adj": 0.85,
|
||||
"lstm_weight_adj": -0.05,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 감성 분석 가중치 강화",
|
||||
"risk_level": "LOW",
|
||||
},
|
||||
MarketRegime.BEAR_MILD: {
|
||||
"description": "코스피 약세장 1500~2000",
|
||||
"recommended_strategy": "현금 비중 확대(50%+), 방어주 선별 매수",
|
||||
"buy_threshold_adj": 0.08,
|
||||
"position_size_adj": 0.5,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "현재 LSTM v3 적합 - 리스크 관리 파라미터 강화",
|
||||
"risk_level": "HIGH",
|
||||
},
|
||||
MarketRegime.BEAR_SEVERE: {
|
||||
"description": "코스피 극약세장 1500 미만",
|
||||
"recommended_strategy": "전면 현금화, 매수 중단",
|
||||
"buy_threshold_adj": 0.20,
|
||||
"position_size_adj": 0.2,
|
||||
"lstm_weight_adj": 0.0,
|
||||
"model_recommendation": "매크로 팩터 기반 방어 모델 전환 필요",
|
||||
"risk_level": "EXTREME",
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def detect(
|
||||
cls,
|
||||
kospi_price: float,
|
||||
kospi_change_pct: float = 0.0,
|
||||
volatility_20d: float = 0.0,
|
||||
) -> RegimeAnalysis:
|
||||
"""
|
||||
코스피 지수 수준 + 변동성으로 시장 레짐 감지
|
||||
|
||||
Args:
|
||||
kospi_price: 현재 코스피 지수 (예: 2600, 6300)
|
||||
kospi_change_pct: 전일 대비 등락률 (%)
|
||||
volatility_20d: 20일 변동성 (선택, 0이면 무시)
|
||||
|
||||
Returns:
|
||||
RegimeAnalysis: 레짐 분석 결과 및 전략 파라미터
|
||||
"""
|
||||
# 1. 지수 수준으로 기본 레짐 결정
|
||||
if kospi_price >= 5000:
|
||||
regime = MarketRegime.BULL_EXTREME
|
||||
elif kospi_price >= 3500:
|
||||
regime = MarketRegime.BULL_STRONG
|
||||
elif kospi_price >= 2500:
|
||||
regime = MarketRegime.BULL_NORMAL
|
||||
elif kospi_price >= 2000:
|
||||
regime = MarketRegime.SIDEWAYS
|
||||
elif kospi_price >= 1500:
|
||||
regime = MarketRegime.BEAR_MILD
|
||||
else:
|
||||
regime = MarketRegime.BEAR_SEVERE
|
||||
|
||||
params = cls._REGIME_PARAMS[regime]
|
||||
|
||||
# 2. 변동성 기반 포지션 사이징 추가 조정
|
||||
position_adj = params["position_size_adj"]
|
||||
if volatility_20d > 30:
|
||||
position_adj *= 0.6 # 극단적 변동성 → 추가 50% 축소
|
||||
elif volatility_20d > 20:
|
||||
position_adj *= 0.8 # 높은 변동성 → 20% 축소
|
||||
|
||||
# 3. 급락 중 레짐 하향 조정 (패닉 감지)
|
||||
if kospi_change_pct <= -3.0:
|
||||
# 극단적 일일 급락 → 포지션 추가 축소
|
||||
position_adj *= 0.5
|
||||
print(f"[Regime] PANIC DETECTED (일일 {kospi_change_pct:.1f}%) → 포지션 50% 추가 축소")
|
||||
|
||||
return RegimeAnalysis(
|
||||
regime=regime,
|
||||
kospi_level=kospi_price,
|
||||
description=params["description"],
|
||||
recommended_strategy=params["recommended_strategy"],
|
||||
buy_threshold_adj=params["buy_threshold_adj"],
|
||||
position_size_adj=round(position_adj, 3),
|
||||
lstm_weight_adj=params["lstm_weight_adj"],
|
||||
model_recommendation=params["model_recommendation"],
|
||||
risk_level=params["risk_level"],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def validate_model_for_regime(
|
||||
cls,
|
||||
regime: MarketRegime,
|
||||
backtest_sharpe: Optional[float] = None,
|
||||
backtest_winrate: Optional[float] = None,
|
||||
backtest_mdd: Optional[float] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
현재 LSTM v3 모델이 해당 레짐에서 적합한지 검증
|
||||
|
||||
Returns:
|
||||
{
|
||||
"is_suitable": bool,
|
||||
"confidence_score": float (0~1),
|
||||
"recommendation": str,
|
||||
"should_replace": bool,
|
||||
"alternative_models": list[str],
|
||||
"reason": str,
|
||||
}
|
||||
"""
|
||||
result = {
|
||||
"is_suitable": True,
|
||||
"confidence_score": 0.75,
|
||||
"recommendation": "현재 LSTM v3 모델 유지",
|
||||
"should_replace": False,
|
||||
"alternative_models": [],
|
||||
"reason": "정상 상승장 구간 - LSTM v3 최적 환경",
|
||||
}
|
||||
|
||||
# 레짐 기반 기본 평가
|
||||
if regime == MarketRegime.BULL_EXTREME:
|
||||
result.update({
|
||||
"is_suitable": False,
|
||||
"confidence_score": 0.38,
|
||||
"recommendation": "Transformer 계열 모델 교체 강력 권고",
|
||||
"should_replace": True,
|
||||
"alternative_models": [
|
||||
"Temporal Fusion Transformer (TFT) - 장기 시계열 최강",
|
||||
"Mamba (SSM) - 초고속 추론 + 긴 컨텍스트",
|
||||
"PatchTST - Transformer 기반 주가 예측 특화",
|
||||
"TimesNet - 2D 시계열 변환 + CNN",
|
||||
"N-BEATS / N-HiTS - 해석 가능 딥러닝",
|
||||
],
|
||||
"reason": (
|
||||
"코스피 5000+ 극강세장에서 LSTM은 비선형적 가격 급등 패턴을 "
|
||||
"충분히 학습하지 못함. Attention 메커니즘만으로는 장기 상승 추세의 "
|
||||
"복잡한 의존성 포착에 한계 존재."
|
||||
),
|
||||
})
|
||||
elif regime == MarketRegime.BEAR_SEVERE:
|
||||
result.update({
|
||||
"is_suitable": False,
|
||||
"confidence_score": 0.30,
|
||||
"recommendation": "매크로 팩터 + Regime-Switching 모델 교체 권고",
|
||||
"should_replace": True,
|
||||
"alternative_models": [
|
||||
"Regime-Switching LSTM (HMM + LSTM)",
|
||||
"매크로 멀티팩터 모델 (환율, 금리, VIX 통합)",
|
||||
"GRU + Attention (LSTM 경량 대안)",
|
||||
],
|
||||
"reason": "극약세장에서는 기술적 지표보다 거시경제 팩터가 지배적",
|
||||
})
|
||||
elif regime == MarketRegime.BULL_STRONG:
|
||||
result.update({
|
||||
"confidence_score": 0.72,
|
||||
"reason": "강상승장 - LSTM 추세 학습 양호하나 성능 모니터링 필요",
|
||||
})
|
||||
elif regime == MarketRegime.SIDEWAYS:
|
||||
result.update({
|
||||
"confidence_score": 0.68,
|
||||
"reason": "횡보장 - LSTM 예측력 저하, LLM 감성 보완 필수",
|
||||
"recommendation": "현재 LSTM v3 유지 + LLM 감성 가중치 상향",
|
||||
})
|
||||
|
||||
# 백테스트 결과 반영
|
||||
if backtest_sharpe is not None:
|
||||
if backtest_sharpe < 0:
|
||||
result["confidence_score"] *= 0.5
|
||||
result["should_replace"] = True
|
||||
result["recommendation"] += " ⚠️ Sharpe < 0 → 즉시 교체 검토"
|
||||
elif backtest_sharpe < 0.5:
|
||||
result["confidence_score"] *= 0.75
|
||||
result["recommendation"] += f" (Sharpe={backtest_sharpe:.2f} 미흡)"
|
||||
|
||||
if backtest_winrate is not None and backtest_winrate < 45:
|
||||
result["confidence_score"] *= 0.8
|
||||
result["recommendation"] += f" (승률={backtest_winrate:.1f}% 미흡)"
|
||||
|
||||
if backtest_mdd is not None and backtest_mdd < -25:
|
||||
result["confidence_score"] *= 0.7
|
||||
result["should_replace"] = True
|
||||
result["recommendation"] += f" ⚠️ MDD={backtest_mdd:.1f}% 과다"
|
||||
|
||||
result["confidence_score"] = round(max(0.0, min(1.0, result["confidence_score"])), 3)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def get_regime_label(kospi_price: float) -> str:
|
||||
"""간략 레짐 라벨 반환 (로그/UI 표시용)"""
|
||||
if kospi_price >= 5000:
|
||||
return f"BULL_EXTREME({kospi_price:.0f})"
|
||||
elif kospi_price >= 3500:
|
||||
return f"BULL_STRONG({kospi_price:.0f})"
|
||||
elif kospi_price >= 2500:
|
||||
return f"BULL_NORMAL({kospi_price:.0f})"
|
||||
elif kospi_price >= 2000:
|
||||
return f"SIDEWAYS({kospi_price:.0f})"
|
||||
elif kospi_price >= 1500:
|
||||
return f"BEAR_MILD({kospi_price:.0f})"
|
||||
return f"BEAR_SEVERE({kospi_price:.0f})"
|
||||
348
legacy/signal_v1/modules/analysis/model_validator.py
Normal file
348
legacy/signal_v1/modules/analysis/model_validator.py
Normal file
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
모델 검증 시스템 (Market-Regime Aware Model Validator)
|
||||
- 백테스트 기반 현재 LSTM v3 성능 검증
|
||||
- 코스피 레짐별 모델 적합성 평가
|
||||
- 코스피 6300 강세장 시나리오 대응 점검
|
||||
- 모델 교체 권고 보고서 생성
|
||||
|
||||
사용법:
|
||||
validator = ModelValidator()
|
||||
report = validator.validate(ticker, ohlcv_data, strategy_fn, kospi_price=2600)
|
||||
print(report.summary())
|
||||
validator.send_alert(report) # 텔레그램 알림 (심각한 경우만)
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List
|
||||
|
||||
from modules.config import Config
|
||||
from modules.analysis.backtest import Backtester, BacktestResult
|
||||
from modules.analysis.market_regime import MarketRegimeDetector, MarketRegime, RegimeAnalysis
|
||||
|
||||
|
||||
# 모델 적합성 최소 기준
|
||||
_MIN_SHARPE = 0.5
|
||||
_MIN_WIN_RATE = 50.0 # %
|
||||
_MAX_MDD = -20.0 # % (초과 시 문제)
|
||||
_MIN_PROFIT_FACTOR = 1.2
|
||||
_CACHE_TTL_SECONDS = 86400 # 24시간
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationReport:
|
||||
"""모델 검증 보고서"""
|
||||
ticker: str
|
||||
kospi_level: float
|
||||
regime: str
|
||||
regime_description: str
|
||||
backtest_result: Optional[BacktestResult]
|
||||
model_suitable: bool
|
||||
suitability_score: float # 0~1
|
||||
issues: List[str] = field(default_factory=list)
|
||||
recommendations: List[str] = field(default_factory=list)
|
||||
alternative_models: List[str] = field(default_factory=list)
|
||||
regime_strategy_hint: str = ""
|
||||
risk_level: str = "LOW"
|
||||
|
||||
def summary(self) -> str:
|
||||
lines = [
|
||||
"=" * 55,
|
||||
f"🔍 모델 검증 보고서 [{self.ticker}]",
|
||||
"=" * 55,
|
||||
f"코스피 수준 : {self.kospi_level:.0f} ({self.regime_description})",
|
||||
f"시장 레짐 : {self.regime} [리스크: {self.risk_level}]",
|
||||
f"모델 적합성 : {'✅ 적합' if self.model_suitable else '⚠️ 부적합'} "
|
||||
f"({self.suitability_score:.0%})",
|
||||
]
|
||||
|
||||
if self.backtest_result:
|
||||
bt = self.backtest_result
|
||||
lines += [
|
||||
"",
|
||||
"📊 백테스트 성과",
|
||||
f" 총 수익률 : {bt.total_return_pct:+.2f}%",
|
||||
f" Sharpe Ratio : {bt.sharpe_ratio:.3f}",
|
||||
f" Max Drawdown : {bt.max_drawdown_pct:.2f}%",
|
||||
f" 승률 : {bt.win_rate:.1f}% ({bt.winning_trades}/{bt.total_trades})",
|
||||
f" 손익비(PF) : {bt.profit_factor:.2f}",
|
||||
]
|
||||
|
||||
if self.issues:
|
||||
lines.append("")
|
||||
lines.append(f"⚠️ 발견된 문제 ({len(self.issues)}건)")
|
||||
for issue in self.issues:
|
||||
lines.append(f" - {issue}")
|
||||
|
||||
if self.recommendations:
|
||||
lines.append("")
|
||||
lines.append("💡 권고사항")
|
||||
for rec in self.recommendations:
|
||||
lines.append(f" → {rec}")
|
||||
|
||||
if self.alternative_models:
|
||||
lines.append("")
|
||||
lines.append("🔄 대안 모델 목록")
|
||||
for model in self.alternative_models:
|
||||
lines.append(f" • {model}")
|
||||
|
||||
if self.regime_strategy_hint:
|
||||
lines.append("")
|
||||
lines.append(f"📌 레짐 전략: {self.regime_strategy_hint}")
|
||||
|
||||
lines.append("=" * 55)
|
||||
return "\n".join(lines)
|
||||
|
||||
def is_critical(self) -> bool:
|
||||
"""즉각적인 조치가 필요한 수준인지 (텔레그램 알림 기준)"""
|
||||
if not self.model_suitable and self.suitability_score < 0.4:
|
||||
return True
|
||||
if self.backtest_result and self.backtest_result.sharpe_ratio < 0:
|
||||
return True
|
||||
if self.backtest_result and self.backtest_result.max_drawdown_pct < -30:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class ModelValidator:
|
||||
"""
|
||||
LSTM v3 모델 검증기
|
||||
|
||||
검증 흐름:
|
||||
1. 시장 레짐 감지 (코스피 수준)
|
||||
2. 백테스트 실행 (선택)
|
||||
3. 레짐별 모델 적합성 평가
|
||||
4. 종합 보고서 생성
|
||||
5. 심각한 경우 텔레그램 알림
|
||||
"""
|
||||
|
||||
_CACHE_FILE = "model_validation_cache.json"
|
||||
|
||||
def __init__(self):
|
||||
self._cache_path = os.path.join(Config.DATA_DIR, self._CACHE_FILE)
|
||||
self._cache: dict = self._load_cache()
|
||||
|
||||
def _load_cache(self) -> dict:
|
||||
if os.path.exists(self._cache_path):
|
||||
try:
|
||||
with open(self._cache_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_cache(self):
|
||||
try:
|
||||
with open(self._cache_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._cache, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Validator] 캐시 저장 실패: {e}")
|
||||
|
||||
def validate(
|
||||
self,
|
||||
ticker: str,
|
||||
ohlcv_data: dict,
|
||||
strategy_fn=None,
|
||||
kospi_price: float = 2500.0,
|
||||
kospi_change_pct: float = 0.0,
|
||||
run_backtest: bool = True,
|
||||
) -> ValidationReport:
|
||||
"""
|
||||
모델 검증 실행
|
||||
|
||||
Args:
|
||||
ticker: 종목 코드
|
||||
ohlcv_data: OHLCV 딕셔너리
|
||||
strategy_fn: 백테스트용 전략 함수 (None이면 백테스트 생략)
|
||||
kospi_price: 현재 코스피 지수
|
||||
kospi_change_pct: 코스피 당일 등락률
|
||||
run_backtest: 백테스트 실행 여부
|
||||
|
||||
Returns:
|
||||
ValidationReport
|
||||
"""
|
||||
issues: List[str] = []
|
||||
recommendations: List[str] = []
|
||||
|
||||
# ── 1. 시장 레짐 감지 ────────────────────────────────
|
||||
regime_analysis: RegimeAnalysis = MarketRegimeDetector.detect(
|
||||
kospi_price, kospi_change_pct
|
||||
)
|
||||
|
||||
# ── 2. 백테스트 (선택) ───────────────────────────────
|
||||
backtest_result: Optional[BacktestResult] = None
|
||||
if run_backtest and strategy_fn is not None:
|
||||
try:
|
||||
backtester = Backtester()
|
||||
backtest_result = backtester.run(ohlcv_data, strategy_fn, ticker)
|
||||
except Exception as e:
|
||||
issues.append(f"백테스트 실행 오류: {e}")
|
||||
|
||||
# ── 3. 백테스트 결과 기준 위반 체크 ─────────────────
|
||||
bt_sharpe = backtest_result.sharpe_ratio if backtest_result else None
|
||||
bt_winrate = backtest_result.win_rate if backtest_result else None
|
||||
bt_mdd = backtest_result.max_drawdown_pct if backtest_result else None
|
||||
bt_pf = backtest_result.profit_factor if backtest_result else None
|
||||
|
||||
if backtest_result:
|
||||
if bt_sharpe < _MIN_SHARPE:
|
||||
issues.append(
|
||||
f"Sharpe Ratio 미흡: {bt_sharpe:.3f} (최소 {_MIN_SHARPE})"
|
||||
)
|
||||
recommendations.append("LSTM 피처 확장 또는 모델 아키텍처 재검토")
|
||||
if bt_winrate < _MIN_WIN_RATE:
|
||||
issues.append(
|
||||
f"승률 미흡: {bt_winrate:.1f}% (최소 {_MIN_WIN_RATE:.0f}%)"
|
||||
)
|
||||
recommendations.append("매수 진입 임계값 상향 조정 (+0.05)")
|
||||
if bt_mdd < _MAX_MDD:
|
||||
issues.append(
|
||||
f"MDD 과다: {bt_mdd:.2f}% (허용 {_MAX_MDD:.0f}%)"
|
||||
)
|
||||
recommendations.append("ATR 손절 배수 축소 (ATR×2 → ATR×1.5)")
|
||||
if bt_pf < _MIN_PROFIT_FACTOR:
|
||||
issues.append(
|
||||
f"손익비 미흡: {bt_pf:.2f} (최소 {_MIN_PROFIT_FACTOR})"
|
||||
)
|
||||
recommendations.append("익절 배수 확대 (ATR×3 → ATR×4)")
|
||||
|
||||
# ── 4. 레짐 기반 모델 적합성 평가 ───────────────────
|
||||
regime_validation = MarketRegimeDetector.validate_model_for_regime(
|
||||
regime_analysis.regime,
|
||||
backtest_sharpe=bt_sharpe,
|
||||
backtest_winrate=bt_winrate,
|
||||
backtest_mdd=bt_mdd,
|
||||
)
|
||||
|
||||
if not regime_validation["is_suitable"]:
|
||||
issues.append(
|
||||
f"레짐 부적합: {regime_analysis.regime.value} 환경에서 "
|
||||
f"LSTM v3 한계 감지"
|
||||
)
|
||||
recommendations.append(regime_validation["recommendation"])
|
||||
|
||||
# 코스피 6300 특별 경고
|
||||
if kospi_price >= 5000:
|
||||
issues.append(
|
||||
f"⚠️ 코스피 {kospi_price:.0f} - 역사적 극고점 수준 "
|
||||
"LSTM 비선형 패턴 포착 한계 주의"
|
||||
)
|
||||
recommendations.append(
|
||||
"Temporal Fusion Transformer(TFT) 또는 Mamba 모델 전환 검토"
|
||||
)
|
||||
|
||||
# ── 5. 종합 적합성 점수 ──────────────────────────────
|
||||
suitability_score = regime_validation["confidence_score"]
|
||||
# 문제 건수에 따라 감점 (건당 10%, 최대 50% 감점)
|
||||
penalty = min(len(issues) * 0.10, 0.50)
|
||||
suitability_score = max(0.0, suitability_score - penalty)
|
||||
suitability_score = round(suitability_score, 3)
|
||||
|
||||
# ── 6. 보고서 생성 ───────────────────────────────────
|
||||
report = ValidationReport(
|
||||
ticker=ticker,
|
||||
kospi_level=kospi_price,
|
||||
regime=regime_analysis.regime.value,
|
||||
regime_description=regime_analysis.description,
|
||||
backtest_result=backtest_result,
|
||||
model_suitable=(suitability_score >= 0.5 and not regime_validation["should_replace"]),
|
||||
suitability_score=suitability_score,
|
||||
issues=issues,
|
||||
recommendations=list(set(recommendations)), # 중복 제거
|
||||
alternative_models=regime_validation.get("alternative_models", []),
|
||||
regime_strategy_hint=regime_analysis.recommended_strategy,
|
||||
risk_level=regime_analysis.risk_level,
|
||||
)
|
||||
|
||||
# ── 7. 캐시 저장 ─────────────────────────────────────
|
||||
self._cache[ticker] = {
|
||||
"timestamp": time.time(),
|
||||
"kospi_level": kospi_price,
|
||||
"regime": regime_analysis.regime.value,
|
||||
"suitability_score": suitability_score,
|
||||
"should_replace": regime_validation["should_replace"],
|
||||
"issue_count": len(issues),
|
||||
}
|
||||
self._save_cache()
|
||||
|
||||
return report
|
||||
|
||||
def get_cached(self, ticker: str) -> Optional[dict]:
|
||||
"""캐시된 검증 결과 반환 (24시간 이내)"""
|
||||
cached = self._cache.get(ticker)
|
||||
if not cached:
|
||||
return None
|
||||
if time.time() - cached.get("timestamp", 0) > _CACHE_TTL_SECONDS:
|
||||
return None
|
||||
return cached
|
||||
|
||||
def send_alert(self, report: ValidationReport):
|
||||
"""심각한 검증 결과 텔레그램 알림"""
|
||||
if not report.is_critical():
|
||||
return
|
||||
try:
|
||||
from modules.services.telegram import TelegramMessenger
|
||||
msg = (
|
||||
f"🚨 [모델 경고] {report.ticker}\n"
|
||||
f"코스피 {report.kospi_level:.0f} | 레짐: {report.regime}\n"
|
||||
f"적합성: {report.suitability_score:.0%}\n"
|
||||
)
|
||||
if report.issues:
|
||||
msg += "문제:\n" + "\n".join(f"• {i}" for i in report.issues[:3])
|
||||
if report.alternative_models:
|
||||
msg += f"\n권고 모델: {report.alternative_models[0]}"
|
||||
TelegramMessenger().send_message(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def generate_regime_report(self, kospi_price: float) -> str:
|
||||
"""코스피 수준만으로 빠른 레짐 보고서 생성 (백테스트 없음)"""
|
||||
regime_analysis = MarketRegimeDetector.detect(kospi_price)
|
||||
validation = MarketRegimeDetector.validate_model_for_regime(regime_analysis.regime)
|
||||
|
||||
lines = [
|
||||
"=" * 55,
|
||||
f"📈 코스피 {kospi_price:.0f} 레짐 분석",
|
||||
"=" * 55,
|
||||
f"레짐 : {regime_analysis.regime.value}",
|
||||
f"설명 : {regime_analysis.description}",
|
||||
f"리스크 수준 : {regime_analysis.risk_level}",
|
||||
"",
|
||||
"─ 전략 파라미터 조정 ─",
|
||||
f"매수 임계값 : {'+' if regime_analysis.buy_threshold_adj >= 0 else ''}"
|
||||
f"{regime_analysis.buy_threshold_adj:+.2f} 조정",
|
||||
f"포지션 크기 : x{regime_analysis.position_size_adj:.2f}",
|
||||
f"LSTM 가중치 : {'+' if regime_analysis.lstm_weight_adj >= 0 else ''}"
|
||||
f"{regime_analysis.lstm_weight_adj:+.2f}",
|
||||
"",
|
||||
"─ 모델 평가 ─",
|
||||
f"현재 모델 적합: {'✅' if validation['is_suitable'] else '⚠️'} "
|
||||
f"(신뢰도 {validation['confidence_score']:.0%})",
|
||||
f"교체 필요 : {'예' if validation['should_replace'] else '아니오'}",
|
||||
f"권고사항 : {validation['recommendation']}",
|
||||
]
|
||||
|
||||
if validation["alternative_models"]:
|
||||
lines.append("")
|
||||
lines.append("대안 모델 목록:")
|
||||
for model in validation["alternative_models"]:
|
||||
lines.append(f" • {model}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"📌 전략: {regime_analysis.recommended_strategy}")
|
||||
lines.append("=" * 55)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# 전역 싱글톤
|
||||
_validator_instance: Optional[ModelValidator] = None
|
||||
|
||||
|
||||
def get_validator() -> ModelValidator:
|
||||
"""ModelValidator 싱글톤 반환"""
|
||||
global _validator_instance
|
||||
if _validator_instance is None:
|
||||
_validator_instance = ModelValidator()
|
||||
return _validator_instance
|
||||
@@ -4,17 +4,21 @@ 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
|
||||
from modules.services.news import AsyncNewsCollector
|
||||
from modules.services.news_snapshot import NewsSnapshotStore
|
||||
from modules.services.ollama import OllamaManager
|
||||
from modules.services.telegram import TelegramMessenger
|
||||
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.strategy.process import analyze_stock_process
|
||||
from modules.strategy.risk_gate import PortfolioRiskGate, RiskConfig
|
||||
from modules.strategy.daily_ledger import DailyLedger
|
||||
from modules.analysis.ensemble import get_ensemble
|
||||
|
||||
try:
|
||||
from theme_manager import ThemeManager
|
||||
@@ -46,11 +50,21 @@ class AutoTradingBot:
|
||||
def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None):
|
||||
# 1. 서비스 초기화
|
||||
self.kis = KISClient()
|
||||
self.news = AsyncNewsCollector()
|
||||
self.news_snapshot = NewsSnapshotStore("data/news_snapshots.db")
|
||||
self.news = AsyncNewsCollector(snapshot_store=self.news_snapshot)
|
||||
self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker)
|
||||
|
||||
self.messenger = TelegramMessenger()
|
||||
self.theme_manager = ThemeManager()
|
||||
# 포트폴리오 리스크 게이트 (v3.2) — 테마 집중/동시보유 한도 검증
|
||||
self.risk_gate = PortfolioRiskGate(
|
||||
theme_lookup=lambda t: self.theme_manager.get_themes(t),
|
||||
config=RiskConfig(
|
||||
max_total_holdings=Config.MAX_TOTAL_HOLDINGS,
|
||||
max_tickers_per_theme=Config.MAX_TICKERS_PER_THEME,
|
||||
max_theme_exposure_ratio=Config.MAX_THEME_EXPOSURE_RATIO,
|
||||
),
|
||||
)
|
||||
self.ollama_monitor = OllamaManager()
|
||||
|
||||
# 2. 유틸리티 초기화
|
||||
@@ -70,9 +84,16 @@ class AutoTradingBot:
|
||||
# [v2.0] 최근 매크로 상태 캐싱
|
||||
self.last_macro_status = None
|
||||
|
||||
# [v3.2] 당일 상태 집약 (연속손절/당일매수/신호점수/플래그)
|
||||
self.ledger = DailyLedger()
|
||||
|
||||
# 4. 프로세스 관리
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
# KRX 캘린더 (장 운영 여부 판단)
|
||||
from modules.utils.market_calendar import get_calendar
|
||||
self._calendar = get_calendar()
|
||||
|
||||
# 5. IPC (Shared Memory)
|
||||
try:
|
||||
from modules.utils.ipc import SharedIPC
|
||||
@@ -92,10 +113,8 @@ class AutoTradingBot:
|
||||
self.history_file = Config.HISTORY_FILE
|
||||
self.load_trade_history()
|
||||
|
||||
# 7-1. 성과 DB 및 평가 플래그
|
||||
# 7-1. 성과 DB 및 수동 평가 요청 플래그 (주간/스냅샷 플래그는 ledger로 이관)
|
||||
self.perf_db = PerformanceDB()
|
||||
self.weekly_eval_sent = False
|
||||
self._snapshot_taken_today = False
|
||||
self._pending_evaluate = False
|
||||
|
||||
# 8. AI 하드웨어 점검
|
||||
@@ -155,7 +174,7 @@ class AutoTradingBot:
|
||||
|
||||
self.perf_db.save_daily_snapshot(
|
||||
total_eval_snap, deposit_snap, holdings_count_snap, kospi_close)
|
||||
self._snapshot_taken_today = True
|
||||
self.ledger.snapshot_taken = True
|
||||
except Exception as e:
|
||||
print(f"[Bot] Daily snapshot error: {e}")
|
||||
|
||||
@@ -170,7 +189,7 @@ class AutoTradingBot:
|
||||
if len(report) > 4000:
|
||||
report = report[:4000] + "\n... (일부 생략)"
|
||||
self.messenger.send_message(report)
|
||||
self.weekly_eval_sent = True
|
||||
self.ledger.weekly_eval_sent = True
|
||||
print("[Bot] Weekly evaluation report sent.")
|
||||
except Exception as e:
|
||||
print(f"[Bot] Weekly evaluation error: {e}")
|
||||
@@ -365,17 +384,16 @@ class AutoTradingBot:
|
||||
except Exception as e:
|
||||
self.messenger.send_message(f"Update Failed: {e}")
|
||||
|
||||
# 4. 리셋 (09:00)
|
||||
# 4. 리셋 (09:00) — 일별 상태는 ledger.reset_if_new_day가 통합 관리
|
||||
if now.hour == 9 and now.minute < 5:
|
||||
self.daily_trade_history = []
|
||||
self.save_trade_history()
|
||||
self.report_sent = False
|
||||
self.weekly_eval_sent = False
|
||||
self._snapshot_taken_today = False
|
||||
self.discovered_stocks.clear()
|
||||
self.watchlist_updated_today = False
|
||||
# 전일 최고가 초기화 (보유하지 않는 종목)
|
||||
self._load_peak_prices()
|
||||
if self.ledger.reset_if_new_day(now):
|
||||
print(f"[Bot] 일일 장부 리셋 (날짜: {now.date()})")
|
||||
|
||||
# 5. 시스템 감시 (3분 간격)
|
||||
self.monitor.check_health()
|
||||
@@ -385,7 +403,7 @@ class AutoTradingBot:
|
||||
if now.hour == 15 and now.minute >= 40:
|
||||
self.send_daily_report()
|
||||
# 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록)
|
||||
if now.hour == 16 and now.minute <= 30 and not self._snapshot_taken_today:
|
||||
if now.hour == 16 and now.minute <= 30 and not self.ledger.snapshot_taken:
|
||||
try:
|
||||
balance_snap = self.kis.get_balance()
|
||||
self._take_daily_snapshot(macro_status, balance_snap)
|
||||
@@ -393,8 +411,9 @@ class AutoTradingBot:
|
||||
print(f"[Bot] Snapshot error: {e}")
|
||||
# 주간 평가 (금요일 15:35~15:45, 장 마감 직후)
|
||||
if (now.weekday() == 4 and now.hour == 15
|
||||
and 35 <= now.minute <= 45 and not self.weekly_eval_sent):
|
||||
and 35 <= now.minute <= 45 and not self.ledger.weekly_eval_sent):
|
||||
await self._run_weekly_evaluation()
|
||||
|
||||
# 장 외 시간에는 서킷 브레이커도 리셋
|
||||
self.monitor.reset_circuit()
|
||||
print("[Bot] Market Closed. Waiting...")
|
||||
@@ -438,7 +457,19 @@ 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))
|
||||
# 날짜 전환 안전망 (09:00 리셋 블록에서 누락됐을 가능성 대비)
|
||||
self.ledger.reset_if_new_day(now)
|
||||
|
||||
kis_today_buy = int(balance.get("today_buy_amt", 0))
|
||||
effective_today_buy = self.ledger.effective_today_buy(kis_today_buy)
|
||||
tracking_deposit = self.ledger.available_deposit(
|
||||
raw_deposit, Config.MAX_DAILY_BUY_RATIO, kis_today_buy
|
||||
)
|
||||
max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO)
|
||||
|
||||
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 +486,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 +517,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 +539,62 @@ 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.ledger.is_buy_paused(datetime.now()):
|
||||
print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: "
|
||||
f"{self.ledger.buy_paused_until.strftime('%H:%M')}) - {ticker_name}")
|
||||
continue
|
||||
|
||||
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
|
||||
|
||||
# 예수금 확인
|
||||
# [v3.2] 포트폴리오 리스크 게이트 검증 (테마 집중/동시보유 상한)
|
||||
risk_holdings = [
|
||||
{"ticker": c, "eval_amount": int(float(h.get("current_price", 0))
|
||||
* int(h.get("qty", 0)))}
|
||||
for c, h in current_holdings.items()
|
||||
]
|
||||
risk_dec = self.risk_gate.evaluate_buy(
|
||||
ticker=ticker,
|
||||
candidate_amount=int(required_amount),
|
||||
current_holdings=risk_holdings,
|
||||
total_capital=max(total_eval, 1),
|
||||
)
|
||||
if not risk_dec.allowed:
|
||||
print(f"[Bot] [Skip Buy] RiskGate: {risk_dec.reason} ({ticker_name})")
|
||||
continue
|
||||
if risk_dec.max_allowed_amount < required_amount:
|
||||
new_qty = int(risk_dec.max_allowed_amount / current_price)
|
||||
if new_qty <= 0:
|
||||
print(f"[Bot] [Skip Buy] RiskGate 부분허용 금액 부족 ({ticker_name})")
|
||||
continue
|
||||
print(f"[Bot] RiskGate 부분허용: qty {qty}→{new_qty} "
|
||||
f"({risk_dec.reason})")
|
||||
qty = new_qty
|
||||
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 +640,21 @@ class AutoTradingBot:
|
||||
)
|
||||
|
||||
tracking_deposit -= required_amount
|
||||
self.ledger.record_buy(
|
||||
ticker, int(required_amount),
|
||||
{"tech": res.get("tech", 0.5),
|
||||
"sentiment": res.get("sentiment", 0.5),
|
||||
"lstm": res.get("lstm_score", 0.5)},
|
||||
)
|
||||
buys_this_cycle += 1
|
||||
print(f"[Bot] 당일 누적 매수: {self.ledger.today_buy_total:,}원 "
|
||||
f"(잔여 예수금: {tracking_deposit:,}원)")
|
||||
|
||||
# 최고가 초기 설정
|
||||
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 +686,35 @@ class AutoTradingBot:
|
||||
# 성과 DB 매도 결과 기록
|
||||
self.perf_db.close_trade(ticker, sell_price, yld)
|
||||
|
||||
# [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률)
|
||||
buy_sig = self.ledger.pop_buy_scores(ticker)
|
||||
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] 손절 횟수 추적 → 연속 N회 손절 시 매수 일시 중단
|
||||
triggered = self.ledger.record_sell_outcome(yld, datetime.now())
|
||||
if triggered:
|
||||
warn_msg = (
|
||||
f"⛔ <b>[매수 일시 중단]</b> 당일 손절 "
|
||||
f"{self.ledger.consecutive_stop_losses}회 → "
|
||||
f"{self.ledger.stop_loss_pause_minutes}분간 매수 정지 "
|
||||
f"(재개: {self.ledger.buy_paused_until.strftime('%H:%M')})"
|
||||
)
|
||||
self.messenger.send_message(warn_msg)
|
||||
print(f"[Bot] 연속 손절 {self.ledger.consecutive_stop_losses}회 → 매수 일시 중단")
|
||||
|
||||
# 최고가 기록 삭제
|
||||
if ticker in self.peak_prices:
|
||||
del self.peak_prices[ticker]
|
||||
@@ -637,12 +741,19 @@ 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]")
|
||||
|
||||
_llm_label = (
|
||||
f"Gemini ({Config.GEMINI_MODEL})"
|
||||
if Config.GEMINI_API_KEY
|
||||
else f"Ollama ({Config.OLLAMA_MODEL})"
|
||||
)
|
||||
self.messenger.send_message(
|
||||
"🚀 <b>[Bot Started v3.0]</b>\n"
|
||||
"🚀 <b>[Bot Started v3.1]</b>\n"
|
||||
f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n"
|
||||
f"✅ AI 모델: {Config.OLLAMA_MODEL}\n"
|
||||
f"✅ LLM 엔진: {_llm_label}\n"
|
||||
f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n"
|
||||
f"✅ 장 상태: {self._calendar.status_summary()}\n"
|
||||
"✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징")
|
||||
|
||||
# 최고가 데이터 로드
|
||||
@@ -1,9 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# .env 파일 로드
|
||||
load_dotenv()
|
||||
load_dotenv(Path(__file__).parent.parent.parent / ".env")
|
||||
|
||||
class Config:
|
||||
# 1. 기본 설정
|
||||
@@ -18,6 +19,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 +48,14 @@ 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")) # 예수금 대비 일일 최대 매수 비율
|
||||
|
||||
# 포트폴리오 리스크 게이트 (v3.2)
|
||||
MAX_TICKERS_PER_THEME = int(os.getenv("MAX_TICKERS_PER_THEME", "2")) # 테마당 최대 종목 수
|
||||
MAX_THEME_EXPOSURE_RATIO = float(os.getenv("MAX_THEME_EXPOSURE_RATIO", "0.40")) # 테마당 최대 노출 비율 (총자산 대비)
|
||||
MAX_TOTAL_HOLDINGS = int(os.getenv("MAX_TOTAL_HOLDINGS", "7")) # 총 보유 종목 수 상한
|
||||
|
||||
# 6. 데이터 경로
|
||||
DATA_DIR = os.path.join(BASE_DIR, "data")
|
||||
@@ -80,6 +95,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():
|
||||
"""필수 설정 검증"""
|
||||
@@ -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}")
|
||||
199
legacy/signal_v1/modules/services/llm_client.py
Normal file
199
legacy/signal_v1/modules/services/llm_client.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
통합 LLM 클라이언트 — Gemini 2.5 Flash (Primary) + Ollama (Fallback)
|
||||
|
||||
설계 원칙:
|
||||
- OllamaManager.request_inference(prompt) 와 동일한 인터페이스 유지
|
||||
→ process.py, ai_council.py 코드 변경 최소화
|
||||
- Gemini 실패(네트워크, Rate Limit) 시 자동으로 로컬 Ollama 폴백
|
||||
- 15 RPM 제한 준수를 위한 자동 스로틀링
|
||||
- VRAM 충돌 없음 (외부 API 호출이므로 LSTM 학습과 간섭 없음)
|
||||
|
||||
Rate Limit (Gemini 2.5 Flash 무료 티어):
|
||||
- 15 RPM, 1,500 RPD (봇 필요량 ~240/일 → 여유 6배)
|
||||
|
||||
추가 패키지 불필요:
|
||||
- requests (이미 설치됨) 기반 REST API 직접 호출
|
||||
"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
import json
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
class GeminiLLMClient:
|
||||
"""
|
||||
Gemini API 클라이언트
|
||||
|
||||
사용법:
|
||||
client = GeminiLLMClient()
|
||||
result = client.request_inference(prompt) # str | None
|
||||
"""
|
||||
|
||||
_GENERATE_URL = (
|
||||
"https://generativelanguage.googleapis.com/v1beta/models"
|
||||
"/{model}:generateContent?key={key}"
|
||||
)
|
||||
# 15 RPM → 최소 4초 간격 (여유 0.1초 추가)
|
||||
_MIN_INTERVAL = 4.1
|
||||
# 클래스 변수: 같은 프로세스 내 재생성 시에도 마지막 호출 시각 유지
|
||||
# (워커 OOM 재시작 후 싱글톤 교체 시에도 스로틀 유효)
|
||||
_class_last_call_ts: float = 0.0
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = Config.GEMINI_API_KEY
|
||||
self.model = Config.GEMINI_MODEL
|
||||
self._ollama = None # Ollama 폴백 (lazy init)
|
||||
self._use_gemini = bool(self.api_key)
|
||||
|
||||
if self._use_gemini:
|
||||
print(f"✅ [LLMClient] Primary: Gemini {self.model}")
|
||||
else:
|
||||
print("⚠️ [LLMClient] GEMINI_API_KEY 미설정 → Ollama 전용 모드")
|
||||
|
||||
# ── 내부 헬퍼 ────────────────────────────────────────────────────────────
|
||||
|
||||
def _throttle(self):
|
||||
"""15 RPM 제한 준수 — 최소 호출 간격 강제 대기 (클래스 공유 타임스탬프)"""
|
||||
elapsed = time.time() - GeminiLLMClient._class_last_call_ts
|
||||
if elapsed < self._MIN_INTERVAL:
|
||||
time.sleep(self._MIN_INTERVAL - elapsed)
|
||||
|
||||
def _call_gemini(self, prompt: str) -> str | None:
|
||||
"""
|
||||
Gemini REST API 단일 호출
|
||||
|
||||
설정:
|
||||
- systemInstruction: JSON 전용 응답 강제
|
||||
- thinkingBudget=0: 내부 추론 비활성 (속도 1.5초 / 토큰 절약)
|
||||
- maxOutputTokens=512: 200은 thinking 소모로 잘리므로 여유 확보
|
||||
"""
|
||||
self._throttle()
|
||||
|
||||
url = self._GENERATE_URL.format(model=self.model, key=self.api_key)
|
||||
payload = {
|
||||
"system_instruction": {
|
||||
"parts": [{"text": (
|
||||
"You are a Korean stock market analyst. "
|
||||
"Respond with valid JSON only. "
|
||||
"No markdown, no code blocks, no explanations."
|
||||
)}]
|
||||
},
|
||||
"contents": [{"parts": [{"text": prompt}]}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": 512, # 200→512 (thinking 비활성 후 실제 응답 공간 확보)
|
||||
"temperature": 0.1, # 결정론적 출력
|
||||
"thinkingConfig": {"thinkingBudget": 0}, # 내부 추론 끔 (속도↑, 토큰↓)
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=30)
|
||||
GeminiLLMClient._class_last_call_ts = time.time()
|
||||
|
||||
# Rate Limit 초과
|
||||
if resp.status_code == 429:
|
||||
print("[LLMClient] Gemini Rate Limit (429) → Ollama 폴백")
|
||||
return None
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# thinking 파트 제외, 실제 텍스트 파트만 결합
|
||||
candidate = data.get("candidates", [{}])[0]
|
||||
parts = candidate.get("content", {}).get("parts", [])
|
||||
text = "".join(
|
||||
p.get("text", "") for p in parts
|
||||
if "text" in p and not p.get("thought")
|
||||
).strip()
|
||||
|
||||
return text if text else None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
print("[LLMClient] Gemini Timeout (30s) → Ollama 폴백")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[LLMClient] Gemini Error: {e} → Ollama 폴백")
|
||||
return None
|
||||
|
||||
def _get_ollama(self):
|
||||
"""Ollama 폴백 인스턴스 (lazy init — 필요할 때만 로드)"""
|
||||
if self._ollama is None:
|
||||
from modules.services.ollama import OllamaManager
|
||||
self._ollama = OllamaManager()
|
||||
# Ollama 실행 여부 사전 확인 (WinError 10061 조기 감지)
|
||||
try:
|
||||
requests.get(
|
||||
f"{Config.OLLAMA_API_URL}/api/tags",
|
||||
timeout=3,
|
||||
)
|
||||
except Exception:
|
||||
print(
|
||||
f"❌ [LLMClient] Ollama 미실행 (localhost:11434 연결 거부) — "
|
||||
f"`ollama serve` 명령으로 Ollama를 시작하세요."
|
||||
)
|
||||
return self._ollama
|
||||
|
||||
# ── 공개 인터페이스 ───────────────────────────────────────────────────────
|
||||
|
||||
def request_inference(self, prompt: str, context_data=None) -> str | None:
|
||||
"""
|
||||
LLM 추론 요청 — OllamaManager.request_inference()와 동일한 시그니처
|
||||
|
||||
순서:
|
||||
1) GEMINI_API_KEY 있음 → Gemini API 호출
|
||||
2) Gemini 실패(에러/타임아웃/Rate Limit) → Ollama 로컬 폴백
|
||||
3) GEMINI_API_KEY 없음 → 바로 Ollama 사용
|
||||
"""
|
||||
if self._use_gemini:
|
||||
result = self._call_gemini(prompt)
|
||||
if result is not None:
|
||||
return result
|
||||
# Gemini 실패 → Ollama 폴백
|
||||
print("[LLMClient] Ollama 폴백 시도 중...")
|
||||
|
||||
return self._get_ollama().request_inference(prompt, context_data)
|
||||
|
||||
# ── OllamaManager 호환 메서드 (ai_council, evaluator 등에서 사용) ─────────
|
||||
|
||||
def check_vram(self) -> float:
|
||||
"""VRAM 사용량 반환 (Ollama 측 정보, Gemini 호출 시엔 무관)"""
|
||||
if self._ollama:
|
||||
return self._ollama.check_vram()
|
||||
return 0.0
|
||||
|
||||
def get_gpu_status(self) -> dict:
|
||||
"""GPU 상태 반환 (OllamaManager 호환)"""
|
||||
return self._get_ollama().get_gpu_status()
|
||||
|
||||
def unload_model(self):
|
||||
"""Ollama 모델 언로드 (LSTM 학습 전 호출용, Gemini는 무작동)"""
|
||||
if self._ollama:
|
||||
try:
|
||||
requests.post(
|
||||
f"{Config.OLLAMA_API_URL}/api/generate",
|
||||
json={"model": Config.OLLAMA_MODEL, "keep_alive": 0},
|
||||
timeout=5,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── 워커 프로세스 전역 싱글톤 ─────────────────────────────────────────────────
|
||||
|
||||
_llm_client: GeminiLLMClient | None = None
|
||||
|
||||
|
||||
def get_llm_client() -> GeminiLLMClient:
|
||||
"""
|
||||
워커 프로세스 내 GeminiLLMClient 싱글톤 반환
|
||||
|
||||
process.py에서 기존 get_ollama() 대신 이 함수를 사용:
|
||||
ollama = get_llm_client()
|
||||
result = ollama.request_inference(prompt)
|
||||
"""
|
||||
global _llm_client
|
||||
if _llm_client is None:
|
||||
_llm_client = GeminiLLMClient()
|
||||
return _llm_client
|
||||
@@ -1,6 +1,23 @@
|
||||
import time
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _parse_items(root, max_items):
|
||||
"""RSS item → [{title, url, pub_date, source}]"""
|
||||
out = []
|
||||
for item in root.findall(".//item")[:max_items]:
|
||||
t = item.find("title")
|
||||
l = item.find("link")
|
||||
p = item.find("pubDate")
|
||||
title = (t.text or "").strip() if t is not None else ""
|
||||
url = (l.text or "").strip() if l is not None else ""
|
||||
pub = (p.text or "").strip() if p is not None else ""
|
||||
if not title:
|
||||
continue
|
||||
out.append({"title": title, "url": url, "pub_date": pub, "source": "Google News"})
|
||||
return out
|
||||
|
||||
|
||||
class NewsCollector:
|
||||
@@ -11,24 +28,29 @@ class NewsCollector:
|
||||
try:
|
||||
resp = requests.get(url, timeout=5)
|
||||
root = ET.fromstring(resp.content)
|
||||
items = []
|
||||
for item in root.findall(".//item")[:5]:
|
||||
title = item.find("title").text
|
||||
items.append({"title": title, "source": "Google News"})
|
||||
return items
|
||||
return _parse_items(root, 5)
|
||||
except Exception as e:
|
||||
print(f"[News] Collection failed: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class AsyncNewsCollector:
|
||||
"""비동기 뉴스 수집 + 5분 캐싱"""
|
||||
"""비동기 뉴스 수집 + 5분 캐싱 + (옵션) 스냅샷 저장"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, snapshot_store=None):
|
||||
self._cache = None
|
||||
self._cache_time = 0
|
||||
self._cache_ttl = 300 # 5분
|
||||
self._stock_cache = {} # {stock_name: (items, timestamp)}
|
||||
self._snap = snapshot_store # NewsSnapshotStore | None
|
||||
|
||||
def _save_snapshot(self, items, query: str, ticker: Optional[str] = None):
|
||||
if not self._snap or not items:
|
||||
return
|
||||
try:
|
||||
self._snap.save_many(items, query=query, ticker=ticker)
|
||||
except Exception as e:
|
||||
print(f"[News] snapshot 저장 실패: {e}")
|
||||
|
||||
def get_market_news(self, query="주식 시장"):
|
||||
"""동기 인터페이스 (하위 호환)"""
|
||||
@@ -39,6 +61,7 @@ class AsyncNewsCollector:
|
||||
result = NewsCollector.get_market_news(query)
|
||||
self._cache = result
|
||||
self._cache_time = now
|
||||
self._save_snapshot(result, query=query)
|
||||
return result
|
||||
|
||||
async def get_market_news_async(self, query="주식 시장"):
|
||||
@@ -54,13 +77,10 @@ class AsyncNewsCollector:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = []
|
||||
for item in root.findall(".//item")[:5]:
|
||||
title = item.find("title").text
|
||||
items.append({"title": title, "source": "Google News"})
|
||||
|
||||
items = _parse_items(root, 5)
|
||||
self._cache = items
|
||||
self._cache_time = now
|
||||
self._save_snapshot(items, query=query)
|
||||
return items
|
||||
except ImportError:
|
||||
return self.get_market_news(query)
|
||||
@@ -70,9 +90,10 @@ class AsyncNewsCollector:
|
||||
return self._cache
|
||||
return self.get_market_news(query)
|
||||
|
||||
async def get_stock_news_async(self, stock_name, max_items=3):
|
||||
async def get_stock_news_async(self, stock_name, max_items=3, ticker: Optional[str] = None):
|
||||
"""종목별 뉴스 수집 (5분 캐싱)
|
||||
stock_name: 종목 이름 (e.g. '삼성전자', 'SK하이닉스')
|
||||
ticker: 스냅샷 저장 시 종목코드 (옵션)
|
||||
"""
|
||||
now = time.time()
|
||||
cached = self._stock_cache.get(stock_name)
|
||||
@@ -88,13 +109,9 @@ class AsyncNewsCollector:
|
||||
async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp:
|
||||
content = await resp.read()
|
||||
root = ET.fromstring(content)
|
||||
items = []
|
||||
for item in root.findall(".//item")[:max_items]:
|
||||
title_el = item.find("title")
|
||||
if title_el is not None and title_el.text:
|
||||
items.append({"title": title_el.text, "source": "Google News"})
|
||||
|
||||
items = _parse_items(root, max_items)
|
||||
self._stock_cache[stock_name] = (items, now)
|
||||
self._save_snapshot(items, query=f"{stock_name} 주가", ticker=ticker)
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[News] 종목 뉴스 수집 실패 ({stock_name}): {e}")
|
||||
189
legacy/signal_v1/modules/services/news_snapshot.py
Normal file
189
legacy/signal_v1/modules/services/news_snapshot.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
뉴스 스냅샷 인프라 (v3.2)
|
||||
|
||||
목적:
|
||||
- 수집한 뉴스를 SQLite에 타임스탬프와 함께 영구 저장
|
||||
- 사후 감성 신호 재검증 (LLM 재호출 / 모델 비교) 가능하게
|
||||
- 백테스트에서 '그 시점에 실제로 알 수 있던 뉴스'만 사용
|
||||
|
||||
스키마:
|
||||
news_snapshots(
|
||||
id INTEGER PK,
|
||||
captured_at TEXT, # ISO8601 (KST) — 수집 시점
|
||||
query TEXT, # 수집 쿼리 (예: '주식 시장', '삼성전자')
|
||||
ticker TEXT, # 종목 코드 (종목 뉴스일 때, else NULL)
|
||||
title TEXT,
|
||||
url TEXT UNIQUE,
|
||||
pub_date TEXT, # RSS pubDate 원본
|
||||
source TEXT DEFAULT 'google_news'
|
||||
)
|
||||
sentiment_scores( # 야간 배치로 사후 생성
|
||||
news_id INTEGER PK,
|
||||
scored_at TEXT,
|
||||
model TEXT,
|
||||
sentiment REAL, # -1.0 ~ 1.0
|
||||
confidence REAL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
)
|
||||
|
||||
순수 I/O 모듈 — 네트워크 의존성 없음 → unit 테스트 가능.
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Iterable, List, Optional, Dict
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
|
||||
class NewsSnapshotStore:
|
||||
"""
|
||||
SQLite 기반 뉴스 스냅샷 저장소.
|
||||
|
||||
사용 예:
|
||||
store = NewsSnapshotStore("data/news_snapshots.db")
|
||||
store.save_many(items, query="삼성전자", ticker="005930")
|
||||
rows = store.query_between(start, end, ticker="005930")
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str):
|
||||
self.db_path = db_path
|
||||
os.makedirs(os.path.dirname(db_path) or ".", exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 스키마
|
||||
# ──────────────────────────────────────────────
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self):
|
||||
with self._connect() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS news_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
captured_at TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
ticker TEXT,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
pub_date TEXT,
|
||||
source TEXT DEFAULT 'google_news'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_captured
|
||||
ON news_snapshots(captured_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_ticker
|
||||
ON news_snapshots(ticker, captured_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sentiment_scores (
|
||||
news_id INTEGER PRIMARY KEY,
|
||||
scored_at TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
sentiment REAL NOT NULL,
|
||||
confidence REAL NOT NULL,
|
||||
raw_json TEXT,
|
||||
FOREIGN KEY (news_id) REFERENCES news_snapshots(id)
|
||||
);
|
||||
""")
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 쓰기
|
||||
# ──────────────────────────────────────────────
|
||||
def save_many(self, items: Iterable[Dict], query: str,
|
||||
ticker: Optional[str] = None,
|
||||
captured_at: Optional[datetime] = None) -> int:
|
||||
"""
|
||||
뉴스 다건 저장. URL 기준 중복 자동 무시.
|
||||
|
||||
Args:
|
||||
items: [{"title": str, "url": str, "pub_date": str?}, ...]
|
||||
|
||||
Returns:
|
||||
실제로 삽입된 행 수
|
||||
"""
|
||||
if captured_at is None:
|
||||
captured_at = datetime.now(KST)
|
||||
ts = captured_at.isoformat()
|
||||
|
||||
rows = []
|
||||
for it in items:
|
||||
title = (it.get("title") or "").strip()
|
||||
url = (it.get("url") or "").strip()
|
||||
if not title or not url:
|
||||
continue
|
||||
rows.append((ts, query, ticker, title, url, it.get("pub_date")))
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
with self._connect() as conn:
|
||||
before = conn.total_changes
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO news_snapshots "
|
||||
"(captured_at, query, ticker, title, url, pub_date) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
rows,
|
||||
)
|
||||
inserted = conn.total_changes - before
|
||||
return inserted
|
||||
|
||||
def save_sentiment(self, news_id: int, model: str,
|
||||
sentiment: float, confidence: float,
|
||||
raw_json: str = "",
|
||||
scored_at: Optional[datetime] = None) -> None:
|
||||
if scored_at is None:
|
||||
scored_at = datetime.now(KST)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO sentiment_scores "
|
||||
"(news_id, scored_at, model, sentiment, confidence, raw_json) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(news_id, scored_at.isoformat(), model,
|
||||
float(sentiment), float(confidence), raw_json),
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 읽기
|
||||
# ──────────────────────────────────────────────
|
||||
def query_between(self, start: datetime, end: datetime,
|
||||
ticker: Optional[str] = None,
|
||||
query: Optional[str] = None) -> List[sqlite3.Row]:
|
||||
"""특정 기간 내 수집된 뉴스 조회."""
|
||||
sql = "SELECT * FROM news_snapshots WHERE captured_at >= ? AND captured_at < ?"
|
||||
args = [start.isoformat(), end.isoformat()]
|
||||
if ticker is not None:
|
||||
sql += " AND ticker = ?"
|
||||
args.append(ticker)
|
||||
if query is not None:
|
||||
sql += " AND query = ?"
|
||||
args.append(query)
|
||||
sql += " ORDER BY captured_at ASC"
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(sql, args))
|
||||
|
||||
def pending_sentiment(self, limit: int = 100) -> List[sqlite3.Row]:
|
||||
"""아직 감성 점수가 없는 뉴스 반환 (야간 배치용)."""
|
||||
with self._connect() as conn:
|
||||
return list(conn.execute(
|
||||
"""SELECT n.* FROM news_snapshots n
|
||||
LEFT JOIN sentiment_scores s ON s.news_id = n.id
|
||||
WHERE s.news_id IS NULL
|
||||
ORDER BY n.captured_at DESC
|
||||
LIMIT ?""",
|
||||
(limit,)
|
||||
))
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""DB 통계 (row 수, 감성 커버리지)."""
|
||||
with self._connect() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM news_snapshots").fetchone()[0]
|
||||
scored = conn.execute("SELECT COUNT(*) FROM sentiment_scores").fetchone()[0]
|
||||
return {
|
||||
"total_news": total,
|
||||
"scored": scored,
|
||||
"pending": total - scored,
|
||||
"coverage_pct": (scored / total * 100) if total else 0.0,
|
||||
}
|
||||
@@ -6,9 +6,10 @@ import os
|
||||
import sys
|
||||
import time
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv(Path(__file__).parent.parent.parent.parent.parent / ".env")
|
||||
|
||||
|
||||
def run_telegram_bot_standalone(ipc_lock=None, command_queue=None, shutdown_event=None):
|
||||
@@ -62,6 +62,14 @@ class TelegramBotServer:
|
||||
"/system - PC 리소스(CPU/GPU) 상태\n"
|
||||
"/ai - AI 모델 학습 상태 조회\n"
|
||||
"/evaluate - 즉시 성과 평가 보고서 생성\n\n"
|
||||
"<b>[AI 진단 스킬]</b>\n"
|
||||
"/syshealth - 시스템 종합 건강 진단\n"
|
||||
"/risk - 리스크 대시보드 (MDD, 연속손절)\n"
|
||||
"/regime - 코스피 시장 레짐 감지\n"
|
||||
"/model_health - LSTM 모델 건강 체크\n"
|
||||
"/weights - 앙상블 가중치 분석\n"
|
||||
"/postmortem [일수] - 매매 사후 분석 (기본 30일)\n"
|
||||
"/watchlist_check - 감시 종목 스코어링\n\n"
|
||||
"<b>[관리 명령어]</b>\n"
|
||||
"/restart - 메인 봇 재시작 요청\n"
|
||||
"/exec <code>명령어</code> - 원격 명령어 실행\n"
|
||||
@@ -222,7 +230,11 @@ class TelegramBotServer:
|
||||
volume = int(v.get('volume', 0))
|
||||
|
||||
if price == 0:
|
||||
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
|
||||
# 장 마감 후: prev_close(전일 종가)라도 표시
|
||||
if prev_close > 0:
|
||||
msg += f"⚫ <b>{k}:</b> <code>{prev_close:,.2f}</code> <i>(전일 종가 기준, 장 마감)</i>\n\n"
|
||||
else:
|
||||
msg += f"⚫ <b>{k}:</b> <i>데이터 없음 (장 마감 후)</i>\n\n"
|
||||
continue
|
||||
|
||||
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 = "<b>AI Model Status</b>\n"
|
||||
msg += f"* <b>LLM Engine:</b> Ollama ({Config.OLLAMA_MODEL})\n"
|
||||
msg += f"* <b>Device:</b> {gpu.get('name', 'GPU')}\n"
|
||||
msg += f"* <b>LLM Engine:</b> {llm_primary}\n"
|
||||
if llm_fallback:
|
||||
msg += f"* <b>Fallback:</b> {llm_fallback}\n"
|
||||
msg += f"* <b>LSTM Device:</b> {gpu.get('name', 'GPU')}\n"
|
||||
|
||||
if gpu:
|
||||
msg += f"* <b>GPU Load:</b> <code>{gpu.get('load', 0)}%</code>\n"
|
||||
@@ -417,6 +438,121 @@ class TelegramBotServer:
|
||||
logging.error(f"[Command] /evaluate error: {e}")
|
||||
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)
|
||||
463
legacy/signal_v1/modules/services/telegram_bot/skill_runner.py
Normal file
463
legacy/signal_v1/modules/services/telegram_bot/skill_runner.py
Normal file
@@ -0,0 +1,463 @@
|
||||
"""
|
||||
Skill Runner — 텔레그램 봇에서 Claude Skills 스크립트를 실행하는 유틸리티
|
||||
|
||||
각 스킬 스크립트를 subprocess로 실행하고, 결과를 텔레그램 HTML 메시지로 포맷합니다.
|
||||
Claude Code 없이도 텔레그램 명령어만으로 분석 리포트를 받을 수 있습니다.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 봇 프로젝트 루트 (이 파일 기준 3단계 상위)
|
||||
BOT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
SKILLS_DIR = BOT_ROOT / ".claude" / "skills"
|
||||
PYTHON_EXE = sys.executable # 현재 봇과 동일한 Python 인터프리터 사용
|
||||
|
||||
|
||||
def _skill_script(skill_name: str, script_name: str) -> Path:
|
||||
return SKILLS_DIR / skill_name / "scripts" / script_name
|
||||
|
||||
|
||||
async def _run_script(script_path: Path, extra_args: Optional[list] = None,
|
||||
timeout: int = 60) -> dict:
|
||||
"""
|
||||
스킬 스크립트를 비동기 subprocess로 실행.
|
||||
--bot-path, --json 플래그를 자동으로 추가.
|
||||
반환: {"ok": bool, "output": str, "json_data": dict|None}
|
||||
"""
|
||||
if not script_path.exists():
|
||||
return {"ok": False, "output": f"스크립트 없음: {script_path}", "json_data": None}
|
||||
|
||||
cmd = [PYTHON_EXE, str(script_path),
|
||||
"--bot-path", str(BOT_ROOT),
|
||||
"--json"]
|
||||
if extra_args:
|
||||
cmd.extend(extra_args)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# PYTHONIOENCODING=utf-8: 서브프로세스 stdout에서 유니코드/이모지 출력 허용
|
||||
_env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
|
||||
result = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
cwd=str(BOT_ROOT),
|
||||
env=_env,
|
||||
)
|
||||
)
|
||||
|
||||
raw_out = result.stdout.strip()
|
||||
raw_err = result.stderr.strip()
|
||||
|
||||
# JSON 파싱 시도
|
||||
json_data = None
|
||||
if raw_out:
|
||||
try:
|
||||
json_data = json.loads(raw_out)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
if result.returncode != 0 and not raw_out:
|
||||
return {"ok": False, "output": raw_err or "알 수 없는 오류", "json_data": None}
|
||||
|
||||
return {"ok": True, "output": raw_out, "json_data": json_data}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "output": f"실행 시간 초과 ({timeout}초)", "json_data": None}
|
||||
except Exception as e:
|
||||
return {"ok": False, "output": str(e), "json_data": None}
|
||||
|
||||
|
||||
def _truncate(text: str, limit: int = 3800) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[:limit] + "\n<i>... (일부 생략)</i>"
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 스킬별 포맷터
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _fmt_syshealth(data: dict) -> str:
|
||||
ipc = data.get("ipc", {})
|
||||
gpu = data.get("gpu", {})
|
||||
token = data.get("kis_token", {})
|
||||
procs = data.get("processes", {})
|
||||
|
||||
ipc_status = ipc.get("status", "?")
|
||||
ipc_emoji = {"FRESH": "✅", "NORMAL": "✅", "STALE": "⚠️",
|
||||
"EXPIRED": "🔴", "EMPTY": "⚠️", "ERROR": "🔴"}.get(ipc_status, "❓")
|
||||
age = ipc.get("age_seconds")
|
||||
age_str = f"{age}초 전" if age is not None else "알 수 없음"
|
||||
|
||||
api_str = "✅ 실행 중" if procs.get("api_running") else "🔴 오프라인"
|
||||
token_str = "✅ 유효" if token.get("status") == "VALID" else f"🔴 {token.get('status','?')}"
|
||||
token_env = token.get("env", "?")
|
||||
|
||||
vram = gpu.get("vram_used_gb")
|
||||
vram_str = f"{vram}GB / {gpu.get('vram_total_gb', 16)}GB" if vram else "측정 불가"
|
||||
cuda_str = "✅" if gpu.get("cuda_available") else "❌"
|
||||
|
||||
# 로그 에러 집계
|
||||
logs = data.get("logs", {})
|
||||
all_errors = {}
|
||||
for ld in logs.values():
|
||||
for k, v in ld.get("errors", {}).items():
|
||||
all_errors[k] = all_errors.get(k, 0) + v
|
||||
err_lines = "\n".join(
|
||||
f" ⚠️ {k}: {v}회" for k, v in sorted(all_errors.items(), key=lambda x: x[1], reverse=True)
|
||||
) or " ✅ 없음"
|
||||
|
||||
balance = ipc.get("balance")
|
||||
balance_str = f"\n 잔고: <code>{int(balance):,}원</code>" if balance else ""
|
||||
wl_count = ipc.get("watchlist_count", 0)
|
||||
|
||||
msg = (
|
||||
f"<b>🔧 시스템 헬스 진단</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>API 서버:</b> {api_str}\n"
|
||||
f"<b>IPC 상태:</b> {ipc_emoji} {ipc_status} ({age_str})"
|
||||
f"{balance_str}\n"
|
||||
f" 감시종목: {wl_count}개\n"
|
||||
f"<b>GPU/CUDA:</b> {cuda_str} VRAM: <code>{vram_str}</code>\n"
|
||||
f"<b>KIS 토큰:</b> {token_str} ({token_env})\n\n"
|
||||
f"<b>로그 에러 (최근):</b>\n{err_lines}"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_risk(data: dict) -> str:
|
||||
mdd = data.get("mdd", {})
|
||||
dl = data.get("daily_loss", {})
|
||||
cl = data.get("consecutive_losses", {})
|
||||
cap = data.get("total_capital", 0)
|
||||
|
||||
mdd_val = mdd.get("mdd", 0) or 0
|
||||
mdd_emoji = "✅" if mdd_val > -5 else ("⚠️" if mdd_val > -10 else "🔴")
|
||||
|
||||
dl_ratio = dl.get("ratio", 0) or 0
|
||||
dl_emoji = "✅" if dl_ratio < 50 else ("⚠️" if dl_ratio < 75 else "🔴")
|
||||
|
||||
cl_count = cl.get("count", 0)
|
||||
cl_active = cl.get("cooldown_active", False)
|
||||
cl_emoji = "🚨" if cl_active else ("⚠️" if cl_count >= 2 else "✅")
|
||||
|
||||
msg = (
|
||||
f"<b>🛡️ 리스크 대시보드</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>총 자산:</b> <code>{int(cap):,}원</code>\n\n"
|
||||
f"<b>MDD:</b> {mdd_emoji} <code>{mdd_val:.1f}%</code> ({mdd.get('level','?')})\n"
|
||||
f" 최고점: <code>{int(mdd.get('peak',0) or 0):,}원</code> ({mdd.get('peak_days_ago','?')}일 전)\n"
|
||||
f" 복구 필요: <code>+{mdd.get('recovery_needed',0):.1f}%</code>\n\n"
|
||||
f"<b>일일 손실한도:</b> {dl_emoji} {dl_ratio:.0f}% 소진\n"
|
||||
f" 한도: <code>{int(dl.get('limit',0) or 0):,}원</code> "
|
||||
f"사용: <code>{int(dl.get('used',0) or 0):,}원</code>\n\n"
|
||||
f"<b>연속 손절:</b> {cl_emoji} {cl_count}회"
|
||||
)
|
||||
if cl_active:
|
||||
msg += f"\n 🚨 매수 중단 중 (재개: {cl.get('resume_time','?')})"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_regime(data: dict) -> str:
|
||||
regime = data.get("regime", "?")
|
||||
msi = data.get("msi", {})
|
||||
params = data.get("recommended_params", {})
|
||||
ens = params.get("ensemble", {})
|
||||
data_source = data.get("data_source", "ipc")
|
||||
source_note = " <i>(IPC 데이터 없음 — 기본값 기반)</i>\n" if data_source == "default" else ""
|
||||
|
||||
regime_emoji = {
|
||||
"BULL_EXTREME": "🔥", "BULL_STRONG": "📈",
|
||||
"NORMAL": "➡️", "BEAR_WEAK": "📉", "BEAR_STRONG": "🚨"
|
||||
}.get(regime, "❓")
|
||||
status_emoji = {"SAFE": "✅", "CAUTION": "⚠️", "DANGER": "🚨"}.get(msi.get("status", ""), "❓")
|
||||
|
||||
flags = msi.get("flags", {})
|
||||
flag_lines = "\n".join(f" {v}" for v in flags.values())
|
||||
|
||||
msg = (
|
||||
f"<b>📊 시장 레짐 분석</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"{source_note}"
|
||||
f"<b>레짐:</b> {regime_emoji} {regime}\n"
|
||||
f"<b>MSI:</b> {status_emoji} {msi.get('score','?')}/{msi.get('max','?')} ({msi.get('status','?')})\n\n"
|
||||
f"<b>지표 현황:</b>\n{flag_lines}\n\n"
|
||||
f"<b>권고 파라미터:</b>\n"
|
||||
f" buy_threshold: <code>{params.get('buy_threshold','?')}</code>\n"
|
||||
f" max_position: <code>{params.get('max_position_ratio','?')}</code>\n"
|
||||
f" sl_atr_mult: <code>{params.get('sl_atr_multiplier','?')}</code>\n\n"
|
||||
f"<b>앙상블 권고:</b>\n"
|
||||
f" tech: <code>{ens.get('tech','?')}</code> "
|
||||
f"lstm: <code>{ens.get('lstm','?')}</code> "
|
||||
f"sent: <code>{ens.get('sentiment','?')}</code>\n"
|
||||
f"<i>다음 점검: {params.get('next_check_days','?')}일 후</i>"
|
||||
)
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_model_health(data: dict) -> str:
|
||||
models = data.get("models", {})
|
||||
missing = data.get("missing_models", [])
|
||||
|
||||
grade_emoji = {"HEALTHY": "🟢", "WARNING": "🟡", "DEGRADED": "🟠",
|
||||
"CRITICAL": "🔴", "MISSING": "⚫"}
|
||||
grade_counts = {}
|
||||
for info in models.values():
|
||||
g = info.get("grade", "?")
|
||||
grade_counts[g] = grade_counts.get(g, 0) + 1
|
||||
|
||||
# 우선순위 높은 종목 상위 5개
|
||||
critical = [(t, i) for t, i in models.items() if i.get("grade") in ("CRITICAL", "DEGRADED")]
|
||||
critical.sort(key=lambda x: {"CRITICAL": 0, "DEGRADED": 1}.get(x[1].get("grade"), 9))
|
||||
|
||||
summary_lines = "\n".join(
|
||||
f" {grade_emoji.get(g,'?')} {g}: {cnt}개"
|
||||
for g, cnt in grade_counts.items()
|
||||
)
|
||||
critical_lines = ""
|
||||
for t, info in critical[:5]:
|
||||
critical_lines += f"\n {grade_emoji.get(info['grade'],'?')} {t}: {info.get('reason','?')}"
|
||||
|
||||
missing_str = ""
|
||||
if missing:
|
||||
missing_str = f"\n\n<b>모델 없는 감시종목:</b>\n " + ", ".join(missing[:5])
|
||||
if len(missing) > 5:
|
||||
missing_str += f" 외 {len(missing)-5}개"
|
||||
|
||||
msg = (
|
||||
f"<b>🤖 LSTM 모델 건강도</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>체크포인트 {len(models)}개:</b>\n"
|
||||
f"{summary_lines}"
|
||||
)
|
||||
if critical_lines:
|
||||
msg += f"\n\n<b>조치 필요:</b>{critical_lines}"
|
||||
msg += missing_str
|
||||
if not critical and not missing:
|
||||
msg += "\n\n✅ 모든 모델 정상"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_weights(data: dict) -> str:
|
||||
current = data.get("current_global", {})
|
||||
optimal = data.get("optimal_global", {})
|
||||
health = data.get("ema_health", {})
|
||||
contribs = data.get("signal_contributions", {})
|
||||
|
||||
issues = "\n".join(f" {i}" for i in health.get("issues", []))
|
||||
health_status = "✅" if health.get("status") == "OK" else "⚠️"
|
||||
|
||||
contrib_lines = ""
|
||||
for sig, c in contribs.items():
|
||||
if c.get("total_trades", 0) > 0:
|
||||
acc = c.get("accuracy", 0)
|
||||
contrib_lines += f"\n {sig}: 정확도 {acc:.1%} ({c['total_trades']}거래)"
|
||||
|
||||
delta_lines = ""
|
||||
for sig in ["tech", "lstm", "sentiment"]:
|
||||
cur = current.get(sig, 0)
|
||||
opt = optimal.get(sig, cur)
|
||||
diff = round(opt - cur, 3)
|
||||
arrow = "↑" if diff > 0 else ("↓" if diff < 0 else "→")
|
||||
delta_lines += f"\n {sig:12s}: {cur} {arrow} <b>{opt}</b>"
|
||||
|
||||
msg = (
|
||||
f"<b>⚖️ 앙상블 가중치</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>EMA 학습 상태:</b> {health_status}\n{issues}\n"
|
||||
)
|
||||
if contrib_lines:
|
||||
msg += f"\n<b>신호 기여도:</b>{contrib_lines}\n"
|
||||
msg += f"\n<b>권고 조정:</b>{delta_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_postmortem(data: dict) -> str:
|
||||
stats = data.get("basic_stats", {})
|
||||
combos = data.get("signal_combinations", {})
|
||||
suggestions = data.get("parameter_suggestions", {})
|
||||
days = data.get("days", 30)
|
||||
|
||||
wr = stats.get("win_rate", 0)
|
||||
pr = stats.get("profit_ratio", 0)
|
||||
wr_emoji = "✅" if wr >= 55 else ("⚠️" if wr >= 50 else "🔴")
|
||||
pr_emoji = "✅" if pr >= 2.0 else ("⚠️" if pr >= 1.5 else "🔴")
|
||||
|
||||
best_combos = list(combos.items())[:2]
|
||||
worst_combos = list(combos.items())[-2:]
|
||||
|
||||
combo_lines = ""
|
||||
for k, v in best_combos:
|
||||
combo_lines += f"\n ✅ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||
for k, v in worst_combos:
|
||||
if v["win_rate"] < 50:
|
||||
combo_lines += f"\n ⚠️ {k}: 승률 {v['win_rate']}% ({v['trades']}건)"
|
||||
|
||||
suggest_lines = ""
|
||||
for param, s in suggestions.items():
|
||||
suggest_lines += f"\n {param}: {s.get('current','?')} → <b>{s.get('recommended','?')}</b>"
|
||||
|
||||
msg = (
|
||||
f"<b>📊 매매 사후분석</b> (최근 {days}일)\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"<b>총 거래:</b> {stats.get('total',0)}건 "
|
||||
f"승률: {wr_emoji} <code>{wr}%</code>\n"
|
||||
f"<b>손익비:</b> {pr_emoji} <code>{pr}</code> "
|
||||
f"Sharpe: <code>{stats.get('sharpe',0)}</code>\n"
|
||||
f"평균 수익: <code>+{stats.get('avg_win_pct',0)}%</code> "
|
||||
f"평균 손실: <code>-{stats.get('avg_loss_pct',0)}%</code>"
|
||||
)
|
||||
if combo_lines:
|
||||
msg += f"\n\n<b>신호 조합:</b>{combo_lines}"
|
||||
if suggest_lines:
|
||||
msg += f"\n\n<b>파라미터 권고:</b>{suggest_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
def _fmt_watchlist(data: dict) -> str:
|
||||
scored = data.get("scored", [])
|
||||
current = data.get("current_watchlist", [])
|
||||
r_min, r_max = data.get("recommended_range", (8, 15))
|
||||
|
||||
to_add = [s for s in scored if s.get("action") == "편입"]
|
||||
to_remove = [s for s in scored if s.get("action") == "제거"]
|
||||
to_keep = [s for s in scored if s.get("action") == "유지" and s.get("in_watchlist")]
|
||||
to_keep.sort(key=lambda x: x.get("total_score", 0), reverse=True)
|
||||
|
||||
add_lines = ""
|
||||
for s in to_add[:5]:
|
||||
wr = f" ({s['win_rate']:.0%})" if s.get("win_rate") else ""
|
||||
add_lines += f"\n ✅ {s['ticker']} {s['total_score']}점 — {s.get('theme','?')}{wr}"
|
||||
|
||||
remove_lines = ""
|
||||
for s in to_remove:
|
||||
remove_lines += f"\n ✕ {s['ticker']} {s['total_score']}점"
|
||||
|
||||
keep_lines = ""
|
||||
for s in to_keep[:3]:
|
||||
keep_lines += f"\n • {s['ticker']} {s['total_score']}점"
|
||||
|
||||
final = len(current) - len(to_remove) + len(to_add)
|
||||
size_ok = "✅" if r_min <= final <= r_max else "⚠️"
|
||||
|
||||
msg = (
|
||||
f"<b>📋 Watchlist 분석</b>\n"
|
||||
f"━━━━━━━━━━━━━━━━━━\n"
|
||||
f"현재 {len(current)}종목 → 최종 {final}종목 {size_ok}\n"
|
||||
f"권고 규모: {r_min}~{r_max}종목"
|
||||
)
|
||||
if add_lines:
|
||||
msg += f"\n\n<b>편입 추천:</b>{add_lines}"
|
||||
if remove_lines:
|
||||
msg += f"\n\n<b>제거 추천:</b>{remove_lines}"
|
||||
if keep_lines:
|
||||
msg += f"\n\n<b>상위 유지 종목:</b>{keep_lines}"
|
||||
return msg
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# 공개 API — 텔레그램 핸들러에서 호출
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _to_chunks(text: str, limit: int = 3800) -> List[str]:
|
||||
"""메시지가 Telegram 4096자 제한을 초과하면 청크로 분할"""
|
||||
if len(text) <= limit:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
chunks.append(text[:limit])
|
||||
text = text[limit:]
|
||||
return chunks
|
||||
|
||||
|
||||
async def run_syshealth() -> List[str]:
|
||||
script = _skill_script("bot-system-health-diagnostics", "health_checker.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 시스템 헬스 실행 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_syshealth(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_risk() -> List[str]:
|
||||
script = _skill_script("auto-trade-risk-manager", "risk_dashboard.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 리스크 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_risk(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_regime() -> List[str]:
|
||||
script = _skill_script("korean-market-regime-detector", "regime_calculator.py")
|
||||
r = await _run_script(script, timeout=60)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 레짐 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_regime(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_model_health() -> List[str]:
|
||||
script = _skill_script("lstm-model-health-monitor", "model_health_report.py")
|
||||
r = await _run_script(script, timeout=60)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 모델 건강도 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_model_health(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_weights() -> List[str]:
|
||||
script = _skill_script("ensemble-weight-optimizer", "weight_optimizer.py")
|
||||
r = await _run_script(script, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 가중치 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_weights(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_postmortem(days: int = 30) -> List[str]:
|
||||
script = _skill_script("trade-post-mortem-analyzer", "post_mortem_report.py")
|
||||
r = await _run_script(script, extra_args=["--days", str(days)], timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ 매매 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_postmortem(r["json_data"]))
|
||||
if not r["output"].strip():
|
||||
return [f"<b>📊 매매 사후분석</b> (최근 {days}일)\n━━━━━━━━━━━━━━━━━━\n<i>분석 대상 매매 기록이 없습니다.</i>"]
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
|
||||
|
||||
async def run_watchlist_check(candidates: Optional[List[str]] = None) -> List[str]:
|
||||
script = _skill_script("watchlist-intelligence-curator", "watchlist_scorer.py")
|
||||
extra = []
|
||||
if candidates:
|
||||
extra = ["--candidates"] + candidates
|
||||
r = await _run_script(script, extra_args=extra, timeout=30)
|
||||
if not r["ok"]:
|
||||
return [f"⚠️ Watchlist 분석 오류:\n<code>{_escape_html(r['output'])}</code>"]
|
||||
if r["json_data"]:
|
||||
return _to_chunks(_fmt_watchlist(r["json_data"]))
|
||||
return _to_chunks(f"<pre>{_escape_html(r['output'])}</pre>")
|
||||
130
legacy/signal_v1/modules/strategy/daily_ledger.py
Normal file
130
legacy/signal_v1/modules/strategy/daily_ledger.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
일일 거래 장부 (DailyLedger) — v3.2
|
||||
|
||||
bot.py에 흩어져 있던 당일 상태를 한 객체로 집약:
|
||||
- 당일 누적 매수금액 (KIS T+2 미차감 보완용)
|
||||
- 연속 손절 카운터 + 매수 일시중단 타이머
|
||||
- 미매도 종목의 매수 신호 점수 (앙상블 학습용)
|
||||
- 일별 스냅샷/주간평가 플래그
|
||||
|
||||
날짜가 바뀌면 reset_if_new_day()가 자동 초기화.
|
||||
순수 객체로 구현 — 외부 I/O 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, date as date_cls
|
||||
from typing import Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DailyLedger:
|
||||
# ── 당일 매수 회계 ──
|
||||
today_buy_total: int = 0
|
||||
today_buy_date: Optional[date_cls] = None
|
||||
|
||||
# ── 연속 손절 / 매수 일시 중단 ──
|
||||
consecutive_stop_losses: int = 0
|
||||
buy_paused_until: Optional[datetime] = None
|
||||
stop_loss_pause_threshold: int = 3
|
||||
stop_loss_pause_minutes: int = 30
|
||||
|
||||
# ── 앙상블 학습용: 미매도 종목의 매수 신호 점수 ──
|
||||
buy_scores: Dict[str, dict] = field(default_factory=dict)
|
||||
|
||||
# ── 일일 플래그 ──
|
||||
snapshot_taken: bool = False
|
||||
weekly_eval_sent: bool = False
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 날짜 전환
|
||||
# ──────────────────────────────────────────────
|
||||
def reset_if_new_day(self, now: datetime) -> bool:
|
||||
"""
|
||||
오늘 날짜 기준으로 상태 초기화. 이미 오늘 자로 초기화됐으면 no-op.
|
||||
|
||||
Returns:
|
||||
True — 실제로 초기화를 수행한 경우
|
||||
False — 같은 날이라 그대로 둔 경우
|
||||
"""
|
||||
today = now.date()
|
||||
if self.today_buy_date == today:
|
||||
return False
|
||||
self.today_buy_total = 0
|
||||
self.today_buy_date = today
|
||||
self.buy_scores.clear()
|
||||
self.snapshot_taken = False
|
||||
self.weekly_eval_sent = False
|
||||
# 연속 손절 카운터 / 일시중단 타이머는 날짜 전환 시에만 초기화
|
||||
self.consecutive_stop_losses = 0
|
||||
self.buy_paused_until = None
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 매수 / 매도 기록
|
||||
# ──────────────────────────────────────────────
|
||||
def record_buy(self, ticker: str, amount: int, scores: dict) -> None:
|
||||
"""매수 체결 기록. amount는 집행 금액(원), scores는 앙상블 신호."""
|
||||
self.today_buy_total += int(amount)
|
||||
self.buy_scores[ticker] = dict(scores)
|
||||
|
||||
def pop_buy_scores(self, ticker: str) -> Optional[dict]:
|
||||
"""매도 체결 시 앙상블 학습을 위해 매수 당시 신호를 반환하고 제거."""
|
||||
return self.buy_scores.pop(ticker, None)
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 손절 관리
|
||||
# ──────────────────────────────────────────────
|
||||
def record_sell_outcome(self, outcome_pct: float, now: datetime) -> bool:
|
||||
"""
|
||||
매도 결과를 반영해 연속 손절 카운터 업데이트.
|
||||
|
||||
Returns:
|
||||
True — 임계치 도달 → 매수 일시중단 활성화됨
|
||||
False — 임계치 미도달
|
||||
"""
|
||||
if outcome_pct < 0:
|
||||
self.consecutive_stop_losses += 1
|
||||
if self.consecutive_stop_losses >= self.stop_loss_pause_threshold:
|
||||
self.buy_paused_until = now + timedelta(
|
||||
minutes=self.stop_loss_pause_minutes
|
||||
)
|
||||
return True
|
||||
else:
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
|
||||
def is_buy_paused(self, now: datetime) -> bool:
|
||||
"""
|
||||
매수 일시중단 상태 조회. 만료되면 자동 해제 + 카운터 리셋.
|
||||
"""
|
||||
if self.buy_paused_until is None:
|
||||
return False
|
||||
if now >= self.buy_paused_until:
|
||||
self.buy_paused_until = None
|
||||
self.consecutive_stop_losses = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 예수금 계산 (KIS T+2 보완)
|
||||
# ──────────────────────────────────────────────
|
||||
def effective_today_buy(self, kis_today_buy: int) -> int:
|
||||
"""
|
||||
KIS API가 반환한 당일 매수금(`thdt_buy_amt`)과
|
||||
로컬 누적값 중 더 큰 값을 신뢰.
|
||||
(모의투자는 T+2 미차감으로 인해 과소 보고되는 경우 있음)
|
||||
"""
|
||||
return max(int(kis_today_buy or 0), self.today_buy_total)
|
||||
|
||||
def available_deposit(self, raw_deposit: int, max_daily_buy_ratio: float,
|
||||
kis_today_buy: int = 0) -> int:
|
||||
"""
|
||||
당일 사용 가능한 예수금 계산.
|
||||
|
||||
max_daily_buy = raw_deposit × ratio
|
||||
avail = min(raw_deposit, max_daily_buy) − effective_today_buy
|
||||
"""
|
||||
if raw_deposit <= 0:
|
||||
return 0
|
||||
max_daily_buy = int(raw_deposit * max_daily_buy_ratio)
|
||||
used = self.effective_today_buy(kis_today_buy)
|
||||
return max(0, min(raw_deposit, max_daily_buy) - used)
|
||||
@@ -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:
|
||||
150
legacy/signal_v1/modules/strategy/risk_gate.py
Normal file
150
legacy/signal_v1/modules/strategy/risk_gate.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
포트폴리오 리스크 게이트 (v3.2)
|
||||
|
||||
매수 체결 직전 호출되어 포트폴리오 레벨 제약을 검증:
|
||||
1. 총 보유 종목 수 상한
|
||||
2. 테마당 동시 보유 종목 수 상한
|
||||
3. 테마당 노출 금액 비율 상한 (총자산 대비)
|
||||
|
||||
기존 매수 필터(예수금, 종목당 상한, 사이클당 매수 수)는 유지하고
|
||||
이 게이트가 "같은 테마에 집중되는 포지션"을 차단한다.
|
||||
|
||||
순수 함수로 구현 — 의존성 없음 → 단위 테스트 가능.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskDecision:
|
||||
allowed: bool
|
||||
reason: str = ""
|
||||
max_allowed_amount: int = 0 # 일부만 허용되는 경우 (테마 노출 상한)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskConfig:
|
||||
max_total_holdings: int = 7
|
||||
max_tickers_per_theme: int = 2
|
||||
max_theme_exposure_ratio: float = 0.40
|
||||
|
||||
|
||||
class PortfolioRiskGate:
|
||||
"""
|
||||
사용 예:
|
||||
gate = PortfolioRiskGate(theme_map, RiskConfig())
|
||||
decision = gate.evaluate_buy(
|
||||
ticker="005930",
|
||||
candidate_amount=3_000_000,
|
||||
current_holdings=[{"ticker":"000660","eval_amount":2_500_000}, ...],
|
||||
total_capital=50_000_000,
|
||||
)
|
||||
if not decision.allowed: skip
|
||||
elif decision.max_allowed_amount < candidate_amount: partial buy
|
||||
"""
|
||||
|
||||
def __init__(self, theme_lookup, config: Optional[RiskConfig] = None):
|
||||
"""
|
||||
Args:
|
||||
theme_lookup: callable(ticker:str) -> list[str] (종목→테마 매핑 함수)
|
||||
혹은 dict 형태도 허용.
|
||||
config: RiskConfig
|
||||
"""
|
||||
if callable(theme_lookup):
|
||||
self._theme_of = theme_lookup
|
||||
elif isinstance(theme_lookup, dict):
|
||||
self._theme_of = lambda t: theme_lookup.get(t, [])
|
||||
else:
|
||||
raise TypeError("theme_lookup must be callable or dict")
|
||||
self.config = config or RiskConfig()
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 내부: 테마별 현재 노출 집계
|
||||
# ──────────────────────────────────────────────
|
||||
def _aggregate_by_theme(self, holdings: Iterable[dict]) -> Dict[str, dict]:
|
||||
"""
|
||||
Returns:
|
||||
{theme: {"tickers": set, "amount": int}}
|
||||
"""
|
||||
agg: Dict[str, dict] = {}
|
||||
for h in holdings:
|
||||
tkr = h.get("ticker")
|
||||
amt = int(h.get("eval_amount", 0) or 0)
|
||||
if not tkr:
|
||||
continue
|
||||
themes = self._theme_of(tkr) or []
|
||||
for th in themes:
|
||||
bucket = agg.setdefault(th, {"tickers": set(), "amount": 0})
|
||||
bucket["tickers"].add(tkr)
|
||||
bucket["amount"] += amt
|
||||
return agg
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# 공개 API
|
||||
# ──────────────────────────────────────────────
|
||||
def evaluate_buy(self, ticker: str, candidate_amount: int,
|
||||
current_holdings: List[dict],
|
||||
total_capital: int) -> RiskDecision:
|
||||
"""
|
||||
매수 허가 여부 판단.
|
||||
|
||||
Returns:
|
||||
RiskDecision
|
||||
- allowed=False: 이유와 함께 차단
|
||||
- allowed=True : max_allowed_amount만큼 허용 (candidate_amount 이하)
|
||||
"""
|
||||
if candidate_amount <= 0 or total_capital <= 0:
|
||||
return RiskDecision(False, "invalid_amount")
|
||||
|
||||
cfg = self.config
|
||||
|
||||
# 이미 보유 중이면 추가 매수는 이 게이트 대상 아님 (scale-in은 상위에서 처리)
|
||||
held_tickers = {h.get("ticker") for h in current_holdings}
|
||||
is_new_position = ticker not in held_tickers
|
||||
|
||||
# 1. 총 보유 종목 수 상한
|
||||
if is_new_position and len(held_tickers) >= cfg.max_total_holdings:
|
||||
return RiskDecision(
|
||||
False,
|
||||
f"max_total_holdings: {len(held_tickers)}/{cfg.max_total_holdings}"
|
||||
)
|
||||
|
||||
themes = self._theme_of(ticker) or []
|
||||
if not themes:
|
||||
# 테마 정보 없음 → 테마 제약은 건너뛰고 통과
|
||||
return RiskDecision(True, "no_theme_info", candidate_amount)
|
||||
|
||||
by_theme = self._aggregate_by_theme(current_holdings)
|
||||
|
||||
allowed_amount = candidate_amount
|
||||
blocking_reasons = []
|
||||
|
||||
for th in themes:
|
||||
bucket = by_theme.get(th, {"tickers": set(), "amount": 0})
|
||||
|
||||
# 2. 테마당 종목 수 상한 (신규 포지션일 때만)
|
||||
if is_new_position and len(bucket["tickers"]) >= cfg.max_tickers_per_theme:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] tickers {len(bucket['tickers'])}/{cfg.max_tickers_per_theme}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 3. 테마당 노출 금액 비율 상한
|
||||
max_theme_amount = int(total_capital * cfg.max_theme_exposure_ratio)
|
||||
remaining = max_theme_amount - bucket["amount"]
|
||||
if remaining <= 0:
|
||||
blocking_reasons.append(
|
||||
f"theme[{th}] exposure {bucket['amount']:,}/{max_theme_amount:,}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 테마 잔여액이 candidate보다 작으면 부분 허용
|
||||
allowed_amount = min(allowed_amount, remaining)
|
||||
|
||||
if blocking_reasons:
|
||||
return RiskDecision(False, "; ".join(blocking_reasons))
|
||||
|
||||
if allowed_amount <= 0:
|
||||
return RiskDecision(False, "theme_exposure_full")
|
||||
|
||||
return RiskDecision(True, "ok", allowed_amount)
|
||||
213
legacy/signal_v1/modules/utils/market_calendar.py
Normal file
213
legacy/signal_v1/modules/utils/market_calendar.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
KRX (한국거래소) 시장 캘린더
|
||||
장 운영: 평일 09:00~15:30 KST (공휴일 제외)
|
||||
|
||||
우선순위:
|
||||
1. exchange_calendars 라이브러리 (pip install exchange-calendars) → 음력 자동 계산
|
||||
2. 하드코딩 폴백 (2024~2026 공휴일 내장)
|
||||
"""
|
||||
import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
MARKET_OPEN = datetime.time(9, 0)
|
||||
MARKET_CLOSE = datetime.time(15, 30)
|
||||
|
||||
# ── KRX 공휴일 하드코딩 (exchange_calendars 미설치 시 폴백) ──────────────────
|
||||
# 출처: KRX 공식 휴장일 공고 (2024~2026)
|
||||
STATIC_HOLIDAYS: frozenset[datetime.date] = frozenset({
|
||||
# 2024
|
||||
datetime.date(2024, 1, 1), # 신정
|
||||
datetime.date(2024, 2, 9), # 설날 연휴
|
||||
datetime.date(2024, 2, 12), # 대체공휴일
|
||||
datetime.date(2024, 3, 1), # 삼일절
|
||||
datetime.date(2024, 4, 10), # 국회의원선거
|
||||
datetime.date(2024, 5, 5), # 어린이날
|
||||
datetime.date(2024, 5, 6), # 대체공휴일
|
||||
datetime.date(2024, 5, 15), # 부처님오신날
|
||||
datetime.date(2024, 6, 6), # 현충일
|
||||
datetime.date(2024, 8, 15), # 광복절
|
||||
datetime.date(2024, 9, 16), # 추석 연휴
|
||||
datetime.date(2024, 9, 17), # 추석
|
||||
datetime.date(2024, 9, 18), # 추석 연휴
|
||||
datetime.date(2024, 10, 3), # 개천절
|
||||
datetime.date(2024, 10, 9), # 한글날
|
||||
datetime.date(2024, 12, 25), # 성탄절
|
||||
datetime.date(2024, 12, 31), # 연말 휴장
|
||||
# 2025
|
||||
datetime.date(2025, 1, 1), # 신정
|
||||
datetime.date(2025, 1, 28), # 설날 연휴
|
||||
datetime.date(2025, 1, 29), # 설날
|
||||
datetime.date(2025, 1, 30), # 설날 연휴
|
||||
datetime.date(2025, 3, 1), # 삼일절
|
||||
datetime.date(2025, 3, 3), # 대체공휴일
|
||||
datetime.date(2025, 5, 5), # 어린이날
|
||||
datetime.date(2025, 5, 6), # 대체공휴일
|
||||
datetime.date(2025, 6, 6), # 현충일
|
||||
datetime.date(2025, 8, 15), # 광복절
|
||||
datetime.date(2025, 10, 2), # 대체공휴일
|
||||
datetime.date(2025, 10, 3), # 개천절
|
||||
datetime.date(2025, 10, 6), # 추석 연휴
|
||||
datetime.date(2025, 10, 7), # 추석
|
||||
datetime.date(2025, 10, 8), # 추석 연휴
|
||||
datetime.date(2025, 10, 9), # 한글날
|
||||
datetime.date(2025, 12, 25), # 성탄절
|
||||
datetime.date(2025, 12, 31), # 연말 휴장
|
||||
# 2026
|
||||
datetime.date(2026, 1, 1), # 신정
|
||||
datetime.date(2026, 2, 16), # 설날 연휴
|
||||
datetime.date(2026, 2, 17), # 설날
|
||||
datetime.date(2026, 2, 18), # 설날 연휴
|
||||
datetime.date(2026, 3, 1), # 삼일절
|
||||
datetime.date(2026, 3, 2), # 대체공휴일
|
||||
datetime.date(2026, 5, 5), # 어린이날
|
||||
datetime.date(2026, 5, 24), # 부처님오신날
|
||||
datetime.date(2026, 6, 6), # 현충일
|
||||
datetime.date(2026, 8, 14), # 대체공휴일
|
||||
datetime.date(2026, 8, 15), # 광복절
|
||||
datetime.date(2026, 9, 24), # 추석 연휴
|
||||
datetime.date(2026, 9, 25), # 추석
|
||||
datetime.date(2026, 10, 3), # 개천절
|
||||
datetime.date(2026, 10, 9), # 한글날
|
||||
datetime.date(2026, 12, 25), # 성탄절
|
||||
datetime.date(2026, 12, 31), # 연말 휴장
|
||||
})
|
||||
|
||||
|
||||
class KRXCalendar:
|
||||
"""
|
||||
KRX 시장 캘린더
|
||||
|
||||
>>> cal = KRXCalendar()
|
||||
>>> cal.is_trading_day(datetime.date(2026, 1, 1)) # 신정
|
||||
False
|
||||
>>> cal.is_trading_day(datetime.date(2026, 1, 2)) # 평일
|
||||
True
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._ec_cal = None
|
||||
try:
|
||||
import exchange_calendars as ec
|
||||
self._ec_cal = ec.get_calendar("XKRX")
|
||||
print("[KRXCalendar] exchange_calendars 로드 성공 (정확한 음력 공휴일 사용)")
|
||||
except ImportError:
|
||||
print("[KRXCalendar] exchange_calendars 미설치 → 하드코딩 폴백 (pip install exchange-calendars 권장)")
|
||||
except Exception as e:
|
||||
print(f"[KRXCalendar] exchange_calendars 로드 실패: {e} → 폴백 사용")
|
||||
|
||||
# ── 날짜 판별 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def is_trading_day(self, date: datetime.date | None = None) -> bool:
|
||||
"""주어진 날짜가 KRX 거래일인지 확인 (기본: 오늘 KST)"""
|
||||
if date is None:
|
||||
date = datetime.datetime.now(KST).date()
|
||||
if date.weekday() >= 5: # 토(5), 일(6)
|
||||
return False
|
||||
if self._ec_cal:
|
||||
try:
|
||||
return self._ec_cal.is_session(date.isoformat())
|
||||
except Exception:
|
||||
pass
|
||||
return date not in STATIC_HOLIDAYS
|
||||
|
||||
def now_kst(self) -> datetime.datetime:
|
||||
"""현재 KST 시각"""
|
||||
return datetime.datetime.now(KST)
|
||||
|
||||
def is_market_open(self) -> bool:
|
||||
"""현재 KST 기준 장 중 여부 (09:00 ≤ time < 15:30)"""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return False
|
||||
return MARKET_OPEN <= now.time() < MARKET_CLOSE
|
||||
|
||||
def is_pre_market(self) -> bool:
|
||||
"""장 시작 전 (당일 거래일이고 09:00 이전)"""
|
||||
now = self.now_kst()
|
||||
return self.is_trading_day(now.date()) and now.time() < MARKET_OPEN
|
||||
|
||||
def is_post_market(self) -> bool:
|
||||
"""장 마감 후 (당일 거래일이고 15:30 이후)"""
|
||||
now = self.now_kst()
|
||||
return self.is_trading_day(now.date()) and now.time() >= MARKET_CLOSE
|
||||
|
||||
# ── 다음 장 시각 계산 ──────────────────────────────────────────────────────
|
||||
|
||||
def next_trading_open(self) -> datetime.datetime:
|
||||
"""
|
||||
다음 장 시작 시각 (KST)
|
||||
- 오늘이 거래일이고 아직 09:00 이전 → 오늘 09:00 반환
|
||||
- 그 외 → 다음 거래일 09:00 반환
|
||||
"""
|
||||
now = self.now_kst()
|
||||
date = now.date()
|
||||
if self.is_trading_day(date) and now.time() < MARKET_OPEN:
|
||||
return datetime.datetime.combine(date, MARKET_OPEN, tzinfo=KST)
|
||||
# 다음 거래일 탐색 (최대 14일)
|
||||
next_date = date + datetime.timedelta(days=1)
|
||||
for _ in range(14):
|
||||
if self.is_trading_day(next_date):
|
||||
return datetime.datetime.combine(next_date, MARKET_OPEN, tzinfo=KST)
|
||||
next_date += datetime.timedelta(days=1)
|
||||
raise RuntimeError("14일 이내에 거래일을 찾지 못했습니다.")
|
||||
|
||||
def today_close(self) -> datetime.datetime | None:
|
||||
"""오늘 장 종료 시각. 오늘이 거래일이 아니면 None."""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return None
|
||||
return datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||
|
||||
# ── 잔여 시간 계산 ──────────────────────────────────────────────────────────
|
||||
|
||||
def seconds_to_open(self) -> float:
|
||||
"""장 시작까지 남은 초 (이미 장 중이거나 장 마감 후면 0)"""
|
||||
if self.is_market_open():
|
||||
return 0.0
|
||||
try:
|
||||
return max(0.0, (self.next_trading_open() - self.now_kst()).total_seconds())
|
||||
except RuntimeError:
|
||||
return 0.0
|
||||
|
||||
def seconds_to_close(self) -> float:
|
||||
"""장 종료까지 남은 초 (장 외 시간이면 0)"""
|
||||
now = self.now_kst()
|
||||
if not self.is_trading_day(now.date()):
|
||||
return 0.0
|
||||
close_dt = datetime.datetime.combine(now.date(), MARKET_CLOSE, tzinfo=KST)
|
||||
return max(0.0, (close_dt - now).total_seconds())
|
||||
|
||||
def minutes_to_close(self) -> float:
|
||||
return self.seconds_to_close() / 60
|
||||
|
||||
# ── 상태 요약 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def status_summary(self) -> str:
|
||||
"""현재 시장 상태 요약 문자열 (로그/알림용)"""
|
||||
now = self.now_kst()
|
||||
today = now.date()
|
||||
if not self.is_trading_day(today):
|
||||
try:
|
||||
nxt = self.next_trading_open()
|
||||
return f"휴장 | 다음 거래일: {nxt.strftime('%m/%d(%a) %H:%M')}"
|
||||
except Exception:
|
||||
return "휴장"
|
||||
if self.is_market_open():
|
||||
mins = int(self.minutes_to_close())
|
||||
return f"장 중 | 마감까지 {mins}분"
|
||||
if now.time() < MARKET_OPEN:
|
||||
secs = self.seconds_to_open()
|
||||
return f"장 시작 전 | 개장까지 {int(secs / 60)}분"
|
||||
return "장 마감"
|
||||
|
||||
|
||||
# 싱글톤 (프로세스 내 공유)
|
||||
_calendar: KRXCalendar | None = None
|
||||
|
||||
|
||||
def get_calendar() -> KRXCalendar:
|
||||
global _calendar
|
||||
if _calendar is None:
|
||||
_calendar = KRXCalendar()
|
||||
return _calendar
|
||||
@@ -11,7 +11,7 @@ from multiprocessing.shared_memory import SharedMemory
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
# EOD 마커 파일: 오늘 장 마감 후 봇이 기록, Watchdog가 재시작 여부 결정에 사용
|
||||
class ProcessTracker:
|
||||
"""메모리 기반 프로세스 추적기"""
|
||||
|
||||
@@ -150,7 +150,7 @@ class ProcessWatchdog:
|
||||
if proc.is_alive():
|
||||
continue
|
||||
|
||||
# 프로세스가 죽었음
|
||||
# 프로세스가 종료됨
|
||||
exit_code = proc.exitcode
|
||||
restart_count = entry['restart_count']
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from modules.services.kis import KISClient
|
||||
from modules.services.ollama import OllamaManager
|
||||
from modules.services.news import NewsCollector
|
||||
|
||||
load_dotenv()
|
||||
load_dotenv(Path(__file__).parent.parent / ".env")
|
||||
|
||||
class WatchlistManager:
|
||||
"""
|
||||
3
legacy/start_v1.bat
Normal file
3
legacy/start_v1.bat
Normal file
@@ -0,0 +1,3 @@
|
||||
@echo off
|
||||
cd /d "%~dp0\signal_v1"
|
||||
python main_server.py
|
||||
@@ -1,230 +0,0 @@
|
||||
"""
|
||||
앙상블 예측 모듈 (Phase 3-2)
|
||||
- LSTM + 기술지표 + LLM 감성 → 적응형 가중치
|
||||
- 과거 매매 결과 기반 가중치 자동 조정
|
||||
- process.py의 하드코딩된 w_tech/w_news/w_ai 대체
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import numpy as np
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
|
||||
from modules.config import Config
|
||||
|
||||
|
||||
@dataclass
|
||||
class SignalWeights:
|
||||
"""앙상블 가중치"""
|
||||
tech: float = 0.35
|
||||
sentiment: float = 0.30
|
||||
lstm: float = 0.35
|
||||
|
||||
def normalize(self):
|
||||
total = self.tech + self.sentiment + self.lstm
|
||||
if total > 0:
|
||||
self.tech /= total
|
||||
self.sentiment /= total
|
||||
self.lstm /= total
|
||||
return self
|
||||
|
||||
def to_dict(self):
|
||||
return {"tech": self.tech, "sentiment": self.sentiment, "lstm": self.lstm}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d):
|
||||
return cls(tech=d.get("tech", 0.35),
|
||||
sentiment=d.get("sentiment", 0.30),
|
||||
lstm=d.get("lstm", 0.35))
|
||||
|
||||
|
||||
class AdaptiveEnsemble:
|
||||
"""
|
||||
적응형 앙상블 가중치 관리자
|
||||
|
||||
핵심 로직:
|
||||
1. 종목별 최근 N 매매의 결과를 추적
|
||||
2. 어떤 신호가 정확했는지 소급 평가
|
||||
3. 정확도가 높은 신호의 가중치를 점진적으로 증가
|
||||
4. 시장 상황(ADX, 거시경제) 반영한 컨텍스트별 가중치 분리
|
||||
"""
|
||||
|
||||
def __init__(self, history_file=None, max_history=50):
|
||||
self.max_history = max_history
|
||||
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}, ...]}
|
||||
self._trade_history: Dict[str, list] = {}
|
||||
# {context: SignalWeights} - context: "strong_trend" | "sideways" | "danger"
|
||||
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),
|
||||
}
|
||||
self._load()
|
||||
|
||||
def _load(self):
|
||||
if os.path.exists(self.history_file):
|
||||
try:
|
||||
with open(self.history_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
self._trade_history = data.get("history", {})
|
||||
weights_raw = data.get("weights", {})
|
||||
for ctx, w in weights_raw.items():
|
||||
self._context_weights[ctx] = SignalWeights.from_dict(w)
|
||||
except Exception as e:
|
||||
print(f"[Ensemble] Load failed: {e}")
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
data = {
|
||||
"history": {k: v[-self.max_history:] for k, v in self._trade_history.items()},
|
||||
"weights": {ctx: w.to_dict() for ctx, w in self._context_weights.items()}
|
||||
}
|
||||
with open(self.history_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
print(f"[Ensemble] Save failed: {e}")
|
||||
|
||||
def get_context(self, adx: float, macro_state: str) -> str:
|
||||
"""현재 시장 컨텍스트 결정"""
|
||||
if macro_state == "DANGER":
|
||||
return "danger"
|
||||
if adx >= 25:
|
||||
return "strong_trend"
|
||||
if adx < 20:
|
||||
return "sideways"
|
||||
return "default"
|
||||
|
||||
def get_weights(self, ticker: str, adx: float = 20.0,
|
||||
macro_state: str = "SAFE",
|
||||
ai_confidence: float = 0.5) -> SignalWeights:
|
||||
"""
|
||||
종목 + 시장 컨텍스트에 맞는 가중치 반환
|
||||
|
||||
1. 기본: 컨텍스트별 기준 가중치
|
||||
2. AI 신뢰도 높으면 lstm 가중치 보정
|
||||
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])
|
||||
|
||||
# 정확도 기반 가중치 미세 조정 (±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)))
|
||||
|
||||
# AI 신뢰도 보정
|
||||
if ai_confidence >= 0.85:
|
||||
adjusted.lstm = min(0.70, adjusted.lstm * 1.3)
|
||||
elif ai_confidence < 0.5:
|
||||
adjusted.lstm = max(0.10, adjusted.lstm * 0.7)
|
||||
|
||||
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,
|
||||
weights: Optional[SignalWeights] = None) -> float:
|
||||
"""
|
||||
앙상블 통합 점수 계산
|
||||
|
||||
Args:
|
||||
weights: 가중치 (None이면 기본값 사용)
|
||||
"""
|
||||
if weights is None:
|
||||
weights = SignalWeights()
|
||||
|
||||
total = (weights.tech * tech_score
|
||||
+ weights.sentiment * sentiment_score
|
||||
+ weights.lstm * lstm_score)
|
||||
|
||||
# 수급 가산점 (최대 +0.15)
|
||||
total += min(investor_score, 0.15)
|
||||
return min(1.0, max(0.0, total))
|
||||
|
||||
@staticmethod
|
||||
def _accuracy(scores: list, outcomes: list) -> float:
|
||||
"""신호와 결과의 상관도 계산 (0.5 = 무관, 1.0 = 완전 일치)"""
|
||||
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)
|
||||
|
||||
|
||||
# 전역 싱글톤
|
||||
_ensemble_instance: Optional[AdaptiveEnsemble] = None
|
||||
|
||||
|
||||
def get_ensemble() -> AdaptiveEnsemble:
|
||||
"""워커 프로세스 내 싱글톤 앙상블 관리자"""
|
||||
global _ensemble_instance
|
||||
if _ensemble_instance is None:
|
||||
_ensemble_instance = AdaptiveEnsemble()
|
||||
return _ensemble_instance
|
||||
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
# Signal V2 dependencies (added 2026-05-16, Phase 2)
|
||||
httpx>=0.27
|
||||
fastapi>=0.110
|
||||
uvicorn>=0.27
|
||||
python-dotenv>=1.0
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
respx>=0.21
|
||||
websockets>=12
|
||||
# Phase 3b dependencies (Chronos-2 + ML)
|
||||
transformers>=4.40
|
||||
chronos-forecasting>=1.4
|
||||
# torch: typically already installed via V1 venv; if not, install with CUDA support manually
|
||||
0
services/_shared/__init__.py
Normal file
0
services/_shared/__init__.py
Normal file
2
services/_shared/pytest.ini
Normal file
2
services/_shared/pytest.ini
Normal file
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
135
services/_shared/reliable_queue.py
Normal file
135
services/_shared/reliable_queue.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""F6 — Reliable Redis queue with processing list + recovery + retry.
|
||||
|
||||
Pattern:
|
||||
- BLMOVE main → processing (atomic dequeue)
|
||||
- ack: LREM processing (1 occurrence)
|
||||
- fail: LREM processing + (re-enqueue with attempts++ OR move to dead-letter)
|
||||
- recover: startup-time orphan recovery (worker's processing list → main queue)
|
||||
|
||||
Producer side stays unchanged: LPUSH queue:<x> <json payload>.
|
||||
Worker side: dequeue() → process → ack(raw) on success or fail(raw, payload) on error.
|
||||
Startup: await queue.recover() to re-enqueue orphans.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def default_worker_id(queue_key: str) -> str:
|
||||
"""env WORKER_ID > hostname-pid."""
|
||||
explicit = os.getenv("WORKER_ID")
|
||||
if explicit:
|
||||
return explicit
|
||||
return f"{queue_key}-{socket.gethostname()}-{os.getpid()}"
|
||||
|
||||
|
||||
class ReliableQueue:
|
||||
"""BLMOVE-backed atomic dequeue + processing list + retry/dead-letter."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
redis,
|
||||
queue_key: str,
|
||||
worker_id: Optional[str] = None,
|
||||
max_attempts: int = 3,
|
||||
):
|
||||
self._redis = redis
|
||||
self._queue_key = queue_key
|
||||
self._worker_id = worker_id or default_worker_id(queue_key)
|
||||
self._processing_key = f"processing:{queue_key}:{self._worker_id}"
|
||||
self._dead_letter_key = f"dead_letter:{queue_key}"
|
||||
self._max_attempts = max_attempts
|
||||
|
||||
@property
|
||||
def worker_id(self) -> str:
|
||||
return self._worker_id
|
||||
|
||||
@property
|
||||
def processing_key(self) -> str:
|
||||
return self._processing_key
|
||||
|
||||
async def dequeue(self, timeout: int = 5) -> Optional[tuple[dict, bytes]]:
|
||||
"""Atomically move 1 item from main queue tail to processing head.
|
||||
|
||||
Returns (parsed_dict, raw_bytes) or None on timeout/parse-error.
|
||||
Caller MUST call ack(raw) on success or fail(raw, payload) on error.
|
||||
"""
|
||||
raw = await self._redis.blmove(
|
||||
self._queue_key, self._processing_key,
|
||||
timeout, "RIGHT", "LEFT",
|
||||
)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.error(
|
||||
"invalid payload on dequeue, moving to dead-letter: %r", raw[:200]
|
||||
)
|
||||
await self._redis.lrem(self._processing_key, 1, raw)
|
||||
await self._redis.lpush(self._dead_letter_key, raw)
|
||||
return None
|
||||
return payload, raw
|
||||
|
||||
async def ack(self, raw: bytes) -> None:
|
||||
"""Successful processing — remove from processing list."""
|
||||
removed = await self._redis.lrem(self._processing_key, 1, raw)
|
||||
if removed == 0:
|
||||
logger.warning("ack on missing payload (already removed?): %r", raw[:100])
|
||||
|
||||
async def fail(self, raw: bytes, payload: dict) -> None:
|
||||
"""Failed processing — remove from processing list and re-enqueue or dead-letter."""
|
||||
await self._redis.lrem(self._processing_key, 1, raw)
|
||||
attempts = int(payload.get("attempts", 0)) + 1
|
||||
if attempts >= self._max_attempts:
|
||||
payload["attempts"] = attempts
|
||||
await self._redis.lpush(self._dead_letter_key, json.dumps(payload).encode())
|
||||
logger.error(
|
||||
"task moved to dead-letter after %d attempts: task_id=%s",
|
||||
attempts, payload.get("task_id"),
|
||||
)
|
||||
return
|
||||
payload["attempts"] = attempts
|
||||
await self._redis.lpush(self._queue_key, json.dumps(payload).encode())
|
||||
logger.info(
|
||||
"task re-enqueued (attempt %d/%d): task_id=%s",
|
||||
attempts, self._max_attempts, payload.get("task_id"),
|
||||
)
|
||||
|
||||
async def recover(self) -> int:
|
||||
"""Startup: move all orphans from this worker's processing list back to main queue.
|
||||
|
||||
Increments attempts counter (orphan == implicit failure). Returns count.
|
||||
"""
|
||||
count = 0
|
||||
while True:
|
||||
raw = await self._redis.lpop(self._processing_key)
|
||||
if raw is None:
|
||||
break
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
await self._redis.lpush(self._dead_letter_key, raw)
|
||||
count += 1
|
||||
continue
|
||||
payload["attempts"] = int(payload.get("attempts", 0)) + 1
|
||||
if payload["attempts"] >= self._max_attempts:
|
||||
await self._redis.lpush(
|
||||
self._dead_letter_key, json.dumps(payload).encode()
|
||||
)
|
||||
else:
|
||||
await self._redis.lpush(
|
||||
self._queue_key, json.dumps(payload).encode()
|
||||
)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(
|
||||
"recovered %d orphaned items for worker %s", count, self._worker_id
|
||||
)
|
||||
return count
|
||||
1
services/_shared/requirements.txt
Normal file
1
services/_shared/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
redis>=5.0.0
|
||||
0
services/_shared/tests/__init__.py
Normal file
0
services/_shared/tests/__init__.py
Normal file
84
services/_shared/tests/test_reliable_queue.py
Normal file
84
services/_shared/tests/test_reliable_queue.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""F6 — ReliableQueue: atomic dequeue + recovery + retry."""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import fakeredis.aioredis
|
||||
import pytest
|
||||
|
||||
# Make `_shared` importable when tests run from services/_shared
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||
|
||||
from _shared.reliable_queue import ReliableQueue
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def redis():
|
||||
r = fakeredis.aioredis.FakeRedis(decode_responses=False)
|
||||
yield r
|
||||
await r.flushall()
|
||||
await r.aclose()
|
||||
|
||||
|
||||
async def test_dequeue_atomically_moves_to_processing(redis):
|
||||
"""BLMOVE: queue → processing 원자적 이동."""
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||
result = await q.dequeue(timeout=1)
|
||||
assert result is not None
|
||||
payload, raw = result
|
||||
assert payload["task_id"] == "t1"
|
||||
assert await redis.llen("queue:test") == 0
|
||||
assert await redis.llen("processing:queue:test:w1") == 1
|
||||
|
||||
|
||||
async def test_dequeue_returns_none_on_timeout(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
result = await q.dequeue(timeout=1)
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_ack_removes_from_processing(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1"}).encode())
|
||||
_, raw = await q.dequeue(timeout=1)
|
||||
await q.ack(raw)
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
|
||||
|
||||
async def test_recover_returns_orphaned_to_main_queue(redis):
|
||||
"""startup recovery: 잔존 processing list 항목을 main queue로 되돌림."""
|
||||
orphan = json.dumps({"task_id": "t1", "attempts": 0}).encode()
|
||||
await redis.lpush("processing:queue:test:w1", orphan)
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1")
|
||||
recovered = await q.recover()
|
||||
assert recovered == 1
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
payload, _ = await q.dequeue(timeout=1)
|
||||
assert payload["task_id"] == "t1"
|
||||
assert payload["attempts"] == 1 # incremented on recover
|
||||
|
||||
|
||||
async def test_fail_below_max_attempts_returns_to_main_queue(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||
await redis.lpush("queue:test", json.dumps({"task_id": "t1", "attempts": 0}).encode())
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
await q.fail(raw, payload)
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
assert await redis.llen("queue:test") == 1
|
||||
requeued_raw = await redis.lindex("queue:test", 0)
|
||||
requeued = json.loads(requeued_raw)
|
||||
assert requeued["attempts"] == 1
|
||||
|
||||
|
||||
async def test_fail_at_max_attempts_moves_to_dead_letter(redis):
|
||||
q = ReliableQueue(redis, queue_key="queue:test", worker_id="w1", max_attempts=3)
|
||||
await redis.lpush(
|
||||
"queue:test", json.dumps({"task_id": "t1", "attempts": 2}).encode()
|
||||
)
|
||||
payload, raw = await q.dequeue(timeout=1)
|
||||
await q.fail(raw, payload)
|
||||
# attempts 2 → 3 (== max) → dead-letter
|
||||
assert await redis.llen("queue:test") == 0
|
||||
assert await redis.llen("processing:queue:test:w1") == 0
|
||||
assert await redis.llen("dead_letter:queue:test") == 1
|
||||
128
services/docker-compose.yml
Normal file
128
services/docker-compose.yml
Normal file
@@ -0,0 +1,128 @@
|
||||
name: web-ai-services
|
||||
|
||||
services:
|
||||
insta-render:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: insta-render/Dockerfile
|
||||
container_name: insta-render
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18710:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18700}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- INSTA_MEDIA_ROOT=${INSTA_MEDIA_ROOT:-/mnt/nas/webpage/data/insta/insta_cards}
|
||||
- INSTA_MEDIA_URL_PREFIX=${INSTA_MEDIA_URL_PREFIX:-/media/insta}
|
||||
- CARD_TEMPLATE_DIR=/app/templates
|
||||
volumes:
|
||||
- /mnt/nas/webpage/data/insta:/mnt/nas/webpage/data/insta
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
music-render:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: music-render/Dockerfile
|
||||
container_name: music-render
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18711:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18600}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- SUNO_API_KEY=${SUNO_API_KEY:-}
|
||||
- MUSIC_AI_SERVER_URL=${MUSIC_AI_SERVER_URL:-http://host.docker.internal:8765}
|
||||
- MUSIC_MEDIA_ROOT=${MUSIC_MEDIA_ROOT:-/mnt/nas/webpage/data/music}
|
||||
- MUSIC_MEDIA_URL_PREFIX=${MUSIC_MEDIA_URL_PREFIX:-/media/music}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /mnt/nas/webpage/data/music:/mnt/nas/webpage/data/music
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
video-render:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: video-render/Dockerfile
|
||||
container_name: video-render
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18712:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18801}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- KLING_ACCESS_KEY=${KLING_ACCESS_KEY:-}
|
||||
- KLING_SECRET_KEY=${KLING_SECRET_KEY:-}
|
||||
- SEEDANCE_API_KEY=${SEEDANCE_API_KEY:-}
|
||||
- VIDEO_MEDIA_ROOT=${VIDEO_MEDIA_ROOT:-/mnt/nas/webpage/data/video}
|
||||
- VIDEO_MEDIA_URL_PREFIX=${VIDEO_MEDIA_URL_PREFIX:-/media/video}
|
||||
volumes:
|
||||
- /mnt/nas/webpage/data/video:/mnt/nas/webpage/data/video
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
task-watcher:
|
||||
build:
|
||||
context: ./task-watcher
|
||||
container_name: task-watcher
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18713:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500}
|
||||
- TRADING_START=${TRADING_START:-07:00}
|
||||
- TRADING_END=${TRADING_END:-16:30}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
image-render:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: image-render/Dockerfile
|
||||
container_name: image-render
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "18714:8000"
|
||||
environment:
|
||||
- TZ=Asia/Seoul
|
||||
- REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379}
|
||||
- NAS_BASE_URL=${NAS_BASE_URL:-http://192.168.45.54:18802}
|
||||
- INTERNAL_API_KEY=${INTERNAL_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- COMFYUI_URL=${COMFYUI_URL:-http://host.docker.internal:8188}
|
||||
- FLUX_BLOCK_TRADING_HOURS=${FLUX_BLOCK_TRADING_HOURS:-1}
|
||||
- IMAGE_MEDIA_ROOT=${IMAGE_MEDIA_ROOT:-/mnt/nas/webpage/data/image}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- /mnt/nas/webpage/data/image:/mnt/nas/webpage/data/image
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
|
||||
interval: 60s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
19
services/image-render/Dockerfile
Normal file
19
services/image-render/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim-bookworm
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY image-render/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt
|
||||
|
||||
# F6: 공통 ReliableQueue 모듈 (services/_shared)
|
||||
COPY _shared /app/_shared
|
||||
COPY image-render/. /app/
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
||||
5
services/image-render/conftest.py
Normal file
5
services/image-render/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Make services/ root importable so `from _shared.reliable_queue import ...` works during tests."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
18
services/image-render/env.example
Normal file
18
services/image-render/env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Redis (NAS)
|
||||
REDIS_URL=redis://192.168.45.54:6379
|
||||
|
||||
# NAS image-lab webhook
|
||||
NAS_BASE_URL=http://192.168.45.54:18802
|
||||
INTERNAL_API_KEY=replace-me
|
||||
|
||||
# API provider keys (worker reports failed if missing)
|
||||
OPENAI_API_KEY=
|
||||
GEMINI_API_KEY=
|
||||
# Seedance key not used by image-render
|
||||
|
||||
# FLUX local
|
||||
COMFYUI_URL=http://host.docker.internal:8188
|
||||
FLUX_BLOCK_TRADING_HOURS=1
|
||||
|
||||
# NAS SMB mount target (image-render writes to this, NAS reads via /media/image/)
|
||||
IMAGE_MEDIA_ROOT=/mnt/nas/webpage/data/image
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user