Compare commits
81 Commits
b690900cfc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fb3d12244 | |||
| 789a807d50 | |||
| 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 |
27
.gitignore
vendored
27
.gitignore
vendored
@@ -47,11 +47,11 @@ daily_trade_history.json
|
||||
watchlist.json
|
||||
bot_ipc.json
|
||||
|
||||
# Test (top-level only; signal_v2/tests tracked separately)
|
||||
# Test (top-level only; ai_trade/tests tracked separately)
|
||||
tests/
|
||||
tests/*
|
||||
!signal_v2/tests/
|
||||
!signal_v2/tests/**
|
||||
!ai_trade/tests/
|
||||
!ai_trade/tests/**
|
||||
|
||||
# System
|
||||
Thumbs.db
|
||||
@@ -63,5 +63,22 @@ KIS_SETUP.md
|
||||
.claude/
|
||||
|
||||
# Signal V2 runtime data
|
||||
signal_v2/data/*.db
|
||||
signal_v2/data/*.db-*
|
||||
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
|
||||
|
||||
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
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).
|
||||
152
CLAUDE.md
152
CLAUDE.md
@@ -1,24 +1,150 @@
|
||||
# web-ai — Workspace 가이드
|
||||
|
||||
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti) 의 두 시그널 파이프라인 컨테이너.
|
||||
Windows AI 머신 (AMD 9800X3D + RTX 5070 Ti 16GB) 의 두 신호 파이프라인.
|
||||
**Confidence Signal Pipeline V2 의 Windows-side 구현체** (NAS stock 백엔드와 HTTP 연동).
|
||||
|
||||
상위 워크스페이스 컨텍스트는 `../CLAUDE.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
| 경로 | 역할 | 상태 |
|
||||
|------|------|------|
|
||||
| `signal_v1/` | V1 자체 자동매매 시스템 (main_server.py + Trading Bot + Telegram Bot + LSTM + Ollama + KIS 자동주문) | 운영 중. Confidence Signal Pipeline V2 Phase 6 에서 deprecation 예정 |
|
||||
| `signal_v2/` | V2 신호 파이프라인 (stock pull worker + Chronos-2 + signal API client) | Phase 2 에서 신설 |
|
||||
| `.env` | V1 + V2 환경변수 공유 | KIS_*, TELEGRAM_*, STOCK_API_URL, WEBAI_API_KEY 등 |
|
||||
| `start.bat` | V1 진입 (signal_v1 디렉토리 안 main_server.py 실행) | V2 별도 start 스크립트는 signal_v2/start.bat |
|
||||
| 경로 | 역할 | 포트 | 상태 |
|
||||
|------|------|------|------|
|
||||
| `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 시작: `start.bat` 또는 `cd signal_v1 && python main_server.py`
|
||||
- V2 시작 (Phase 2 이후): `cd signal_v2 && python -m uvicorn main:app --port 8001`
|
||||
- 둘 다 동시 실행 가능 (포트 분리: V1=8000, V2=8001)
|
||||
---
|
||||
|
||||
## 서버 시작 방식
|
||||
|
||||
### 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)
|
||||
|
||||
`web-ui/docs/superpowers/specs/2026-05-15-confidence-signal-pipeline-v2-architecture.md` 참조.
|
||||
| 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주 |
|
||||
|
||||
자세한 V1 가이드는 `signal_v1/CLAUDE.md` 참조.
|
||||
상세 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` 참조 (있다면).
|
||||
|
||||
---
|
||||
|
||||
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **AI**
|
||||
|
||||
이 세션은 AI 리서치(AI) 역할이다. co-gahusb MCP 툴로 다른 세션(FE/BE/Producer)과 협업한다.
|
||||
- **소유권**: 이 세션은 `web-ai` repo만 쓴다(FE=web-ui, BE=web-backend).
|
||||
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "AI")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||
- **모든 툴 호출에 `role="AI"`** (또는 `from_role`/`created_by`에 AI).
|
||||
- **수신**: `/loop`로 주기적으로 `read_inbox("AI", after_id=<last>)` + `list_tasks(assignee_role="AI")` 확인.
|
||||
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지). `.mcp.json`의 `${CO_BUS_KEY}`가 프로세스 환경변수에서 치환됨 → `setx CO_BUS_KEY "..."` 후 새 터미널에서 `claude` 실행.
|
||||
|
||||
211
README.md
Normal file
211
README.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# web-ai
|
||||
|
||||
Windows AI 머신(AMD 9800X3D + RTX 5070 Ti 16GB)에서 동작하는 두 영역의 서비스:
|
||||
|
||||
1. **ai_trade** — Confidence Signal Pipeline V2. NAS stock 백엔드와 KIS Open API를 결합해 매수/매도 신호를 생성하는 FastAPI 워커.
|
||||
2. **services** — NAS↔Windows 분산 렌더링 워커(인스타 카드 / 음악 / 영상 / 이미지) + task-watcher.
|
||||
|
||||
상위 워크스페이스 컨텍스트는 `../CLAUDE.md`, 본 디렉토리 상세는 `CLAUDE.md`, 운영 체크포인트는 `CHECK_POINT.md` 참조.
|
||||
|
||||
---
|
||||
|
||||
## 디렉토리 구조
|
||||
|
||||
| 경로 | 역할 | 포트 |
|
||||
|------|------|------|
|
||||
| `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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## services — NAS↔Windows 분산 워커
|
||||
|
||||
NAS측 lab 서비스(insta-lab / music-lab / video-lab / image-render NAS측)가 `queue:<worker>-render` 에 LPUSH로 작업을 enqueue. Windows worker가 BLMOVE로 atomic dequeue 후 처리, 완료 시 NAS internal webhook으로 결과 통지.
|
||||
|
||||
### 신뢰성 패턴 (`_shared.ReliableQueue`)
|
||||
|
||||
- **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)
|
||||
|
||||
```bash
|
||||
cd services
|
||||
docker compose up -d insta-render music-render video-render image-render task-watcher
|
||||
```
|
||||
|
||||
build context는 `services/` 루트. 각 Dockerfile은 `_shared` 모듈을 함께 COPY하고 `PYTHONPATH=/app`.
|
||||
|
||||
### 운영 조작
|
||||
|
||||
```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 보호) |
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (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).
|
||||
|
||||
---
|
||||
|
||||
## 라이선스 / 사용
|
||||
|
||||
비공개. 박재오 개인 웹 플랫폼.
|
||||
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
|
||||
@@ -18,7 +18,7 @@ class Settings:
|
||||
)
|
||||
port: int = field(default_factory=lambda: int(os.getenv("SIGNAL_V2_PORT", "8001")))
|
||||
db_path: Path = field(
|
||||
default_factory=lambda: Path(__file__).parent / "data" / "signal_v2.db"
|
||||
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())
|
||||
@@ -31,9 +31,31 @@ class Settings:
|
||||
v1_token_path: Path = field(
|
||||
default_factory=lambda: Path(
|
||||
os.getenv("V1_TOKEN_PATH",
|
||||
str(Path(__file__).parent.parent / "signal_v1" / "data" / "kis_token.json"))
|
||||
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:
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
@@ -38,6 +38,7 @@ class KISClient:
|
||||
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()
|
||||
@@ -56,10 +57,13 @@ class KISClient:
|
||||
return token
|
||||
|
||||
async def _throttle(self) -> None:
|
||||
elapsed = time.monotonic() - self._last_throttle_at
|
||||
if elapsed < _THROTTLE_INTERVAL:
|
||||
await asyncio.sleep(_THROTTLE_INTERVAL - elapsed)
|
||||
self._last_throttle_at = time.monotonic()
|
||||
# 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()
|
||||
@@ -153,3 +157,41 @@ class KISClient:
|
||||
"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:]
|
||||
@@ -6,13 +6,14 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from signal_v2 import state as state_mod
|
||||
from signal_v2.config import get_settings
|
||||
from signal_v2.kis_client import KISClient
|
||||
from signal_v2.kis_websocket import KISWebSocket
|
||||
from signal_v2.pull_worker import poll_loop, make_asking_price_callback
|
||||
from signal_v2.rate_limit import SignalDedup
|
||||
from signal_v2.stock_client import StockClient
|
||||
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__)
|
||||
|
||||
@@ -24,6 +25,7 @@ class AppContext:
|
||||
poll_task: asyncio.Task | None = None
|
||||
kis_client: KISClient | None = None
|
||||
kis_ws: KISWebSocket | None = None
|
||||
chronos: ChronosPredictor | None = None
|
||||
|
||||
|
||||
_ctx = AppContext()
|
||||
@@ -69,10 +71,19 @@ async def lifespan(app: FastAPI):
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
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
|
||||
@@ -5,12 +5,12 @@ import logging
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
|
||||
from signal_v2.kis_client import KISClient
|
||||
from signal_v2.scheduler import (
|
||||
KST, _is_market_day, _is_polling_window, _next_interval,
|
||||
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 signal_v2.state import PollState
|
||||
from signal_v2.stock_client import StockClient
|
||||
from ai_trade.state import PollState
|
||||
from ai_trade.stock_client import StockClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -18,9 +18,13 @@ 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):
|
||||
@@ -28,6 +32,33 @@ async def poll_loop(
|
||||
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)
|
||||
@@ -125,3 +156,48 @@ 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
|
||||
@@ -76,6 +76,23 @@ def _seconds_until_nxt_or_market_open(now: datetime) -> float:
|
||||
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)
|
||||
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()
|
||||
@@ -9,11 +9,12 @@ import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache TTL by endpoint (seconds)
|
||||
# Cache TTL by endpoint (seconds).
|
||||
# 2026-05-18 — NAS 인바운드 호출 부담 완화 (Plan-A SP-A1).
|
||||
_TTL = {
|
||||
"portfolio": 60.0,
|
||||
"news-sentiment": 300.0,
|
||||
"screener-preview": 60.0,
|
||||
"portfolio": 180.0, # 3분 (1분 폴링 시 3 폴링당 1회 실제 fetch)
|
||||
"news-sentiment": 600.0, # 10분 (뉴스 sentiment는 자주 안 바뀜)
|
||||
"screener-preview": 300.0, # 5분 (Top-20은 분 단위로 거의 안 바뀜)
|
||||
}
|
||||
|
||||
# Retry policy
|
||||
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.
@@ -1,4 +1,4 @@
|
||||
"""Pytest fixtures for signal_v2 tests."""
|
||||
"""Pytest fixtures for ai_trade tests."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -8,7 +8,7 @@ import respx
|
||||
@pytest.fixture
|
||||
def tmp_dedup_db(tmp_path) -> Path:
|
||||
"""SQLite 단위 테스트용 임시 DB path."""
|
||||
return tmp_path / "test_signal_v2.db"
|
||||
return tmp_path / "test_ai_trade.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
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
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Tests for KISClient (REST)."""
|
||||
import asyncio
|
||||
import json
|
||||
import time as time_module
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from signal_v2.kis_client import KISClient
|
||||
from ai_trade.kis_client import KISClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -126,3 +128,63 @@ async def test_get_asking_price_computes_bid_ratio(kis_client_factory):
|
||||
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()
|
||||
@@ -7,7 +7,7 @@ import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from signal_v2.kis_websocket import KISWebSocket
|
||||
from ai_trade.kis_websocket import KISWebSocket
|
||||
|
||||
|
||||
BASE_REST = "https://openapivts.koreainvestment.com:29443"
|
||||
@@ -10,9 +10,9 @@ def test_health_endpoint_returns_status_online(monkeypatch):
|
||||
monkeypatch.setenv("WEBAI_API_KEY", "test-secret")
|
||||
# Reload modules so they pick up the new env
|
||||
import importlib
|
||||
from signal_v2 import config as cfg
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
from signal_v2 import main as main_mod
|
||||
from ai_trade import main as main_mod
|
||||
importlib.reload(main_mod)
|
||||
with TestClient(main_mod.app) as client:
|
||||
r = client.get("/health")
|
||||
@@ -24,17 +24,17 @@ def test_health_endpoint_returns_status_online(monkeypatch):
|
||||
|
||||
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("signal_v2.config.load_dotenv", lambda *a, **k: None)
|
||||
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 signal_v2 import config as cfg
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
# After reload, load_dotenv reference is fresh — re-patch
|
||||
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
|
||||
from signal_v2 import main as main_mod
|
||||
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="signal_v2.main"):
|
||||
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)
|
||||
@@ -42,7 +42,7 @@ def test_startup_warns_if_webai_api_key_missing(monkeypatch, caplog):
|
||||
|
||||
def test_startup_warns_if_kis_app_key_missing(monkeypatch, caplog):
|
||||
"""KIS app_key 미설정 시 startup WARNING (KIS 호출 disabled) — V1 패턴."""
|
||||
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
|
||||
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
|
||||
@@ -51,12 +51,12 @@ def test_startup_warns_if_kis_app_key_missing(monkeypatch, caplog):
|
||||
monkeypatch.setenv("KIS_REAL_APP_KEY", "")
|
||||
|
||||
import importlib
|
||||
from signal_v2 import config as cfg
|
||||
from ai_trade import config as cfg
|
||||
importlib.reload(cfg)
|
||||
monkeypatch.setattr("signal_v2.config.load_dotenv", lambda *a, **k: None)
|
||||
from signal_v2 import main as main_mod
|
||||
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="signal_v2.main"):
|
||||
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
|
||||
@@ -2,7 +2,7 @@
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from signal_v2.rate_limit import SignalDedup
|
||||
from ai_trade.rate_limit import SignalDedup
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
@@ -24,11 +24,11 @@ def test_is_recent_returns_false_after_24h(tmp_dedup_db, monkeypatch):
|
||||
now = datetime.now(KST)
|
||||
fake_now = now - timedelta(hours=25)
|
||||
monkeypatch.setattr(
|
||||
"signal_v2.rate_limit._now_iso", lambda: fake_now.isoformat()
|
||||
"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(
|
||||
"signal_v2.rate_limit._now_iso", lambda: now.isoformat()
|
||||
"ai_trade.rate_limit._now_iso", lambda: now.isoformat()
|
||||
)
|
||||
assert dedup.is_recent("005930", "buy", within_hours=24) is False
|
||||
@@ -3,7 +3,7 @@ from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from signal_v2.scheduler import _next_interval, _is_market_day, KST
|
||||
from ai_trade.scheduler import _next_interval, _is_market_day, KST
|
||||
|
||||
|
||||
def _kst(year, month, day, hour, minute=0):
|
||||
@@ -79,3 +79,41 @@ def test_next_interval_dead_zone_skip():
|
||||
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
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import pytest
|
||||
import httpx
|
||||
|
||||
from signal_v2.stock_client import StockClient
|
||||
from ai_trade.stock_client import StockClient
|
||||
|
||||
|
||||
BASE_URL = "https://test.stock.local"
|
||||
@@ -34,7 +34,7 @@ async def test_get_portfolio_normal_returns_dict_with_pnl_pct(mock_stock_api):
|
||||
|
||||
|
||||
async def test_get_portfolio_uses_cache_within_ttl(mock_stock_api):
|
||||
"""60s TTL 내 두번째 호출 = mock 콜 1회."""
|
||||
"""180s TTL 내 두번째 호출 = mock 콜 1회."""
|
||||
route = mock_stock_api.get("/api/webai/portfolio").mock(
|
||||
return_value=httpx.Response(
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
@@ -56,16 +56,16 @@ async def test_get_portfolio_refetches_after_ttl_expiry(mock_stock_api, monkeypa
|
||||
200, json={"holdings": [], "cash": [], "summary": {}}
|
||||
)
|
||||
)
|
||||
# Fake clock: starts at 0, jumps to 61 between calls
|
||||
# Fake clock: starts at 0, jumps past portfolio TTL (180s) between calls
|
||||
fake_time = [0.0]
|
||||
monkeypatch.setattr(
|
||||
"signal_v2.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
"ai_trade.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
)
|
||||
|
||||
client = StockClient(BASE_URL, API_KEY)
|
||||
try:
|
||||
await client.get_portfolio()
|
||||
fake_time[0] = 61.0 # 60s TTL 만료
|
||||
fake_time[0] = 181.0 # 180s TTL 만료
|
||||
await client.get_portfolio()
|
||||
assert route.call_count == 2
|
||||
finally:
|
||||
@@ -137,7 +137,7 @@ async def test_get_portfolio_falls_back_to_stale_on_all_failures(
|
||||
# Patch time.monotonic BEFORE first call so cached timestamp uses fake clock
|
||||
fake_time = [0.0]
|
||||
monkeypatch.setattr(
|
||||
"signal_v2.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
"ai_trade.stock_client.time.monotonic", lambda: fake_time[0]
|
||||
)
|
||||
|
||||
# First call succeeds
|
||||
@@ -152,13 +152,13 @@ async def test_get_portfolio_falls_back_to_stale_on_all_failures(
|
||||
first = await client.get_portfolio()
|
||||
assert first["holdings"][0]["ticker"] == "005930"
|
||||
|
||||
# Advance fake clock past TTL (60s) so cache is stale
|
||||
fake_time[0] = 61.0
|
||||
# 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="signal_v2.stock_client"):
|
||||
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(
|
||||
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
|
||||
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`
|
||||
@@ -7,3 +7,7 @@ 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