Compare commits
97 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebd0a0297c | ||
| 02a72e0f7e | |||
| 478a659ac2 | |||
|
|
16b9b6832d | ||
|
|
48b87a79f6 | ||
|
|
ad79082dcc | ||
|
|
11dff9d3e5 | ||
|
|
3c5f1752e6 | ||
|
|
d6a389e0b7 | ||
| cd36d53a47 | |||
|
|
1242794fc4 | ||
| b45d136894 | |||
|
|
ce82121f04 | ||
| 0e2987e66d | |||
|
|
cdd5a218a7 | ||
|
|
f3491e94e4 | ||
|
|
342511a6ed | ||
| 2d5912dc08 | |||
|
|
40ea41cf3c | ||
| af5bfbac24 | |||
|
|
7e9a573390 | ||
| 7dbc48260c | |||
|
|
4b883a4fc4 | ||
|
|
98071a8ee3 | ||
|
|
f2ad270e8b | ||
| 04c73a1a06 | |||
|
|
4da22b10eb | ||
| c920b257b6 | |||
| 9927bfa13e | |||
|
|
aceba86186 | ||
|
|
b961c53a92 | ||
| 76a7ee7cdb | |||
|
|
77577f3f4d | ||
| 17112b864a | |||
|
|
28bcc7acd7 | ||
|
|
39b9f179f4 | ||
| bd2b3241b2 | |||
| 561faaaafa | |||
| a33d6a145f | |||
| 7e6c912214 | |||
|
|
d6edbc0fa2 | ||
|
|
c7640a30d7 | ||
|
|
60a22d6cd4 | ||
|
|
b1f48d859e | ||
| 03f8d220a4 | |||
|
|
305120f599 | ||
| faa23b3f1b | |||
|
|
5844ec5ad3 | ||
| ff5ff736d8 | |||
|
|
4a59d7e66d | ||
|
|
8dd625bfd1 | ||
| b50977aa76 | |||
|
|
fbcd016e1a | ||
| ce5773ba45 | |||
|
|
7834b89f10 | ||
| e0d6c9f81d | |||
|
|
2e550f8b58 | ||
| c76e2dfed5 | |||
|
|
24fa22e77b | ||
| cd1579058c | |||
| 45b48fa7cd | |||
|
|
3952a5337b | ||
|
|
ccc97ebaa9 | ||
|
|
3a54db8948 | ||
|
|
96e2ad4f1f | ||
| c5a8982122 | |||
|
|
f7289606fc | ||
| 0c5c90201f | |||
|
|
b484f0daff | ||
|
|
1288181e39 | ||
|
|
b625f41621 | ||
| 77d3ba967c | |||
|
|
aeed881d85 | ||
|
|
d0bbdb5dc1 | ||
| 44339c52d7 | |||
|
|
22ffdafacc | ||
|
|
c49765e951 | ||
| 64000b9967 | |||
|
|
733e6b36e9 | ||
|
|
0659cc0aca | ||
|
|
748b9b848e | ||
|
|
6a1ad230ee | ||
| 90bbc78867 | |||
|
|
1ef5dcb2b3 | ||
|
|
d105a3ff5e | ||
| 0424c78f6c | |||
|
|
3fdb7a29d4 | ||
| 31b4d0bf1e | |||
|
|
e2275a23b1 | ||
| 7522bb7e66 | |||
|
|
63fa6841a2 | ||
| ece3c5597b | |||
|
|
63f4e49d88 | ||
|
|
e0a6b307a2 | ||
| 75320eb587 | |||
|
|
afb31b7f4b | ||
| a429a9f4da |
64
.env.example
64
.env.example
@@ -1,36 +1,82 @@
|
|||||||
|
# ============================================================
|
||||||
|
# The Ouroboros — Environment Configuration
|
||||||
|
# ============================================================
|
||||||
|
# Copy this file to .env and fill in your values.
|
||||||
|
# Lines starting with # are comments.
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Korea Investment Securities API
|
# Korea Investment Securities API
|
||||||
|
# ============================================================
|
||||||
KIS_APP_KEY=your_app_key_here
|
KIS_APP_KEY=your_app_key_here
|
||||||
KIS_APP_SECRET=your_app_secret_here
|
KIS_APP_SECRET=your_app_secret_here
|
||||||
KIS_ACCOUNT_NO=12345678-01
|
KIS_ACCOUNT_NO=12345678-01
|
||||||
KIS_BASE_URL=https://openapivts.koreainvestment.com:9443
|
|
||||||
|
|
||||||
|
# Paper trading (VTS): https://openapivts.koreainvestment.com:29443
|
||||||
|
# Live trading: https://openapi.koreainvestment.com:9443
|
||||||
|
KIS_BASE_URL=https://openapivts.koreainvestment.com:29443
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Trading Mode
|
||||||
|
# ============================================================
|
||||||
|
# paper = 모의투자 (safe for testing), live = 실전투자 (real money)
|
||||||
|
MODE=paper
|
||||||
|
|
||||||
|
# daily = batch per session, realtime = per-stock continuous scan
|
||||||
|
TRADE_MODE=daily
|
||||||
|
|
||||||
|
# Comma-separated market codes: KR, US, JP, HK, CN, VN
|
||||||
|
ENABLED_MARKETS=KR,US
|
||||||
|
|
||||||
|
# Simulated USD cash for paper (VTS) overseas trading.
|
||||||
|
# VTS overseas balance API often returns 0; this value is used as fallback.
|
||||||
|
# Set to 0 to disable fallback (not used in live mode).
|
||||||
|
PAPER_OVERSEAS_CASH=50000.0
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Google Gemini
|
# Google Gemini
|
||||||
|
# ============================================================
|
||||||
GEMINI_API_KEY=your_gemini_api_key_here
|
GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
GEMINI_MODEL=gemini-pro
|
# Recommended: gemini-2.0-flash-exp or gemini-1.5-pro
|
||||||
|
GEMINI_MODEL=gemini-2.0-flash-exp
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Risk Management
|
# Risk Management
|
||||||
|
# ============================================================
|
||||||
CIRCUIT_BREAKER_PCT=-3.0
|
CIRCUIT_BREAKER_PCT=-3.0
|
||||||
FAT_FINGER_PCT=30.0
|
FAT_FINGER_PCT=30.0
|
||||||
CONFIDENCE_THRESHOLD=80
|
CONFIDENCE_THRESHOLD=80
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Database
|
# Database
|
||||||
|
# ============================================================
|
||||||
DB_PATH=data/trade_logs.db
|
DB_PATH=data/trade_logs.db
|
||||||
|
|
||||||
# Rate Limiting (requests per second for KIS API)
|
# ============================================================
|
||||||
# Reduced to 5.0 to avoid "초당 거래건수 초과" errors (EGW00201)
|
# Rate Limiting
|
||||||
RATE_LIMIT_RPS=5.0
|
# ============================================================
|
||||||
|
# KIS API real limit is ~2 RPS. Keep at 2.0 for maximum safety.
|
||||||
|
# Increasing this risks EGW00201 "초당 거래건수 초과" errors.
|
||||||
|
RATE_LIMIT_RPS=2.0
|
||||||
|
|
||||||
# Trading Mode (paper / live)
|
# ============================================================
|
||||||
MODE=paper
|
# External Data APIs (optional)
|
||||||
|
# ============================================================
|
||||||
# External Data APIs (optional — for enhanced decision-making)
|
|
||||||
# NEWS_API_KEY=your_news_api_key_here
|
# NEWS_API_KEY=your_news_api_key_here
|
||||||
# NEWS_API_PROVIDER=alphavantage
|
# NEWS_API_PROVIDER=alphavantage
|
||||||
# MARKET_DATA_API_KEY=your_market_data_key_here
|
# MARKET_DATA_API_KEY=your_market_data_key_here
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
# Telegram Notifications (optional)
|
# Telegram Notifications (optional)
|
||||||
|
# ============================================================
|
||||||
# Get bot token from @BotFather on Telegram
|
# Get bot token from @BotFather on Telegram
|
||||||
# Get chat ID from @userinfobot or your chat
|
# Get chat ID from @userinfobot or your chat
|
||||||
# TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
# TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
# TELEGRAM_CHAT_ID=123456789
|
# TELEGRAM_CHAT_ID=123456789
|
||||||
# TELEGRAM_ENABLED=true
|
# TELEGRAM_ENABLED=true
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Dashboard (optional)
|
||||||
|
# ============================================================
|
||||||
|
# DASHBOARD_ENABLED=false
|
||||||
|
# DASHBOARD_HOST=127.0.0.1
|
||||||
|
# DASHBOARD_PORT=8080
|
||||||
|
|||||||
24
CLAUDE.md
24
CLAUDE.md
@@ -15,6 +15,9 @@ pytest -v --cov=src
|
|||||||
|
|
||||||
# Run (paper trading)
|
# Run (paper trading)
|
||||||
python -m src.main --mode=paper
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
|
# Run with dashboard
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
## Telegram Notifications (Optional)
|
## Telegram Notifications (Optional)
|
||||||
@@ -43,6 +46,10 @@ Get real-time alerts for trades, circuit breakers, and system events via Telegra
|
|||||||
- ℹ️ Market open/close notifications
|
- ℹ️ Market open/close notifications
|
||||||
- 📝 System startup/shutdown status
|
- 📝 System startup/shutdown status
|
||||||
|
|
||||||
|
### Interactive Commands
|
||||||
|
|
||||||
|
With `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot supports 9 bidirectional commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`.
|
||||||
|
|
||||||
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
|
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
|
||||||
|
|
||||||
## Smart Volatility Scanner (Optional)
|
## Smart Volatility Scanner (Optional)
|
||||||
@@ -109,17 +116,23 @@ User requirements and feedback are tracked in [docs/requirements-log.md](docs/re
|
|||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── analysis/ # Technical analysis (RSI, volatility, smart scanner)
|
├── analysis/ # Technical analysis (RSI, volatility, smart scanner)
|
||||||
|
├── backup/ # Disaster recovery (scheduler, cloud storage, health)
|
||||||
|
├── brain/ # Gemini AI decision engine (prompt optimizer, context selector)
|
||||||
├── broker/ # KIS API client (domestic + overseas)
|
├── broker/ # KIS API client (domestic + overseas)
|
||||||
├── brain/ # Gemini AI decision engine
|
├── context/ # L1-L7 hierarchical memory system
|
||||||
├── core/ # Risk manager (READ-ONLY)
|
├── core/ # Risk manager (READ-ONLY)
|
||||||
├── evolution/ # Self-improvement optimizer
|
├── dashboard/ # FastAPI read-only monitoring (8 API endpoints)
|
||||||
|
├── data/ # External data integration (news, market data, calendar)
|
||||||
|
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
|
||||||
|
├── logging/ # Decision logger (audit trail)
|
||||||
├── markets/ # Market schedules and timezone handling
|
├── markets/ # Market schedules and timezone handling
|
||||||
├── notifications/ # Telegram real-time alerts
|
├── notifications/ # Telegram alerts + bidirectional commands (9 commands)
|
||||||
|
├── strategy/ # Pre-market planner, scenario engine, playbook store
|
||||||
├── db.py # SQLite trade logging
|
├── db.py # SQLite trade logging
|
||||||
├── main.py # Trading loop orchestrator
|
├── main.py # Trading loop orchestrator
|
||||||
└── config.py # Settings (from .env)
|
└── config.py # Settings (from .env)
|
||||||
|
|
||||||
tests/ # 343 tests across 14 files
|
tests/ # 551 tests across 25 files
|
||||||
docs/ # Extended documentation
|
docs/ # Extended documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -131,6 +144,7 @@ ruff check src/ tests/ # Lint
|
|||||||
mypy src/ --strict # Type check
|
mypy src/ --strict # Type check
|
||||||
|
|
||||||
python -m src.main --mode=paper # Paper trading
|
python -m src.main --mode=paper # Paper trading
|
||||||
|
python -m src.main --mode=paper --dashboard # With dashboard
|
||||||
python -m src.main --mode=live # Live trading (⚠️ real money)
|
python -m src.main --mode=live # Live trading (⚠️ real money)
|
||||||
|
|
||||||
# Gitea workflow (requires tea CLI)
|
# Gitea workflow (requires tea CLI)
|
||||||
@@ -156,7 +170,7 @@ Markets auto-detected based on timezone and enabled in `ENABLED_MARKETS` env var
|
|||||||
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
|
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
|
||||||
- Circuit breaker at -3.0% P&L — may only be made **stricter**
|
- Circuit breaker at -3.0% P&L — may only be made **stricter**
|
||||||
- Fat-finger protection: max 30% of cash per order — always enforced
|
- Fat-finger protection: max 30% of cash per order — always enforced
|
||||||
- Confidence < 80 → force HOLD — cannot be weakened
|
- Confidence 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
|
||||||
- All code changes → corresponding tests → coverage ≥ 80%
|
- All code changes → corresponding tests → coverage ≥ 80%
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|||||||
156
README.md
156
README.md
@@ -10,28 +10,41 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
|
|||||||
│ (매매 실행) │ │ (거래 루프) │ │ (의사결정) │
|
│ (매매 실행) │ │ (거래 루프) │ │ (의사결정) │
|
||||||
└─────────────┘ └──────┬──────┘ └─────────────┘
|
└─────────────┘ └──────┬──────┘ └─────────────┘
|
||||||
│
|
│
|
||||||
┌──────┴──────┐
|
┌────────────┼────────────┐
|
||||||
│Risk Manager │
|
│ │ │
|
||||||
│ (안전장치) │
|
┌──────┴──────┐ ┌──┴───┐ ┌──────┴──────┐
|
||||||
└──────┬──────┘
|
│Risk Manager │ │ DB │ │ Telegram │
|
||||||
|
│ (안전장치) │ │ │ │ (알림+명령) │
|
||||||
|
└──────┬──────┘ └──────┘ └─────────────┘
|
||||||
│
|
│
|
||||||
┌──────┴──────┐
|
┌────────┼────────┐
|
||||||
│ Evolution │
|
│ │ │
|
||||||
│ (전략 진화) │
|
┌────┴────┐┌──┴──┐┌────┴─────┐
|
||||||
└─────────────┘
|
│Strategy ││Ctx ││Evolution │
|
||||||
|
│(플레이북)││(메모리)││ (진화) │
|
||||||
|
└─────────┘└─────┘└──────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**v2 핵심**: "Plan Once, Execute Locally" — 장 시작 전 AI가 시나리오 플레이북을 1회 생성하고, 거래 시간에는 로컬 시나리오 매칭만 수행하여 API 비용과 지연 시간을 대폭 절감.
|
||||||
|
|
||||||
## 핵심 모듈
|
## 핵심 모듈
|
||||||
|
|
||||||
| 모듈 | 파일 | 설명 |
|
| 모듈 | 위치 | 설명 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 설정 | `src/config.py` | Pydantic 기반 환경변수 로딩 및 타입 검증 |
|
| 설정 | `src/config.py` | Pydantic 기반 환경변수 로딩 및 타입 검증 (35+ 변수) |
|
||||||
| 브로커 | `src/broker/kis_api.py` | KIS API 비동기 래퍼 (토큰 갱신, 레이트 리미터, 해시키) |
|
| 브로커 | `src/broker/` | KIS API 비동기 래퍼 (국내 + 해외 9개 시장) |
|
||||||
| 두뇌 | `src/brain/gemini_client.py` | Gemini 프롬프트 구성 및 JSON 응답 파싱 |
|
| 두뇌 | `src/brain/` | Gemini 프롬프트 구성, JSON 파싱, 토큰 최적화 |
|
||||||
| 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 |
|
| 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 (READ-ONLY) |
|
||||||
| 알림 | `src/notifications/telegram_client.py` | 텔레그램 실시간 거래 알림 (선택사항) |
|
| 전략 | `src/strategy/` | Pre-Market Planner, Scenario Engine, Playbook Store |
|
||||||
| 진화 | `src/evolution/optimizer.py` | 실패 패턴 분석 → 새 전략 생성 → 테스트 → PR |
|
| 컨텍스트 | `src/context/` | L1-L7 계층형 메모리 시스템 |
|
||||||
| DB | `src/db.py` | SQLite 거래 로그 기록 |
|
| 분석 | `src/analysis/` | RSI, ATR, Smart Volatility Scanner |
|
||||||
|
| 알림 | `src/notifications/` | 텔레그램 양방향 (알림 + 9개 명령어) |
|
||||||
|
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (8개 API) |
|
||||||
|
| 진화 | `src/evolution/` | 전략 진화 + Daily Review + Scorecard |
|
||||||
|
| 의사결정 로그 | `src/logging/` | 전체 거래 결정 감사 추적 |
|
||||||
|
| 데이터 | `src/data/` | 뉴스, 시장 데이터, 경제 캘린더 연동 |
|
||||||
|
| 백업 | `src/backup/` | 자동 백업, S3 클라우드, 무결성 검증 |
|
||||||
|
| DB | `src/db.py` | SQLite 거래 로그 (5개 테이블) |
|
||||||
|
|
||||||
## 안전장치
|
## 안전장치
|
||||||
|
|
||||||
@@ -42,6 +55,7 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
|
|||||||
| 신뢰도 임계값 | Gemini 신뢰도 80 미만이면 강제 HOLD |
|
| 신뢰도 임계값 | Gemini 신뢰도 80 미만이면 강제 HOLD |
|
||||||
| 레이트 리미터 | Leaky Bucket 알고리즘으로 API 호출 제한 |
|
| 레이트 리미터 | Leaky Bucket 알고리즘으로 API 호출 제한 |
|
||||||
| 토큰 자동 갱신 | 만료 1분 전 자동으로 Access Token 재발급 |
|
| 토큰 자동 갱신 | 만료 1분 전 자동으로 Access Token 재발급 |
|
||||||
|
| 손절 모니터링 | 플레이북 시나리오 기반 실시간 포지션 보호 |
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|
||||||
@@ -67,7 +81,11 @@ pytest -v --cov=src --cov-report=term-missing
|
|||||||
### 4. 실행 (모의투자)
|
### 4. 실행 (모의투자)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 기본 실행
|
||||||
python -m src.main --mode=paper
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
|
# 대시보드 활성화
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Docker 실행
|
### 5. Docker 실행
|
||||||
@@ -76,7 +94,20 @@ python -m src.main --mode=paper
|
|||||||
docker compose up -d ouroboros
|
docker compose up -d ouroboros
|
||||||
```
|
```
|
||||||
|
|
||||||
## 텔레그램 알림 (선택사항)
|
## 지원 시장
|
||||||
|
|
||||||
|
| 국가 | 거래소 | 코드 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 🇰🇷 한국 | KRX | KR |
|
||||||
|
| 🇺🇸 미국 | NASDAQ, NYSE, AMEX | US_NASDAQ, US_NYSE, US_AMEX |
|
||||||
|
| 🇯🇵 일본 | TSE | JP |
|
||||||
|
| 🇭🇰 홍콩 | SEHK | HK |
|
||||||
|
| 🇨🇳 중국 | 상하이, 선전 | CN_SHA, CN_SZA |
|
||||||
|
| 🇻🇳 베트남 | 하노이, 호치민 | VN_HNX, VN_HSX |
|
||||||
|
|
||||||
|
`ENABLED_MARKETS` 환경변수로 활성 시장 선택 (기본: `KR,US`).
|
||||||
|
|
||||||
|
## 텔레그램 (선택사항)
|
||||||
|
|
||||||
거래 실행, 서킷 브레이커 발동, 시스템 상태 등을 텔레그램으로 실시간 알림 받을 수 있습니다.
|
거래 실행, 서킷 브레이커 발동, 시스템 상태 등을 텔레그램으로 실시간 알림 받을 수 있습니다.
|
||||||
|
|
||||||
@@ -102,25 +133,51 @@ docker compose up -d ouroboros
|
|||||||
- ℹ️ 장 시작/종료 알림
|
- ℹ️ 장 시작/종료 알림
|
||||||
- 📝 시스템 시작/종료 상태
|
- 📝 시스템 시작/종료 상태
|
||||||
|
|
||||||
**안전장치**: 알림 실패해도 거래는 계속 진행됩니다. 텔레그램 API 오류나 설정 누락이 있어도 거래 시스템은 정상 작동합니다.
|
### 양방향 명령어
|
||||||
|
|
||||||
|
`TELEGRAM_COMMANDS_ENABLED=true` (기본값) 설정 시 9개 대화형 명령어 지원:
|
||||||
|
|
||||||
|
| 명령어 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| `/help` | 사용 가능한 명령어 목록 |
|
||||||
|
| `/status` | 거래 상태 (모드, 시장, P&L) |
|
||||||
|
| `/positions` | 계좌 요약 (잔고, 현금, P&L) |
|
||||||
|
| `/report` | 일일 요약 (거래 수, P&L, 승률) |
|
||||||
|
| `/scenarios` | 오늘의 플레이북 시나리오 |
|
||||||
|
| `/review` | 최근 스코어카드 (L6_DAILY) |
|
||||||
|
| `/dashboard` | 대시보드 URL 표시 |
|
||||||
|
| `/stop` | 거래 일시 정지 |
|
||||||
|
| `/resume` | 거래 재개 |
|
||||||
|
|
||||||
|
**안전장치**: 알림 실패해도 거래는 계속 진행됩니다.
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
35개 테스트가 TDD 방식으로 구현 전에 먼저 작성되었습니다.
|
551개 테스트가 25개 파일에 걸쳐 구현되어 있습니다. 최소 커버리지 80%.
|
||||||
|
|
||||||
```
|
```
|
||||||
tests/test_risk.py — 서킷 브레이커, 팻 핑거, 통합 검증 (11개)
|
tests/test_scenario_engine.py — 시나리오 매칭 (44개)
|
||||||
tests/test_broker.py — 토큰 관리, 타임아웃, HTTP 에러, 해시키 (6개)
|
tests/test_data_integration.py — 외부 데이터 연동 (38개)
|
||||||
tests/test_brain.py — JSON 파싱, 신뢰도 임계값, 비정상 응답 처리 (15개)
|
tests/test_pre_market_planner.py — 플레이북 생성 (37개)
|
||||||
|
tests/test_main.py — 거래 루프 통합 (37개)
|
||||||
|
tests/test_token_efficiency.py — 토큰 최적화 (34개)
|
||||||
|
tests/test_strategy_models.py — 전략 모델 검증 (33개)
|
||||||
|
tests/test_telegram_commands.py — 텔레그램 명령어 (31개)
|
||||||
|
tests/test_latency_control.py — 지연시간 제어 (30개)
|
||||||
|
tests/test_telegram.py — 텔레그램 알림 (25개)
|
||||||
|
... 외 16개 파일
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**상세**: [docs/testing.md](docs/testing.md)
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
|
|
||||||
- **언어**: Python 3.11+ (asyncio 기반)
|
- **언어**: Python 3.11+ (asyncio 기반)
|
||||||
- **브로커**: KIS Open API (REST)
|
- **브로커**: KIS Open API (REST, 국내+해외)
|
||||||
- **AI**: Google Gemini Pro
|
- **AI**: Google Gemini Pro
|
||||||
- **DB**: SQLite
|
- **DB**: SQLite (5개 테이블: trades, contexts, decision_logs, playbooks, context_metadata)
|
||||||
- **검증**: pytest + coverage
|
- **대시보드**: FastAPI + uvicorn
|
||||||
|
- **검증**: pytest + coverage (551 tests)
|
||||||
- **CI/CD**: GitHub Actions
|
- **CI/CD**: GitHub Actions
|
||||||
- **배포**: Docker + Docker Compose
|
- **배포**: Docker + Docker Compose
|
||||||
|
|
||||||
@@ -128,27 +185,50 @@ tests/test_brain.py — JSON 파싱, 신뢰도 임계값, 비정상 응답 처
|
|||||||
|
|
||||||
```
|
```
|
||||||
The-Ouroboros/
|
The-Ouroboros/
|
||||||
├── .github/workflows/ci.yml # CI 파이프라인
|
|
||||||
├── docs/
|
├── docs/
|
||||||
│ ├── agents.md # AI 에이전트 페르소나 정의
|
│ ├── architecture.md # 시스템 아키텍처
|
||||||
│ └── skills.md # 사용 가능한 도구 목록
|
│ ├── testing.md # 테스트 가이드
|
||||||
|
│ ├── commands.md # 명령어 레퍼런스
|
||||||
|
│ ├── context-tree.md # L1-L7 메모리 시스템
|
||||||
|
│ ├── workflow.md # Git 워크플로우
|
||||||
|
│ ├── agents.md # 에이전트 정책
|
||||||
|
│ ├── skills.md # 도구 목록
|
||||||
|
│ ├── disaster_recovery.md # 백업/복구
|
||||||
|
│ └── requirements-log.md # 요구사항 기록
|
||||||
├── src/
|
├── src/
|
||||||
|
│ ├── analysis/ # 기술적 분석 (RSI, ATR, Smart Scanner)
|
||||||
|
│ ├── backup/ # 백업 (스케줄러, S3, 무결성 검증)
|
||||||
|
│ ├── brain/ # Gemini 의사결정 (프롬프트 최적화, 컨텍스트 선택)
|
||||||
|
│ ├── broker/ # KIS API (국내 + 해외)
|
||||||
|
│ ├── context/ # L1-L7 계층 메모리
|
||||||
|
│ ├── core/ # 리스크 관리 (READ-ONLY)
|
||||||
|
│ ├── dashboard/ # FastAPI 모니터링 대시보드
|
||||||
|
│ ├── data/ # 외부 데이터 연동
|
||||||
|
│ ├── evolution/ # 전략 진화 + Daily Review
|
||||||
|
│ ├── logging/ # 의사결정 감사 추적
|
||||||
|
│ ├── markets/ # 시장 스케줄 + 타임존
|
||||||
|
│ ├── notifications/ # 텔레그램 알림 + 명령어
|
||||||
|
│ ├── strategy/ # 플레이북 (Planner, Scenario Engine)
|
||||||
│ ├── config.py # Pydantic 설정
|
│ ├── config.py # Pydantic 설정
|
||||||
│ ├── logging_config.py # JSON 구조화 로깅
|
│ ├── db.py # SQLite 데이터베이스
|
||||||
│ ├── db.py # SQLite 거래 기록
|
│ └── main.py # 비동기 거래 루프
|
||||||
│ ├── main.py # 비동기 거래 루프
|
├── tests/ # 551개 테스트 (25개 파일)
|
||||||
│ ├── broker/kis_api.py # KIS API 클라이언트
|
|
||||||
│ ├── brain/gemini_client.py # Gemini 의사결정 엔진
|
|
||||||
│ ├── core/risk_manager.py # 리스크 관리
|
|
||||||
│ ├── notifications/telegram_client.py # 텔레그램 알림
|
|
||||||
│ ├── evolution/optimizer.py # 전략 진화 엔진
|
|
||||||
│ └── strategies/base.py # 전략 베이스 클래스
|
|
||||||
├── tests/ # TDD 테스트 스위트
|
|
||||||
├── Dockerfile # 멀티스테이지 빌드
|
├── Dockerfile # 멀티스테이지 빌드
|
||||||
├── docker-compose.yml # 서비스 오케스트레이션
|
├── docker-compose.yml # 서비스 오케스트레이션
|
||||||
└── pyproject.toml # 의존성 및 도구 설정
|
└── pyproject.toml # 의존성 및 도구 설정
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 문서
|
||||||
|
|
||||||
|
- **[아키텍처](docs/architecture.md)** — 시스템 설계, 컴포넌트, 데이터 흐름
|
||||||
|
- **[테스트](docs/testing.md)** — 테스트 구조, 커버리지, 작성 가이드
|
||||||
|
- **[명령어](docs/commands.md)** — CLI, Dashboard, Telegram 명령어
|
||||||
|
- **[컨텍스트 트리](docs/context-tree.md)** — L1-L7 계층 메모리
|
||||||
|
- **[워크플로우](docs/workflow.md)** — Git 워크플로우 정책
|
||||||
|
- **[에이전트 정책](docs/agents.md)** — 안전 제약, 금지 행위
|
||||||
|
- **[백업/복구](docs/disaster_recovery.md)** — 재해 복구 절차
|
||||||
|
- **[요구사항](docs/requirements-log.md)** — 사용자 요구사항 추적
|
||||||
|
|
||||||
## 라이선스
|
## 라이선스
|
||||||
|
|
||||||
이 프로젝트의 라이선스는 [LICENSE](LICENSE) 파일을 참조하세요.
|
이 프로젝트의 라이선스는 [LICENSE](LICENSE) 파일을 참조하세요.
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates four components across multiple markets with two trading modes: daily (batch API calls) or realtime (per-stock decisions).
|
Self-evolving AI trading agent for global stock markets via KIS (Korea Investment & Securities) API. The main loop in `src/main.py` orchestrates components across multiple markets with two trading modes: daily (batch API calls) or realtime (per-stock decisions).
|
||||||
|
|
||||||
|
**v2 Proactive Playbook Architecture**: The system uses a "plan once, execute locally" approach. Pre-market, the AI generates a playbook of scenarios (one Gemini API call per market per day). During trading hours, a local scenario engine matches live market data against these pre-computed scenarios — no additional AI calls needed. This dramatically reduces API costs and latency.
|
||||||
|
|
||||||
## Trading Modes
|
## Trading Modes
|
||||||
|
|
||||||
@@ -46,9 +48,11 @@ High-frequency trading with individual stock analysis:
|
|||||||
**KISBroker** (`kis_api.py`) — Async KIS API client for domestic Korean market
|
**KISBroker** (`kis_api.py`) — Async KIS API client for domestic Korean market
|
||||||
|
|
||||||
- Automatic OAuth token refresh (valid for 24 hours)
|
- Automatic OAuth token refresh (valid for 24 hours)
|
||||||
- Leaky-bucket rate limiter (10 requests per second)
|
- Leaky-bucket rate limiter (configurable RPS, default 2.0)
|
||||||
- POST body hash-key signing for order authentication
|
- POST body hash-key signing for order authentication
|
||||||
- Custom SSL context with disabled hostname verification for VTS (virtual trading) endpoint due to known certificate mismatch
|
- Custom SSL context with disabled hostname verification for VTS (virtual trading) endpoint due to known certificate mismatch
|
||||||
|
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API
|
||||||
|
- `get_daily_prices()` — Fetch OHLCV history for technical analysis
|
||||||
|
|
||||||
**OverseasBroker** (`overseas.py`) — KIS overseas stock API wrapper
|
**OverseasBroker** (`overseas.py`) — KIS overseas stock API wrapper
|
||||||
|
|
||||||
@@ -63,10 +67,11 @@ High-frequency trading with individual stock analysis:
|
|||||||
- `is_market_open()` checks weekends, trading hours, lunch breaks
|
- `is_market_open()` checks weekends, trading hours, lunch breaks
|
||||||
- `get_open_markets()` returns currently active markets
|
- `get_open_markets()` returns currently active markets
|
||||||
- `get_next_market_open()` finds next market to open and when
|
- `get_next_market_open()` finds next market to open and when
|
||||||
|
- 10 global markets defined (KR, US_NASDAQ, US_NYSE, US_AMEX, JP, HK, CN_SHA, CN_SZA, VN_HNX, VN_HSX)
|
||||||
|
|
||||||
**New API Methods** (added in v0.9.0):
|
**Overseas Ranking API Methods** (added in v0.10.x):
|
||||||
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API
|
- `fetch_overseas_rankings()` — Fetch overseas ranking universe (fluctuation / volume)
|
||||||
- `get_daily_prices()` — Fetch OHLCV history for technical analysis
|
- Ranking endpoint paths and TR_IDs are configurable via environment variables
|
||||||
|
|
||||||
### 2. Analysis (`src/analysis/`)
|
### 2. Analysis (`src/analysis/`)
|
||||||
|
|
||||||
@@ -81,24 +86,28 @@ High-frequency trading with individual stock analysis:
|
|||||||
|
|
||||||
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
|
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
|
||||||
|
|
||||||
- **Step 1**: Fetch volume rankings from KIS API (top 30 stocks)
|
- **Domestic (KR)**:
|
||||||
- **Step 2**: Calculate RSI and volume ratio for each stock
|
- **Step 1**: Fetch domestic fluctuation ranking as primary universe
|
||||||
- **Step 3**: Apply filters:
|
- **Step 2**: Fetch domestic volume ranking for liquidity bonus
|
||||||
- Volume ratio >= `VOL_MULTIPLIER` (default 2.0x previous day)
|
- **Step 3**: Compute volatility-first score (max of daily change% and intraday range%)
|
||||||
- RSI < `RSI_OVERSOLD_THRESHOLD` (30) OR RSI > `RSI_MOMENTUM_THRESHOLD` (70)
|
- **Step 4**: Apply liquidity bonus and return top N candidates
|
||||||
- **Step 4**: Score candidates by RSI extremity (60%) + volume surge (40%)
|
- **Overseas (US/JP/HK/CN/VN)**:
|
||||||
- **Step 5**: Return top N candidates (default 3) for AI analysis
|
- **Step 1**: Fetch overseas ranking universe (fluctuation rank + volume rank bonus)
|
||||||
- **Fallback**: Uses static watchlist if ranking API unavailable
|
- **Step 2**: Compute volatility-first score (max of daily change% and intraday range%)
|
||||||
|
- **Step 3**: Apply liquidity bonus from volume ranking
|
||||||
|
- **Step 4**: Return top N candidates (default 3)
|
||||||
|
- **Fallback (overseas only)**: If ranking API is unavailable, uses dynamic universe
|
||||||
|
from runtime active symbols + recent traded symbols + current holdings (no static watchlist)
|
||||||
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
|
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
|
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
|
||||||
- Fast Python-based filtering before expensive AI judgment
|
- Fast Python-based filtering before expensive AI judgment
|
||||||
- Logs selection context (RSI, volume_ratio, signal, score) for Evolution system
|
- Logs selection context (RSI-compatible proxy, volume_ratio, signal, score) for Evolution system
|
||||||
|
|
||||||
### 3. Brain (`src/brain/gemini_client.py`)
|
### 3. Brain (`src/brain/`)
|
||||||
|
|
||||||
**GeminiClient** — AI decision engine powered by Google Gemini
|
**GeminiClient** (`gemini_client.py`) — AI decision engine powered by Google Gemini
|
||||||
|
|
||||||
- Constructs structured prompts from market data
|
- Constructs structured prompts from market data
|
||||||
- Parses JSON responses into `TradeDecision` objects (`action`, `confidence`, `rationale`)
|
- Parses JSON responses into `TradeDecision` objects (`action`, `confidence`, `rationale`)
|
||||||
@@ -106,11 +115,20 @@ High-frequency trading with individual stock analysis:
|
|||||||
- Falls back to safe HOLD on any parse/API error
|
- Falls back to safe HOLD on any parse/API error
|
||||||
- Handles markdown-wrapped JSON, malformed responses, invalid actions
|
- Handles markdown-wrapped JSON, malformed responses, invalid actions
|
||||||
|
|
||||||
|
**PromptOptimizer** (`prompt_optimizer.py`) — Token efficiency optimization
|
||||||
|
|
||||||
|
- Reduces prompt size while preserving decision quality
|
||||||
|
- Caches optimized prompts
|
||||||
|
|
||||||
|
**ContextSelector** (`context_selector.py`) — Relevant context selection for prompts
|
||||||
|
|
||||||
|
- Selects appropriate context layers for current market conditions
|
||||||
|
|
||||||
### 4. Risk Manager (`src/core/risk_manager.py`)
|
### 4. Risk Manager (`src/core/risk_manager.py`)
|
||||||
|
|
||||||
**RiskManager** — Safety circuit breaker and order validation
|
**RiskManager** — Safety circuit breaker and order validation
|
||||||
|
|
||||||
⚠️ **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md))
|
> **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md))
|
||||||
|
|
||||||
- **Circuit Breaker**: Halts all trading via `SystemExit` when daily P&L drops below -3.0%
|
- **Circuit Breaker**: Halts all trading via `SystemExit` when daily P&L drops below -3.0%
|
||||||
- Threshold may only be made stricter, never relaxed
|
- Threshold may only be made stricter, never relaxed
|
||||||
@@ -118,7 +136,79 @@ High-frequency trading with individual stock analysis:
|
|||||||
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
|
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
|
||||||
- Must always be enforced, cannot be disabled
|
- Must always be enforced, cannot be disabled
|
||||||
|
|
||||||
### 5. Notifications (`src/notifications/telegram_client.py`)
|
### 5. Strategy (`src/strategy/`)
|
||||||
|
|
||||||
|
**Pre-Market Planner** (`pre_market_planner.py`) — AI playbook generation
|
||||||
|
|
||||||
|
- Runs before market open (configurable `PRE_MARKET_MINUTES`, default 30)
|
||||||
|
- Generates scenario-based playbooks via single Gemini API call per market
|
||||||
|
- Handles timeout (`PLANNER_TIMEOUT_SECONDS`, default 60) with defensive playbook fallback
|
||||||
|
- Persists playbooks to database for audit trail
|
||||||
|
|
||||||
|
**Scenario Engine** (`scenario_engine.py`) — Local scenario matching
|
||||||
|
|
||||||
|
- Matches live market data against pre-computed playbook scenarios
|
||||||
|
- No AI calls during trading hours — pure Python matching logic
|
||||||
|
- Returns matched scenarios with confidence scores
|
||||||
|
- Configurable `MAX_SCENARIOS_PER_STOCK` (default 5)
|
||||||
|
- Periodic rescan at `RESCAN_INTERVAL_SECONDS` (default 300)
|
||||||
|
|
||||||
|
**Playbook Store** (`playbook_store.py`) — Playbook persistence
|
||||||
|
|
||||||
|
- SQLite-backed storage for daily playbooks
|
||||||
|
- Date and market-based retrieval
|
||||||
|
- Status tracking (generated, active, expired)
|
||||||
|
|
||||||
|
**Models** (`models.py`) — Pydantic data models
|
||||||
|
|
||||||
|
- Scenario, Playbook, MatchResult, and related type definitions
|
||||||
|
|
||||||
|
### 6. Context System (`src/context/`)
|
||||||
|
|
||||||
|
**Context Store** (`store.py`) — L1-L7 hierarchical memory
|
||||||
|
|
||||||
|
- 7-layer context system (see [docs/context-tree.md](./context-tree.md)):
|
||||||
|
- L1: Tick-level (real-time price)
|
||||||
|
- L2: Intraday (session summary)
|
||||||
|
- L3: Daily (end-of-day)
|
||||||
|
- L4: Weekly (trend analysis)
|
||||||
|
- L5: Monthly (strategy review)
|
||||||
|
- L6: Daily Review (scorecard)
|
||||||
|
- L7: Evolution (long-term learning)
|
||||||
|
- Key-value storage with timeframe tagging
|
||||||
|
- SQLite persistence in `contexts` table
|
||||||
|
|
||||||
|
**Context Scheduler** (`scheduler.py`) — Periodic aggregation
|
||||||
|
|
||||||
|
- Scheduled summarization from lower to higher layers
|
||||||
|
- Configurable aggregation intervals
|
||||||
|
|
||||||
|
**Context Summarizer** (`summarizer.py`) — Layer summarization
|
||||||
|
|
||||||
|
- Aggregates lower-layer data into higher-layer summaries
|
||||||
|
|
||||||
|
### 7. Dashboard (`src/dashboard/`)
|
||||||
|
|
||||||
|
**FastAPI App** (`app.py`) — Read-only monitoring dashboard
|
||||||
|
|
||||||
|
- Runs as daemon thread when enabled (`--dashboard` CLI flag or `DASHBOARD_ENABLED=true`)
|
||||||
|
- Configurable host/port (`DASHBOARD_HOST`, `DASHBOARD_PORT`, default `127.0.0.1:8080`)
|
||||||
|
- Serves static HTML frontend
|
||||||
|
|
||||||
|
**8 API Endpoints:**
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/` | GET | Static HTML dashboard |
|
||||||
|
| `/api/status` | GET | Daily trading status by market |
|
||||||
|
| `/api/playbook/{date}` | GET | Playbook for specific date and market |
|
||||||
|
| `/api/scorecard/{date}` | GET | Daily scorecard from L6_DAILY context |
|
||||||
|
| `/api/performance` | GET | Trading performance metrics (by market + combined) |
|
||||||
|
| `/api/context/{layer}` | GET | Query context by layer (L1-L7) |
|
||||||
|
| `/api/decisions` | GET | Decision log entries with outcomes |
|
||||||
|
| `/api/scenarios/active` | GET | Today's matched scenarios |
|
||||||
|
|
||||||
|
### 8. Notifications (`src/notifications/telegram_client.py`)
|
||||||
|
|
||||||
**TelegramClient** — Real-time event notifications via Telegram Bot API
|
**TelegramClient** — Real-time event notifications via Telegram Bot API
|
||||||
|
|
||||||
@@ -126,7 +216,13 @@ High-frequency trading with individual stock analysis:
|
|||||||
- Non-blocking: failures are logged but never crash trading
|
- Non-blocking: failures are logged but never crash trading
|
||||||
- Rate-limited: 1 message/second default to respect Telegram API limits
|
- Rate-limited: 1 message/second default to respect Telegram API limits
|
||||||
- Auto-disabled when credentials missing
|
- Auto-disabled when credentials missing
|
||||||
- Gracefully handles API errors, network timeouts, invalid tokens
|
|
||||||
|
**TelegramCommandHandler** — Bidirectional command interface
|
||||||
|
|
||||||
|
- Long polling from Telegram API (configurable `TELEGRAM_POLLING_INTERVAL`)
|
||||||
|
- 9 interactive commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`
|
||||||
|
- Authorization filtering by `TELEGRAM_CHAT_ID`
|
||||||
|
- Enable/disable via `TELEGRAM_COMMANDS_ENABLED` (default: true)
|
||||||
|
|
||||||
**Notification Types:**
|
**Notification Types:**
|
||||||
- Trade execution (BUY/SELL with confidence)
|
- Trade execution (BUY/SELL with confidence)
|
||||||
@@ -134,12 +230,12 @@ High-frequency trading with individual stock analysis:
|
|||||||
- Fat-finger protection triggers (order rejection)
|
- Fat-finger protection triggers (order rejection)
|
||||||
- Market open/close events
|
- Market open/close events
|
||||||
- System startup/shutdown status
|
- System startup/shutdown status
|
||||||
|
- Playbook generation results
|
||||||
|
- Stop-loss monitoring alerts
|
||||||
|
|
||||||
**Setup:** See [src/notifications/README.md](../src/notifications/README.md) for bot creation and configuration.
|
### 9. Evolution (`src/evolution/`)
|
||||||
|
|
||||||
### 6. Evolution (`src/evolution/optimizer.py`)
|
**StrategyOptimizer** (`optimizer.py`) — Self-improvement loop
|
||||||
|
|
||||||
**StrategyOptimizer** — Self-improvement loop
|
|
||||||
|
|
||||||
- Analyzes high-confidence losing trades from SQLite
|
- Analyzes high-confidence losing trades from SQLite
|
||||||
- Asks Gemini to generate new `BaseStrategy` subclasses
|
- Asks Gemini to generate new `BaseStrategy` subclasses
|
||||||
@@ -147,8 +243,122 @@ High-frequency trading with individual stock analysis:
|
|||||||
- Simulates PR creation for human review
|
- Simulates PR creation for human review
|
||||||
- Only activates strategies that pass all tests
|
- Only activates strategies that pass all tests
|
||||||
|
|
||||||
|
**DailyReview** (`daily_review.py`) — End-of-day review
|
||||||
|
|
||||||
|
- Generates comprehensive trade performance summary
|
||||||
|
- Stores results in L6_DAILY context layer
|
||||||
|
- Tracks win rate, P&L, confidence accuracy
|
||||||
|
|
||||||
|
**DailyScorecard** (`scorecard.py`) — Performance scoring
|
||||||
|
|
||||||
|
- Calculates daily metrics (trades, P&L, win rate, avg confidence)
|
||||||
|
- Enables trend tracking across days
|
||||||
|
|
||||||
|
**Stop-Loss Monitoring** — Real-time position protection
|
||||||
|
|
||||||
|
- Monitors positions against stop-loss levels from playbook scenarios
|
||||||
|
- Sends Telegram alerts when thresholds approached or breached
|
||||||
|
|
||||||
|
### 10. Decision Logger (`src/logging/decision_logger.py`)
|
||||||
|
|
||||||
|
**DecisionLogger** — Comprehensive audit trail
|
||||||
|
|
||||||
|
- Logs every trading decision with full context snapshot
|
||||||
|
- Captures input data, rationale, confidence, and outcomes
|
||||||
|
- Supports outcome tracking (P&L, accuracy) for post-analysis
|
||||||
|
- Stored in `decision_logs` table with indexed queries
|
||||||
|
- Review workflow support (reviewed flag, review notes)
|
||||||
|
|
||||||
|
### 11. Data Integration (`src/data/`)
|
||||||
|
|
||||||
|
**External Data Sources** (optional):
|
||||||
|
|
||||||
|
- `news_api.py` — News sentiment data
|
||||||
|
- `market_data.py` — Extended market data
|
||||||
|
- `economic_calendar.py` — Economic event calendar
|
||||||
|
|
||||||
|
### 12. Backup (`src/backup/`)
|
||||||
|
|
||||||
|
**Disaster Recovery** (see [docs/disaster_recovery.md](./disaster_recovery.md)):
|
||||||
|
|
||||||
|
- `scheduler.py` — Automated backup scheduling
|
||||||
|
- `exporter.py` — Data export to various formats
|
||||||
|
- `cloud_storage.py` — S3-compatible cloud backup
|
||||||
|
- `health_monitor.py` — Backup integrity verification
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
|
### Playbook Mode (Daily — Primary v2 Flow)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Pre-Market Phase (before market open) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Pre-Market Planner │
|
||||||
|
│ - 1 Gemini API call per market │
|
||||||
|
│ - Generate scenario playbook │
|
||||||
|
│ - Store in playbooks table │
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Trading Hours (market open → close) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Market Schedule Check │
|
||||||
|
│ - Get open markets │
|
||||||
|
│ - Filter by enabled markets │
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Scenario Engine (local) │
|
||||||
|
│ - Match live data vs playbook │
|
||||||
|
│ - No AI calls needed │
|
||||||
|
│ - Return matched scenarios │
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Risk Manager: Validate Order │
|
||||||
|
│ - Check circuit breaker │
|
||||||
|
│ - Check fat-finger limit │
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Broker: Execute Order │
|
||||||
|
│ - Domestic: send_order() │
|
||||||
|
│ - Overseas: send_overseas_order()│
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Decision Logger + DB │
|
||||||
|
│ - Full audit trail │
|
||||||
|
│ - Context snapshot │
|
||||||
|
│ - Telegram notification │
|
||||||
|
└──────────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Post-Market Phase │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Daily Review + Scorecard │
|
||||||
|
│ - Performance summary │
|
||||||
|
│ - Store in L6_DAILY context │
|
||||||
|
│ - Evolution learning │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
### Realtime Mode (with Smart Scanner)
|
### Realtime Mode (with Smart Scanner)
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -162,35 +372,31 @@ High-frequency trading with individual stock analysis:
|
|||||||
│ - Get open markets │
|
│ - Get open markets │
|
||||||
│ - Filter by enabled markets │
|
│ - Filter by enabled markets │
|
||||||
│ - Wait if all closed │
|
│ - Wait if all closed │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Smart Scanner (Python-first) │
|
│ Smart Scanner (Python-first) │
|
||||||
│ - Fetch volume rankings (KIS) │
|
│ - Domestic: fluctuation rank │
|
||||||
│ - Get 20d price history per stock│
|
│ + volume rank bonus │
|
||||||
│ - Calculate RSI(14) + vol ratio │
|
│ + volatility-first scoring │
|
||||||
│ - Filter: vol>2x AND RSI extreme │
|
│ - Overseas: ranking universe │
|
||||||
|
│ + volatility-first scoring │
|
||||||
|
│ - Fallback: dynamic universe │
|
||||||
│ - Return top 3 qualified stocks │
|
│ - Return top 3 qualified stocks │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ For Each Qualified Candidate │
|
│ For Each Qualified Candidate │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Broker: Fetch Market Data │
|
│ Broker: Fetch Market Data │
|
||||||
│ - Domestic: orderbook + balance │
|
│ - Domestic: orderbook + balance │
|
||||||
│ - Overseas: price + balance │
|
│ - Overseas: price + balance │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ Calculate P&L │
|
|
||||||
│ pnl_pct = (eval - cost) / cost │
|
|
||||||
└──────────────────┬────────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
@@ -199,47 +405,36 @@ High-frequency trading with individual stock analysis:
|
|||||||
│ - Call Gemini API │
|
│ - Call Gemini API │
|
||||||
│ - Parse JSON response │
|
│ - Parse JSON response │
|
||||||
│ - Return TradeDecision │
|
│ - Return TradeDecision │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Risk Manager: Validate Order │
|
│ Risk Manager: Validate Order │
|
||||||
│ - Check circuit breaker │
|
│ - Check circuit breaker │
|
||||||
│ - Check fat-finger limit │
|
│ - Check fat-finger limit │
|
||||||
│ - Raise if validation fails │
|
└──────────────────┬───────────────┘
|
||||||
└──────────────────┬────────────────┘
|
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Broker: Execute Order │
|
│ Broker: Execute Order │
|
||||||
│ - Domestic: send_order() │
|
│ - Domestic: send_order() │
|
||||||
│ - Overseas: send_overseas_order()│
|
│ - Overseas: send_overseas_order()│
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────┬───────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌──────────────────────────────────┐
|
┌──────────────────────────────────┐
|
||||||
│ Notifications: Send Alert │
|
│ Decision Logger + Notifications │
|
||||||
│ - Trade execution notification │
|
│ - Log trade to SQLite │
|
||||||
│ - Non-blocking (errors logged) │
|
│ - selection_context (JSON) │
|
||||||
│ - Rate-limited to 1/sec │
|
│ - Telegram notification │
|
||||||
└──────────────────┬────────────────┘
|
└──────────────────────────────────┘
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────────────┐
|
|
||||||
│ Database: Log Trade │
|
|
||||||
│ - SQLite (data/trades.db) │
|
|
||||||
│ - Track: action, confidence, │
|
|
||||||
│ rationale, market, exchange │
|
|
||||||
│ - NEW: selection_context (JSON) │
|
|
||||||
│ - RSI, volume_ratio, signal │
|
|
||||||
│ - For Evolution optimization │
|
|
||||||
└───────────────────────────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
**SQLite** (`src/db.py`)
|
**SQLite** (`src/db.py`) — Database: `data/trades.db`
|
||||||
|
|
||||||
|
### trades
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE trades (
|
CREATE TABLE trades (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -251,25 +446,73 @@ CREATE TABLE trades (
|
|||||||
quantity INTEGER,
|
quantity INTEGER,
|
||||||
price REAL,
|
price REAL,
|
||||||
pnl REAL DEFAULT 0.0,
|
pnl REAL DEFAULT 0.0,
|
||||||
market TEXT DEFAULT 'KR', -- KR | US_NASDAQ | JP | etc.
|
market TEXT DEFAULT 'KR',
|
||||||
exchange_code TEXT DEFAULT 'KRX', -- KRX | NASD | NYSE | etc.
|
exchange_code TEXT DEFAULT 'KRX',
|
||||||
selection_context TEXT -- JSON: {rsi, volume_ratio, signal, score}
|
selection_context TEXT, -- JSON: {rsi, volume_ratio, signal, score}
|
||||||
|
decision_id TEXT -- Links to decision_logs
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Selection Context** (new in v0.9.0): Stores scanner selection criteria as JSON:
|
### contexts
|
||||||
```json
|
```sql
|
||||||
{
|
CREATE TABLE contexts (
|
||||||
"rsi": 28.5,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
"volume_ratio": 2.7,
|
layer TEXT NOT NULL, -- L1 through L7
|
||||||
"signal": "oversold",
|
timeframe TEXT,
|
||||||
"score": 85.2
|
key TEXT NOT NULL,
|
||||||
}
|
value TEXT NOT NULL, -- JSON data
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
-- Indices: idx_contexts_layer, idx_contexts_timeframe, idx_contexts_updated
|
||||||
```
|
```
|
||||||
|
|
||||||
Enables Evolution system to analyze correlation between selection criteria and trade outcomes.
|
### decision_logs
|
||||||
|
```sql
|
||||||
|
CREATE TABLE decision_logs (
|
||||||
|
decision_id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
stock_code TEXT,
|
||||||
|
market TEXT,
|
||||||
|
exchange_code TEXT,
|
||||||
|
action TEXT,
|
||||||
|
confidence INTEGER,
|
||||||
|
rationale TEXT,
|
||||||
|
context_snapshot TEXT, -- JSON: full context at decision time
|
||||||
|
input_data TEXT, -- JSON: market data used
|
||||||
|
outcome_pnl REAL,
|
||||||
|
outcome_accuracy REAL,
|
||||||
|
reviewed INTEGER DEFAULT 0,
|
||||||
|
review_notes TEXT
|
||||||
|
);
|
||||||
|
-- Indices: idx_decision_logs_timestamp, idx_decision_logs_reviewed, idx_decision_logs_confidence
|
||||||
|
```
|
||||||
|
|
||||||
Auto-migration: Adds `market`, `exchange_code`, and `selection_context` columns if missing for backward compatibility.
|
### playbooks
|
||||||
|
```sql
|
||||||
|
CREATE TABLE playbooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
market TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'generated',
|
||||||
|
playbook_json TEXT NOT NULL, -- Full playbook with scenarios
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
token_count INTEGER,
|
||||||
|
scenario_count INTEGER,
|
||||||
|
match_count INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
-- Indices: idx_playbooks_date, idx_playbooks_market
|
||||||
|
```
|
||||||
|
|
||||||
|
### context_metadata
|
||||||
|
```sql
|
||||||
|
CREATE TABLE context_metadata (
|
||||||
|
layer TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
retention_days INTEGER,
|
||||||
|
aggregation_source TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
@@ -284,29 +527,81 @@ KIS_APP_SECRET=your_app_secret
|
|||||||
KIS_ACCOUNT_NO=XXXXXXXX-XX
|
KIS_ACCOUNT_NO=XXXXXXXX-XX
|
||||||
GEMINI_API_KEY=your_gemini_key
|
GEMINI_API_KEY=your_gemini_key
|
||||||
|
|
||||||
# Optional
|
# Optional — Trading Mode
|
||||||
MODE=paper # paper | live
|
MODE=paper # paper | live
|
||||||
DB_PATH=data/trades.db
|
|
||||||
CONFIDENCE_THRESHOLD=80
|
|
||||||
MAX_LOSS_PCT=3.0
|
|
||||||
MAX_ORDER_PCT=30.0
|
|
||||||
ENABLED_MARKETS=KR,US_NASDAQ # Comma-separated market codes
|
|
||||||
|
|
||||||
# Trading Mode (API efficiency)
|
|
||||||
TRADE_MODE=daily # daily | realtime
|
TRADE_MODE=daily # daily | realtime
|
||||||
DAILY_SESSIONS=4 # Sessions per day (daily mode only)
|
DAILY_SESSIONS=4 # Sessions per day (daily mode only)
|
||||||
SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only)
|
SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only)
|
||||||
|
|
||||||
# Telegram Notifications (optional)
|
# Optional — Database
|
||||||
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
DB_PATH=data/trades.db
|
||||||
TELEGRAM_CHAT_ID=123456789
|
|
||||||
TELEGRAM_ENABLED=true
|
|
||||||
|
|
||||||
# Smart Scanner (optional, realtime mode only)
|
# Optional — Risk
|
||||||
|
CONFIDENCE_THRESHOLD=80
|
||||||
|
MAX_LOSS_PCT=3.0
|
||||||
|
MAX_ORDER_PCT=30.0
|
||||||
|
|
||||||
|
# Optional — Markets
|
||||||
|
ENABLED_MARKETS=KR,US # Comma-separated market codes
|
||||||
|
RATE_LIMIT_RPS=2.0 # KIS API requests per second
|
||||||
|
|
||||||
|
# Optional — Pre-Market Planner (v2)
|
||||||
|
PRE_MARKET_MINUTES=30 # Minutes before market open to generate playbook
|
||||||
|
MAX_SCENARIOS_PER_STOCK=5 # Max scenarios per stock in playbook
|
||||||
|
PLANNER_TIMEOUT_SECONDS=60 # Timeout for playbook generation
|
||||||
|
DEFENSIVE_PLAYBOOK_ON_FAILURE=true # Fallback on AI failure
|
||||||
|
RESCAN_INTERVAL_SECONDS=300 # Scenario rescan interval during trading
|
||||||
|
|
||||||
|
# Optional — Smart Scanner (realtime mode only)
|
||||||
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
|
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
|
||||||
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold
|
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold
|
||||||
VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%)
|
VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%)
|
||||||
SCANNER_TOP_N=3 # Max qualified candidates per scan
|
SCANNER_TOP_N=3 # Max qualified candidates per scan
|
||||||
|
|
||||||
|
# Optional — Dashboard
|
||||||
|
DASHBOARD_ENABLED=false # Enable FastAPI dashboard
|
||||||
|
DASHBOARD_HOST=127.0.0.1 # Dashboard bind address
|
||||||
|
DASHBOARD_PORT=8080 # Dashboard port (1-65535)
|
||||||
|
|
||||||
|
# Optional — Telegram
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
TELEGRAM_COMMANDS_ENABLED=true # Enable bidirectional commands
|
||||||
|
TELEGRAM_POLLING_INTERVAL=1.0 # Command polling interval (seconds)
|
||||||
|
|
||||||
|
# Optional — Backup
|
||||||
|
BACKUP_ENABLED=false
|
||||||
|
BACKUP_DIR=data/backups
|
||||||
|
S3_ENDPOINT_URL=...
|
||||||
|
S3_ACCESS_KEY=...
|
||||||
|
S3_SECRET_KEY=...
|
||||||
|
S3_BUCKET_NAME=...
|
||||||
|
S3_REGION=...
|
||||||
|
|
||||||
|
# Optional — External Data
|
||||||
|
NEWS_API_KEY=...
|
||||||
|
NEWS_API_PROVIDER=...
|
||||||
|
MARKET_DATA_API_KEY=...
|
||||||
|
|
||||||
|
# Position Sizing (optional)
|
||||||
|
POSITION_SIZING_ENABLED=true
|
||||||
|
POSITION_BASE_ALLOCATION_PCT=5.0
|
||||||
|
POSITION_MIN_ALLOCATION_PCT=1.0
|
||||||
|
POSITION_MAX_ALLOCATION_PCT=10.0
|
||||||
|
POSITION_VOLATILITY_TARGET_SCORE=50.0
|
||||||
|
|
||||||
|
# Legacy/compat scanner thresholds (kept for backward compatibility)
|
||||||
|
RSI_OVERSOLD_THRESHOLD=30
|
||||||
|
RSI_MOMENTUM_THRESHOLD=70
|
||||||
|
VOL_MULTIPLIER=2.0
|
||||||
|
|
||||||
|
# Overseas Ranking API (optional override; account-dependent)
|
||||||
|
OVERSEAS_RANKING_ENABLED=true
|
||||||
|
OVERSEAS_RANKING_FLUCT_TR_ID=HHDFS76200100
|
||||||
|
OVERSEAS_RANKING_VOLUME_TR_ID=HHDFS76200200
|
||||||
|
OVERSEAS_RANKING_FLUCT_PATH=/uapi/overseas-price/v1/quotations/inquire-updown-rank
|
||||||
|
OVERSEAS_RANKING_VOLUME_PATH=/uapi/overseas-price/v1/quotations/inquire-volume-rank
|
||||||
```
|
```
|
||||||
|
|
||||||
Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.
|
Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.
|
||||||
@@ -340,4 +635,9 @@ Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tes
|
|||||||
- Invalid token → log error, trading unaffected
|
- Invalid token → log error, trading unaffected
|
||||||
- Rate limit exceeded → queued via rate limiter
|
- Rate limit exceeded → queued via rate limiter
|
||||||
|
|
||||||
**Guarantee**: Notification failures never interrupt trading operations.
|
### Playbook Generation Failure
|
||||||
|
- Timeout → fall back to defensive playbook (`DEFENSIVE_PLAYBOOK_ON_FAILURE`)
|
||||||
|
- API error → use previous day's playbook if available
|
||||||
|
- No playbook → skip pre-market phase, fall back to direct AI calls
|
||||||
|
|
||||||
|
**Guarantee**: Notification and dashboard failures never interrupt trading operations.
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ No decorator needed for async tests.
|
|||||||
# Install all dependencies (production + dev)
|
# Install all dependencies (production + dev)
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
# Run full test suite with coverage
|
# Run full test suite with coverage (551 tests across 25 files)
|
||||||
pytest -v --cov=src --cov-report=term-missing
|
pytest -v --cov=src --cov-report=term-missing
|
||||||
|
|
||||||
# Run a single test file
|
# Run a single test file
|
||||||
@@ -137,11 +137,82 @@ mypy src/ --strict
|
|||||||
# Run the trading agent
|
# Run the trading agent
|
||||||
python -m src.main --mode=paper
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
|
# Run with dashboard enabled
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker compose up -d ouroboros # Run agent
|
docker compose up -d ouroboros # Run agent
|
||||||
docker compose --profile test up test # Run tests in container
|
docker compose --profile test up test # Run tests in container
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
|
||||||
|
The FastAPI dashboard provides read-only monitoring of the trading system.
|
||||||
|
|
||||||
|
### Starting the Dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Via CLI flag
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
|
|
||||||
|
# Via environment variable
|
||||||
|
DASHBOARD_ENABLED=true python -m src.main --mode=paper
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard runs as a daemon thread on `DASHBOARD_HOST:DASHBOARD_PORT` (default: `127.0.0.1:8080`).
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `GET /` | HTML dashboard UI |
|
||||||
|
| `GET /api/status` | Daily trading status by market |
|
||||||
|
| `GET /api/playbook/{date}` | Playbook for specific date (query: `market`) |
|
||||||
|
| `GET /api/scorecard/{date}` | Daily scorecard from L6_DAILY context |
|
||||||
|
| `GET /api/performance` | Performance metrics by market and combined |
|
||||||
|
| `GET /api/context/{layer}` | Context data by layer L1-L7 (query: `timeframe`) |
|
||||||
|
| `GET /api/decisions` | Decision log entries (query: `limit`, `market`) |
|
||||||
|
| `GET /api/scenarios/active` | Today's matched scenarios |
|
||||||
|
|
||||||
|
## Telegram Commands
|
||||||
|
|
||||||
|
When `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot accepts these interactive commands:
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/help` | List available commands |
|
||||||
|
| `/status` | Show trading status (mode, markets, P&L) |
|
||||||
|
| `/positions` | Display account summary (balance, cash, P&L) |
|
||||||
|
| `/report` | Daily summary metrics (trades, P&L, win rate) |
|
||||||
|
| `/scenarios` | Show today's playbook scenarios |
|
||||||
|
| `/review` | Display recent scorecards (L6_DAILY layer) |
|
||||||
|
| `/dashboard` | Show dashboard URL if enabled |
|
||||||
|
| `/stop` | Pause trading |
|
||||||
|
| `/resume` | Resume trading |
|
||||||
|
|
||||||
|
Commands are only processed from the authorized `TELEGRAM_CHAT_ID`.
|
||||||
|
|
||||||
|
## KIS API TR_ID 참조 문서
|
||||||
|
|
||||||
|
**TR_ID를 추가하거나 수정할 때 반드시 공식 문서를 먼저 확인할 것.**
|
||||||
|
|
||||||
|
공식 문서: `docs/한국투자증권_오픈API_전체문서_20260221_030000.xlsx`
|
||||||
|
|
||||||
|
> ⚠️ 커뮤니티 블로그, GitHub 예제 등 비공식 자료의 TR_ID는 오래되거나 틀릴 수 있음.
|
||||||
|
> 실제로 `VTTT1006U`(미국 매도 — 잘못됨)가 오랫동안 코드에 남아있던 사례가 있음 (Issue #189).
|
||||||
|
|
||||||
|
### 주요 TR_ID 목록
|
||||||
|
|
||||||
|
| 구분 | 모의투자 TR_ID | 실전투자 TR_ID | 시트명 |
|
||||||
|
|------|---------------|---------------|--------|
|
||||||
|
| 해외주식 매수 (미국) | `VTTT1002U` | `TTTT1002U` | 해외주식 주문 |
|
||||||
|
| 해외주식 매도 (미국) | `VTTT1001U` | `TTTT1006U` | 해외주식 주문 |
|
||||||
|
|
||||||
|
새로운 TR_ID가 필요할 때:
|
||||||
|
1. 위 xlsx 파일에서 해당 거래 유형의 시트를 찾는다.
|
||||||
|
2. 모의투자(`VTTT`) / 실전투자(`TTTT`) 컬럼을 구분하여 정확한 값을 사용한다.
|
||||||
|
3. 코드에 출처 주석을 남긴다: `# Source: 한국투자증권_오픈API_전체문서 — '<시트명>' 시트`
|
||||||
|
|
||||||
## Environment Setup
|
## Environment Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -7,6 +7,32 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-02-21
|
||||||
|
|
||||||
|
### 거래 상태 확인 중 발견된 버그 (#187)
|
||||||
|
|
||||||
|
- 거래 상태 점검 요청 → SELL 주문(손절/익절)이 Fat Finger에 막혀 전혀 실행 안 됨 발견
|
||||||
|
- **#187 (Critical)**: SELL 주문에서 Fat Finger 오탐 — `order_amount/total_cash > 30%`가 SELL에도 적용되어 대형 포지션 매도 불가
|
||||||
|
- JELD stop-loss -6.20% → 차단, RXT take-profit +46.13% → 차단
|
||||||
|
- 수정: SELL은 `check_circuit_breaker`만 호출, `validate_order`(Fat Finger 포함) 미호출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-20
|
||||||
|
|
||||||
|
### 지속적 모니터링 및 개선점 도출 (이슈 #178~#182)
|
||||||
|
|
||||||
|
- Dashboard 포함해서 실행하며 간헐적 문제 모니터링 및 개선점 자동 도출 요청
|
||||||
|
- 모니터링 결과 발견된 이슈 목록:
|
||||||
|
- **#178**: uvicorn 미설치 → dashboard 미작동 + 오해의 소지 있는 시작 로그 → uvicorn 설치 완료
|
||||||
|
- **#179 (Critical)**: 잔액 부족 주문 실패 후 매 사이클마다 무한 재시도 (MLECW 20분 이상 반복)
|
||||||
|
- **#180**: 다중 인스턴스 실행 시 Telegram 409 충돌
|
||||||
|
- **#181**: implied_rsi 공식 포화 문제 (change_rate≥12.5% → RSI=100)
|
||||||
|
- **#182 (Critical)**: 보유 종목이 SmartScanner 변동성 필터에 걸려 SELL 신호 미생성 → SELL 체결 0건, 잔고 소진
|
||||||
|
- 요구사항: 모니터링 자동화 및 주기적 개선점 리포트 도출
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2026-02-05
|
## 2026-02-05
|
||||||
|
|
||||||
### API 효율화
|
### API 효율화
|
||||||
@@ -86,3 +112,183 @@
|
|||||||
- Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리
|
- Plan Consistency (필수), Safety & Constraints, Quality, Workflow 4개 카테고리
|
||||||
|
|
||||||
**이슈/PR:** #114
|
**이슈/PR:** #114
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-16
|
||||||
|
|
||||||
|
### 문서 v2 동기화 (전체 문서 현행화)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- v2 기능 구현 완료 후 문서가 실제 코드 상태와 크게 괴리
|
||||||
|
- 문서에는 54 tests / 4 files로 기록되었으나 실제로는 551 tests / 25 files
|
||||||
|
- v2 핵심 기능(Playbook, Scenario Engine, Dashboard, Telegram Commands, Daily Review, Context System, Backup) 문서화 누락
|
||||||
|
|
||||||
|
**요구사항:**
|
||||||
|
1. `docs/testing.md` — 551 tests / 25 files 반영, 전체 테스트 파일 설명
|
||||||
|
2. `docs/architecture.md` — v2 컴포넌트(Strategy, Context, Dashboard, Decision Logger 등) 추가, Playbook Mode 데이터 플로우, DB 스키마 5개 테이블, v2 환경변수
|
||||||
|
3. `docs/commands.md` — Dashboard 실행 명령어, Telegram 명령어 9종 레퍼런스
|
||||||
|
4. `CLAUDE.md` — Project Structure 트리 확장, 테스트 수 업데이트, `--dashboard` 플래그
|
||||||
|
5. `docs/skills.md` — DB 파일명 `trades.db`로 통일, Dashboard 명령어 추가
|
||||||
|
6. 기존에 유효한 트러블슈팅, 코드 예제 등은 유지
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- 6개 문서 파일 업데이트
|
||||||
|
- 이전 시도(2개 커밋)는 기존 내용을 과도하게 삭제하여 폐기, main 기준으로 재작업
|
||||||
|
|
||||||
|
**이슈/PR:** #131, PR #134
|
||||||
|
|
||||||
|
### 해외 스캐너 개선: 랭킹 연동 + 변동성 우선 선별
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- `run_overnight` 실운영에서 미국장 동안 거래가 0건 지속
|
||||||
|
- 원인: 해외 시장에서도 국내 랭킹/일봉 API 경로를 사용하던 구조적 불일치
|
||||||
|
|
||||||
|
**요구사항:**
|
||||||
|
1. 해외 시장도 랭킹 API 기반 유니버스 탐색 지원
|
||||||
|
2. 단순 상승률/거래대금 상위가 아니라, **변동성이 큰 종목**을 우선 선별
|
||||||
|
3. 고정 티커 fallback 금지
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/broker/overseas.py`
|
||||||
|
- `fetch_overseas_rankings()` 추가 (fluctuation / volume)
|
||||||
|
- 해외 랭킹 API 경로/TR_ID를 설정값으로 오버라이드 가능하게 구현
|
||||||
|
- `src/analysis/smart_scanner.py`
|
||||||
|
- market-aware 스캔(국내/해외 분리)
|
||||||
|
- 해외: 랭킹 API 유니버스 + 변동성 우선 점수(일변동률 vs 장중 고저폭)
|
||||||
|
- 거래대금/거래량 랭킹은 유동성 보정 점수로 활용
|
||||||
|
- 랭킹 실패 시에는 동적 유니버스(active/recent/holdings)만 사용
|
||||||
|
- `src/config.py`
|
||||||
|
- `OVERSEAS_RANKING_*` 설정 추가
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- 해외 시장에서 스캐너 후보 0개로 정지되는 상황 완화
|
||||||
|
- 종목 선정 기준이 단순 상승률 중심에서 변동성 중심으로 개선
|
||||||
|
- 고정 티커 없이도 시장 주도 변동 종목 탐지 가능
|
||||||
|
|
||||||
|
### 국내 스캐너/주문수량 정렬: 변동성 우선 + 리스크 타기팅
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 해외만 변동성 우선으로 동작하고, 국내는 RSI/거래량 필터 중심으로 동작해 시장 간 전략 일관성이 낮았음
|
||||||
|
- 매수 수량이 고정 1주라서 변동성 구간별 익스포저 관리가 어려웠음
|
||||||
|
|
||||||
|
**요구사항:**
|
||||||
|
1. 국내 스캐너도 변동성 우선 선별로 해외와 통일
|
||||||
|
2. 고변동 종목일수록 포지션 크기를 줄이는 수량 산식 적용
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/analysis/smart_scanner.py`
|
||||||
|
- 국내: `fluctuation ranking + volume ranking bonus` 기반 점수화로 전환
|
||||||
|
- 점수는 `max(abs(change_rate), intraday_range_pct)` 중심으로 계산
|
||||||
|
- 국내 랭킹 응답 스키마 키(`price`, `change_rate`, `volume`) 파싱 보강
|
||||||
|
- `src/main.py`
|
||||||
|
- `_determine_order_quantity()` 추가
|
||||||
|
- BUY 시 변동성 점수 기반 동적 수량 산정 적용
|
||||||
|
- `trading_cycle`, `run_daily_session` 경로 모두 동일 수량 로직 사용
|
||||||
|
- `src/config.py`
|
||||||
|
- `POSITION_SIZING_*` 설정 추가
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- 국내/해외 스캐너 기준이 변동성 중심으로 일관화
|
||||||
|
- 고변동 구간에서 자동 익스포저 축소, 저변동 구간에서 과소진입 완화
|
||||||
|
|
||||||
|
## 2026-02-18
|
||||||
|
|
||||||
|
### KIS 해외 랭킹 API 404 에러 수정
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- KIS 해외주식 랭킹 API(`fetch_overseas_rankings`)가 모든 거래소에서 HTTP 404를 반환
|
||||||
|
- Smart Scanner가 해외 시장 후보 종목을 찾지 못해 거래가 전혀 실행되지 않음
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- TR_ID, API 경로, 거래소 코드가 모두 KIS 공식 문서와 불일치
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/config.py`: TR_ID/Path 기본값을 KIS 공식 스펙으로 수정
|
||||||
|
- `src/broker/overseas.py`: 랭킹 API 전용 거래소 코드 매핑 추가 (NASD→NAS, NYSE→NYS, AMEX→AMS), 올바른 API 파라미터 사용
|
||||||
|
- `tests/test_overseas_broker.py`: 19개 단위 테스트 추가
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- 해외 시장 랭킹 스캔이 정상 동작하여 Smart Scanner가 후보 종목 탐지 가능
|
||||||
|
|
||||||
|
### Gemini prompt_override 미적용 버그 수정
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- `run_overnight` 실행 시 모든 시장에서 Playbook 생성 실패 (`JSONDecodeError`)
|
||||||
|
- defensive playbook으로 폴백되어 모든 종목이 HOLD 처리
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- `pre_market_planner.py`가 `market_data["prompt_override"]`에 Playbook 전용 프롬프트를 넣어 `gemini.decide()` 호출
|
||||||
|
- `gemini_client.py`의 `decide()` 메서드가 `prompt_override` 키를 전혀 확인하지 않고 항상 일반 트레이드 결정 프롬프트 생성
|
||||||
|
- Gemini가 Playbook JSON 대신 일반 트레이드 결정을 반환하여 파싱 실패
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/brain/gemini_client.py`: `decide()` 메서드에서 `prompt_override` 우선 사용 로직 추가
|
||||||
|
- `tests/test_brain.py`: 3개 테스트 추가 (override 전달, optimization 우회, 미지정 시 기존 동작 유지)
|
||||||
|
|
||||||
|
**이슈/PR:** #143
|
||||||
|
|
||||||
|
### 미국장 거래 미실행 근본 원인 분석 및 수정 (자율 실행 세션)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 사용자 요청: "미국장 열면 프로그램 돌려서 거래 한 번도 못 한 거 꼭 원인 찾아서 해결해줘"
|
||||||
|
- 프로그램을 미국장 개장(9:30 AM EST) 전부터 실행하여 실시간 로그를 분석
|
||||||
|
|
||||||
|
**발견된 근본 원인 #1: Defensive Playbook — BUY 조건 없음**
|
||||||
|
|
||||||
|
- Gemini free tier (20 RPD) 소진 → `generate_playbook()` 실패 → `_defensive_playbook()` 폴백
|
||||||
|
- Defensive playbook은 `price_change_pct_below: -3.0 → SELL` 조건만 존재, BUY 조건 없음
|
||||||
|
- ScenarioEngine이 항상 HOLD 반환 → 거래 0건
|
||||||
|
|
||||||
|
**수정 #1 (PR #146, Issue #145):**
|
||||||
|
- `src/strategy/pre_market_planner.py`: `_smart_fallback_playbook()` 메서드 추가
|
||||||
|
- 스캐너 signal 기반 BUY 조건 생성: `momentum → volume_ratio_above`, `oversold → rsi_below`
|
||||||
|
- 기존 defensive stop-loss SELL 조건 유지
|
||||||
|
- Gemini 실패 시 defensive → smart fallback으로 전환
|
||||||
|
- 테스트 10개 추가
|
||||||
|
|
||||||
|
**발견된 근본 원인 #2: 가격 API 거래소 코드 불일치 + VTS 잔고 API 오류**
|
||||||
|
|
||||||
|
실제 로그:
|
||||||
|
```
|
||||||
|
Scenario matched for MRNX: BUY (confidence=80) ✓
|
||||||
|
Decision for EWUS (NYSE American): BUY (confidence=80) ✓
|
||||||
|
Skip BUY APLZ (NYSE American): no affordable quantity (cash=0.00, price=0.00) ✗
|
||||||
|
```
|
||||||
|
|
||||||
|
- `get_overseas_price()`: `NASD`/`NYSE`/`AMEX` 전송 → API가 `NAS`/`NYS`/`AMS` 기대 → 빈 응답 → `price=0`
|
||||||
|
- `VTTS3012R` 잔고 API: "ERROR : INPUT INVALID_CHECK_ACNO" → `total_cash=0`
|
||||||
|
- 결과: `_determine_order_quantity()` 가 0 반환 → 주문 건너뜀
|
||||||
|
|
||||||
|
**수정 #2 (PR #148, Issue #147):**
|
||||||
|
- `src/broker/overseas.py`: `_PRICE_EXCHANGE_MAP = _RANKING_EXCHANGE_MAP` 추가, 가격 API에 매핑 적용
|
||||||
|
- `src/config.py`: `PAPER_OVERSEAS_CASH: float = Field(default=50000.0)` — paper 모드 시뮬레이션 잔고
|
||||||
|
- `src/main.py`: 잔고 0일 때 PAPER_OVERSEAS_CASH 폴백, 가격 0일 때 candidate.price 폴백
|
||||||
|
- 테스트 8개 추가
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- BUY 결정 → 실제 주문 전송까지의 파이프라인이 완전히 동작
|
||||||
|
- Paper 모드에서 KIS VTS 해외 잔고 API 오류에 관계없이 시뮬레이션 거래 가능
|
||||||
|
|
||||||
|
**이슈/PR:** #145, #146, #147, #148
|
||||||
|
|
||||||
|
### 해외주식 시장가 주문 거부 수정 (Fix #3, 연속 발견)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- Fix #147 적용 후 주문 전송 시작 → KIS VTS가 거부: "지정가만 가능한 상품입니다"
|
||||||
|
|
||||||
|
**근본 원인:**
|
||||||
|
- `trading_cycle()`, `run_daily_session()` 양쪽에서 `send_overseas_order(price=0.0)` 하드코딩
|
||||||
|
- `price=0` → `ORD_DVSN="01"` (시장가) 전송 → KIS VTS 거부
|
||||||
|
- Fix #147에서 이미 `current_price`를 올바르게 계산했으나 주문 시 미사용
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/main.py`: 두 곳에서 `price=0.0` → `price=current_price`/`price=stock_data["current_price"]`
|
||||||
|
- `tests/test_main.py`: 회귀 테스트 `test_overseas_buy_order_uses_limit_price` 추가
|
||||||
|
|
||||||
|
**최종 확인 로그:**
|
||||||
|
```
|
||||||
|
Order result: 모의투자 매수주문이 완료 되었습니다. ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
**이슈/PR:** #149, #150
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ python -m src.main --mode=paper
|
|||||||
```
|
```
|
||||||
Runs the agent in paper-trading mode (no real orders).
|
Runs the agent in paper-trading mode (no real orders).
|
||||||
|
|
||||||
|
### Start Trading Agent with Dashboard
|
||||||
|
```bash
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
|
```
|
||||||
|
Runs the agent with FastAPI dashboard on `127.0.0.1:8080` (configurable via `DASHBOARD_HOST`/`DASHBOARD_PORT`).
|
||||||
|
|
||||||
### Start Trading Agent (Production)
|
### Start Trading Agent (Production)
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d ouroboros
|
docker compose up -d ouroboros
|
||||||
@@ -59,7 +65,7 @@ Analyze the last 30 days of trade logs and generate performance metrics.
|
|||||||
python -m src.evolution.optimizer --evolve
|
python -m src.evolution.optimizer --evolve
|
||||||
```
|
```
|
||||||
Triggers the evolution engine to:
|
Triggers the evolution engine to:
|
||||||
1. Analyze `trade_logs.db` for failing patterns
|
1. Analyze `trades.db` for failing patterns
|
||||||
2. Ask Gemini to generate a new strategy
|
2. Ask Gemini to generate a new strategy
|
||||||
3. Run tests on the new strategy
|
3. Run tests on the new strategy
|
||||||
4. Create a PR if tests pass
|
4. Create a PR if tests pass
|
||||||
@@ -91,12 +97,12 @@ curl http://localhost:8080/health
|
|||||||
|
|
||||||
### View Trade Logs
|
### View Trade Logs
|
||||||
```bash
|
```bash
|
||||||
sqlite3 data/trade_logs.db "SELECT * FROM trades ORDER BY timestamp DESC LIMIT 20;"
|
sqlite3 data/trades.db "SELECT * FROM trades ORDER BY timestamp DESC LIMIT 20;"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Export Trade History
|
### Export Trade History
|
||||||
```bash
|
```bash
|
||||||
sqlite3 -header -csv data/trade_logs.db "SELECT * FROM trades;" > trades_export.csv
|
sqlite3 -header -csv data/trades.db "SELECT * FROM trades;" > trades_export.csv
|
||||||
```
|
```
|
||||||
|
|
||||||
## Safety Checklist (Pre-Deploy)
|
## Safety Checklist (Pre-Deploy)
|
||||||
|
|||||||
206
docs/testing.md
206
docs/testing.md
@@ -2,51 +2,29 @@
|
|||||||
|
|
||||||
## Test Structure
|
## Test Structure
|
||||||
|
|
||||||
**54 tests** across four files. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
|
**551 tests** across **25 files**. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
|
||||||
|
|
||||||
The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
|
The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
|
||||||
|
|
||||||
### Test Files
|
### Test Files
|
||||||
|
|
||||||
#### `tests/test_risk.py` (11 tests)
|
#### Core Components
|
||||||
- Circuit breaker boundaries
|
|
||||||
- Fat-finger edge cases
|
##### `tests/test_risk.py` (14 tests)
|
||||||
|
- Circuit breaker boundaries and exact threshold triggers
|
||||||
|
- Fat-finger edge cases and percentage validation
|
||||||
- P&L calculation edge cases
|
- P&L calculation edge cases
|
||||||
- Order validation logic
|
- Order validation logic
|
||||||
|
|
||||||
**Example:**
|
##### `tests/test_broker.py` (11 tests)
|
||||||
```python
|
|
||||||
def test_circuit_breaker_exact_threshold(risk_manager):
|
|
||||||
"""Circuit breaker should trip at exactly -3.0%."""
|
|
||||||
with pytest.raises(CircuitBreakerTripped):
|
|
||||||
risk_manager.validate_order(
|
|
||||||
current_pnl_pct=-3.0,
|
|
||||||
order_amount=1000,
|
|
||||||
total_cash=10000
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `tests/test_broker.py` (6 tests)
|
|
||||||
- OAuth token lifecycle
|
- OAuth token lifecycle
|
||||||
- Rate limiting enforcement
|
- Rate limiting enforcement
|
||||||
- Hash key generation
|
- Hash key generation
|
||||||
- Network error handling
|
- Network error handling
|
||||||
- SSL context configuration
|
- SSL context configuration
|
||||||
|
|
||||||
**Example:**
|
##### `tests/test_brain.py` (24 tests)
|
||||||
```python
|
- Valid JSON parsing and markdown-wrapped JSON handling
|
||||||
async def test_rate_limiter(broker):
|
|
||||||
"""Rate limiter should delay requests to stay under 10 RPS."""
|
|
||||||
start = time.monotonic()
|
|
||||||
for _ in range(15): # 15 requests
|
|
||||||
await broker._rate_limiter.acquire()
|
|
||||||
elapsed = time.monotonic() - start
|
|
||||||
assert elapsed >= 1.0 # Should take at least 1 second
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `tests/test_brain.py` (18 tests)
|
|
||||||
- Valid JSON parsing
|
|
||||||
- Markdown-wrapped JSON handling
|
|
||||||
- Malformed JSON fallback
|
- Malformed JSON fallback
|
||||||
- Missing fields handling
|
- Missing fields handling
|
||||||
- Invalid action validation
|
- Invalid action validation
|
||||||
@@ -54,33 +32,143 @@ async def test_rate_limiter(broker):
|
|||||||
- Empty response handling
|
- Empty response handling
|
||||||
- Prompt construction for different markets
|
- Prompt construction for different markets
|
||||||
|
|
||||||
**Example:**
|
##### `tests/test_market_schedule.py` (24 tests)
|
||||||
```python
|
|
||||||
async def test_confidence_below_threshold_forces_hold(brain):
|
|
||||||
"""Decisions below confidence threshold should force HOLD."""
|
|
||||||
decision = brain.parse_response('{"action":"BUY","confidence":70,"rationale":"test"}')
|
|
||||||
assert decision.action == "HOLD"
|
|
||||||
assert decision.confidence == 70
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `tests/test_market_schedule.py` (19 tests)
|
|
||||||
- Market open/close logic
|
- Market open/close logic
|
||||||
- Timezone handling (UTC, Asia/Seoul, America/New_York, etc.)
|
- Timezone handling (UTC, Asia/Seoul, America/New_York, etc.)
|
||||||
- DST (Daylight Saving Time) transitions
|
- DST (Daylight Saving Time) transitions
|
||||||
- Weekend handling
|
- Weekend handling and lunch break logic
|
||||||
- Lunch break logic
|
|
||||||
- Multiple market filtering
|
- Multiple market filtering
|
||||||
- Next market open calculation
|
- Next market open calculation
|
||||||
|
|
||||||
**Example:**
|
##### `tests/test_db.py` (3 tests)
|
||||||
```python
|
- Database initialization and table creation
|
||||||
def test_is_market_open_during_trading_hours():
|
- Trade logging with all fields (market, exchange_code, decision_id)
|
||||||
"""Market should be open during regular trading hours."""
|
- Query and retrieval operations
|
||||||
# KRX: 9:00-15:30 KST, no lunch break
|
|
||||||
market = MARKETS["KR"]
|
##### `tests/test_main.py` (37 tests)
|
||||||
trading_time = datetime(2026, 2, 3, 10, 0, tzinfo=ZoneInfo("Asia/Seoul")) # Monday 10:00
|
- Trading loop orchestration
|
||||||
assert is_market_open(market, trading_time) is True
|
- Market iteration and stock processing
|
||||||
```
|
- Dashboard integration (`--dashboard` flag)
|
||||||
|
- Telegram command handler wiring
|
||||||
|
- Error handling and graceful shutdown
|
||||||
|
|
||||||
|
#### Strategy & Playbook (v2)
|
||||||
|
|
||||||
|
##### `tests/test_pre_market_planner.py` (37 tests)
|
||||||
|
- Pre-market playbook generation
|
||||||
|
- Gemini API integration for scenario creation
|
||||||
|
- Timeout handling and defensive playbook fallback
|
||||||
|
- Multi-market playbook generation
|
||||||
|
|
||||||
|
##### `tests/test_scenario_engine.py` (44 tests)
|
||||||
|
- Scenario matching against live market data
|
||||||
|
- Confidence scoring and threshold filtering
|
||||||
|
- Multiple scenario type handling
|
||||||
|
- Edge cases (no match, partial match, expired scenarios)
|
||||||
|
|
||||||
|
##### `tests/test_playbook_store.py` (23 tests)
|
||||||
|
- Playbook persistence to SQLite
|
||||||
|
- Date-based retrieval and market filtering
|
||||||
|
- Playbook status management (generated, active, expired)
|
||||||
|
- JSON serialization/deserialization
|
||||||
|
|
||||||
|
##### `tests/test_strategy_models.py` (33 tests)
|
||||||
|
- Pydantic model validation for scenarios, playbooks, decisions
|
||||||
|
- Field constraints and default values
|
||||||
|
- Serialization round-trips
|
||||||
|
|
||||||
|
#### Analysis & Scanning
|
||||||
|
|
||||||
|
##### `tests/test_volatility.py` (24 tests)
|
||||||
|
- ATR and RSI calculation accuracy
|
||||||
|
- Volume surge ratio computation
|
||||||
|
- Momentum scoring
|
||||||
|
- Breakout/breakdown pattern detection
|
||||||
|
- Market scanner watchlist management
|
||||||
|
|
||||||
|
##### `tests/test_smart_scanner.py` (13 tests)
|
||||||
|
- Python-first filtering pipeline
|
||||||
|
- RSI and volume ratio filter logic
|
||||||
|
- Candidate scoring and ranking
|
||||||
|
- Fallback to static watchlist
|
||||||
|
|
||||||
|
#### Context & Memory
|
||||||
|
|
||||||
|
##### `tests/test_context.py` (18 tests)
|
||||||
|
- L1-L7 layer storage and retrieval
|
||||||
|
- Context key-value CRUD operations
|
||||||
|
- Timeframe-based queries
|
||||||
|
- Layer metadata management
|
||||||
|
|
||||||
|
##### `tests/test_context_scheduler.py` (5 tests)
|
||||||
|
- Periodic context aggregation scheduling
|
||||||
|
- Layer summarization triggers
|
||||||
|
|
||||||
|
#### Evolution & Review
|
||||||
|
|
||||||
|
##### `tests/test_evolution.py` (24 tests)
|
||||||
|
- Strategy optimization loop
|
||||||
|
- High-confidence losing trade analysis
|
||||||
|
- Generated strategy validation
|
||||||
|
|
||||||
|
##### `tests/test_daily_review.py` (10 tests)
|
||||||
|
- End-of-day review generation
|
||||||
|
- Trade performance summarization
|
||||||
|
- Context layer (L6_DAILY) integration
|
||||||
|
|
||||||
|
##### `tests/test_scorecard.py` (3 tests)
|
||||||
|
- Daily scorecard metrics calculation
|
||||||
|
- Win rate, P&L, confidence tracking
|
||||||
|
|
||||||
|
#### Notifications & Commands
|
||||||
|
|
||||||
|
##### `tests/test_telegram.py` (25 tests)
|
||||||
|
- Message sending and formatting
|
||||||
|
- Rate limiting (leaky bucket)
|
||||||
|
- Error handling (network timeout, invalid token)
|
||||||
|
- Auto-disable on missing credentials
|
||||||
|
- Notification types (trade, circuit breaker, fat-finger, market events)
|
||||||
|
|
||||||
|
##### `tests/test_telegram_commands.py` (31 tests)
|
||||||
|
- 9 command handlers (/help, /status, /positions, /report, /scenarios, /review, /dashboard, /stop, /resume)
|
||||||
|
- Long polling and command dispatch
|
||||||
|
- Authorization filtering by chat_id
|
||||||
|
- Command response formatting
|
||||||
|
|
||||||
|
#### Dashboard
|
||||||
|
|
||||||
|
##### `tests/test_dashboard.py` (14 tests)
|
||||||
|
- FastAPI endpoint responses (8 API routes)
|
||||||
|
- Status, playbook, scorecard, performance, context, decisions, scenarios
|
||||||
|
- Query parameter handling (market, date, limit)
|
||||||
|
|
||||||
|
#### Performance & Quality
|
||||||
|
|
||||||
|
##### `tests/test_token_efficiency.py` (34 tests)
|
||||||
|
- Gemini token usage optimization
|
||||||
|
- Prompt size reduction verification
|
||||||
|
- Cache effectiveness
|
||||||
|
|
||||||
|
##### `tests/test_latency_control.py` (30 tests)
|
||||||
|
- API call latency measurement
|
||||||
|
- Rate limiter timing accuracy
|
||||||
|
- Async operation overhead
|
||||||
|
|
||||||
|
##### `tests/test_decision_logger.py` (9 tests)
|
||||||
|
- Decision audit trail completeness
|
||||||
|
- Context snapshot capture
|
||||||
|
- Outcome tracking (P&L, accuracy)
|
||||||
|
|
||||||
|
##### `tests/test_data_integration.py` (38 tests)
|
||||||
|
- External data source integration
|
||||||
|
- News API, market data, economic calendar
|
||||||
|
- Error handling for API failures
|
||||||
|
|
||||||
|
##### `tests/test_backup.py` (23 tests)
|
||||||
|
- Backup scheduler and execution
|
||||||
|
- Cloud storage (S3) upload
|
||||||
|
- Health monitoring
|
||||||
|
- Data export functionality
|
||||||
|
|
||||||
## Coverage Requirements
|
## Coverage Requirements
|
||||||
|
|
||||||
@@ -91,20 +179,6 @@ Check coverage:
|
|||||||
pytest -v --cov=src --cov-report=term-missing
|
pytest -v --cov=src --cov-report=term-missing
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected output:
|
|
||||||
```
|
|
||||||
Name Stmts Miss Cover Missing
|
|
||||||
-----------------------------------------------------------
|
|
||||||
src/brain/gemini_client.py 85 5 94% 165-169
|
|
||||||
src/broker/kis_api.py 120 12 90% ...
|
|
||||||
src/core/risk_manager.py 35 2 94% ...
|
|
||||||
src/db.py 25 1 96% ...
|
|
||||||
src/main.py 150 80 47% (excluded from CI)
|
|
||||||
src/markets/schedule.py 95 3 97% ...
|
|
||||||
-----------------------------------------------------------
|
|
||||||
TOTAL 510 103 80%
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note:** `main.py` has lower coverage as it contains the main loop which is tested via integration/manual testing.
|
**Note:** `main.py` has lower coverage as it contains the main loop which is tested via integration/manual testing.
|
||||||
|
|
||||||
## Test Configuration
|
## Test Configuration
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ dependencies = [
|
|||||||
"pydantic-settings>=2.1,<3",
|
"pydantic-settings>=2.1,<3",
|
||||||
"google-genai>=1.0,<2",
|
"google-genai>=1.0,<2",
|
||||||
"scipy>=1.11,<2",
|
"scipy>=1.11,<2",
|
||||||
|
"fastapi>=0.110,<1",
|
||||||
|
"uvicorn>=0.29,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
54
scripts/morning_report.sh
Executable file
54
scripts/morning_report.sh
Executable file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Morning summary for overnight run logs.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||||
|
|
||||||
|
if [ ! -d "$LOG_DIR" ]; then
|
||||||
|
echo "로그 디렉터리가 없습니다: $LOG_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
latest_run="$(ls -1t "$LOG_DIR"/run_*.log 2>/dev/null | head -n 1 || true)"
|
||||||
|
latest_watchdog="$(ls -1t "$LOG_DIR"/watchdog_*.log 2>/dev/null | head -n 1 || true)"
|
||||||
|
|
||||||
|
if [ -z "$latest_run" ]; then
|
||||||
|
echo "run 로그가 없습니다: $LOG_DIR/run_*.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Overnight report"
|
||||||
|
echo "- run log: $latest_run"
|
||||||
|
if [ -n "$latest_watchdog" ]; then
|
||||||
|
echo "- watchdog log: $latest_watchdog"
|
||||||
|
fi
|
||||||
|
|
||||||
|
start_line="$(head -n 1 "$latest_run" || true)"
|
||||||
|
end_line="$(tail -n 1 "$latest_run" || true)"
|
||||||
|
|
||||||
|
info_count="$(rg -c '"level": "INFO"' "$latest_run" || true)"
|
||||||
|
warn_count="$(rg -c '"level": "WARNING"' "$latest_run" || true)"
|
||||||
|
error_count="$(rg -c '"level": "ERROR"' "$latest_run" || true)"
|
||||||
|
critical_count="$(rg -c '"level": "CRITICAL"' "$latest_run" || true)"
|
||||||
|
traceback_count="$(rg -c 'Traceback' "$latest_run" || true)"
|
||||||
|
|
||||||
|
echo "- start: ${start_line:-N/A}"
|
||||||
|
echo "- end: ${end_line:-N/A}"
|
||||||
|
echo "- INFO: ${info_count:-0}"
|
||||||
|
echo "- WARNING: ${warn_count:-0}"
|
||||||
|
echo "- ERROR: ${error_count:-0}"
|
||||||
|
echo "- CRITICAL: ${critical_count:-0}"
|
||||||
|
echo "- Traceback: ${traceback_count:-0}"
|
||||||
|
|
||||||
|
if [ -n "$latest_watchdog" ]; then
|
||||||
|
watchdog_errors="$(rg -c '\[ERROR\]' "$latest_watchdog" || true)"
|
||||||
|
echo "- watchdog ERROR: ${watchdog_errors:-0}"
|
||||||
|
echo ""
|
||||||
|
echo "최근 watchdog 로그:"
|
||||||
|
tail -n 5 "$latest_watchdog" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "최근 앱 로그:"
|
||||||
|
tail -n 20 "$latest_run" || true
|
||||||
87
scripts/run_overnight.sh
Executable file
87
scripts/run_overnight.sh
Executable file
@@ -0,0 +1,87 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start The Ouroboros overnight with logs and watchdog.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||||
|
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
||||||
|
TMUX_AUTO="${TMUX_AUTO:-true}"
|
||||||
|
TMUX_ATTACH="${TMUX_ATTACH:-true}"
|
||||||
|
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
||||||
|
|
||||||
|
if [ -z "${APP_CMD:-}" ]; then
|
||||||
|
if [ -x ".venv/bin/python" ]; then
|
||||||
|
PYTHON_BIN=".venv/bin/python"
|
||||||
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python3"
|
||||||
|
elif command -v python >/dev/null 2>&1; then
|
||||||
|
PYTHON_BIN="python"
|
||||||
|
else
|
||||||
|
echo ".venv/bin/python 또는 python3/python 실행 파일을 찾을 수 없습니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
dashboard_port="${DASHBOARD_PORT:-8080}"
|
||||||
|
|
||||||
|
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=paper --dashboard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
timestamp="$(date +"%Y%m%d_%H%M%S")"
|
||||||
|
RUN_LOG="$LOG_DIR/run_${timestamp}.log"
|
||||||
|
WATCHDOG_LOG="$LOG_DIR/watchdog_${timestamp}.log"
|
||||||
|
PID_FILE="$LOG_DIR/app.pid"
|
||||||
|
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
||||||
|
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
old_pid="$(cat "$PID_FILE" || true)"
|
||||||
|
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||||
|
echo "앱이 이미 실행 중입니다. pid=$old_pid"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
|
||||||
|
nohup bash -lc "$APP_CMD" >>"$RUN_LOG" 2>&1 &
|
||||||
|
app_pid=$!
|
||||||
|
echo "$app_pid" > "$PID_FILE"
|
||||||
|
|
||||||
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] app pid=$app_pid" | tee -a "$RUN_LOG"
|
||||||
|
|
||||||
|
nohup env PID_FILE="$PID_FILE" LOG_FILE="$WATCHDOG_LOG" CHECK_INTERVAL="$CHECK_INTERVAL" \
|
||||||
|
bash scripts/watchdog.sh >/dev/null 2>&1 &
|
||||||
|
watchdog_pid=$!
|
||||||
|
echo "$watchdog_pid" > "$WATCHDOG_PID_FILE"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
시작 완료
|
||||||
|
- app pid: $app_pid
|
||||||
|
- watchdog pid: $watchdog_pid
|
||||||
|
- app log: $RUN_LOG
|
||||||
|
- watchdog log: $WATCHDOG_LOG
|
||||||
|
|
||||||
|
실시간 확인:
|
||||||
|
tail -f "$RUN_LOG"
|
||||||
|
tail -f "$WATCHDOG_LOG"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ "$TMUX_AUTO" = "true" ]; then
|
||||||
|
if ! command -v tmux >/dev/null 2>&1; then
|
||||||
|
echo "tmux를 찾지 못해 자동 세션 생성은 건너뜁니다."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
session_name="${TMUX_SESSION_PREFIX}_${timestamp}"
|
||||||
|
window_name="overnight"
|
||||||
|
tmux new-session -d -s "$session_name" -n "$window_name" "tail -f '$RUN_LOG'"
|
||||||
|
tmux split-window -t "${session_name}:${window_name}" -v "tail -f '$WATCHDOG_LOG'"
|
||||||
|
tmux select-layout -t "${session_name}:${window_name}" even-vertical
|
||||||
|
|
||||||
|
echo "tmux session 생성: $session_name"
|
||||||
|
echo "수동 접속: tmux attach -t $session_name"
|
||||||
|
|
||||||
|
if [ -z "${TMUX:-}" ] && [ "$TMUX_ATTACH" = "true" ]; then
|
||||||
|
tmux attach -t "$session_name"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
76
scripts/stop_overnight.sh
Executable file
76
scripts/stop_overnight.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop The Ouroboros overnight app/watchdog/tmux session.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="${LOG_DIR:-data/overnight}"
|
||||||
|
PID_FILE="$LOG_DIR/app.pid"
|
||||||
|
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
||||||
|
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
||||||
|
KILL_TIMEOUT="${KILL_TIMEOUT:-5}"
|
||||||
|
|
||||||
|
stop_pid() {
|
||||||
|
local name="$1"
|
||||||
|
local pid="$2"
|
||||||
|
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
echo "$name PID가 비어 있습니다."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$name 프로세스가 이미 종료됨 (pid=$pid)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
for _ in $(seq 1 "$KILL_TIMEOUT"); do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$name 종료됨 (pid=$pid)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$name 강제 종료됨 (pid=$pid)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$name 종료 실패 (pid=$pid)"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
status=0
|
||||||
|
|
||||||
|
if [ -f "$WATCHDOG_PID_FILE" ]; then
|
||||||
|
watchdog_pid="$(cat "$WATCHDOG_PID_FILE" || true)"
|
||||||
|
stop_pid "watchdog" "$watchdog_pid" || status=1
|
||||||
|
rm -f "$WATCHDOG_PID_FILE"
|
||||||
|
else
|
||||||
|
echo "watchdog pid 파일 없음: $WATCHDOG_PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
app_pid="$(cat "$PID_FILE" || true)"
|
||||||
|
stop_pid "app" "$app_pid" || status=1
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
else
|
||||||
|
echo "app pid 파일 없음: $PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v tmux >/dev/null 2>&1; then
|
||||||
|
sessions="$(tmux ls 2>/dev/null | awk -F: -v p="$TMUX_SESSION_PREFIX" '$1 ~ "^" p "_" {print $1}')"
|
||||||
|
if [ -n "$sessions" ]; then
|
||||||
|
while IFS= read -r s; do
|
||||||
|
[ -z "$s" ] && continue
|
||||||
|
tmux kill-session -t "$s" 2>/dev/null || true
|
||||||
|
echo "tmux 세션 종료: $s"
|
||||||
|
done <<< "$sessions"
|
||||||
|
else
|
||||||
|
echo "종료할 tmux 세션 없음 (prefix=${TMUX_SESSION_PREFIX}_)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
42
scripts/watchdog.sh
Executable file
42
scripts/watchdog.sh
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Simple watchdog for The Ouroboros process.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PID_FILE="${PID_FILE:-data/overnight/app.pid}"
|
||||||
|
LOG_FILE="${LOG_FILE:-data/overnight/watchdog.log}"
|
||||||
|
CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
||||||
|
STATUS_EVERY="${STATUS_EVERY:-10}"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOG_FILE")"
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '%s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "$1" | tee -a "$LOG_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ ! -f "$PID_FILE" ]; then
|
||||||
|
log "[ERROR] pid file not found: $PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PID="$(cat "$PID_FILE")"
|
||||||
|
if [ -z "$PID" ]; then
|
||||||
|
log "[ERROR] pid file is empty: $PID_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "[INFO] watchdog started (pid=$PID, interval=${CHECK_INTERVAL}s)"
|
||||||
|
|
||||||
|
count=0
|
||||||
|
while true; do
|
||||||
|
if kill -0 "$PID" 2>/dev/null; then
|
||||||
|
count=$((count + 1))
|
||||||
|
if [ $((count % STATUS_EVERY)) -eq 0 ]; then
|
||||||
|
log "[INFO] process alive (pid=$PID)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "[ERROR] process stopped (pid=$PID)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep "$CHECK_INTERVAL"
|
||||||
|
done
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
"""Smart Volatility Scanner with RSI and volume filters.
|
"""Smart Volatility Scanner with volatility-first market ranking logic."""
|
||||||
|
|
||||||
Fetches market rankings from KIS API and applies technical filters
|
|
||||||
to identify high-probability trading candidates.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -12,7 +8,9 @@ from typing import Any
|
|||||||
|
|
||||||
from src.analysis.volatility import VolatilityAnalyzer
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
from src.broker.kis_api import KISBroker
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.broker.overseas import OverseasBroker
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
from src.markets.schedule import MarketInfo
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -32,19 +30,19 @@ class ScanCandidate:
|
|||||||
|
|
||||||
|
|
||||||
class SmartVolatilityScanner:
|
class SmartVolatilityScanner:
|
||||||
"""Scans market rankings and applies RSI/volume filters.
|
"""Scans market rankings and applies volatility-first filters.
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. Fetch volume rankings from KIS API
|
1. Fetch fluctuation rankings as primary universe
|
||||||
2. For each ranked stock, fetch daily prices
|
2. Fetch volume rankings for liquidity bonus
|
||||||
3. Calculate RSI and volume ratio
|
3. Score by volatility first, liquidity second
|
||||||
4. Apply filters: volume > VOL_MULTIPLIER AND (RSI < 30 OR RSI > 70)
|
4. Return top N qualified candidates
|
||||||
5. Return top N qualified candidates
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
broker: KISBroker,
|
broker: KISBroker,
|
||||||
|
overseas_broker: OverseasBroker | None,
|
||||||
volatility_analyzer: VolatilityAnalyzer,
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -56,6 +54,7 @@ class SmartVolatilityScanner:
|
|||||||
settings: Application settings
|
settings: Application settings
|
||||||
"""
|
"""
|
||||||
self.broker = broker
|
self.broker = broker
|
||||||
|
self.overseas_broker = overseas_broker
|
||||||
self.analyzer = volatility_analyzer
|
self.analyzer = volatility_analyzer
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
|
|
||||||
@@ -67,108 +66,130 @@ class SmartVolatilityScanner:
|
|||||||
|
|
||||||
async def scan(
|
async def scan(
|
||||||
self,
|
self,
|
||||||
|
market: MarketInfo | None = None,
|
||||||
fallback_stocks: list[str] | None = None,
|
fallback_stocks: list[str] | None = None,
|
||||||
) -> list[ScanCandidate]:
|
) -> list[ScanCandidate]:
|
||||||
"""Execute smart scan and return qualified candidates.
|
"""Execute smart scan and return qualified candidates.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
market: Target market info (domestic vs overseas behavior)
|
||||||
fallback_stocks: Stock codes to use if ranking API fails
|
fallback_stocks: Stock codes to use if ranking API fails
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of ScanCandidate, sorted by score, up to top_n items
|
List of ScanCandidate, sorted by score, up to top_n items
|
||||||
"""
|
"""
|
||||||
# Step 1: Fetch rankings
|
if market and not market.is_domestic:
|
||||||
|
return await self._scan_overseas(market, fallback_stocks)
|
||||||
|
|
||||||
|
return await self._scan_domestic(fallback_stocks)
|
||||||
|
|
||||||
|
async def _scan_domestic(
|
||||||
|
self,
|
||||||
|
fallback_stocks: list[str] | None = None,
|
||||||
|
) -> list[ScanCandidate]:
|
||||||
|
"""Scan domestic market using volatility-first ranking + liquidity bonus."""
|
||||||
|
# 1) Primary universe from fluctuation ranking.
|
||||||
try:
|
try:
|
||||||
rankings = await self.broker.fetch_market_rankings(
|
fluct_rows = await self.broker.fetch_market_rankings(
|
||||||
ranking_type="volume",
|
ranking_type="fluctuation",
|
||||||
limit=30, # Fetch more than needed for filtering
|
limit=50,
|
||||||
)
|
)
|
||||||
logger.info("Fetched %d stocks from volume rankings", len(rankings))
|
|
||||||
except ConnectionError as exc:
|
except ConnectionError as exc:
|
||||||
logger.warning("Ranking API failed, using fallback: %s", exc)
|
logger.warning("Domestic fluctuation ranking failed: %s", exc)
|
||||||
if fallback_stocks:
|
fluct_rows = []
|
||||||
# Create minimal ranking data for fallback
|
|
||||||
rankings = [
|
# 2) Liquidity bonus from volume ranking.
|
||||||
|
try:
|
||||||
|
volume_rows = await self.broker.fetch_market_rankings(
|
||||||
|
ranking_type="volume",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
except ConnectionError as exc:
|
||||||
|
logger.warning("Domestic volume ranking failed: %s", exc)
|
||||||
|
volume_rows = []
|
||||||
|
|
||||||
|
if not fluct_rows and fallback_stocks:
|
||||||
|
logger.info(
|
||||||
|
"Domestic ranking unavailable; using fallback symbols (%d)",
|
||||||
|
len(fallback_stocks),
|
||||||
|
)
|
||||||
|
fluct_rows = [
|
||||||
{
|
{
|
||||||
"stock_code": code,
|
"stock_code": code,
|
||||||
"name": code,
|
"name": code,
|
||||||
"price": 0,
|
"price": 0.0,
|
||||||
"volume": 0,
|
"volume": 0.0,
|
||||||
"change_rate": 0,
|
"change_rate": 0.0,
|
||||||
"volume_increase_rate": 0,
|
"volume_increase_rate": 0.0,
|
||||||
}
|
}
|
||||||
for code in fallback_stocks
|
for code in fallback_stocks
|
||||||
]
|
]
|
||||||
else:
|
|
||||||
|
if not fluct_rows:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Step 2: Analyze each stock
|
volume_rank_bonus: dict[str, float] = {}
|
||||||
candidates: list[ScanCandidate] = []
|
for idx, row in enumerate(volume_rows):
|
||||||
|
code = _extract_stock_code(row)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
volume_rank_bonus[code] = max(0.0, 15.0 - idx * 0.3)
|
||||||
|
|
||||||
for stock in rankings:
|
candidates: list[ScanCandidate] = []
|
||||||
stock_code = stock["stock_code"]
|
for stock in fluct_rows:
|
||||||
|
stock_code = _extract_stock_code(stock)
|
||||||
if not stock_code:
|
if not stock_code:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Fetch daily prices for RSI calculation
|
price = _extract_last_price(stock)
|
||||||
daily_prices = await self.broker.get_daily_prices(stock_code, days=20)
|
change_rate = _extract_change_rate_pct(stock)
|
||||||
|
volume = _extract_volume(stock)
|
||||||
|
|
||||||
if len(daily_prices) < 15: # Need at least 14+1 for RSI
|
intraday_range_pct = 0.0
|
||||||
logger.debug("Insufficient price history for %s", stock_code)
|
volume_ratio = _safe_float(stock.get("volume_increase_rate"), 0.0) / 100.0 + 1.0
|
||||||
|
|
||||||
|
# Use daily chart to refine range/volume when available.
|
||||||
|
daily_prices = await self.broker.get_daily_prices(stock_code, days=2)
|
||||||
|
if daily_prices:
|
||||||
|
latest = daily_prices[-1]
|
||||||
|
latest_close = _safe_float(latest.get("close"), default=price)
|
||||||
|
if price <= 0:
|
||||||
|
price = latest_close
|
||||||
|
latest_high = _safe_float(latest.get("high"))
|
||||||
|
latest_low = _safe_float(latest.get("low"))
|
||||||
|
if latest_close > 0 and latest_high > 0 and latest_low > 0 and latest_high >= latest_low:
|
||||||
|
intraday_range_pct = (latest_high - latest_low) / latest_close * 100.0
|
||||||
|
if volume <= 0:
|
||||||
|
volume = _safe_float(latest.get("volume"))
|
||||||
|
if len(daily_prices) >= 2:
|
||||||
|
prev_day_volume = _safe_float(daily_prices[-2].get("volume"))
|
||||||
|
if prev_day_volume > 0:
|
||||||
|
volume_ratio = max(volume_ratio, volume / prev_day_volume)
|
||||||
|
|
||||||
|
volatility_pct = max(abs(change_rate), intraday_range_pct)
|
||||||
|
if price <= 0 or volatility_pct < 0.8:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Calculate RSI
|
volatility_score = min(volatility_pct / 10.0, 1.0) * 85.0
|
||||||
close_prices = [p["close"] for p in daily_prices]
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
rsi = self.analyzer.calculate_rsi(close_prices, period=14)
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
# Calculate volume ratio (today vs previous day avg)
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
if len(daily_prices) >= 2:
|
|
||||||
prev_day_volume = daily_prices[-2]["volume"]
|
|
||||||
current_volume = stock.get("volume", 0) or daily_prices[-1]["volume"]
|
|
||||||
volume_ratio = (
|
|
||||||
current_volume / prev_day_volume if prev_day_volume > 0 else 1.0
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
volume_ratio = stock.get("volume_increase_rate", 0) / 100 + 1 # Fallback
|
|
||||||
|
|
||||||
# Apply filters
|
|
||||||
volume_qualified = volume_ratio >= self.vol_multiplier
|
|
||||||
rsi_oversold = rsi < self.rsi_oversold
|
|
||||||
rsi_momentum = rsi > self.rsi_momentum
|
|
||||||
|
|
||||||
if volume_qualified and (rsi_oversold or rsi_momentum):
|
|
||||||
signal = "oversold" if rsi_oversold else "momentum"
|
|
||||||
|
|
||||||
# Calculate composite score
|
|
||||||
# Higher score for: extreme RSI + high volume
|
|
||||||
rsi_extremity = abs(rsi - 50) / 50 # 0-1 scale
|
|
||||||
volume_score = min(volume_ratio / 5, 1.0) # Cap at 5x
|
|
||||||
score = (rsi_extremity * 0.6 + volume_score * 0.4) * 100
|
|
||||||
|
|
||||||
candidates.append(
|
candidates.append(
|
||||||
ScanCandidate(
|
ScanCandidate(
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
name=stock.get("name", stock_code),
|
name=stock.get("name", stock_code),
|
||||||
price=stock.get("price", daily_prices[-1]["close"]),
|
price=price,
|
||||||
volume=current_volume,
|
volume=volume,
|
||||||
volume_ratio=volume_ratio,
|
volume_ratio=max(1.0, volume_ratio, volatility_pct / 2.0),
|
||||||
rsi=rsi,
|
rsi=implied_rsi,
|
||||||
signal=signal,
|
signal=signal,
|
||||||
score=score,
|
score=score,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Qualified: %s (%s) RSI=%.1f vol=%.1fx signal=%s score=%.1f",
|
|
||||||
stock_code,
|
|
||||||
stock.get("name", ""),
|
|
||||||
rsi,
|
|
||||||
volume_ratio,
|
|
||||||
signal,
|
|
||||||
score,
|
|
||||||
)
|
|
||||||
|
|
||||||
except ConnectionError as exc:
|
except ConnectionError as exc:
|
||||||
logger.warning("Failed to analyze %s: %s", stock_code, exc)
|
logger.warning("Failed to analyze %s: %s", stock_code, exc)
|
||||||
continue
|
continue
|
||||||
@@ -176,10 +197,171 @@ class SmartVolatilityScanner:
|
|||||||
logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
|
logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Sort by score and return top N
|
logger.info("Domestic ranking scan found %d candidates", len(candidates))
|
||||||
candidates.sort(key=lambda c: c.score, reverse=True)
|
candidates.sort(key=lambda c: c.score, reverse=True)
|
||||||
return candidates[: self.top_n]
|
return candidates[: self.top_n]
|
||||||
|
|
||||||
|
async def _scan_overseas(
|
||||||
|
self,
|
||||||
|
market: MarketInfo,
|
||||||
|
fallback_stocks: list[str] | None = None,
|
||||||
|
) -> list[ScanCandidate]:
|
||||||
|
"""Scan overseas symbols using ranking API first, then fallback universe."""
|
||||||
|
if self.overseas_broker is None:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas scanner unavailable for %s: overseas broker not configured",
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
candidates = await self._scan_overseas_from_rankings(market)
|
||||||
|
if not candidates:
|
||||||
|
candidates = await self._scan_overseas_from_symbols(market, fallback_stocks)
|
||||||
|
|
||||||
|
candidates.sort(key=lambda c: c.score, reverse=True)
|
||||||
|
return candidates[: self.top_n]
|
||||||
|
|
||||||
|
async def _scan_overseas_from_rankings(
|
||||||
|
self,
|
||||||
|
market: MarketInfo,
|
||||||
|
) -> list[ScanCandidate]:
|
||||||
|
"""Build overseas candidates from ranking APIs using volatility-first scoring."""
|
||||||
|
assert self.overseas_broker is not None
|
||||||
|
try:
|
||||||
|
fluct_rows = await self.overseas_broker.fetch_overseas_rankings(
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
ranking_type="fluctuation",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas fluctuation ranking failed for %s: %s", market.code, exc
|
||||||
|
)
|
||||||
|
fluct_rows = []
|
||||||
|
|
||||||
|
if not fluct_rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
volume_rank_bonus: dict[str, float] = {}
|
||||||
|
try:
|
||||||
|
volume_rows = await self.overseas_broker.fetch_overseas_rankings(
|
||||||
|
exchange_code=market.exchange_code,
|
||||||
|
ranking_type="volume",
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas volume ranking failed for %s: %s", market.code, exc
|
||||||
|
)
|
||||||
|
volume_rows = []
|
||||||
|
|
||||||
|
for idx, row in enumerate(volume_rows):
|
||||||
|
code = _extract_stock_code(row)
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
# Top-ranked by traded value/volume gets higher liquidity bonus.
|
||||||
|
volume_rank_bonus[code] = max(0.0, 15.0 - idx * 0.3)
|
||||||
|
|
||||||
|
candidates: list[ScanCandidate] = []
|
||||||
|
for row in fluct_rows:
|
||||||
|
stock_code = _extract_stock_code(row)
|
||||||
|
if not stock_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = _extract_last_price(row)
|
||||||
|
change_rate = _extract_change_rate_pct(row)
|
||||||
|
volume = _extract_volume(row)
|
||||||
|
intraday_range_pct = _extract_intraday_range_pct(row, price)
|
||||||
|
volatility_pct = max(abs(change_rate), intraday_range_pct)
|
||||||
|
|
||||||
|
# Volatility-first filter (not simple gainers/value ranking).
|
||||||
|
if price <= 0 or volatility_pct < 0.8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
volatility_score = min(volatility_pct / 10.0, 1.0) * 85.0
|
||||||
|
liquidity_score = volume_rank_bonus.get(stock_code, 0.0)
|
||||||
|
score = min(100.0, volatility_score + liquidity_score)
|
||||||
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
|
candidates.append(
|
||||||
|
ScanCandidate(
|
||||||
|
stock_code=stock_code,
|
||||||
|
name=str(row.get("name") or row.get("ovrs_item_name") or stock_code),
|
||||||
|
price=price,
|
||||||
|
volume=volume,
|
||||||
|
volume_ratio=max(1.0, volatility_pct / 2.0),
|
||||||
|
rsi=implied_rsi,
|
||||||
|
signal=signal,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if candidates:
|
||||||
|
logger.info(
|
||||||
|
"Overseas ranking scan found %d candidates for %s",
|
||||||
|
len(candidates),
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
async def _scan_overseas_from_symbols(
|
||||||
|
self,
|
||||||
|
market: MarketInfo,
|
||||||
|
symbols: list[str] | None,
|
||||||
|
) -> list[ScanCandidate]:
|
||||||
|
"""Fallback overseas scan from dynamic symbol universe."""
|
||||||
|
assert self.overseas_broker is not None
|
||||||
|
if not symbols:
|
||||||
|
logger.info("Overseas scanner: no symbol universe for %s", market.name)
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Overseas scanner: scanning %d fallback symbols for %s",
|
||||||
|
len(symbols),
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
candidates: list[ScanCandidate] = []
|
||||||
|
for stock_code in symbols:
|
||||||
|
try:
|
||||||
|
price_data = await self.overseas_broker.get_overseas_price(
|
||||||
|
market.exchange_code, stock_code
|
||||||
|
)
|
||||||
|
output = price_data.get("output", {})
|
||||||
|
price = _extract_last_price(output)
|
||||||
|
change_rate = _extract_change_rate_pct(output)
|
||||||
|
volume = _extract_volume(output)
|
||||||
|
intraday_range_pct = _extract_intraday_range_pct(output, price)
|
||||||
|
volatility_pct = max(abs(change_rate), intraday_range_pct)
|
||||||
|
|
||||||
|
if price <= 0 or volatility_pct < 0.8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
score = min(volatility_pct / 10.0, 1.0) * 100.0
|
||||||
|
signal = "momentum" if change_rate >= 0 else "oversold"
|
||||||
|
implied_rsi = max(0.0, min(100.0, 50.0 + (change_rate * 2.0)))
|
||||||
|
candidates.append(
|
||||||
|
ScanCandidate(
|
||||||
|
stock_code=stock_code,
|
||||||
|
name=stock_code,
|
||||||
|
price=price,
|
||||||
|
volume=volume,
|
||||||
|
volume_ratio=max(1.0, volatility_pct / 2.0),
|
||||||
|
rsi=implied_rsi,
|
||||||
|
signal=signal,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ConnectionError as exc:
|
||||||
|
logger.warning("Failed to analyze overseas %s: %s", stock_code, exc)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error analyzing overseas %s: %s", stock_code, exc)
|
||||||
|
logger.info(
|
||||||
|
"Overseas symbol fallback scan found %d candidates for %s",
|
||||||
|
len(candidates),
|
||||||
|
market.name,
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
|
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
|
||||||
"""Extract stock codes from candidates for watchlist update.
|
"""Extract stock codes from candidates for watchlist update.
|
||||||
|
|
||||||
@@ -190,3 +372,78 @@ class SmartVolatilityScanner:
|
|||||||
List of stock codes
|
List of stock codes
|
||||||
"""
|
"""
|
||||||
return [c.stock_code for c in candidates]
|
return [c.stock_code for c in candidates]
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||||||
|
"""Convert arbitrary values to float safely."""
|
||||||
|
if value in (None, ""):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_stock_code(row: dict[str, Any]) -> str:
|
||||||
|
"""Extract normalized stock code from various API schemas."""
|
||||||
|
return (
|
||||||
|
str(
|
||||||
|
row.get("symb")
|
||||||
|
or row.get("ovrs_pdno")
|
||||||
|
or row.get("stock_code")
|
||||||
|
or row.get("pdno")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
.strip()
|
||||||
|
.upper()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_last_price(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract last/close-like price from API schema variants."""
|
||||||
|
return _safe_float(
|
||||||
|
row.get("last")
|
||||||
|
or row.get("ovrs_nmix_prpr")
|
||||||
|
or row.get("stck_prpr")
|
||||||
|
or row.get("price")
|
||||||
|
or row.get("close")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_change_rate_pct(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract daily change rate (%) from API schema variants."""
|
||||||
|
return _safe_float(
|
||||||
|
row.get("rate")
|
||||||
|
or row.get("change_rate")
|
||||||
|
or row.get("prdy_ctrt")
|
||||||
|
or row.get("evlu_pfls_rt")
|
||||||
|
or row.get("chg_rt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_volume(row: dict[str, Any]) -> float:
|
||||||
|
"""Extract volume/traded-amount proxy from schema variants."""
|
||||||
|
return _safe_float(
|
||||||
|
row.get("tvol") or row.get("acml_vol") or row.get("vol") or row.get("volume")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_intraday_range_pct(row: dict[str, Any], price: float) -> float:
|
||||||
|
"""Estimate intraday range percentage from high/low fields."""
|
||||||
|
if price <= 0:
|
||||||
|
return 0.0
|
||||||
|
high = _safe_float(
|
||||||
|
row.get("high")
|
||||||
|
or row.get("ovrs_hgpr")
|
||||||
|
or row.get("stck_hgpr")
|
||||||
|
or row.get("day_hgpr")
|
||||||
|
)
|
||||||
|
low = _safe_float(
|
||||||
|
row.get("low")
|
||||||
|
or row.get("ovrs_lwpr")
|
||||||
|
or row.get("stck_lwpr")
|
||||||
|
or row.get("day_lwpr")
|
||||||
|
)
|
||||||
|
if high <= 0 or low <= 0 or high < low:
|
||||||
|
return 0.0
|
||||||
|
return (high - low) / price * 100.0
|
||||||
|
|||||||
@@ -410,8 +410,10 @@ class GeminiClient:
|
|||||||
cached=True,
|
cached=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build optimized prompt
|
# Build prompt (prompt_override takes priority for callers like pre_market_planner)
|
||||||
if self._enable_optimization:
|
if "prompt_override" in market_data:
|
||||||
|
prompt = market_data["prompt_override"]
|
||||||
|
elif self._enable_optimization:
|
||||||
prompt = self._optimizer.build_compressed_prompt(market_data)
|
prompt = self._optimizer.build_compressed_prompt(market_data)
|
||||||
else:
|
else:
|
||||||
prompt = await self.build_prompt(market_data, news_sentiment)
|
prompt = await self.build_prompt(market_data, news_sentiment)
|
||||||
|
|||||||
@@ -20,6 +20,39 @@ _KIS_VTS_HOST = "openapivts.koreainvestment.com"
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def kr_tick_unit(price: float) -> int:
|
||||||
|
"""Return KRX tick size for the given price level.
|
||||||
|
|
||||||
|
KRX price tick rules (domestic stocks):
|
||||||
|
price < 2,000 → 1원
|
||||||
|
2,000 ≤ price < 5,000 → 5원
|
||||||
|
5,000 ≤ price < 20,000 → 10원
|
||||||
|
20,000 ≤ price < 50,000 → 50원
|
||||||
|
50,000 ≤ price < 200,000 → 100원
|
||||||
|
200,000 ≤ price < 500,000 → 500원
|
||||||
|
500,000 ≤ price → 1,000원
|
||||||
|
"""
|
||||||
|
if price < 2_000:
|
||||||
|
return 1
|
||||||
|
if price < 5_000:
|
||||||
|
return 5
|
||||||
|
if price < 20_000:
|
||||||
|
return 10
|
||||||
|
if price < 50_000:
|
||||||
|
return 50
|
||||||
|
if price < 200_000:
|
||||||
|
return 100
|
||||||
|
if price < 500_000:
|
||||||
|
return 500
|
||||||
|
return 1_000
|
||||||
|
|
||||||
|
|
||||||
|
def kr_round_down(price: float) -> int:
|
||||||
|
"""Round *down* price to the nearest KRX tick unit."""
|
||||||
|
tick = kr_tick_unit(price)
|
||||||
|
return int(price // tick * tick)
|
||||||
|
|
||||||
|
|
||||||
class LeakyBucket:
|
class LeakyBucket:
|
||||||
"""Simple leaky-bucket rate limiter for async code."""
|
"""Simple leaky-bucket rate limiter for async code."""
|
||||||
|
|
||||||
@@ -104,12 +137,14 @@ class KISBroker:
|
|||||||
time_since_last_attempt = now - self._last_refresh_attempt
|
time_since_last_attempt = now - self._last_refresh_attempt
|
||||||
if time_since_last_attempt < self._refresh_cooldown:
|
if time_since_last_attempt < self._refresh_cooldown:
|
||||||
remaining = self._refresh_cooldown - time_since_last_attempt
|
remaining = self._refresh_cooldown - time_since_last_attempt
|
||||||
error_msg = (
|
# Do not fail fast here. If token is unavailable, upstream calls
|
||||||
f"Token refresh on cooldown. "
|
# will all fail for up to a minute and scanning returns no trades.
|
||||||
f"Retry in {remaining:.1f}s (KIS allows 1/minute)"
|
logger.warning(
|
||||||
|
"Token refresh on cooldown. Waiting %.1fs before retry (KIS allows 1/minute)",
|
||||||
|
remaining,
|
||||||
)
|
)
|
||||||
logger.warning(error_msg)
|
await asyncio.sleep(remaining)
|
||||||
raise ConnectionError(error_msg)
|
now = asyncio.get_event_loop().time()
|
||||||
|
|
||||||
logger.info("Refreshing KIS access token")
|
logger.info("Refreshing KIS access token")
|
||||||
self._last_refresh_attempt = now
|
self._last_refresh_attempt = now
|
||||||
@@ -196,12 +231,64 @@ class KISBroker:
|
|||||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
|
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
|
||||||
|
|
||||||
|
async def get_current_price(
|
||||||
|
self, stock_code: str
|
||||||
|
) -> tuple[float, float, float]:
|
||||||
|
"""Fetch current price data for a domestic stock.
|
||||||
|
|
||||||
|
Uses the ``inquire-price`` API (FHKST01010100), which works in both
|
||||||
|
real and VTS environments and returns the actual last-traded price.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(current_price, prdy_ctrt, frgn_ntby_qty)
|
||||||
|
- current_price: Last traded price in KRW.
|
||||||
|
- prdy_ctrt: Day change rate (%).
|
||||||
|
- frgn_ntby_qty: Foreigner net buy quantity.
|
||||||
|
"""
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
|
session = self._get_session()
|
||||||
|
|
||||||
|
headers = await self._auth_headers("FHKST01010100")
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": stock_code,
|
||||||
|
}
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||||
|
|
||||||
|
def _f(val: str | None) -> float:
|
||||||
|
try:
|
||||||
|
return float(val or "0")
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ConnectionError(
|
||||||
|
f"get_current_price failed ({resp.status}): {text}"
|
||||||
|
)
|
||||||
|
data = await resp.json()
|
||||||
|
out = data.get("output", {})
|
||||||
|
return (
|
||||||
|
_f(out.get("stck_prpr")),
|
||||||
|
_f(out.get("prdy_ctrt")),
|
||||||
|
_f(out.get("frgn_ntby_qty")),
|
||||||
|
)
|
||||||
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Network error fetching current price: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
async def get_balance(self) -> dict[str, Any]:
|
async def get_balance(self) -> dict[str, Any]:
|
||||||
"""Fetch current account balance and holdings."""
|
"""Fetch current account balance and holdings."""
|
||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
headers = await self._auth_headers("VTTC8434R") # 모의투자 잔고조회
|
# TR_ID: 실전 TTTC8434R, 모의 VTTC8434R
|
||||||
|
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '국내주식 잔고조회' 시트
|
||||||
|
tr_id = "TTTC8434R" if self._settings.MODE == "live" else "VTTC8434R"
|
||||||
|
headers = await self._auth_headers(tr_id)
|
||||||
params = {
|
params = {
|
||||||
"CANO": self._account_no,
|
"CANO": self._account_no,
|
||||||
"ACNT_PRDT_CD": self._product_cd,
|
"ACNT_PRDT_CD": self._product_cd,
|
||||||
@@ -246,14 +333,30 @@ class KISBroker:
|
|||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
tr_id = "VTTC0802U" if order_type == "BUY" else "VTTC0801U"
|
# TR_ID: 실전 BUY=TTTC0012U SELL=TTTC0011U, 모의 BUY=VTTC0012U SELL=VTTC0011U
|
||||||
|
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '주식주문(현금)' 시트
|
||||||
|
# ※ TTTC0802U/VTTC0802U는 미수매수(증거금40% 계좌 전용) — 현금주문에 사용 금지
|
||||||
|
if self._settings.MODE == "live":
|
||||||
|
tr_id = "TTTC0012U" if order_type == "BUY" else "TTTC0011U"
|
||||||
|
else:
|
||||||
|
tr_id = "VTTC0012U" if order_type == "BUY" else "VTTC0011U"
|
||||||
|
|
||||||
|
# KRX requires limit orders to be rounded down to the tick unit.
|
||||||
|
# ORD_DVSN: "00"=지정가, "01"=시장가
|
||||||
|
if price > 0:
|
||||||
|
ord_dvsn = "00" # 지정가
|
||||||
|
ord_price = kr_round_down(price)
|
||||||
|
else:
|
||||||
|
ord_dvsn = "01" # 시장가
|
||||||
|
ord_price = 0
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"CANO": self._account_no,
|
"CANO": self._account_no,
|
||||||
"ACNT_PRDT_CD": self._product_cd,
|
"ACNT_PRDT_CD": self._product_cd,
|
||||||
"PDNO": stock_code,
|
"PDNO": stock_code,
|
||||||
"ORD_DVSN": "01" if price > 0 else "06", # 01=지정가, 06=시장가
|
"ORD_DVSN": ord_dvsn,
|
||||||
"ORD_QTY": str(quantity),
|
"ORD_QTY": str(quantity),
|
||||||
"ORD_UNPR": str(price),
|
"ORD_UNPR": str(ord_price),
|
||||||
}
|
}
|
||||||
|
|
||||||
hash_key = await self._get_hash_key(body)
|
hash_key = await self._get_hash_key(body)
|
||||||
@@ -302,25 +405,45 @@ class KISBroker:
|
|||||||
await self._rate_limiter.acquire()
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
|
|
||||||
# TR_ID for volume ranking
|
if ranking_type == "volume":
|
||||||
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100"
|
# 거래량순위: FHPST01710000 / /quotations/volume-rank
|
||||||
headers = await self._auth_headers(tr_id)
|
tr_id = "FHPST01710000"
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||||
params = {
|
params: dict[str, str] = {
|
||||||
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
|
"FID_COND_SCR_DIV_CODE": "20171",
|
||||||
"FID_INPUT_ISCD": "0000", # All stocks
|
"FID_INPUT_ISCD": "0000",
|
||||||
"FID_DIV_CLS_CODE": "0", # All types
|
"FID_DIV_CLS_CODE": "0",
|
||||||
"FID_BLNG_CLS_CODE": "0",
|
"FID_BLNG_CLS_CODE": "0",
|
||||||
"FID_TRGT_CLS_CODE": "111111111",
|
"FID_TRGT_CLS_CODE": "111111111",
|
||||||
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
"FID_TRGT_EXLS_CLS_CODE": "0000000000",
|
||||||
"FID_INPUT_PRICE_1": "0",
|
"FID_INPUT_PRICE_1": "0",
|
||||||
"FID_INPUT_PRICE_2": "0",
|
"FID_INPUT_PRICE_2": "0",
|
||||||
"FID_VOL_CNT": "0",
|
"FID_VOL_CNT": "0",
|
||||||
"FID_INPUT_DATE_1": "",
|
"FID_INPUT_DATE_1": "",
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
# 등락률순위: FHPST01700000 / /ranking/fluctuation (소문자 파라미터)
|
||||||
|
tr_id = "FHPST01700000"
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/ranking/fluctuation"
|
||||||
|
params = {
|
||||||
|
"fid_cond_mrkt_div_code": "J",
|
||||||
|
"fid_cond_scr_div_code": "20170",
|
||||||
|
"fid_input_iscd": "0000",
|
||||||
|
"fid_rank_sort_cls_code": "0000",
|
||||||
|
"fid_input_cnt_1": str(limit),
|
||||||
|
"fid_prc_cls_code": "0",
|
||||||
|
"fid_input_price_1": "0",
|
||||||
|
"fid_input_price_2": "0",
|
||||||
|
"fid_vol_cnt": "0",
|
||||||
|
"fid_trgt_cls_code": "0",
|
||||||
|
"fid_trgt_exls_cls_code": "0",
|
||||||
|
"fid_div_cls_code": "0",
|
||||||
|
"fid_rsfl_rate1": "0",
|
||||||
|
"fid_rsfl_rate2": "0",
|
||||||
|
}
|
||||||
|
|
||||||
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
headers = await self._auth_headers(tr_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with session.get(url, headers=headers, params=params) as resp:
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
|||||||
@@ -12,6 +12,24 @@ from src.broker.kis_api import KISBroker
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Ranking API uses different exchange codes than order/quote APIs.
|
||||||
|
_RANKING_EXCHANGE_MAP: dict[str, str] = {
|
||||||
|
"NASD": "NAS",
|
||||||
|
"NYSE": "NYS",
|
||||||
|
"AMEX": "AMS",
|
||||||
|
"SEHK": "HKS",
|
||||||
|
"SHAA": "SHS",
|
||||||
|
"SZAA": "SZS",
|
||||||
|
"HSX": "HSX",
|
||||||
|
"HNX": "HNX",
|
||||||
|
"TSE": "TSE",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Price inquiry API (HHDFS00000300) uses the same short exchange codes as rankings.
|
||||||
|
# NASD → NAS, NYSE → NYS, AMEX → AMS (confirmed: AMEX returns empty, AMS returns price).
|
||||||
|
_PRICE_EXCHANGE_MAP: dict[str, str] = _RANKING_EXCHANGE_MAP
|
||||||
|
|
||||||
|
|
||||||
class OverseasBroker:
|
class OverseasBroker:
|
||||||
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
|
"""KIS Overseas Stock API wrapper that reuses KISBroker infrastructure."""
|
||||||
|
|
||||||
@@ -44,9 +62,11 @@ class OverseasBroker:
|
|||||||
session = self._broker._get_session()
|
session = self._broker._get_session()
|
||||||
|
|
||||||
headers = await self._broker._auth_headers("HHDFS00000300")
|
headers = await self._broker._auth_headers("HHDFS00000300")
|
||||||
|
# Map internal exchange codes to the short form expected by the price API.
|
||||||
|
price_excd = _PRICE_EXCHANGE_MAP.get(exchange_code, exchange_code)
|
||||||
params = {
|
params = {
|
||||||
"AUTH": "",
|
"AUTH": "",
|
||||||
"EXCD": exchange_code,
|
"EXCD": price_excd,
|
||||||
"SYMB": stock_code,
|
"SYMB": stock_code,
|
||||||
}
|
}
|
||||||
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
|
url = f"{self._broker._base_url}/uapi/overseas-price/v1/quotations/price"
|
||||||
@@ -64,6 +84,81 @@ class OverseasBroker:
|
|||||||
f"Network error fetching overseas price: {exc}"
|
f"Network error fetching overseas price: {exc}"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
async def fetch_overseas_rankings(
|
||||||
|
self,
|
||||||
|
exchange_code: str,
|
||||||
|
ranking_type: str = "fluctuation",
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch overseas rankings (price change or volume surge).
|
||||||
|
|
||||||
|
Ranking API specs may differ by account/product. Endpoint paths and
|
||||||
|
TR_IDs are configurable via settings and can be overridden in .env.
|
||||||
|
"""
|
||||||
|
if not self._broker._settings.OVERSEAS_RANKING_ENABLED:
|
||||||
|
return []
|
||||||
|
|
||||||
|
await self._broker._rate_limiter.acquire()
|
||||||
|
session = self._broker._get_session()
|
||||||
|
|
||||||
|
ranking_excd = _RANKING_EXCHANGE_MAP.get(exchange_code, exchange_code)
|
||||||
|
|
||||||
|
if ranking_type == "volume":
|
||||||
|
tr_id = self._broker._settings.OVERSEAS_RANKING_VOLUME_TR_ID
|
||||||
|
path = self._broker._settings.OVERSEAS_RANKING_VOLUME_PATH
|
||||||
|
params: dict[str, str] = {
|
||||||
|
"AUTH": "",
|
||||||
|
"EXCD": ranking_excd,
|
||||||
|
"MIXN": "0",
|
||||||
|
"VOL_RANG": "0",
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
tr_id = self._broker._settings.OVERSEAS_RANKING_FLUCT_TR_ID
|
||||||
|
path = self._broker._settings.OVERSEAS_RANKING_FLUCT_PATH
|
||||||
|
params = {
|
||||||
|
"AUTH": "",
|
||||||
|
"EXCD": ranking_excd,
|
||||||
|
"NDAY": "0",
|
||||||
|
"GUBN": "1",
|
||||||
|
"VOL_RANG": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = await self._broker._auth_headers(tr_id)
|
||||||
|
url = f"{self._broker._base_url}{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
if resp.status == 404:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas ranking endpoint unavailable (404) for %s/%s; "
|
||||||
|
"using symbol fallback scan",
|
||||||
|
exchange_code,
|
||||||
|
ranking_type,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
raise ConnectionError(
|
||||||
|
f"fetch_overseas_rankings failed ({resp.status}): {text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
rows = self._extract_ranking_rows(data)
|
||||||
|
if rows:
|
||||||
|
return rows[:limit]
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Overseas ranking returned empty for %s/%s (keys=%s)",
|
||||||
|
exchange_code,
|
||||||
|
ranking_type,
|
||||||
|
list(data.keys()),
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Network error fetching overseas rankings: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
|
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Fetch overseas account balance.
|
Fetch overseas account balance.
|
||||||
@@ -80,8 +175,12 @@ class OverseasBroker:
|
|||||||
await self._broker._rate_limiter.acquire()
|
await self._broker._rate_limiter.acquire()
|
||||||
session = self._broker._get_session()
|
session = self._broker._get_session()
|
||||||
|
|
||||||
# Virtual trading TR_ID for overseas balance inquiry
|
# TR_ID: 실전 TTTS3012R, 모의 VTTS3012R
|
||||||
headers = await self._broker._auth_headers("VTTS3012R")
|
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트
|
||||||
|
balance_tr_id = (
|
||||||
|
"TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
|
||||||
|
)
|
||||||
|
headers = await self._broker._auth_headers(balance_tr_id)
|
||||||
params = {
|
params = {
|
||||||
"CANO": self._broker._account_no,
|
"CANO": self._broker._account_no,
|
||||||
"ACNT_PRDT_CD": self._broker._product_cd,
|
"ACNT_PRDT_CD": self._broker._product_cd,
|
||||||
@@ -134,8 +233,12 @@ class OverseasBroker:
|
|||||||
await self._broker._rate_limiter.acquire()
|
await self._broker._rate_limiter.acquire()
|
||||||
session = self._broker._get_session()
|
session = self._broker._get_session()
|
||||||
|
|
||||||
# Virtual trading TR_IDs for overseas orders
|
# TR_ID: 실전 BUY=TTTT1002U SELL=TTTT1006U, 모의 BUY=VTTT1002U SELL=VTTT1001U
|
||||||
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1006U"
|
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 주문' 시트
|
||||||
|
if self._broker._settings.MODE == "live":
|
||||||
|
tr_id = "TTTT1002U" if order_type == "BUY" else "TTTT1006U"
|
||||||
|
else:
|
||||||
|
tr_id = "VTTT1002U" if order_type == "BUY" else "VTTT1001U"
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"CANO": self._broker._account_no,
|
"CANO": self._broker._account_no,
|
||||||
@@ -162,6 +265,9 @@ class OverseasBroker:
|
|||||||
f"send_overseas_order failed ({resp.status}): {text}"
|
f"send_overseas_order failed ({resp.status}): {text}"
|
||||||
)
|
)
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
rt_cd = data.get("rt_cd", "")
|
||||||
|
msg1 = data.get("msg1", "")
|
||||||
|
if rt_cd == "0":
|
||||||
logger.info(
|
logger.info(
|
||||||
"Overseas order submitted",
|
"Overseas order submitted",
|
||||||
extra={
|
extra={
|
||||||
@@ -170,6 +276,16 @@ class OverseasBroker:
|
|||||||
"action": order_type,
|
"action": order_type,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Overseas order rejected (rt_cd=%s): %s [%s %s %s qty=%d]",
|
||||||
|
rt_cd,
|
||||||
|
msg1,
|
||||||
|
order_type,
|
||||||
|
stock_code,
|
||||||
|
exchange_code,
|
||||||
|
quantity,
|
||||||
|
)
|
||||||
return data
|
return data
|
||||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
raise ConnectionError(
|
raise ConnectionError(
|
||||||
@@ -198,3 +314,11 @@ class OverseasBroker:
|
|||||||
"HSX": "VND",
|
"HSX": "VND",
|
||||||
}
|
}
|
||||||
return currency_map.get(exchange_code, "USD")
|
return currency_map.get(exchange_code, "USD")
|
||||||
|
|
||||||
|
def _extract_ranking_rows(self, data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
|
"""Extract list rows from ranking response across schema variants."""
|
||||||
|
candidates = [data.get("output"), data.get("output1"), data.get("output2")]
|
||||||
|
for value in candidates:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [row for row in value if isinstance(row, dict)]
|
||||||
|
return []
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Settings(BaseSettings):
|
|||||||
KIS_APP_KEY: str
|
KIS_APP_KEY: str
|
||||||
KIS_APP_SECRET: str
|
KIS_APP_SECRET: str
|
||||||
KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX"
|
KIS_ACCOUNT_NO: str # format: "XXXXXXXX-XX"
|
||||||
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:9443"
|
KIS_BASE_URL: str = "https://openapivts.koreainvestment.com:29443"
|
||||||
|
|
||||||
# Google Gemini
|
# Google Gemini
|
||||||
GEMINI_API_KEY: str
|
GEMINI_API_KEY: str
|
||||||
@@ -38,6 +38,11 @@ class Settings(BaseSettings):
|
|||||||
RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100)
|
RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100)
|
||||||
VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0)
|
VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0)
|
||||||
SCANNER_TOP_N: int = Field(default=3, ge=1, le=10)
|
SCANNER_TOP_N: int = Field(default=3, ge=1, le=10)
|
||||||
|
POSITION_SIZING_ENABLED: bool = True
|
||||||
|
POSITION_BASE_ALLOCATION_PCT: float = Field(default=5.0, gt=0.0, le=30.0)
|
||||||
|
POSITION_MIN_ALLOCATION_PCT: float = Field(default=1.0, gt=0.0, le=20.0)
|
||||||
|
POSITION_MAX_ALLOCATION_PCT: float = Field(default=10.0, gt=0.0, le=50.0)
|
||||||
|
POSITION_VOLATILITY_TARGET_SCORE: float = Field(default=50.0, gt=0.0, le=100.0)
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_PATH: str = "data/trade_logs.db"
|
DB_PATH: str = "data/trade_logs.db"
|
||||||
@@ -50,6 +55,11 @@ class Settings(BaseSettings):
|
|||||||
# Trading mode
|
# Trading mode
|
||||||
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
||||||
|
|
||||||
|
# Simulated USD cash for VTS (paper) overseas trading.
|
||||||
|
# KIS VTS overseas balance API returns errors for most accounts.
|
||||||
|
# This value is used as a fallback when the balance API returns 0 in paper mode.
|
||||||
|
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
|
||||||
|
|
||||||
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
||||||
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
||||||
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
||||||
@@ -83,6 +93,33 @@ class Settings(BaseSettings):
|
|||||||
TELEGRAM_COMMANDS_ENABLED: bool = True
|
TELEGRAM_COMMANDS_ENABLED: bool = True
|
||||||
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
|
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
|
||||||
|
|
||||||
|
# Telegram notification type filters (granular control)
|
||||||
|
# circuit_breaker is always sent regardless — safety-critical
|
||||||
|
TELEGRAM_NOTIFY_TRADES: bool = True # BUY/SELL execution alerts
|
||||||
|
TELEGRAM_NOTIFY_MARKET_OPEN_CLOSE: bool = True # Market open/close alerts
|
||||||
|
TELEGRAM_NOTIFY_FAT_FINGER: bool = True # Fat-finger rejection alerts
|
||||||
|
TELEGRAM_NOTIFY_SYSTEM_EVENTS: bool = True # System start/shutdown alerts
|
||||||
|
TELEGRAM_NOTIFY_PLAYBOOK: bool = True # Playbook generated/failed alerts
|
||||||
|
TELEGRAM_NOTIFY_SCENARIO_MATCH: bool = True # Scenario matched alerts (most frequent)
|
||||||
|
TELEGRAM_NOTIFY_ERRORS: bool = True # Error alerts
|
||||||
|
|
||||||
|
# Overseas ranking API (KIS endpoint/TR_ID may vary by account/product)
|
||||||
|
# Override these from .env if your account uses different specs.
|
||||||
|
OVERSEAS_RANKING_ENABLED: bool = True
|
||||||
|
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
|
||||||
|
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
|
||||||
|
OVERSEAS_RANKING_FLUCT_PATH: str = (
|
||||||
|
"/uapi/overseas-stock/v1/ranking/updown-rate"
|
||||||
|
)
|
||||||
|
OVERSEAS_RANKING_VOLUME_PATH: str = (
|
||||||
|
"/uapi/overseas-stock/v1/ranking/volume-surge"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dashboard (optional)
|
||||||
|
DASHBOARD_ENABLED: bool = False
|
||||||
|
DASHBOARD_HOST: str = "127.0.0.1"
|
||||||
|
DASHBOARD_PORT: int = Field(default=8080, ge=1, le=65535)
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -96,4 +133,7 @@ class Settings(BaseSettings):
|
|||||||
@property
|
@property
|
||||||
def enabled_market_list(self) -> list[str]:
|
def enabled_market_list(self) -> list[str]:
|
||||||
"""Parse ENABLED_MARKETS into list of market codes."""
|
"""Parse ENABLED_MARKETS into list of market codes."""
|
||||||
return [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
|
from src.markets.schedule import expand_market_codes
|
||||||
|
|
||||||
|
raw = [m.strip() for m in self.ENABLED_MARKETS.split(",") if m.strip()]
|
||||||
|
return expand_market_codes(raw)
|
||||||
|
|||||||
5
src/dashboard/__init__.py
Normal file
5
src/dashboard/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""FastAPI dashboard package for observability APIs."""
|
||||||
|
|
||||||
|
from src.dashboard.app import create_dashboard_app
|
||||||
|
|
||||||
|
__all__ = ["create_dashboard_app"]
|
||||||
496
src/dashboard/app.py
Normal file
496
src/dashboard/app.py
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
"""FastAPI application for observability dashboard endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
|
||||||
|
def create_dashboard_app(db_path: str) -> FastAPI:
|
||||||
|
"""Create dashboard FastAPI app bound to a SQLite database path."""
|
||||||
|
app = FastAPI(title="The Ouroboros Dashboard", version="1.0.0")
|
||||||
|
app.state.db_path = db_path
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> FileResponse:
|
||||||
|
index_path = Path(__file__).parent / "static" / "index.html"
|
||||||
|
return FileResponse(index_path)
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def get_status() -> dict[str, Any]:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
market_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT market FROM (
|
||||||
|
SELECT market FROM trades WHERE DATE(timestamp) = ?
|
||||||
|
UNION
|
||||||
|
SELECT market FROM decision_logs WHERE DATE(timestamp) = ?
|
||||||
|
UNION
|
||||||
|
SELECT market FROM playbooks WHERE date = ?
|
||||||
|
) ORDER BY market
|
||||||
|
""",
|
||||||
|
(today, today, today),
|
||||||
|
).fetchall()
|
||||||
|
markets = [row[0] for row in market_rows] if market_rows else []
|
||||||
|
market_status: dict[str, Any] = {}
|
||||||
|
total_trades = 0
|
||||||
|
total_pnl = 0.0
|
||||||
|
total_decisions = 0
|
||||||
|
for market in markets:
|
||||||
|
trade_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c, COALESCE(SUM(pnl), 0.0) AS p
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
decision_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS c
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE DATE(timestamp) = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
playbook_row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status
|
||||||
|
FROM playbooks
|
||||||
|
WHERE date = ? AND market = ?
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(today, market),
|
||||||
|
).fetchone()
|
||||||
|
market_status[market] = {
|
||||||
|
"trade_count": int(trade_row["c"] if trade_row else 0),
|
||||||
|
"total_pnl": float(trade_row["p"] if trade_row else 0.0),
|
||||||
|
"decision_count": int(decision_row["c"] if decision_row else 0),
|
||||||
|
"playbook_status": playbook_row["status"] if playbook_row else None,
|
||||||
|
}
|
||||||
|
total_trades += market_status[market]["trade_count"]
|
||||||
|
total_pnl += market_status[market]["total_pnl"]
|
||||||
|
total_decisions += market_status[market]["decision_count"]
|
||||||
|
|
||||||
|
cb_threshold = float(os.getenv("CIRCUIT_BREAKER_PCT", "-3.0"))
|
||||||
|
pnl_pct_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT key, value
|
||||||
|
FROM system_metrics
|
||||||
|
WHERE key LIKE 'portfolio_pnl_pct_%'
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
current_pnl_pct: float | None = None
|
||||||
|
if pnl_pct_rows:
|
||||||
|
values = [
|
||||||
|
json.loads(row["value"]).get("pnl_pct")
|
||||||
|
for row in pnl_pct_rows
|
||||||
|
if json.loads(row["value"]).get("pnl_pct") is not None
|
||||||
|
]
|
||||||
|
if values:
|
||||||
|
current_pnl_pct = round(min(values), 4)
|
||||||
|
|
||||||
|
if current_pnl_pct is None:
|
||||||
|
cb_status = "unknown"
|
||||||
|
elif current_pnl_pct <= cb_threshold:
|
||||||
|
cb_status = "tripped"
|
||||||
|
elif current_pnl_pct <= cb_threshold + 1.0:
|
||||||
|
cb_status = "warning"
|
||||||
|
else:
|
||||||
|
cb_status = "ok"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"date": today,
|
||||||
|
"markets": market_status,
|
||||||
|
"totals": {
|
||||||
|
"trade_count": total_trades,
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"decision_count": total_decisions,
|
||||||
|
},
|
||||||
|
"circuit_breaker": {
|
||||||
|
"threshold_pct": cb_threshold,
|
||||||
|
"current_pnl_pct": current_pnl_pct,
|
||||||
|
"status": cb_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/playbook/{date_str}")
|
||||||
|
def get_playbook(date_str: str, market: str = Query("KR")) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
FROM playbooks
|
||||||
|
WHERE date = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(date_str, market),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="playbook not found")
|
||||||
|
return {
|
||||||
|
"date": row["date"],
|
||||||
|
"market": row["market"],
|
||||||
|
"status": row["status"],
|
||||||
|
"playbook": json.loads(row["playbook_json"]),
|
||||||
|
"generated_at": row["generated_at"],
|
||||||
|
"token_count": row["token_count"],
|
||||||
|
"scenario_count": row["scenario_count"],
|
||||||
|
"match_count": row["match_count"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/scorecard/{date_str}")
|
||||||
|
def get_scorecard(date_str: str, market: str = Query("KR")) -> dict[str, Any]:
|
||||||
|
key = f"scorecard_{market}"
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT value
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = 'L6_DAILY' AND timeframe = ? AND key = ?
|
||||||
|
""",
|
||||||
|
(date_str, key),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="scorecard not found")
|
||||||
|
return {"date": date_str, "market": market, "scorecard": json.loads(row["value"])}
|
||||||
|
|
||||||
|
@app.get("/api/performance")
|
||||||
|
def get_performance(market: str = Query("all")) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
if market == "all":
|
||||||
|
by_market_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT market,
|
||||||
|
COUNT(*) AS total_trades,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) AS losses,
|
||||||
|
COALESCE(SUM(pnl), 0.0) AS total_pnl,
|
||||||
|
COALESCE(AVG(confidence), 0.0) AS avg_confidence
|
||||||
|
FROM trades
|
||||||
|
GROUP BY market
|
||||||
|
ORDER BY market
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
combined = _performance_from_rows(by_market_rows)
|
||||||
|
return {
|
||||||
|
"market": "all",
|
||||||
|
"combined": combined,
|
||||||
|
"by_market": [
|
||||||
|
_row_to_performance(row)
|
||||||
|
for row in by_market_rows
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT market,
|
||||||
|
COUNT(*) AS total_trades,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) AS wins,
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) AS losses,
|
||||||
|
COALESCE(SUM(pnl), 0.0) AS total_pnl,
|
||||||
|
COALESCE(AVG(confidence), 0.0) AS avg_confidence
|
||||||
|
FROM trades
|
||||||
|
WHERE market = ?
|
||||||
|
GROUP BY market
|
||||||
|
""",
|
||||||
|
(market,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return {"market": market, "metrics": _empty_performance(market)}
|
||||||
|
return {"market": market, "metrics": _row_to_performance(row)}
|
||||||
|
|
||||||
|
@app.get("/api/context/{layer}")
|
||||||
|
def get_context_layer(
|
||||||
|
layer: str,
|
||||||
|
timeframe: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
if timeframe is None:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, key, value, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(layer, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, key, value, updated_at
|
||||||
|
FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe = ?
|
||||||
|
ORDER BY key
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(layer, timeframe, limit),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"timeframe": row["timeframe"],
|
||||||
|
"key": row["key"],
|
||||||
|
"value": json.loads(row["value"]),
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"layer": layer,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"count": len(entries),
|
||||||
|
"entries": entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/decisions")
|
||||||
|
def get_decisions(
|
||||||
|
market: str = Query("KR"),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data,
|
||||||
|
outcome_pnl, outcome_accuracy
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE market = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, limit),
|
||||||
|
).fetchall()
|
||||||
|
decisions = []
|
||||||
|
for row in rows:
|
||||||
|
decisions.append(
|
||||||
|
{
|
||||||
|
"decision_id": row["decision_id"],
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"market": row["market"],
|
||||||
|
"exchange_code": row["exchange_code"],
|
||||||
|
"action": row["action"],
|
||||||
|
"confidence": row["confidence"],
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"context_snapshot": json.loads(row["context_snapshot"]),
|
||||||
|
"input_data": json.loads(row["input_data"]),
|
||||||
|
"outcome_pnl": row["outcome_pnl"],
|
||||||
|
"outcome_accuracy": row["outcome_accuracy"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"market": market, "count": len(decisions), "decisions": decisions}
|
||||||
|
|
||||||
|
@app.get("/api/pnl/history")
|
||||||
|
def get_pnl_history(
|
||||||
|
days: int = Query(default=30, ge=1, le=365),
|
||||||
|
market: str = Query("all"),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return daily P&L history for charting."""
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
if market == "all":
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DATE(timestamp) AS date,
|
||||||
|
SUM(pnl) AS daily_pnl,
|
||||||
|
COUNT(*) AS trade_count
|
||||||
|
FROM trades
|
||||||
|
WHERE pnl IS NOT NULL
|
||||||
|
AND DATE(timestamp) >= DATE('now', ?)
|
||||||
|
GROUP BY DATE(timestamp)
|
||||||
|
ORDER BY DATE(timestamp)
|
||||||
|
""",
|
||||||
|
(f"-{days} days",),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DATE(timestamp) AS date,
|
||||||
|
SUM(pnl) AS daily_pnl,
|
||||||
|
COUNT(*) AS trade_count
|
||||||
|
FROM trades
|
||||||
|
WHERE pnl IS NOT NULL
|
||||||
|
AND market = ?
|
||||||
|
AND DATE(timestamp) >= DATE('now', ?)
|
||||||
|
GROUP BY DATE(timestamp)
|
||||||
|
ORDER BY DATE(timestamp)
|
||||||
|
""",
|
||||||
|
(market, f"-{days} days"),
|
||||||
|
).fetchall()
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"market": market,
|
||||||
|
"labels": [row["date"] for row in rows],
|
||||||
|
"pnl": [round(float(row["daily_pnl"]), 2) for row in rows],
|
||||||
|
"trades": [int(row["trade_count"]) for row in rows],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.get("/api/scenarios/active")
|
||||||
|
def get_active_scenarios(
|
||||||
|
market: str = Query("US"),
|
||||||
|
date_str: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if date_str is None:
|
||||||
|
date_str = datetime.now(UTC).date().isoformat()
|
||||||
|
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timestamp, stock_code, action, confidence, rationale, context_snapshot
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE market = ? AND DATE(timestamp) = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, date_str, limit),
|
||||||
|
).fetchall()
|
||||||
|
matches: list[dict[str, Any]] = []
|
||||||
|
for row in rows:
|
||||||
|
snapshot = json.loads(row["context_snapshot"])
|
||||||
|
scenario_match = snapshot.get("scenario_match", {})
|
||||||
|
if not isinstance(scenario_match, dict) or not scenario_match:
|
||||||
|
continue
|
||||||
|
matches.append(
|
||||||
|
{
|
||||||
|
"timestamp": row["timestamp"],
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"action": row["action"],
|
||||||
|
"confidence": row["confidence"],
|
||||||
|
"rationale": row["rationale"],
|
||||||
|
"scenario_match": scenario_match,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"market": market, "date": date_str, "count": len(matches), "matches": matches}
|
||||||
|
|
||||||
|
@app.get("/api/positions")
|
||||||
|
def get_positions() -> dict[str, Any]:
|
||||||
|
"""Return all currently open positions (last trade per symbol is BUY)."""
|
||||||
|
with _connect(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT stock_code, market, exchange_code,
|
||||||
|
price AS entry_price, quantity, timestamp AS entry_time,
|
||||||
|
decision_id
|
||||||
|
FROM (
|
||||||
|
SELECT stock_code, market, exchange_code, price, quantity,
|
||||||
|
timestamp, decision_id, action,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY stock_code, market
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
) AS rn
|
||||||
|
FROM trades
|
||||||
|
)
|
||||||
|
WHERE rn = 1 AND action = 'BUY'
|
||||||
|
ORDER BY entry_time DESC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
positions = []
|
||||||
|
for row in rows:
|
||||||
|
entry_time_str = row["entry_time"]
|
||||||
|
try:
|
||||||
|
entry_dt = datetime.fromisoformat(entry_time_str.replace("Z", "+00:00"))
|
||||||
|
held_seconds = int((now - entry_dt).total_seconds())
|
||||||
|
held_hours = held_seconds // 3600
|
||||||
|
held_minutes = (held_seconds % 3600) // 60
|
||||||
|
if held_hours >= 1:
|
||||||
|
held_display = f"{held_hours}h {held_minutes}m"
|
||||||
|
else:
|
||||||
|
held_display = f"{held_minutes}m"
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
held_display = "--"
|
||||||
|
|
||||||
|
positions.append(
|
||||||
|
{
|
||||||
|
"stock_code": row["stock_code"],
|
||||||
|
"market": row["market"],
|
||||||
|
"exchange_code": row["exchange_code"],
|
||||||
|
"entry_price": row["entry_price"],
|
||||||
|
"quantity": row["quantity"],
|
||||||
|
"entry_time": entry_time_str,
|
||||||
|
"held": held_display,
|
||||||
|
"decision_id": row["decision_id"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"count": len(positions), "positions": positions}
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _connect(db_path: str) -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=8000")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_performance(row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
wins = int(row["wins"] or 0)
|
||||||
|
losses = int(row["losses"] or 0)
|
||||||
|
total = int(row["total_trades"] or 0)
|
||||||
|
win_rate = round((wins / (wins + losses) * 100), 2) if (wins + losses) > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"market": row["market"],
|
||||||
|
"total_trades": total,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"total_pnl": round(float(row["total_pnl"] or 0.0), 2),
|
||||||
|
"avg_confidence": round(float(row["avg_confidence"] or 0.0), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _performance_from_rows(rows: list[sqlite3.Row]) -> dict[str, Any]:
|
||||||
|
total_trades = 0
|
||||||
|
wins = 0
|
||||||
|
losses = 0
|
||||||
|
total_pnl = 0.0
|
||||||
|
confidence_weighted = 0.0
|
||||||
|
for row in rows:
|
||||||
|
market_total = int(row["total_trades"] or 0)
|
||||||
|
market_conf = float(row["avg_confidence"] or 0.0)
|
||||||
|
total_trades += market_total
|
||||||
|
wins += int(row["wins"] or 0)
|
||||||
|
losses += int(row["losses"] or 0)
|
||||||
|
total_pnl += float(row["total_pnl"] or 0.0)
|
||||||
|
confidence_weighted += market_total * market_conf
|
||||||
|
win_rate = round((wins / (wins + losses) * 100), 2) if (wins + losses) > 0 else 0.0
|
||||||
|
avg_confidence = round(confidence_weighted / total_trades, 2) if total_trades > 0 else 0.0
|
||||||
|
return {
|
||||||
|
"market": "all",
|
||||||
|
"total_trades": total_trades,
|
||||||
|
"wins": wins,
|
||||||
|
"losses": losses,
|
||||||
|
"win_rate": win_rate,
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"avg_confidence": avg_confidence,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_performance(market: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"market": market,
|
||||||
|
"total_trades": 0,
|
||||||
|
"wins": 0,
|
||||||
|
"losses": 0,
|
||||||
|
"win_rate": 0.0,
|
||||||
|
"total_pnl": 0.0,
|
||||||
|
"avg_confidence": 0.0,
|
||||||
|
}
|
||||||
771
src/dashboard/static/index.html
Normal file
771
src/dashboard/static/index.html
Normal file
@@ -0,0 +1,771 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>The Ouroboros Dashboard</title>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b1724;
|
||||||
|
--panel: #12263a;
|
||||||
|
--fg: #e6eef7;
|
||||||
|
--muted: #9fb3c8;
|
||||||
|
--accent: #3cb371;
|
||||||
|
--red: #e05555;
|
||||||
|
--warn: #e8a040;
|
||||||
|
--border: #28455f;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
background: radial-gradient(circle at top left, #173b58, var(--bg));
|
||||||
|
color: var(--fg);
|
||||||
|
min-height: 100vh;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1100px; margin: 0 auto; padding: 20px 16px; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header h1 { font-size: 18px; color: var(--accent); letter-spacing: 0.5px; }
|
||||||
|
.header-right { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: 12px; }
|
||||||
|
.refresh-btn {
|
||||||
|
background: none; border: 1px solid var(--border); color: var(--muted);
|
||||||
|
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: inherit;
|
||||||
|
font-size: 12px; transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.refresh-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
/* CB Gauge */
|
||||||
|
.cb-gauge-wrap {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 11px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.cb-dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.cb-dot.ok { background: var(--accent); }
|
||||||
|
.cb-dot.warning { background: var(--warn); animation: pulse-warn 1.2s ease-in-out infinite; }
|
||||||
|
.cb-dot.tripped { background: var(--red); animation: pulse-warn 0.6s ease-in-out infinite; }
|
||||||
|
.cb-dot.unknown { background: var(--border); }
|
||||||
|
@keyframes pulse-warn {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
.cb-bar-wrap { width: 64px; height: 5px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
|
||||||
|
.cb-bar-fill { height: 100%; border-radius: 3px; transition: width 0.4s, background 0.4s; }
|
||||||
|
|
||||||
|
/* Summary cards */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||||
|
@media (max-width: 700px) { .cards { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
.card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.card-label { color: var(--muted); font-size: 11px; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||||
|
.card-value { font-size: 22px; font-weight: 700; }
|
||||||
|
.card-sub { color: var(--muted); font-size: 11px; margin-top: 4px; }
|
||||||
|
.positive { color: var(--accent); }
|
||||||
|
.negative { color: var(--red); }
|
||||||
|
.neutral { color: var(--fg); }
|
||||||
|
|
||||||
|
/* Chart panel */
|
||||||
|
.chart-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.panel-title { font-size: 13px; color: var(--muted); font-weight: 600; }
|
||||||
|
.chart-container { position: relative; height: 180px; }
|
||||||
|
.chart-error { color: var(--muted); text-align: center; padding: 40px 0; font-size: 12px; }
|
||||||
|
|
||||||
|
/* Days selector */
|
||||||
|
.days-selector { display: flex; gap: 4px; }
|
||||||
|
.day-btn {
|
||||||
|
background: none; border: 1px solid var(--border); color: var(--muted);
|
||||||
|
padding: 3px 8px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.day-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
|
||||||
|
|
||||||
|
/* Decisions panel */
|
||||||
|
.decisions-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.market-tabs { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.tab-btn {
|
||||||
|
background: none; border: 1px solid var(--border); color: var(--muted);
|
||||||
|
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 11px;
|
||||||
|
}
|
||||||
|
.tab-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(60, 179, 113, 0.08); }
|
||||||
|
.decisions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
||||||
|
.decisions-table th {
|
||||||
|
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.decisions-table td {
|
||||||
|
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
|
||||||
|
vertical-align: middle; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.decisions-table tr:last-child td { border-bottom: none; }
|
||||||
|
.decisions-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 7px; border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 700; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.badge-buy { background: rgba(60, 179, 113, 0.15); color: var(--accent); }
|
||||||
|
.badge-sell { background: rgba(224, 85, 85, 0.15); color: var(--red); }
|
||||||
|
.badge-hold { background: rgba(159, 179, 200, 0.12); color: var(--muted); }
|
||||||
|
.conf-bar-wrap { display: flex; align-items: center; gap: 6px; min-width: 90px; }
|
||||||
|
.conf-bar { flex: 1; height: 6px; background: rgba(255,255,255,0.08); border-radius: 3px; overflow: hidden; }
|
||||||
|
.conf-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width 0.3s; }
|
||||||
|
.conf-val { color: var(--muted); font-size: 11px; min-width: 26px; text-align: right; }
|
||||||
|
.rationale-cell { max-width: 200px; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
|
||||||
|
.empty-row td { text-align: center; color: var(--muted); padding: 24px; }
|
||||||
|
|
||||||
|
/* Positions panel */
|
||||||
|
.positions-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.positions-table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
||||||
|
.positions-table th {
|
||||||
|
text-align: left; color: var(--muted); font-size: 11px; font-weight: 600;
|
||||||
|
padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.positions-table td {
|
||||||
|
padding: 8px 8px; border-bottom: 1px solid rgba(40, 69, 95, 0.5);
|
||||||
|
vertical-align: middle; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.positions-table tr:last-child td { border-bottom: none; }
|
||||||
|
.positions-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
.pos-empty { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
|
||||||
|
.pos-count {
|
||||||
|
display: inline-block; background: rgba(60, 179, 113, 0.12);
|
||||||
|
color: var(--accent); font-size: 11px; font-weight: 700;
|
||||||
|
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Generic panel */
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Playbook panel - details/summary accordion */
|
||||||
|
.playbook-panel details { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 6px; }
|
||||||
|
.playbook-panel summary { padding: 8px 12px; cursor: pointer; font-weight: 600; background: var(--bg); color: var(--fg); }
|
||||||
|
.playbook-panel summary:hover { color: var(--accent); }
|
||||||
|
.playbook-panel pre { margin: 0; padding: 12px; background: var(--bg); overflow-x: auto;
|
||||||
|
font-size: 11px; color: #a0c4ff; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* Scorecard KPI card grid */
|
||||||
|
.scorecard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
||||||
|
.kpi-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 12px; text-align: center; }
|
||||||
|
.kpi-card .kpi-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; }
|
||||||
|
.kpi-card .kpi-value { font-size: 20px; font-weight: 700; color: var(--fg); }
|
||||||
|
|
||||||
|
/* Scenarios table */
|
||||||
|
.scenarios-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.scenarios-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.scenarios-table td { padding: 7px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); }
|
||||||
|
.scenarios-table tr:hover td { background: rgba(255,255,255,0.02); }
|
||||||
|
|
||||||
|
/* Context table */
|
||||||
|
.context-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.context-table th { background: var(--bg); padding: 8px; text-align: left; border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.context-table td { padding: 6px 8px; border-bottom: 1px solid rgba(40,69,95,0.5); vertical-align: top; }
|
||||||
|
.context-value { max-height: 60px; overflow-y: auto; color: #a0c4ff; word-break: break-all; }
|
||||||
|
|
||||||
|
/* Common panel select controls */
|
||||||
|
.panel-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.panel-controls select, .panel-controls input[type="number"] {
|
||||||
|
background: var(--bg); color: var(--fg); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; padding: 4px 8px; font-size: 13px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.panel-date { color: var(--muted); font-size: 12px; }
|
||||||
|
.empty-msg { color: var(--muted); text-align: center; padding: 20px 0; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<!-- Header -->
|
||||||
|
<header>
|
||||||
|
<h1>🐍 The Ouroboros</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="cb-gauge-wrap" id="cb-gauge" title="Circuit Breaker">
|
||||||
|
<span class="cb-dot unknown" id="cb-dot"></span>
|
||||||
|
<span id="cb-label">CB --</span>
|
||||||
|
<div class="cb-bar-wrap">
|
||||||
|
<div class="cb-bar-fill" id="cb-bar" style="width:0%;background:var(--accent)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span id="last-updated">--</span>
|
||||||
|
<button class="refresh-btn" onclick="refreshAll()">↺ 새로고침</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Summary cards -->
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">오늘 거래</div>
|
||||||
|
<div class="card-value neutral" id="card-trades">--</div>
|
||||||
|
<div class="card-sub" id="card-trades-sub">거래 건수</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">오늘 P&L</div>
|
||||||
|
<div class="card-value" id="card-pnl">--</div>
|
||||||
|
<div class="card-sub" id="card-pnl-sub">실현 손익</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">승률</div>
|
||||||
|
<div class="card-value neutral" id="card-winrate">--</div>
|
||||||
|
<div class="card-sub">전체 누적</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">누적 거래</div>
|
||||||
|
<div class="card-value neutral" id="card-total">--</div>
|
||||||
|
<div class="card-sub">전체 기간</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Positions -->
|
||||||
|
<div class="positions-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">
|
||||||
|
현재 보유 포지션
|
||||||
|
<span class="pos-count" id="positions-count">0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<table class="positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>종목</th>
|
||||||
|
<th>시장</th>
|
||||||
|
<th>수량</th>
|
||||||
|
<th>진입가</th>
|
||||||
|
<th>보유 시간</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="positions-body">
|
||||||
|
<tr><td colspan="5" class="pos-empty"><span class="spinner"></span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- P&L Chart -->
|
||||||
|
<div class="chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">P&L 추이</span>
|
||||||
|
<div class="days-selector">
|
||||||
|
<button class="day-btn active" data-days="7" onclick="selectDays(this)">7일</button>
|
||||||
|
<button class="day-btn" data-days="30" onclick="selectDays(this)">30일</button>
|
||||||
|
<button class="day-btn" data-days="90" onclick="selectDays(this)">90일</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="pnl-chart"></canvas>
|
||||||
|
<div class="chart-error" id="chart-error" style="display:none">데이터 없음</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Decisions log -->
|
||||||
|
<div class="decisions-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">최근 결정 로그</span>
|
||||||
|
<div class="market-tabs" id="market-tabs">
|
||||||
|
<button class="tab-btn active" data-market="KR" onclick="selectMarket(this)">KR</button>
|
||||||
|
<button class="tab-btn" data-market="US_NASDAQ" onclick="selectMarket(this)">US_NASDAQ</button>
|
||||||
|
<button class="tab-btn" data-market="US_NYSE" onclick="selectMarket(this)">US_NYSE</button>
|
||||||
|
<button class="tab-btn" data-market="JP" onclick="selectMarket(this)">JP</button>
|
||||||
|
<button class="tab-btn" data-market="HK" onclick="selectMarket(this)">HK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<table class="decisions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>시각</th>
|
||||||
|
<th>종목</th>
|
||||||
|
<th>액션</th>
|
||||||
|
<th>신뢰도</th>
|
||||||
|
<th>사유</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="decisions-body">
|
||||||
|
<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- playbook panel -->
|
||||||
|
<div class="panel playbook-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">📋 프리마켓 플레이북</span>
|
||||||
|
<div class="panel-controls">
|
||||||
|
<select id="pb-market-select" onchange="fetchPlaybook()">
|
||||||
|
<option value="KR">KR</option>
|
||||||
|
<option value="US_NASDAQ">US_NASDAQ</option>
|
||||||
|
<option value="US_NYSE">US_NYSE</option>
|
||||||
|
</select>
|
||||||
|
<span id="pb-date" class="panel-date"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="playbook-content"><p class="empty-msg">데이터 없음</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- scorecard panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">📊 일간 스코어카드</span>
|
||||||
|
<div class="panel-controls">
|
||||||
|
<select id="sc-market-select" onchange="fetchScorecard()">
|
||||||
|
<option value="KR">KR</option>
|
||||||
|
<option value="US_NASDAQ">US_NASDAQ</option>
|
||||||
|
</select>
|
||||||
|
<span id="sc-date" class="panel-date"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="scorecard-grid" class="scorecard-grid"><p class="empty-msg">데이터 없음</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- scenarios panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">🎯 활성 시나리오 매칭</span>
|
||||||
|
<div class="panel-controls">
|
||||||
|
<select id="scen-market-select" onchange="fetchScenarios()">
|
||||||
|
<option value="KR">KR</option>
|
||||||
|
<option value="US_NASDAQ">US_NASDAQ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="scenarios-content"><p class="empty-msg">데이터 없음</p></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- context layer panel -->
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">🧠 컨텍스트 트리</span>
|
||||||
|
<div class="panel-controls">
|
||||||
|
<select id="ctx-layer-select" onchange="fetchContext()">
|
||||||
|
<option value="L7_REALTIME">L7_REALTIME</option>
|
||||||
|
<option value="L6_DAILY">L6_DAILY</option>
|
||||||
|
<option value="L5_WEEKLY">L5_WEEKLY</option>
|
||||||
|
<option value="L4_MONTHLY">L4_MONTHLY</option>
|
||||||
|
<option value="L3_QUARTERLY">L3_QUARTERLY</option>
|
||||||
|
<option value="L2_YEARLY">L2_YEARLY</option>
|
||||||
|
<option value="L1_LIFETIME">L1_LIFETIME</option>
|
||||||
|
</select>
|
||||||
|
<input id="ctx-limit" type="number" value="20" min="1" max="200"
|
||||||
|
style="width:60px;" onchange="fetchContext()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="context-content"><p class="empty-msg">데이터 없음</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let pnlChart = null;
|
||||||
|
let currentDays = 7;
|
||||||
|
let currentMarket = 'KR';
|
||||||
|
|
||||||
|
function fmt(dt) {
|
||||||
|
try {
|
||||||
|
const d = new Date(dt);
|
||||||
|
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||||
|
} catch { return dt || '--'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPnl(v) {
|
||||||
|
if (v === null || v === undefined) return '--';
|
||||||
|
const n = parseFloat(v);
|
||||||
|
const cls = n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral';
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
return `<span class="${cls}">${sign}${n.toFixed(2)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function badge(action) {
|
||||||
|
const a = (action || '').toUpperCase();
|
||||||
|
const cls = a === 'BUY' ? 'badge-buy' : a === 'SELL' ? 'badge-sell' : 'badge-hold';
|
||||||
|
return `<span class="badge ${cls}">${a}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confBar(conf) {
|
||||||
|
const pct = Math.min(Math.max(conf || 0, 0), 100);
|
||||||
|
return `<div class="conf-bar-wrap">
|
||||||
|
<div class="conf-bar"><div class="conf-fill" style="width:${pct}%"></div></div>
|
||||||
|
<span class="conf-val">${pct}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtPrice(v, market) {
|
||||||
|
if (v === null || v === undefined) return '--';
|
||||||
|
const n = parseFloat(v);
|
||||||
|
const sym = market === 'KR' ? '₩' : market === 'JP' ? '¥' : market === 'HK' ? 'HK$' : '$';
|
||||||
|
return sym + n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPositions() {
|
||||||
|
const tbody = document.getElementById('positions-body');
|
||||||
|
const countEl = document.getElementById('positions-count');
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/positions');
|
||||||
|
if (!r.ok) throw new Error('fetch failed');
|
||||||
|
const d = await r.json();
|
||||||
|
countEl.textContent = d.count ?? 0;
|
||||||
|
if (!d.positions || d.positions.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">현재 보유 중인 포지션 없음</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = d.positions.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${p.stock_code || '--'}</strong></td>
|
||||||
|
<td><span style="color:var(--muted);font-size:11px">${p.market || '--'}</span></td>
|
||||||
|
<td>${p.quantity ?? '--'}</td>
|
||||||
|
<td>${fmtPrice(p.entry_price, p.market)}</td>
|
||||||
|
<td style="color:var(--muted);font-size:11px">${p.held || '--'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5" class="pos-empty">데이터 로드 실패</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCbGauge(cb) {
|
||||||
|
if (!cb) return;
|
||||||
|
const dot = document.getElementById('cb-dot');
|
||||||
|
const label = document.getElementById('cb-label');
|
||||||
|
const bar = document.getElementById('cb-bar');
|
||||||
|
|
||||||
|
const status = cb.status || 'unknown';
|
||||||
|
const threshold = cb.threshold_pct ?? -3.0;
|
||||||
|
const current = cb.current_pnl_pct;
|
||||||
|
|
||||||
|
// dot color
|
||||||
|
dot.className = `cb-dot ${status}`;
|
||||||
|
|
||||||
|
// label
|
||||||
|
if (current !== null && current !== undefined) {
|
||||||
|
const sign = current > 0 ? '+' : '';
|
||||||
|
label.textContent = `CB ${sign}${current.toFixed(2)}%`;
|
||||||
|
} else {
|
||||||
|
label.textContent = 'CB --';
|
||||||
|
}
|
||||||
|
|
||||||
|
// bar: fill = how much of the threshold has been consumed (0%=safe, 100%=tripped)
|
||||||
|
const colorMap = { ok: 'var(--accent)', warning: 'var(--warn)', tripped: 'var(--red)', unknown: 'var(--border)' };
|
||||||
|
bar.style.background = colorMap[status] || 'var(--border)';
|
||||||
|
if (current !== null && current !== undefined && threshold < 0) {
|
||||||
|
const fillPct = Math.min(Math.max((current / threshold) * 100, 0), 100);
|
||||||
|
bar.style.width = `${fillPct}%`;
|
||||||
|
} else {
|
||||||
|
bar.style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/status');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const t = d.totals || {};
|
||||||
|
document.getElementById('card-trades').textContent = t.trade_count ?? '--';
|
||||||
|
const pnlEl = document.getElementById('card-pnl');
|
||||||
|
const pnlV = t.total_pnl;
|
||||||
|
if (pnlV !== undefined) {
|
||||||
|
const n = parseFloat(pnlV);
|
||||||
|
const sign = n > 0 ? '+' : '';
|
||||||
|
pnlEl.textContent = `${sign}${n.toFixed(2)}`;
|
||||||
|
pnlEl.className = `card-value ${n > 0 ? 'positive' : n < 0 ? 'negative' : 'neutral'}`;
|
||||||
|
}
|
||||||
|
document.getElementById('card-pnl-sub').textContent = `결정 ${t.decision_count ?? 0}건`;
|
||||||
|
renderCbGauge(d.circuit_breaker);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPerformance() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/performance?market=all');
|
||||||
|
if (!r.ok) return;
|
||||||
|
const d = await r.json();
|
||||||
|
const c = d.combined || {};
|
||||||
|
document.getElementById('card-winrate').textContent = c.win_rate !== undefined ? `${c.win_rate}%` : '--';
|
||||||
|
document.getElementById('card-total').textContent = c.total_trades ?? '--';
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPnlHistory(days) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/pnl/history?days=${days}`);
|
||||||
|
if (!r.ok) throw new Error('fetch failed');
|
||||||
|
const d = await r.json();
|
||||||
|
renderChart(d);
|
||||||
|
} catch {
|
||||||
|
document.getElementById('chart-error').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(data) {
|
||||||
|
const errEl = document.getElementById('chart-error');
|
||||||
|
if (!data.labels || data.labels.length === 0) {
|
||||||
|
errEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
|
||||||
|
const colors = data.pnl.map(v => v >= 0 ? 'rgba(60,179,113,0.75)' : 'rgba(224,85,85,0.75)');
|
||||||
|
const borderColors = data.pnl.map(v => v >= 0 ? '#3cb371' : '#e05555');
|
||||||
|
|
||||||
|
if (pnlChart) { pnlChart.destroy(); pnlChart = null; }
|
||||||
|
const ctx = document.getElementById('pnl-chart').getContext('2d');
|
||||||
|
pnlChart = new Chart(ctx, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: data.labels,
|
||||||
|
datasets: [{
|
||||||
|
label: 'Daily P&L',
|
||||||
|
data: data.pnl,
|
||||||
|
backgroundColor: colors,
|
||||||
|
borderColor: borderColors,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: 3,
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: ctx => {
|
||||||
|
const v = ctx.parsed.y;
|
||||||
|
const sign = v >= 0 ? '+' : '';
|
||||||
|
const trades = data.trades[ctx.dataIndex];
|
||||||
|
return [`P&L: ${sign}${v.toFixed(2)}`, `거래: ${trades}건`];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { color: '#9fb3c8', font: { size: 10 }, maxRotation: 0 },
|
||||||
|
grid: { color: 'rgba(40,69,95,0.4)' }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
ticks: { color: '#9fb3c8', font: { size: 10 } },
|
||||||
|
grid: { color: 'rgba(40,69,95,0.4)' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDecisions(market) {
|
||||||
|
const tbody = document.getElementById('decisions-body');
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5"><span class="spinner"></span></td></tr>';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/decisions?market=${market}&limit=50`);
|
||||||
|
if (!r.ok) throw new Error('fetch failed');
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.decisions || d.decisions.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">결정 로그 없음</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = d.decisions.map(dec => `
|
||||||
|
<tr>
|
||||||
|
<td>${fmt(dec.timestamp)}</td>
|
||||||
|
<td>${dec.stock_code || '--'}</td>
|
||||||
|
<td>${badge(dec.action)}</td>
|
||||||
|
<td>${confBar(dec.confidence)}</td>
|
||||||
|
<td class="rationale-cell" title="${(dec.rationale || '').replace(/"/g, '"')}">${dec.rationale || '--'}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
tbody.innerHTML = '<tr class="empty-row"><td colspan="5">데이터 로드 실패</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDays(btn) {
|
||||||
|
document.querySelectorAll('.day-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentDays = parseInt(btn.dataset.days, 10);
|
||||||
|
fetchPnlHistory(currentDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMarket(btn) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentMarket = btn.dataset.market;
|
||||||
|
fetchDecisions(currentMarket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return new Date().toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJSON(url) {
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlaybook() {
|
||||||
|
const market = document.getElementById('pb-market-select').value;
|
||||||
|
const date = todayStr();
|
||||||
|
document.getElementById('pb-date').textContent = date;
|
||||||
|
const el = document.getElementById('playbook-content');
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(`/api/playbook/${date}?market=${market}`);
|
||||||
|
const stocks = data.stock_playbooks ?? [];
|
||||||
|
if (stocks.length === 0) {
|
||||||
|
el.innerHTML = '<p class="empty-msg">오늘 플레이북 없음</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = stocks.map(sp =>
|
||||||
|
`<details><summary>${esc(sp.stock_code ?? '?')} — ${esc(sp.signal ?? '')}</summary>` +
|
||||||
|
`<pre>${esc(JSON.stringify(sp, null, 2))}</pre></details>`
|
||||||
|
).join('');
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<p class="empty-msg">플레이북 없음 (오늘 미생성 또는 API 오류)</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScorecard() {
|
||||||
|
const market = document.getElementById('sc-market-select').value;
|
||||||
|
const date = todayStr();
|
||||||
|
document.getElementById('sc-date').textContent = date;
|
||||||
|
const el = document.getElementById('scorecard-grid');
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(`/api/scorecard/${date}?market=${market}`);
|
||||||
|
const sc = data.scorecard ?? {};
|
||||||
|
const entries = Object.entries(sc);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
el.innerHTML = '<p class="empty-msg">스코어카드 없음</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.className = 'scorecard-grid';
|
||||||
|
el.innerHTML = entries.map(([k, v]) => `
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label">${esc(k)}</div>
|
||||||
|
<div class="kpi-value">${typeof v === 'number' ? v.toFixed(2) : esc(String(v))}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<p class="empty-msg">스코어카드 없음 (오늘 미생성 또는 API 오류)</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScenarios() {
|
||||||
|
const market = document.getElementById('scen-market-select').value;
|
||||||
|
const date = todayStr();
|
||||||
|
const el = document.getElementById('scenarios-content');
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(`/api/scenarios/active?market=${market}&date_str=${date}&limit=50`);
|
||||||
|
const matches = data.matches ?? [];
|
||||||
|
if (matches.length === 0) {
|
||||||
|
el.innerHTML = '<p class="empty-msg">활성 시나리오 없음</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="scenarios-table">
|
||||||
|
<thead><tr><th>종목</th><th>신호</th><th>신뢰도</th><th>매칭 조건</th></tr></thead>
|
||||||
|
<tbody>${matches.map(m => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(m.stock_code)}</td>
|
||||||
|
<td>${esc(m.signal ?? '-')}</td>
|
||||||
|
<td>${esc(m.confidence ?? '-')}</td>
|
||||||
|
<td><code style="font-size:11px">${esc(JSON.stringify(m.scenario_match ?? {}))}</code></td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody></table>`;
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchContext() {
|
||||||
|
const layer = document.getElementById('ctx-layer-select').value;
|
||||||
|
const limit = Math.min(Math.max(parseInt(document.getElementById('ctx-limit').value, 10) || 20, 1), 200);
|
||||||
|
const el = document.getElementById('context-content');
|
||||||
|
try {
|
||||||
|
const data = await fetchJSON(`/api/context/${layer}?limit=${limit}`);
|
||||||
|
const entries = data.entries ?? [];
|
||||||
|
if (entries.length === 0) {
|
||||||
|
el.innerHTML = '<p class="empty-msg">컨텍스트 없음</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = `<table class="context-table">
|
||||||
|
<thead><tr><th>timeframe</th><th>key</th><th>value</th><th>updated</th></tr></thead>
|
||||||
|
<tbody>${entries.map(e => `
|
||||||
|
<tr>
|
||||||
|
<td>${esc(e.timeframe)}</td>
|
||||||
|
<td>${esc(e.key)}</td>
|
||||||
|
<td><div class="context-value">${esc(JSON.stringify(e.value ?? e.raw_value))}</div></td>
|
||||||
|
<td style="font-size:11px;color:var(--muted)">${esc((e.updated_at ?? '').slice(0, 16))}</td>
|
||||||
|
</tr>`).join('')}
|
||||||
|
</tbody></table>`;
|
||||||
|
} catch {
|
||||||
|
el.innerHTML = '<p class="empty-msg">데이터 없음</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
document.getElementById('last-updated').textContent = '업데이트 중...';
|
||||||
|
await Promise.all([
|
||||||
|
fetchStatus(),
|
||||||
|
fetchPerformance(),
|
||||||
|
fetchPositions(),
|
||||||
|
fetchPnlHistory(currentDays),
|
||||||
|
fetchDecisions(currentMarket),
|
||||||
|
fetchPlaybook(),
|
||||||
|
fetchScorecard(),
|
||||||
|
fetchScenarios(),
|
||||||
|
fetchContext(),
|
||||||
|
]);
|
||||||
|
const now = new Date();
|
||||||
|
const timeStr = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
||||||
|
document.getElementById('last-updated').textContent = `마지막 업데이트: ${timeStr}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
refreshAll();
|
||||||
|
|
||||||
|
// Auto-refresh every 30 seconds
|
||||||
|
setInterval(refreshAll, 30000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
src/db.py
79
src/db.py
@@ -14,6 +14,11 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
if db_path != ":memory:":
|
if db_path != ":memory:":
|
||||||
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
conn = sqlite3.connect(db_path)
|
conn = sqlite3.connect(db_path)
|
||||||
|
# Enable WAL mode for concurrent read/write (dashboard + trading loop).
|
||||||
|
# WAL does not apply to in-memory databases.
|
||||||
|
if db_path != ":memory:":
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
conn.execute("PRAGMA busy_timeout=5000")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS trades (
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
@@ -28,12 +33,13 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
pnl REAL DEFAULT 0.0,
|
pnl REAL DEFAULT 0.0,
|
||||||
market TEXT DEFAULT 'KR',
|
market TEXT DEFAULT 'KR',
|
||||||
exchange_code TEXT DEFAULT 'KRX',
|
exchange_code TEXT DEFAULT 'KRX',
|
||||||
decision_id TEXT
|
decision_id TEXT,
|
||||||
|
mode TEXT DEFAULT 'paper'
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Migration: Add market and exchange_code columns if they don't exist
|
# Migration: Add columns if they don't exist (backward-compatible schema upgrades)
|
||||||
cursor = conn.execute("PRAGMA table_info(trades)")
|
cursor = conn.execute("PRAGMA table_info(trades)")
|
||||||
columns = {row[1] for row in cursor.fetchall()}
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
@@ -45,6 +51,8 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
|
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
|
||||||
if "decision_id" not in columns:
|
if "decision_id" not in columns:
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
|
conn.execute("ALTER TABLE trades ADD COLUMN decision_id TEXT")
|
||||||
|
if "mode" not in columns:
|
||||||
|
conn.execute("ALTER TABLE trades ADD COLUMN mode TEXT DEFAULT 'paper'")
|
||||||
|
|
||||||
# Context tree tables for multi-layered memory management
|
# Context tree tables for multi-layered memory management
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -131,6 +139,25 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
|
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Index for open-position queries (partition by stock_code, market, ordered by timestamp)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_trades_stock_market_ts"
|
||||||
|
" ON trades (stock_code, market, timestamp DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lightweight key-value store for trading system runtime metrics (dashboard use only)
|
||||||
|
# Intentionally separate from the AI context tree to preserve separation of concerns.
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS system_metrics (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -148,6 +175,7 @@ def log_trade(
|
|||||||
exchange_code: str = "KRX",
|
exchange_code: str = "KRX",
|
||||||
selection_context: dict[str, any] | None = None,
|
selection_context: dict[str, any] | None = None,
|
||||||
decision_id: str | None = None,
|
decision_id: str | None = None,
|
||||||
|
mode: str = "paper",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert a trade record into the database.
|
"""Insert a trade record into the database.
|
||||||
|
|
||||||
@@ -163,6 +191,8 @@ def log_trade(
|
|||||||
market: Market code
|
market: Market code
|
||||||
exchange_code: Exchange code
|
exchange_code: Exchange code
|
||||||
selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
|
selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
|
||||||
|
decision_id: Unique decision identifier for audit linking
|
||||||
|
mode: Trading mode ('paper' or 'live') for data separation
|
||||||
"""
|
"""
|
||||||
# Serialize selection context to JSON
|
# Serialize selection context to JSON
|
||||||
context_json = json.dumps(selection_context) if selection_context else None
|
context_json = json.dumps(selection_context) if selection_context else None
|
||||||
@@ -171,9 +201,10 @@ def log_trade(
|
|||||||
"""
|
"""
|
||||||
INSERT INTO trades (
|
INSERT INTO trades (
|
||||||
timestamp, stock_code, action, confidence, rationale,
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
quantity, price, pnl, market, exchange_code, selection_context, decision_id
|
quantity, price, pnl, market, exchange_code, selection_context, decision_id,
|
||||||
|
mode
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
datetime.now(UTC).isoformat(),
|
datetime.now(UTC).isoformat(),
|
||||||
@@ -188,6 +219,7 @@ def log_trade(
|
|||||||
exchange_code,
|
exchange_code,
|
||||||
context_json,
|
context_json,
|
||||||
decision_id,
|
decision_id,
|
||||||
|
mode,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -214,3 +246,42 @@ def get_latest_buy_trade(
|
|||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
|
return {"decision_id": row[0], "price": row[1], "quantity": row[2]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_open_position(
|
||||||
|
conn: sqlite3.Connection, stock_code: str, market: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Return open position if latest trade is BUY, else None."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT action, decision_id, price, quantity
|
||||||
|
FROM trades
|
||||||
|
WHERE stock_code = ?
|
||||||
|
AND market = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(stock_code, market),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row[0] != "BUY":
|
||||||
|
return None
|
||||||
|
return {"decision_id": row[1], "price": row[2], "quantity": row[3]}
|
||||||
|
|
||||||
|
|
||||||
|
def get_recent_symbols(
|
||||||
|
conn: sqlite3.Connection, market: str, limit: int = 30
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return recent unique symbols for a market, newest first."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT stock_code, MAX(timestamp) AS last_ts
|
||||||
|
FROM trades
|
||||||
|
WHERE market = ?
|
||||||
|
GROUP BY stock_code
|
||||||
|
ORDER BY last_ts DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, limit),
|
||||||
|
)
|
||||||
|
return [row[0] for row in cursor.fetchall() if row and row[0]]
|
||||||
|
|||||||
1042
src/main.py
1042
src/main.py
File diff suppressed because it is too large
Load Diff
@@ -123,6 +123,23 @@ MARKETS: dict[str, MarketInfo] = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MARKET_SHORTHAND: dict[str, list[str]] = {
|
||||||
|
"US": ["US_NASDAQ", "US_NYSE", "US_AMEX"],
|
||||||
|
"CN": ["CN_SHA", "CN_SZA"],
|
||||||
|
"VN": ["VN_HAN", "VN_HCM"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def expand_market_codes(codes: list[str]) -> list[str]:
|
||||||
|
"""Expand shorthand market codes into concrete exchange market codes."""
|
||||||
|
expanded: list[str] = []
|
||||||
|
for code in codes:
|
||||||
|
if code in MARKET_SHORTHAND:
|
||||||
|
expanded.extend(MARKET_SHORTHAND[code])
|
||||||
|
else:
|
||||||
|
expanded.append(code)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
|
||||||
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
|
def is_market_open(market: MarketInfo, now: datetime | None = None) -> bool:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, fields
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -58,6 +59,45 @@ class LeakyBucket:
|
|||||||
self._tokens -= 1.0
|
self._tokens -= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationFilter:
|
||||||
|
"""Granular on/off flags for each notification type.
|
||||||
|
|
||||||
|
circuit_breaker is intentionally omitted — it is always sent regardless.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Maps user-facing command keys to dataclass field names
|
||||||
|
KEYS: ClassVar[dict[str, str]] = {
|
||||||
|
"trades": "trades",
|
||||||
|
"market": "market_open_close",
|
||||||
|
"fatfinger": "fat_finger",
|
||||||
|
"system": "system_events",
|
||||||
|
"playbook": "playbook",
|
||||||
|
"scenario": "scenario_match",
|
||||||
|
"errors": "errors",
|
||||||
|
}
|
||||||
|
|
||||||
|
trades: bool = True
|
||||||
|
market_open_close: bool = True
|
||||||
|
fat_finger: bool = True
|
||||||
|
system_events: bool = True
|
||||||
|
playbook: bool = True
|
||||||
|
scenario_match: bool = True
|
||||||
|
errors: bool = True
|
||||||
|
|
||||||
|
def set_flag(self, key: str, value: bool) -> bool:
|
||||||
|
"""Set a filter flag by user-facing key. Returns False if key is unknown."""
|
||||||
|
field = self.KEYS.get(key.lower())
|
||||||
|
if field is None:
|
||||||
|
return False
|
||||||
|
setattr(self, field, value)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, bool]:
|
||||||
|
"""Return {user_key: current_value} for display."""
|
||||||
|
return {k: getattr(self, field) for k, field in self.KEYS.items()}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class NotificationMessage:
|
class NotificationMessage:
|
||||||
"""Internal notification message structure."""
|
"""Internal notification message structure."""
|
||||||
@@ -79,6 +119,7 @@ class TelegramClient:
|
|||||||
chat_id: str | None = None,
|
chat_id: str | None = None,
|
||||||
enabled: bool = True,
|
enabled: bool = True,
|
||||||
rate_limit: float = DEFAULT_RATE,
|
rate_limit: float = DEFAULT_RATE,
|
||||||
|
notification_filter: NotificationFilter | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize Telegram client.
|
Initialize Telegram client.
|
||||||
@@ -88,12 +129,14 @@ class TelegramClient:
|
|||||||
chat_id: Target chat ID (user or group)
|
chat_id: Target chat ID (user or group)
|
||||||
enabled: Enable/disable notifications globally
|
enabled: Enable/disable notifications globally
|
||||||
rate_limit: Maximum messages per second
|
rate_limit: Maximum messages per second
|
||||||
|
notification_filter: Granular per-type on/off flags
|
||||||
"""
|
"""
|
||||||
self._bot_token = bot_token
|
self._bot_token = bot_token
|
||||||
self._chat_id = chat_id
|
self._chat_id = chat_id
|
||||||
self._enabled = enabled
|
self._enabled = enabled
|
||||||
self._rate_limiter = LeakyBucket(rate=rate_limit)
|
self._rate_limiter = LeakyBucket(rate=rate_limit)
|
||||||
self._session: aiohttp.ClientSession | None = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
self._filter = notification_filter if notification_filter is not None else NotificationFilter()
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
logger.info("Telegram notifications disabled via configuration")
|
logger.info("Telegram notifications disabled via configuration")
|
||||||
@@ -118,6 +161,26 @@ class TelegramClient:
|
|||||||
if self._session is not None and not self._session.closed:
|
if self._session is not None and not self._session.closed:
|
||||||
await self._session.close()
|
await self._session.close()
|
||||||
|
|
||||||
|
def set_notification(self, key: str, value: bool) -> bool:
|
||||||
|
"""Toggle a notification type by user-facing key at runtime.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: User-facing key (e.g. "scenario", "market", "all")
|
||||||
|
value: True to enable, False to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if key was valid, False if unknown.
|
||||||
|
"""
|
||||||
|
if key == "all":
|
||||||
|
for k in NotificationFilter.KEYS:
|
||||||
|
self._filter.set_flag(k, value)
|
||||||
|
return True
|
||||||
|
return self._filter.set_flag(key, value)
|
||||||
|
|
||||||
|
def filter_status(self) -> dict[str, bool]:
|
||||||
|
"""Return current per-type filter state keyed by user-facing names."""
|
||||||
|
return self._filter.as_dict()
|
||||||
|
|
||||||
async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
||||||
"""
|
"""
|
||||||
Send a generic text message to Telegram.
|
Send a generic text message to Telegram.
|
||||||
@@ -193,6 +256,8 @@ class TelegramClient:
|
|||||||
price: Execution price
|
price: Execution price
|
||||||
confidence: AI confidence level (0-100)
|
confidence: AI confidence level (0-100)
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.trades:
|
||||||
|
return
|
||||||
emoji = "🟢" if action == "BUY" else "🔴"
|
emoji = "🟢" if action == "BUY" else "🔴"
|
||||||
message = (
|
message = (
|
||||||
f"<b>{emoji} {action}</b>\n"
|
f"<b>{emoji} {action}</b>\n"
|
||||||
@@ -212,6 +277,8 @@ class TelegramClient:
|
|||||||
Args:
|
Args:
|
||||||
market_name: Name of the market (e.g., "Korea", "United States")
|
market_name: Name of the market (e.g., "Korea", "United States")
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.market_open_close:
|
||||||
|
return
|
||||||
message = f"<b>Market Open</b>\n{market_name} trading session started"
|
message = f"<b>Market Open</b>\n{market_name} trading session started"
|
||||||
await self._send_notification(
|
await self._send_notification(
|
||||||
NotificationMessage(priority=NotificationPriority.LOW, message=message)
|
NotificationMessage(priority=NotificationPriority.LOW, message=message)
|
||||||
@@ -225,6 +292,8 @@ class TelegramClient:
|
|||||||
market_name: Name of the market
|
market_name: Name of the market
|
||||||
pnl_pct: Final P&L percentage for the session
|
pnl_pct: Final P&L percentage for the session
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.market_open_close:
|
||||||
|
return
|
||||||
pnl_sign = "+" if pnl_pct >= 0 else ""
|
pnl_sign = "+" if pnl_pct >= 0 else ""
|
||||||
pnl_emoji = "📈" if pnl_pct >= 0 else "📉"
|
pnl_emoji = "📈" if pnl_pct >= 0 else "📉"
|
||||||
message = (
|
message = (
|
||||||
@@ -271,6 +340,8 @@ class TelegramClient:
|
|||||||
total_cash: Total available cash
|
total_cash: Total available cash
|
||||||
max_pct: Maximum allowed percentage
|
max_pct: Maximum allowed percentage
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.fat_finger:
|
||||||
|
return
|
||||||
attempted_pct = (order_amount / total_cash) * 100 if total_cash > 0 else 0
|
attempted_pct = (order_amount / total_cash) * 100 if total_cash > 0 else 0
|
||||||
message = (
|
message = (
|
||||||
f"<b>Fat-Finger Protection</b>\n"
|
f"<b>Fat-Finger Protection</b>\n"
|
||||||
@@ -293,6 +364,8 @@ class TelegramClient:
|
|||||||
mode: Trading mode ("paper" or "live")
|
mode: Trading mode ("paper" or "live")
|
||||||
enabled_markets: List of enabled market codes
|
enabled_markets: List of enabled market codes
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.system_events:
|
||||||
|
return
|
||||||
mode_emoji = "📝" if mode == "paper" else "💰"
|
mode_emoji = "📝" if mode == "paper" else "💰"
|
||||||
markets_str = ", ".join(enabled_markets)
|
markets_str = ", ".join(enabled_markets)
|
||||||
message = (
|
message = (
|
||||||
@@ -320,6 +393,8 @@ class TelegramClient:
|
|||||||
scenario_count: Total number of scenarios
|
scenario_count: Total number of scenarios
|
||||||
token_count: Gemini token usage for the playbook
|
token_count: Gemini token usage for the playbook
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.playbook:
|
||||||
|
return
|
||||||
message = (
|
message = (
|
||||||
f"<b>Playbook Generated</b>\n"
|
f"<b>Playbook Generated</b>\n"
|
||||||
f"Market: {market}\n"
|
f"Market: {market}\n"
|
||||||
@@ -347,6 +422,8 @@ class TelegramClient:
|
|||||||
condition_summary: Short summary of the matched condition
|
condition_summary: Short summary of the matched condition
|
||||||
confidence: Scenario confidence (0-100)
|
confidence: Scenario confidence (0-100)
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.scenario_match:
|
||||||
|
return
|
||||||
message = (
|
message = (
|
||||||
f"<b>Scenario Matched</b>\n"
|
f"<b>Scenario Matched</b>\n"
|
||||||
f"Symbol: <code>{stock_code}</code>\n"
|
f"Symbol: <code>{stock_code}</code>\n"
|
||||||
@@ -366,6 +443,8 @@ class TelegramClient:
|
|||||||
market: Market code (e.g., "KR", "US")
|
market: Market code (e.g., "KR", "US")
|
||||||
reason: Failure reason summary
|
reason: Failure reason summary
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.playbook:
|
||||||
|
return
|
||||||
message = (
|
message = (
|
||||||
f"<b>Playbook Failed</b>\n"
|
f"<b>Playbook Failed</b>\n"
|
||||||
f"Market: {market}\n"
|
f"Market: {market}\n"
|
||||||
@@ -382,6 +461,8 @@ class TelegramClient:
|
|||||||
Args:
|
Args:
|
||||||
reason: Reason for shutdown (e.g., "Normal shutdown", "Circuit breaker")
|
reason: Reason for shutdown (e.g., "Normal shutdown", "Circuit breaker")
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.system_events:
|
||||||
|
return
|
||||||
message = f"<b>System Shutdown</b>\n{reason}"
|
message = f"<b>System Shutdown</b>\n{reason}"
|
||||||
priority = (
|
priority = (
|
||||||
NotificationPriority.CRITICAL
|
NotificationPriority.CRITICAL
|
||||||
@@ -403,6 +484,8 @@ class TelegramClient:
|
|||||||
error_msg: Error message
|
error_msg: Error message
|
||||||
context: Error context (e.g., stock code, market)
|
context: Error context (e.g., stock code, market)
|
||||||
"""
|
"""
|
||||||
|
if not self._filter.errors:
|
||||||
|
return
|
||||||
message = (
|
message = (
|
||||||
f"<b>Error: {error_type}</b>\n"
|
f"<b>Error: {error_type}</b>\n"
|
||||||
f"Context: {context}\n"
|
f"Context: {context}\n"
|
||||||
@@ -429,6 +512,7 @@ class TelegramCommandHandler:
|
|||||||
self._client = client
|
self._client = client
|
||||||
self._polling_interval = polling_interval
|
self._polling_interval = polling_interval
|
||||||
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
||||||
|
self._commands_with_args: dict[str, Callable[[list[str]], Awaitable[None]]] = {}
|
||||||
self._last_update_id = 0
|
self._last_update_id = 0
|
||||||
self._polling_task: asyncio.Task[None] | None = None
|
self._polling_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -437,7 +521,7 @@ class TelegramCommandHandler:
|
|||||||
self, command: str, handler: Callable[[], Awaitable[None]]
|
self, command: str, handler: Callable[[], Awaitable[None]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Register a command handler.
|
Register a command handler (no arguments).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: Command name (without leading slash, e.g., "start")
|
command: Command name (without leading slash, e.g., "start")
|
||||||
@@ -446,6 +530,19 @@ class TelegramCommandHandler:
|
|||||||
self._commands[command] = handler
|
self._commands[command] = handler
|
||||||
logger.debug("Registered command handler: /%s", command)
|
logger.debug("Registered command handler: /%s", command)
|
||||||
|
|
||||||
|
def register_command_with_args(
|
||||||
|
self, command: str, handler: Callable[[list[str]], Awaitable[None]]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a command handler that receives trailing arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command name (without leading slash, e.g., "notify")
|
||||||
|
handler: Async function receiving list of argument tokens
|
||||||
|
"""
|
||||||
|
self._commands_with_args[command] = handler
|
||||||
|
logger.debug("Registered command handler (with args): /%s", command)
|
||||||
|
|
||||||
async def start_polling(self) -> None:
|
async def start_polling(self) -> None:
|
||||||
"""Start long polling for commands."""
|
"""Start long polling for commands."""
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -507,6 +604,16 @@ class TelegramCommandHandler:
|
|||||||
async with session.post(url, json=payload) as resp:
|
async with session.post(url, json=payload) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
error_text = await resp.text()
|
error_text = await resp.text()
|
||||||
|
if resp.status == 409:
|
||||||
|
# Another bot instance is already polling — stop this poller entirely.
|
||||||
|
# Retrying would keep conflicting with the other instance.
|
||||||
|
self._running = False
|
||||||
|
logger.warning(
|
||||||
|
"Telegram conflict (409): another instance is already polling. "
|
||||||
|
"Disabling Telegram commands for this process. "
|
||||||
|
"Ensure only one instance of The Ouroboros is running at a time.",
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"getUpdates API error (status=%d): %s", resp.status, error_text
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
)
|
)
|
||||||
@@ -566,11 +673,14 @@ class TelegramCommandHandler:
|
|||||||
# Remove @botname suffix if present (for group chats)
|
# Remove @botname suffix if present (for group chats)
|
||||||
command_name = command_parts[0].split("@")[0]
|
command_name = command_parts[0].split("@")[0]
|
||||||
|
|
||||||
# Execute handler
|
# Execute handler (args-aware handlers take priority)
|
||||||
handler = self._commands.get(command_name)
|
args_handler = self._commands_with_args.get(command_name)
|
||||||
if handler:
|
if args_handler:
|
||||||
|
logger.info("Executing command: /%s %s", command_name, command_parts[1:])
|
||||||
|
await args_handler(command_parts[1:])
|
||||||
|
elif command_name in self._commands:
|
||||||
logger.info("Executing command: /%s", command_name)
|
logger.info("Executing command: /%s", command_name)
|
||||||
await handler()
|
await self._commands[command_name]()
|
||||||
else:
|
else:
|
||||||
logger.debug("Unknown command: /%s", command_name)
|
logger.debug("Unknown command: /%s", command_name)
|
||||||
await self._client.send_message(
|
await self._client.send_message(
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ class StockCondition(BaseModel):
|
|||||||
|
|
||||||
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
||||||
A condition matches only if ALL specified fields are satisfied.
|
A condition matches only if ALL specified fields are satisfied.
|
||||||
|
|
||||||
|
Technical indicator fields:
|
||||||
|
rsi_below / rsi_above — RSI threshold
|
||||||
|
volume_ratio_above / volume_ratio_below — volume vs previous day
|
||||||
|
price_above / price_below — absolute price level
|
||||||
|
price_change_pct_above / price_change_pct_below — intraday % change
|
||||||
|
|
||||||
|
Position-aware fields (require market_data enrichment from open position):
|
||||||
|
unrealized_pnl_pct_above — matches if unrealized P&L > threshold (e.g. 3.0 → +3%)
|
||||||
|
unrealized_pnl_pct_below — matches if unrealized P&L < threshold (e.g. -2.0 → -2%)
|
||||||
|
holding_days_above — matches if position held for more than N days
|
||||||
|
holding_days_below — matches if position held for fewer than N days
|
||||||
"""
|
"""
|
||||||
|
|
||||||
rsi_below: float | None = None
|
rsi_below: float | None = None
|
||||||
@@ -56,6 +68,10 @@ class StockCondition(BaseModel):
|
|||||||
price_below: float | None = None
|
price_below: float | None = None
|
||||||
price_change_pct_above: float | None = None
|
price_change_pct_above: float | None = None
|
||||||
price_change_pct_below: float | None = None
|
price_change_pct_below: float | None = None
|
||||||
|
unrealized_pnl_pct_above: float | None = None
|
||||||
|
unrealized_pnl_pct_below: float | None = None
|
||||||
|
holding_days_above: int | None = None
|
||||||
|
holding_days_below: int | None = None
|
||||||
|
|
||||||
def has_any_condition(self) -> bool:
|
def has_any_condition(self) -> bool:
|
||||||
"""Check if at least one condition field is set."""
|
"""Check if at least one condition field is set."""
|
||||||
@@ -70,6 +86,10 @@ class StockCondition(BaseModel):
|
|||||||
self.price_below,
|
self.price_below,
|
||||||
self.price_change_pct_above,
|
self.price_change_pct_above,
|
||||||
self.price_change_pct_below,
|
self.price_change_pct_below,
|
||||||
|
self.unrealized_pnl_pct_above,
|
||||||
|
self.unrealized_pnl_pct_below,
|
||||||
|
self.holding_days_above,
|
||||||
|
self.holding_days_below,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
|
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
|
||||||
|
|
||||||
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
|
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
|
||||||
On failure, returns a defensive playbook (all HOLD, no trades).
|
On failure, returns a smart rule-based fallback playbook that uses scanner signals
|
||||||
|
(momentum/oversold) to generate BUY conditions, avoiding the all-HOLD problem.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -74,6 +75,7 @@ class PreMarketPlanner:
|
|||||||
market: str,
|
market: str,
|
||||||
candidates: list[ScanCandidate],
|
candidates: list[ScanCandidate],
|
||||||
today: date | None = None,
|
today: date | None = None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> DayPlaybook:
|
) -> DayPlaybook:
|
||||||
"""Generate a DayPlaybook for a market using Gemini.
|
"""Generate a DayPlaybook for a market using Gemini.
|
||||||
|
|
||||||
@@ -81,6 +83,10 @@ class PreMarketPlanner:
|
|||||||
market: Market code ("KR" or "US")
|
market: Market code ("KR" or "US")
|
||||||
candidates: Stock candidates from SmartVolatilityScanner
|
candidates: Stock candidates from SmartVolatilityScanner
|
||||||
today: Override date (defaults to date.today()). Use market-local date.
|
today: Override date (defaults to date.today()). Use market-local date.
|
||||||
|
current_holdings: Currently held positions with entry_price and unrealized_pnl_pct.
|
||||||
|
Each dict: {"stock_code": str, "name": str, "qty": int,
|
||||||
|
"entry_price": float, "unrealized_pnl_pct": float,
|
||||||
|
"holding_days": int}
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
||||||
@@ -105,6 +111,7 @@ class PreMarketPlanner:
|
|||||||
context_data,
|
context_data,
|
||||||
self_market_scorecard,
|
self_market_scorecard,
|
||||||
cross_market,
|
cross_market,
|
||||||
|
current_holdings=current_holdings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3. Call Gemini
|
# 3. Call Gemini
|
||||||
@@ -117,7 +124,8 @@ class PreMarketPlanner:
|
|||||||
|
|
||||||
# 4. Parse response
|
# 4. Parse response
|
||||||
playbook = self._parse_response(
|
playbook = self._parse_response(
|
||||||
decision.rationale, today, market, candidates, cross_market
|
decision.rationale, today, market, candidates, cross_market,
|
||||||
|
current_holdings=current_holdings,
|
||||||
)
|
)
|
||||||
playbook_with_tokens = playbook.model_copy(
|
playbook_with_tokens = playbook.model_copy(
|
||||||
update={"token_count": decision.token_count}
|
update={"token_count": decision.token_count}
|
||||||
@@ -134,7 +142,7 @@ class PreMarketPlanner:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Playbook generation failed for %s", market)
|
logger.exception("Playbook generation failed for %s", market)
|
||||||
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
|
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
|
||||||
return self._defensive_playbook(today, market, candidates)
|
return self._smart_fallback_playbook(today, market, candidates, self._settings)
|
||||||
return self._empty_playbook(today, market)
|
return self._empty_playbook(today, market)
|
||||||
|
|
||||||
def build_cross_market_context(
|
def build_cross_market_context(
|
||||||
@@ -229,6 +237,7 @@ class PreMarketPlanner:
|
|||||||
context_data: dict[str, Any],
|
context_data: dict[str, Any],
|
||||||
self_market_scorecard: dict[str, Any] | None,
|
self_market_scorecard: dict[str, Any] | None,
|
||||||
cross_market: CrossMarketContext | None,
|
cross_market: CrossMarketContext | None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
||||||
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
||||||
@@ -240,6 +249,26 @@ class PreMarketPlanner:
|
|||||||
for c in candidates
|
for c in candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
|
holdings_text = ""
|
||||||
|
if current_holdings:
|
||||||
|
lines = []
|
||||||
|
for h in current_holdings:
|
||||||
|
code = h.get("stock_code", "")
|
||||||
|
name = h.get("name", "")
|
||||||
|
qty = h.get("qty", 0)
|
||||||
|
entry_price = h.get("entry_price", 0.0)
|
||||||
|
pnl_pct = h.get("unrealized_pnl_pct", 0.0)
|
||||||
|
holding_days = h.get("holding_days", 0)
|
||||||
|
lines.append(
|
||||||
|
f" - {code} ({name}): {qty}주 @ {entry_price:,.0f}, "
|
||||||
|
f"미실현손익 {pnl_pct:+.2f}%, 보유 {holding_days}일"
|
||||||
|
)
|
||||||
|
holdings_text = (
|
||||||
|
"\n## Current Holdings (보유 중 — SELL/HOLD 전략 고려 필요)\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
|
||||||
cross_market_text = ""
|
cross_market_text = ""
|
||||||
if cross_market:
|
if cross_market:
|
||||||
cross_market_text = (
|
cross_market_text = (
|
||||||
@@ -272,10 +301,20 @@ class PreMarketPlanner:
|
|||||||
for key, value in list(layer_data.items())[:5]:
|
for key, value in list(layer_data.items())[:5]:
|
||||||
context_text += f" - {key}: {value}\n"
|
context_text += f" - {key}: {value}\n"
|
||||||
|
|
||||||
|
holdings_instruction = ""
|
||||||
|
if current_holdings:
|
||||||
|
holding_codes = [h.get("stock_code", "") for h in current_holdings]
|
||||||
|
holdings_instruction = (
|
||||||
|
f"- Also include SELL/HOLD scenarios for held stocks: "
|
||||||
|
f"{', '.join(holding_codes)} "
|
||||||
|
f"(even if not in candidates list)\n"
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f"You are a pre-market trading strategist for the {market} market.\n"
|
f"You are a pre-market trading strategist for the {market} market.\n"
|
||||||
f"Generate structured trading scenarios for today.\n\n"
|
f"Generate structured trading scenarios for today.\n\n"
|
||||||
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
||||||
|
f"{holdings_text}"
|
||||||
f"{self_market_text}"
|
f"{self_market_text}"
|
||||||
f"{cross_market_text}"
|
f"{cross_market_text}"
|
||||||
f"{context_text}\n"
|
f"{context_text}\n"
|
||||||
@@ -293,7 +332,8 @@ class PreMarketPlanner:
|
|||||||
f' "stock_code": "...",\n'
|
f' "stock_code": "...",\n'
|
||||||
f' "scenarios": [\n'
|
f' "scenarios": [\n'
|
||||||
f' {{\n'
|
f' {{\n'
|
||||||
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
|
||||||
|
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
|
||||||
f' "action": "BUY|SELL|HOLD",\n'
|
f' "action": "BUY|SELL|HOLD",\n'
|
||||||
f' "confidence": 85,\n'
|
f' "confidence": 85,\n'
|
||||||
f' "allocation_pct": 10.0,\n'
|
f' "allocation_pct": 10.0,\n'
|
||||||
@@ -307,7 +347,8 @@ class PreMarketPlanner:
|
|||||||
f'}}\n\n'
|
f'}}\n\n'
|
||||||
f"Rules:\n"
|
f"Rules:\n"
|
||||||
f"- Max {max_scenarios} scenarios per stock\n"
|
f"- Max {max_scenarios} scenarios per stock\n"
|
||||||
f"- Only use stocks from the candidates list\n"
|
f"- Candidates list is the primary source for BUY candidates\n"
|
||||||
|
f"{holdings_instruction}"
|
||||||
f"- Confidence 0-100 (80+ for actionable trades)\n"
|
f"- Confidence 0-100 (80+ for actionable trades)\n"
|
||||||
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
|
f"- stop_loss_pct must be <= 0, take_profit_pct must be >= 0\n"
|
||||||
f"- Return ONLY the JSON, no markdown fences or explanation\n"
|
f"- Return ONLY the JSON, no markdown fences or explanation\n"
|
||||||
@@ -320,12 +361,19 @@ class PreMarketPlanner:
|
|||||||
market: str,
|
market: str,
|
||||||
candidates: list[ScanCandidate],
|
candidates: list[ScanCandidate],
|
||||||
cross_market: CrossMarketContext | None,
|
cross_market: CrossMarketContext | None,
|
||||||
|
current_holdings: list[dict] | None = None,
|
||||||
) -> DayPlaybook:
|
) -> DayPlaybook:
|
||||||
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
||||||
cleaned = self._extract_json(response_text)
|
cleaned = self._extract_json(response_text)
|
||||||
data = json.loads(cleaned)
|
data = json.loads(cleaned)
|
||||||
|
|
||||||
valid_codes = {c.stock_code for c in candidates}
|
valid_codes = {c.stock_code for c in candidates}
|
||||||
|
# Holdings are also valid — AI may generate SELL/HOLD scenarios for them
|
||||||
|
if current_holdings:
|
||||||
|
for h in current_holdings:
|
||||||
|
code = h.get("stock_code", "")
|
||||||
|
if code:
|
||||||
|
valid_codes.add(code)
|
||||||
|
|
||||||
# Parse market outlook
|
# Parse market outlook
|
||||||
outlook_str = data.get("market_outlook", "neutral")
|
outlook_str = data.get("market_outlook", "neutral")
|
||||||
@@ -389,6 +437,10 @@ class PreMarketPlanner:
|
|||||||
price_below=cond_data.get("price_below"),
|
price_below=cond_data.get("price_below"),
|
||||||
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
||||||
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
||||||
|
unrealized_pnl_pct_above=cond_data.get("unrealized_pnl_pct_above"),
|
||||||
|
unrealized_pnl_pct_below=cond_data.get("unrealized_pnl_pct_below"),
|
||||||
|
holding_days_above=cond_data.get("holding_days_above"),
|
||||||
|
holding_days_below=cond_data.get("holding_days_below"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not condition.has_any_condition():
|
if not condition.has_any_condition():
|
||||||
@@ -470,3 +522,99 @@ class PreMarketPlanner:
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _smart_fallback_playbook(
|
||||||
|
today: date,
|
||||||
|
market: str,
|
||||||
|
candidates: list[ScanCandidate],
|
||||||
|
settings: Settings,
|
||||||
|
) -> DayPlaybook:
|
||||||
|
"""Rule-based fallback playbook when Gemini is unavailable.
|
||||||
|
|
||||||
|
Uses scanner signals (RSI, volume_ratio) to generate meaningful BUY
|
||||||
|
conditions instead of the all-SELL defensive playbook. Candidates are
|
||||||
|
already pre-qualified by SmartVolatilityScanner, so we trust their
|
||||||
|
signals and build actionable scenarios from them.
|
||||||
|
|
||||||
|
Scenario logic per candidate:
|
||||||
|
- momentum signal: BUY when volume_ratio exceeds scanner threshold
|
||||||
|
- oversold signal: BUY when RSI is below oversold threshold
|
||||||
|
- always: SELL stop-loss at -3.0% as guard
|
||||||
|
"""
|
||||||
|
stock_playbooks = []
|
||||||
|
for c in candidates:
|
||||||
|
scenarios: list[StockScenario] = []
|
||||||
|
|
||||||
|
if c.signal == "momentum":
|
||||||
|
scenarios.append(
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(
|
||||||
|
volume_ratio_above=settings.VOL_MULTIPLIER,
|
||||||
|
),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=80,
|
||||||
|
allocation_pct=10.0,
|
||||||
|
stop_loss_pct=-3.0,
|
||||||
|
take_profit_pct=5.0,
|
||||||
|
rationale=(
|
||||||
|
f"Rule-based BUY: momentum signal, "
|
||||||
|
f"volume={c.volume_ratio:.1f}x (fallback planner)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif c.signal == "oversold":
|
||||||
|
scenarios.append(
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(
|
||||||
|
rsi_below=settings.RSI_OVERSOLD_THRESHOLD,
|
||||||
|
),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=80,
|
||||||
|
allocation_pct=10.0,
|
||||||
|
stop_loss_pct=-3.0,
|
||||||
|
take_profit_pct=5.0,
|
||||||
|
rationale=(
|
||||||
|
f"Rule-based BUY: oversold signal, "
|
||||||
|
f"RSI={c.rsi:.0f} (fallback planner)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always add stop-loss guard
|
||||||
|
scenarios.append(
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(price_change_pct_below=-3.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=90,
|
||||||
|
stop_loss_pct=-3.0,
|
||||||
|
rationale="Rule-based stop-loss (fallback planner)",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
stock_playbooks.append(
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code=c.stock_code,
|
||||||
|
scenarios=scenarios,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Smart fallback playbook for %s: %d stocks with rule-based BUY/SELL conditions",
|
||||||
|
market,
|
||||||
|
len(stock_playbooks),
|
||||||
|
)
|
||||||
|
return DayPlaybook(
|
||||||
|
date=today,
|
||||||
|
market=market,
|
||||||
|
market_outlook=MarketOutlook.NEUTRAL,
|
||||||
|
default_action=ScenarioAction.HOLD,
|
||||||
|
stock_playbooks=stock_playbooks,
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
rationale="Defensive: reduce on loss threshold",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|||||||
@@ -206,6 +206,37 @@ class ScenarioEngine:
|
|||||||
if condition.price_change_pct_below is not None:
|
if condition.price_change_pct_below is not None:
|
||||||
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
|
||||||
|
|
||||||
|
# Position-aware conditions
|
||||||
|
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
if "unrealized_pnl_pct" not in market_data:
|
||||||
|
self._warn_missing_key("unrealized_pnl_pct")
|
||||||
|
if condition.unrealized_pnl_pct_above is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct > condition.unrealized_pnl_pct_above
|
||||||
|
)
|
||||||
|
if condition.unrealized_pnl_pct_below is not None:
|
||||||
|
checks.append(
|
||||||
|
unrealized_pnl_pct is not None
|
||||||
|
and unrealized_pnl_pct < condition.unrealized_pnl_pct_below
|
||||||
|
)
|
||||||
|
|
||||||
|
holding_days = self._safe_float(market_data.get("holding_days"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
if "holding_days" not in market_data:
|
||||||
|
self._warn_missing_key("holding_days")
|
||||||
|
if condition.holding_days_above is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days > condition.holding_days_above
|
||||||
|
)
|
||||||
|
if condition.holding_days_below is not None:
|
||||||
|
checks.append(
|
||||||
|
holding_days is not None
|
||||||
|
and holding_days < condition.holding_days_below
|
||||||
|
)
|
||||||
|
|
||||||
return len(checks) > 0 and all(checks)
|
return len(checks) > 0 and all(checks)
|
||||||
|
|
||||||
def _evaluate_global_condition(
|
def _evaluate_global_condition(
|
||||||
@@ -266,5 +297,9 @@ class ScenarioEngine:
|
|||||||
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
details["current_price"] = self._safe_float(market_data.get("current_price"))
|
||||||
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
||||||
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
||||||
|
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
|
||||||
|
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
|
||||||
|
if condition.holding_days_above is not None or condition.holding_days_below is not None:
|
||||||
|
details["holding_days"] = self._safe_float(market_data.get("holding_days"))
|
||||||
|
|
||||||
return details
|
return details
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src.brain.gemini_client import GeminiClient
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -270,3 +274,97 @@ class TestBatchDecisionParsing:
|
|||||||
|
|
||||||
assert decisions["AAPL"].action == "HOLD"
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
assert decisions["AAPL"].confidence == 0
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompt Override (used by pre_market_planner)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptOverride:
|
||||||
|
"""decide() must use prompt_override when present in market_data."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_override_is_sent_to_gemini(self, settings):
|
||||||
|
"""When prompt_override is in market_data, it should be used as the prompt."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
custom_prompt = "You are a playbook generator. Return JSON with scenarios."
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "test"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "PLANNER",
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": custom_prompt,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
# Verify the custom prompt was sent, not a built prompt
|
||||||
|
mock_generate.assert_called_once()
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
assert actual_prompt == custom_prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prompt_override_skips_optimization(self, settings):
|
||||||
|
"""prompt_override should bypass prompt optimization."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
client._enable_optimization = True
|
||||||
|
|
||||||
|
custom_prompt = "Custom playbook prompt"
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "PLANNER",
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": custom_prompt,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
assert actual_prompt == custom_prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_without_prompt_override_uses_build_prompt(self, settings):
|
||||||
|
"""Without prompt_override, decide() should use build_prompt as before."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "HOLD", "confidence": 50, "rationale": "ok"}'
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
client._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_generate:
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "005930",
|
||||||
|
"current_price": 72000,
|
||||||
|
}
|
||||||
|
await client.decide(market_data)
|
||||||
|
|
||||||
|
actual_prompt = mock_generate.call_args[1].get(
|
||||||
|
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
|
||||||
|
)
|
||||||
|
# Should contain stock code from build_prompt, not be a custom override
|
||||||
|
assert "005930" in actual_prompt
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -90,12 +90,12 @@ class TestTokenManagement:
|
|||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings):
|
async def test_token_refresh_cooldown_waits_then_retries(self, settings):
|
||||||
"""Token refresh should enforce cooldown after failure (issue #54)."""
|
"""Token refresh should wait out cooldown then retry (issue #54)."""
|
||||||
broker = KISBroker(settings)
|
broker = KISBroker(settings)
|
||||||
broker._refresh_cooldown = 2.0 # Short cooldown for testing
|
broker._refresh_cooldown = 0.1 # Short cooldown for testing
|
||||||
|
|
||||||
# First refresh attempt fails with 403 (EGW00133)
|
# All attempts fail with 403 (EGW00133)
|
||||||
mock_resp_403 = AsyncMock()
|
mock_resp_403 = AsyncMock()
|
||||||
mock_resp_403.status = 403
|
mock_resp_403.status = 403
|
||||||
mock_resp_403.text = AsyncMock(
|
mock_resp_403.text = AsyncMock(
|
||||||
@@ -109,8 +109,8 @@ class TestTokenManagement:
|
|||||||
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||||
await broker._ensure_token()
|
await broker._ensure_token()
|
||||||
|
|
||||||
# Second attempt within cooldown should fail with cooldown error
|
# Second attempt within cooldown should wait then retry (and still get 403)
|
||||||
with pytest.raises(ConnectionError, match="Token refresh on cooldown"):
|
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||||
await broker._ensure_token()
|
await broker._ensure_token()
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
@@ -296,3 +296,432 @@ class TestHashKey:
|
|||||||
mock_acquire.assert_called_once()
|
mock_acquire.assert_called_once()
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# fetch_market_rankings — TR_ID, path, params (issue #155)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ranking_mock(items: list[dict]) -> AsyncMock:
|
||||||
|
"""Build a mock HTTP response returning ranking items."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": items})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchMarketRankings:
|
||||||
|
"""Verify correct TR_ID, API path, and params per ranking_type (issue #155)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def broker(self, settings) -> KISBroker:
|
||||||
|
b = KISBroker(settings)
|
||||||
|
b._access_token = "tok"
|
||||||
|
b._token_expires_at = float("inf")
|
||||||
|
b._rate_limiter.acquire = AsyncMock()
|
||||||
|
return b
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = _make_ranking_mock([])
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.fetch_market_rankings(ranking_type="volume")
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
||||||
|
headers = call_kwargs[1].get("headers", {})
|
||||||
|
params = call_kwargs[1].get("params", {})
|
||||||
|
|
||||||
|
assert "volume-rank" in url
|
||||||
|
assert headers.get("tr_id") == "FHPST01710000"
|
||||||
|
assert params.get("FID_COND_SCR_DIV_CODE") == "20171"
|
||||||
|
assert params.get("FID_TRGT_EXLS_CLS_CODE") == "0000000000"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fluctuation_uses_correct_tr_id_and_path(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = _make_ranking_mock([])
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.fetch_market_rankings(ranking_type="fluctuation")
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
||||||
|
headers = call_kwargs[1].get("headers", {})
|
||||||
|
params = call_kwargs[1].get("params", {})
|
||||||
|
|
||||||
|
assert "ranking/fluctuation" in url
|
||||||
|
assert headers.get("tr_id") == "FHPST01700000"
|
||||||
|
assert params.get("fid_cond_scr_div_code") == "20170"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_returns_parsed_rows(self, broker: KISBroker) -> None:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"mksc_shrn_iscd": "005930",
|
||||||
|
"hts_kor_isnm": "삼성전자",
|
||||||
|
"stck_prpr": "75000",
|
||||||
|
"acml_vol": "10000000",
|
||||||
|
"prdy_ctrt": "2.5",
|
||||||
|
"vol_inrt": "150",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
mock_resp = _make_ranking_mock(items)
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
||||||
|
result = await broker.fetch_market_rankings(ranking_type="volume")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["stock_code"] == "005930"
|
||||||
|
assert result[0]["price"] == 75000.0
|
||||||
|
assert result[0]["change_rate"] == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# KRX tick unit / round-down helpers (issue #157)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
from src.broker.kis_api import kr_tick_unit, kr_round_down # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class TestKrTickUnit:
|
||||||
|
"""kr_tick_unit and kr_round_down must implement KRX price tick rules."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"price, expected_tick",
|
||||||
|
[
|
||||||
|
(1999, 1),
|
||||||
|
(2000, 5),
|
||||||
|
(4999, 5),
|
||||||
|
(5000, 10),
|
||||||
|
(19999, 10),
|
||||||
|
(20000, 50),
|
||||||
|
(49999, 50),
|
||||||
|
(50000, 100),
|
||||||
|
(199999, 100),
|
||||||
|
(200000, 500),
|
||||||
|
(499999, 500),
|
||||||
|
(500000, 1000),
|
||||||
|
(1000000, 1000),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_tick_unit_boundaries(self, price: int, expected_tick: int) -> None:
|
||||||
|
assert kr_tick_unit(price) == expected_tick
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"price, expected_rounded",
|
||||||
|
[
|
||||||
|
(188150, 188100), # 100원 단위, 50원 잔여 → 내림
|
||||||
|
(188100, 188100), # 이미 정렬됨
|
||||||
|
(75050, 75000), # 100원 단위, 50원 잔여 → 내림
|
||||||
|
(49950, 49950), # 50원 단위 정렬됨
|
||||||
|
(49960, 49950), # 50원 단위, 10원 잔여 → 내림
|
||||||
|
(1999, 1999), # 1원 단위 → 그대로
|
||||||
|
(5003, 5000), # 10원 단위, 3원 잔여 → 내림
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_round_down_to_tick(self, price: int, expected_rounded: int) -> None:
|
||||||
|
assert kr_round_down(price) == expected_rounded
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_current_price (issue #157)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrentPrice:
|
||||||
|
"""get_current_price must use inquire-price API and return (price, change, foreigner)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def broker(self, settings) -> KISBroker:
|
||||||
|
b = KISBroker(settings)
|
||||||
|
b._access_token = "tok"
|
||||||
|
b._token_expires_at = float("inf")
|
||||||
|
b._rate_limiter.acquire = AsyncMock()
|
||||||
|
return b
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_correct_fields(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"rt_cd": "0",
|
||||||
|
"output": {
|
||||||
|
"stck_prpr": "188600",
|
||||||
|
"prdy_ctrt": "3.97",
|
||||||
|
"frgn_ntby_qty": "12345",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
price, change_pct, foreigner = await broker.get_current_price("005930")
|
||||||
|
|
||||||
|
assert price == 188600.0
|
||||||
|
assert change_pct == 3.97
|
||||||
|
assert foreigner == 12345.0
|
||||||
|
|
||||||
|
call_kwargs = mock_get.call_args
|
||||||
|
url = call_kwargs[0][0] if call_kwargs[0] else call_kwargs[1].get("url", "")
|
||||||
|
headers = call_kwargs[1].get("headers", {})
|
||||||
|
assert "inquire-price" in url
|
||||||
|
assert headers.get("tr_id") == "FHKST01010100"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_error_raises_connection_error(self, broker: KISBroker) -> None:
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 500
|
||||||
|
mock_resp.text = AsyncMock(return_value="Internal Server Error")
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp):
|
||||||
|
with pytest.raises(ConnectionError, match="get_current_price failed"):
|
||||||
|
await broker.get_current_price("005930")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# send_order tick rounding and ORD_DVSN (issue #157)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendOrderTickRounding:
|
||||||
|
"""send_order must apply KRX tick rounding and correct ORD_DVSN codes."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def broker(self, settings) -> KISBroker:
|
||||||
|
b = KISBroker(settings)
|
||||||
|
b._access_token = "tok"
|
||||||
|
b._token_expires_at = float("inf")
|
||||||
|
b._rate_limiter.acquire = AsyncMock()
|
||||||
|
return b
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_limit_order_rounds_down_to_tick(self, broker: KISBroker) -> None:
|
||||||
|
"""Price 188150 (not on 100-won tick) must be rounded to 188100."""
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "BUY", 1, price=188150)
|
||||||
|
|
||||||
|
order_call = mock_post.call_args_list[1]
|
||||||
|
body = order_call[1].get("json", {})
|
||||||
|
assert body["ORD_UNPR"] == "188100" # rounded down
|
||||||
|
assert body["ORD_DVSN"] == "00" # 지정가
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_limit_order_ord_dvsn_is_00(self, broker: KISBroker) -> None:
|
||||||
|
"""send_order with price>0 must use ORD_DVSN='00' (지정가)."""
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "BUY", 1, price=50000)
|
||||||
|
|
||||||
|
order_call = mock_post.call_args_list[1]
|
||||||
|
body = order_call[1].get("json", {})
|
||||||
|
assert body["ORD_DVSN"] == "00"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_market_order_ord_dvsn_is_01(self, broker: KISBroker) -> None:
|
||||||
|
"""send_order with price=0 must use ORD_DVSN='01' (시장가)."""
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "SELL", 1, price=0)
|
||||||
|
|
||||||
|
order_call = mock_post.call_args_list[1]
|
||||||
|
body = order_call[1].get("json", {})
|
||||||
|
assert body["ORD_DVSN"] == "01"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TR_ID live/paper branching (issues #201, #202, #203)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTRIDBranchingDomestic:
|
||||||
|
"""get_balance and send_order must use correct TR_ID for live vs paper mode."""
|
||||||
|
|
||||||
|
def _make_broker(self, settings, mode: str) -> KISBroker:
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
s = Settings(
|
||||||
|
KIS_APP_KEY=settings.KIS_APP_KEY,
|
||||||
|
KIS_APP_SECRET=settings.KIS_APP_SECRET,
|
||||||
|
KIS_ACCOUNT_NO=settings.KIS_ACCOUNT_NO,
|
||||||
|
GEMINI_API_KEY=settings.GEMINI_API_KEY,
|
||||||
|
DB_PATH=":memory:",
|
||||||
|
ENABLED_MARKETS="KR",
|
||||||
|
MODE=mode,
|
||||||
|
)
|
||||||
|
b = KISBroker(s)
|
||||||
|
b._access_token = "tok"
|
||||||
|
b._token_expires_at = float("inf")
|
||||||
|
b._rate_limiter.acquire = AsyncMock()
|
||||||
|
return b
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_balance_paper_uses_vttc8434r(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "paper")
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={"output1": [], "output2": {}}
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.get_balance()
|
||||||
|
|
||||||
|
headers = mock_get.call_args[1].get("headers", {})
|
||||||
|
assert headers["tr_id"] == "VTTC8434R"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_balance_live_uses_tttc8434r(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "live")
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={"output1": [], "output2": {}}
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.get", return_value=mock_resp) as mock_get:
|
||||||
|
await broker.get_balance()
|
||||||
|
|
||||||
|
headers = mock_get.call_args[1].get("headers", {})
|
||||||
|
assert headers["tr_id"] == "TTTC8434R"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_buy_paper_uses_vttc0012u(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "paper")
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "BUY", 1)
|
||||||
|
|
||||||
|
order_headers = mock_post.call_args_list[1][1].get("headers", {})
|
||||||
|
assert order_headers["tr_id"] == "VTTC0012U"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_buy_live_uses_tttc0012u(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "live")
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "BUY", 1)
|
||||||
|
|
||||||
|
order_headers = mock_post.call_args_list[1][1].get("headers", {})
|
||||||
|
assert order_headers["tr_id"] == "TTTC0012U"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_sell_paper_uses_vttc0011u(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "paper")
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "SELL", 1)
|
||||||
|
|
||||||
|
order_headers = mock_post.call_args_list[1][1].get("headers", {})
|
||||||
|
assert order_headers["tr_id"] == "VTTC0011U"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_sell_live_uses_tttc0011u(self, settings) -> None:
|
||||||
|
broker = self._make_broker(settings, "live")
|
||||||
|
mock_hash = AsyncMock()
|
||||||
|
mock_hash.status = 200
|
||||||
|
mock_hash.json = AsyncMock(return_value={"HASH": "h"})
|
||||||
|
mock_hash.__aenter__ = AsyncMock(return_value=mock_hash)
|
||||||
|
mock_hash.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_order = AsyncMock()
|
||||||
|
mock_order.status = 200
|
||||||
|
mock_order.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
|
||||||
|
mock_order.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
|
||||||
|
) as mock_post:
|
||||||
|
await broker.send_order("005930", "SELL", 1)
|
||||||
|
|
||||||
|
order_headers = mock_post.call_args_list[1][1].get("headers", {})
|
||||||
|
assert order_headers["tr_id"] == "TTTC0011U"
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ from src.evolution.daily_review import DailyReviewer
|
|||||||
from src.evolution.scorecard import DailyScorecard
|
from src.evolution.scorecard import DailyScorecard
|
||||||
from src.logging.decision_logger import DecisionLogger
|
from src.logging.decision_logger import DecisionLogger
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def db_conn() -> sqlite3.Connection:
|
def db_conn() -> sqlite3.Connection:
|
||||||
@@ -116,7 +120,7 @@ def test_generate_scorecard_market_scoped(
|
|||||||
exchange_code="NASDAQ",
|
exchange_code="NASDAQ",
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.market == "KR"
|
assert scorecard.market == "KR"
|
||||||
assert scorecard.total_decisions == 2
|
assert scorecard.total_decisions == 2
|
||||||
@@ -158,7 +162,7 @@ def test_generate_scorecard_top_winners_and_losers(
|
|||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
assert scorecard.top_winners == ["005930", "000660"]
|
assert scorecard.top_winners == ["005930", "000660"]
|
||||||
assert scorecard.top_losers == ["035420", "051910"]
|
assert scorecard.top_losers == ["035420", "051910"]
|
||||||
|
|
||||||
@@ -167,7 +171,7 @@ def test_generate_scorecard_empty_day(
|
|||||||
db_conn: sqlite3.Connection, context_store: ContextStore,
|
db_conn: sqlite3.Connection, context_store: ContextStore,
|
||||||
) -> None:
|
) -> None:
|
||||||
reviewer = DailyReviewer(db_conn, context_store)
|
reviewer = DailyReviewer(db_conn, context_store)
|
||||||
scorecard = reviewer.generate_scorecard("2026-02-14", "KR")
|
scorecard = reviewer.generate_scorecard(TODAY, "KR")
|
||||||
|
|
||||||
assert scorecard.total_decisions == 0
|
assert scorecard.total_decisions == 0
|
||||||
assert scorecard.total_pnl == 0.0
|
assert scorecard.total_pnl == 0.0
|
||||||
|
|||||||
415
tests/test_dashboard.py
Normal file
415
tests/test_dashboard.py
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
"""Tests for dashboard endpoint handlers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
|
from src.dashboard.app import create_dashboard_app
|
||||||
|
from src.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_db(conn: sqlite3.Connection) -> None:
|
||||||
|
today = datetime.now(UTC).date().isoformat()
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playbooks (
|
||||||
|
date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"2026-02-14",
|
||||||
|
"KR",
|
||||||
|
"ready",
|
||||||
|
json.dumps({"market": "KR", "stock_playbooks": []}),
|
||||||
|
"2026-02-14T08:30:00+00:00",
|
||||||
|
123,
|
||||||
|
2,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO playbooks (
|
||||||
|
date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
today,
|
||||||
|
"US_NASDAQ",
|
||||||
|
"ready",
|
||||||
|
json.dumps({"market": "US_NASDAQ", "stock_playbooks": []}),
|
||||||
|
f"{today}T08:30:00+00:00",
|
||||||
|
100,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"L6_DAILY",
|
||||||
|
"2026-02-14",
|
||||||
|
"scorecard_KR",
|
||||||
|
json.dumps({"market": "KR", "total_pnl": 1.5, "win_rate": 60.0}),
|
||||||
|
"2026-02-14T15:30:00+00:00",
|
||||||
|
"2026-02-14T15:30:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"L7_REALTIME",
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
"volatility_KR_005930",
|
||||||
|
json.dumps({"momentum_score": 70.0}),
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
"2026-02-14T10:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"d-kr-1",
|
||||||
|
f"{today}T09:10:00+00:00",
|
||||||
|
"005930",
|
||||||
|
"KR",
|
||||||
|
"KRX",
|
||||||
|
"BUY",
|
||||||
|
85,
|
||||||
|
"signal matched",
|
||||||
|
json.dumps({"scenario_match": {"rsi": 28.0}}),
|
||||||
|
json.dumps({"current_price": 70000}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
"d-us-1",
|
||||||
|
f"{today}T21:10:00+00:00",
|
||||||
|
"AAPL",
|
||||||
|
"US_NASDAQ",
|
||||||
|
"NASDAQ",
|
||||||
|
"SELL",
|
||||||
|
80,
|
||||||
|
"no match",
|
||||||
|
json.dumps({"scenario_match": {}}),
|
||||||
|
json.dumps({"current_price": 200}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (
|
||||||
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
|
quantity, price, pnl, market, exchange_code, selection_context, decision_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"{today}T09:11:00+00:00",
|
||||||
|
"005930",
|
||||||
|
"BUY",
|
||||||
|
85,
|
||||||
|
"buy",
|
||||||
|
1,
|
||||||
|
70000,
|
||||||
|
2.0,
|
||||||
|
"KR",
|
||||||
|
"KRX",
|
||||||
|
None,
|
||||||
|
"d-kr-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (
|
||||||
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
|
quantity, price, pnl, market, exchange_code, selection_context, decision_id
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
f"{today}T21:11:00+00:00",
|
||||||
|
"AAPL",
|
||||||
|
"SELL",
|
||||||
|
80,
|
||||||
|
"sell",
|
||||||
|
1,
|
||||||
|
200,
|
||||||
|
-1.0,
|
||||||
|
"US_NASDAQ",
|
||||||
|
"NASDAQ",
|
||||||
|
None,
|
||||||
|
"d-us-1",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _app(tmp_path: Path) -> Any:
|
||||||
|
db_path = tmp_path / "dashboard_test.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_db(conn)
|
||||||
|
conn.close()
|
||||||
|
return create_dashboard_app(str(db_path))
|
||||||
|
|
||||||
|
|
||||||
|
def _endpoint(app: Any, path: str) -> Callable[..., Any]:
|
||||||
|
for route in app.routes:
|
||||||
|
if getattr(route, "path", None) == path:
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"route not found: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_serves_html(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
index = _endpoint(app, "/")
|
||||||
|
resp = index()
|
||||||
|
assert isinstance(resp, FileResponse)
|
||||||
|
assert "index.html" in str(resp.path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_endpoint(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert "KR" in body["markets"]
|
||||||
|
assert "US_NASDAQ" in body["markets"]
|
||||||
|
assert "totals" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_playbook_found(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
|
||||||
|
body = get_playbook("2026-02-14", market="KR")
|
||||||
|
assert body["market"] == "KR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_playbook_not_found(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_playbook = _endpoint(app, "/api/playbook/{date_str}")
|
||||||
|
with pytest.raises(HTTPException, match="playbook not found"):
|
||||||
|
get_playbook("2026-02-15", market="KR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_scorecard_found(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
|
||||||
|
body = get_scorecard("2026-02-14", market="KR")
|
||||||
|
assert body["scorecard"]["total_pnl"] == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_scorecard_not_found(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_scorecard = _endpoint(app, "/api/scorecard/{date_str}")
|
||||||
|
with pytest.raises(HTTPException, match="scorecard not found"):
|
||||||
|
get_scorecard("2026-02-15", market="KR")
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_all(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
|
body = get_performance(market="all")
|
||||||
|
assert body["market"] == "all"
|
||||||
|
assert body["combined"]["total_trades"] == 2
|
||||||
|
assert len(body["by_market"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_market_filter(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
|
body = get_performance(market="KR")
|
||||||
|
assert body["market"] == "KR"
|
||||||
|
assert body["metrics"]["total_trades"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_empty_market(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_performance = _endpoint(app, "/api/performance")
|
||||||
|
body = get_performance(market="JP")
|
||||||
|
assert body["metrics"]["total_trades"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_layer_all(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_context_layer = _endpoint(app, "/api/context/{layer}")
|
||||||
|
body = get_context_layer("L7_REALTIME", timeframe=None, limit=100)
|
||||||
|
assert body["layer"] == "L7_REALTIME"
|
||||||
|
assert body["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_context_layer_timeframe_filter(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_context_layer = _endpoint(app, "/api/context/{layer}")
|
||||||
|
body = get_context_layer("L6_DAILY", timeframe="2026-02-14", limit=100)
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["entries"][0]["key"] == "scorecard_KR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_decisions_endpoint(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_decisions = _endpoint(app, "/api/decisions")
|
||||||
|
body = get_decisions(market="KR", limit=50)
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["decisions"][0]["decision_id"] == "d-kr-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenarios_active_filters_non_matched(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
|
||||||
|
body = get_active_scenarios(
|
||||||
|
market="KR",
|
||||||
|
date_str=datetime.now(UTC).date().isoformat(),
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
assert body["count"] == 1
|
||||||
|
assert body["matches"][0]["stock_code"] == "005930"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scenarios_active_empty_when_no_matches(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_active_scenarios = _endpoint(app, "/api/scenarios/active")
|
||||||
|
body = get_active_scenarios(market="US", date_str="2026-02-14", limit=50)
|
||||||
|
assert body["count"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_pnl_history_all_markets(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_pnl_history = _endpoint(app, "/api/pnl/history")
|
||||||
|
body = get_pnl_history(days=30, market="all")
|
||||||
|
assert body["market"] == "all"
|
||||||
|
assert isinstance(body["labels"], list)
|
||||||
|
assert isinstance(body["pnl"], list)
|
||||||
|
assert len(body["labels"]) == len(body["pnl"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_pnl_history_market_filter(tmp_path: Path) -> None:
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_pnl_history = _endpoint(app, "/api/pnl/history")
|
||||||
|
body = get_pnl_history(days=30, market="KR")
|
||||||
|
assert body["market"] == "KR"
|
||||||
|
# KR has 1 trade with pnl=2.0
|
||||||
|
assert len(body["labels"]) >= 1
|
||||||
|
assert body["pnl"][0] == 2.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_returns_open_buy(tmp_path: Path) -> None:
|
||||||
|
"""BUY가 마지막 거래인 종목은 포지션으로 반환되어야 한다."""
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
# seed_db: 005930은 BUY (오픈), AAPL은 SELL (마지막)
|
||||||
|
assert body["count"] == 1
|
||||||
|
pos = body["positions"][0]
|
||||||
|
assert pos["stock_code"] == "005930"
|
||||||
|
assert pos["market"] == "KR"
|
||||||
|
assert pos["quantity"] == 1
|
||||||
|
assert pos["entry_price"] == 70000
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_excludes_closed_sell(tmp_path: Path) -> None:
|
||||||
|
"""마지막 거래가 SELL인 종목은 포지션에 나타나지 않아야 한다."""
|
||||||
|
app = _app(tmp_path)
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
codes = [p["stock_code"] for p in body["positions"]]
|
||||||
|
assert "AAPL" not in codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_positions_empty_when_no_trades(tmp_path: Path) -> None:
|
||||||
|
"""거래 내역이 없으면 빈 포지션 목록을 반환해야 한다."""
|
||||||
|
db_path = tmp_path / "empty.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_positions = _endpoint(app, "/api/positions")
|
||||||
|
body = get_positions()
|
||||||
|
assert body["count"] == 0
|
||||||
|
assert body["positions"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_cb_context(conn: sqlite3.Connection, pnl_pct: float, market: str = "KR") -> None:
|
||||||
|
import json as _json
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)",
|
||||||
|
(
|
||||||
|
f"portfolio_pnl_pct_{market}",
|
||||||
|
_json.dumps({"pnl_pct": pnl_pct}),
|
||||||
|
"2026-02-22T10:00:00+00:00",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_circuit_breaker_ok(tmp_path: Path) -> None:
|
||||||
|
"""pnl_pct가 -2.0%보다 높으면 status=ok를 반환해야 한다."""
|
||||||
|
db_path = tmp_path / "cb_ok.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_cb_context(conn, -1.0)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
cb = body["circuit_breaker"]
|
||||||
|
assert cb["status"] == "ok"
|
||||||
|
assert cb["current_pnl_pct"] == -1.0
|
||||||
|
assert cb["threshold_pct"] == -3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_circuit_breaker_warning(tmp_path: Path) -> None:
|
||||||
|
"""pnl_pct가 -2.0% 이하이면 status=warning을 반환해야 한다."""
|
||||||
|
db_path = tmp_path / "cb_warn.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_cb_context(conn, -2.5)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert body["circuit_breaker"]["status"] == "warning"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_circuit_breaker_tripped(tmp_path: Path) -> None:
|
||||||
|
"""pnl_pct가 임계값(-3.0%) 이하이면 status=tripped를 반환해야 한다."""
|
||||||
|
db_path = tmp_path / "cb_tripped.db"
|
||||||
|
conn = init_db(str(db_path))
|
||||||
|
_seed_cb_context(conn, -3.5)
|
||||||
|
conn.close()
|
||||||
|
app = create_dashboard_app(str(db_path))
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
assert body["circuit_breaker"]["status"] == "tripped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_circuit_breaker_unknown_when_no_data(tmp_path: Path) -> None:
|
||||||
|
"""L7 context에 pnl_pct 데이터가 없으면 status=unknown을 반환해야 한다."""
|
||||||
|
app = _app(tmp_path) # seed_db에는 portfolio_pnl_pct 없음
|
||||||
|
get_status = _endpoint(app, "/api/status")
|
||||||
|
body = get_status()
|
||||||
|
cb = body["circuit_breaker"]
|
||||||
|
assert cb["status"] == "unknown"
|
||||||
|
assert cb["current_pnl_pct"] is None
|
||||||
195
tests/test_db.py
Normal file
195
tests/test_db.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""Tests for database helper functions."""
|
||||||
|
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
from src.db import get_open_position, init_db, log_trade
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_latest_buy() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=2,
|
||||||
|
price=70000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-buy-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
position = get_open_position(conn, "005930", "KR")
|
||||||
|
assert position is not None
|
||||||
|
assert position["decision_id"] == "d-buy-1"
|
||||||
|
assert position["price"] == 70000.0
|
||||||
|
assert position["quantity"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_none_when_latest_is_sell() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="entry",
|
||||||
|
quantity=1,
|
||||||
|
price=70000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-buy-1",
|
||||||
|
)
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="SELL",
|
||||||
|
confidence=95,
|
||||||
|
rationale="exit",
|
||||||
|
quantity=1,
|
||||||
|
price=71000.0,
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
decision_id="d-sell-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert get_open_position(conn, "005930", "KR") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_open_position_returns_none_when_no_trades() -> None:
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
assert get_open_position(conn, "AAPL", "US_NASDAQ") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# WAL mode tests (issue #210)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_wal_mode_applied_to_file_db() -> None:
|
||||||
|
"""File-based DB must use WAL journal mode for dashboard concurrent reads."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
db_path = f.name
|
||||||
|
try:
|
||||||
|
conn = init_db(db_path)
|
||||||
|
cursor = conn.execute("PRAGMA journal_mode")
|
||||||
|
mode = cursor.fetchone()[0]
|
||||||
|
assert mode == "wal", f"Expected WAL mode, got {mode}"
|
||||||
|
conn.close()
|
||||||
|
finally:
|
||||||
|
os.unlink(db_path)
|
||||||
|
# Clean up WAL auxiliary files if they exist
|
||||||
|
for ext in ("-wal", "-shm"):
|
||||||
|
path = db_path + ext
|
||||||
|
if os.path.exists(path):
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wal_mode_not_applied_to_memory_db() -> None:
|
||||||
|
""":memory: DB must not apply WAL (SQLite does not support WAL for in-memory)."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
cursor = conn.execute("PRAGMA journal_mode")
|
||||||
|
mode = cursor.fetchone()[0]
|
||||||
|
# In-memory DBs default to 'memory' journal mode
|
||||||
|
assert mode != "wal", "WAL should not be set on in-memory database"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# mode column tests (issue #212)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_trade_stores_mode_paper() -> None:
|
||||||
|
"""log_trade must persist mode='paper' in the trades table."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="test",
|
||||||
|
mode="paper",
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == "paper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_trade_stores_mode_live() -> None:
|
||||||
|
"""log_trade must persist mode='live' in the trades table."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="test",
|
||||||
|
mode="live",
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == "live"
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_trade_default_mode_is_paper() -> None:
|
||||||
|
"""log_trade without explicit mode must default to 'paper'."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
log_trade(
|
||||||
|
conn=conn,
|
||||||
|
stock_code="005930",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=50,
|
||||||
|
rationale="test",
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT mode FROM trades ORDER BY id DESC LIMIT 1").fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == "paper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_column_exists_in_schema() -> None:
|
||||||
|
"""trades table must have a mode column after init_db."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
cursor = conn.execute("PRAGMA table_info(trades)")
|
||||||
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
assert "mode" in columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_migration_adds_column_to_existing_db() -> None:
|
||||||
|
"""init_db must add mode column to existing DBs that lack it (migration)."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
db_path = f.name
|
||||||
|
try:
|
||||||
|
# Create DB without mode column (simulate old schema)
|
||||||
|
old_conn = sqlite3.connect(db_path)
|
||||||
|
old_conn.execute(
|
||||||
|
"""CREATE TABLE trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
confidence INTEGER NOT NULL,
|
||||||
|
rationale TEXT,
|
||||||
|
quantity INTEGER,
|
||||||
|
price REAL,
|
||||||
|
pnl REAL DEFAULT 0.0,
|
||||||
|
market TEXT DEFAULT 'KR',
|
||||||
|
exchange_code TEXT DEFAULT 'KRX',
|
||||||
|
decision_id TEXT
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
old_conn.commit()
|
||||||
|
old_conn.close()
|
||||||
|
|
||||||
|
# Run init_db — should add mode column via migration
|
||||||
|
conn = init_db(db_path)
|
||||||
|
cursor = conn.execute("PRAGMA table_info(trades)")
|
||||||
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
assert "mode" in columns
|
||||||
|
conn.close()
|
||||||
|
finally:
|
||||||
|
os.unlink(db_path)
|
||||||
1913
tests/test_main.py
1913
tests/test_main.py
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import pytest
|
|||||||
|
|
||||||
from src.markets.schedule import (
|
from src.markets.schedule import (
|
||||||
MARKETS,
|
MARKETS,
|
||||||
|
expand_market_codes,
|
||||||
get_next_market_open,
|
get_next_market_open,
|
||||||
get_open_markets,
|
get_open_markets,
|
||||||
is_market_open,
|
is_market_open,
|
||||||
@@ -199,3 +200,28 @@ class TestGetNextMarketOpen:
|
|||||||
enabled_markets=["INVALID", "KR"], now=test_time
|
enabled_markets=["INVALID", "KR"], now=test_time
|
||||||
)
|
)
|
||||||
assert market.code == "KR"
|
assert market.code == "KR"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpandMarketCodes:
|
||||||
|
"""Test shorthand market expansion."""
|
||||||
|
|
||||||
|
def test_expand_us_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["US"]) == ["US_NASDAQ", "US_NYSE", "US_AMEX"]
|
||||||
|
|
||||||
|
def test_expand_cn_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["CN"]) == ["CN_SHA", "CN_SZA"]
|
||||||
|
|
||||||
|
def test_expand_vn_shorthand(self) -> None:
|
||||||
|
assert expand_market_codes(["VN"]) == ["VN_HAN", "VN_HCM"]
|
||||||
|
|
||||||
|
def test_expand_mixed_codes(self) -> None:
|
||||||
|
assert expand_market_codes(["KR", "US", "JP"]) == [
|
||||||
|
"KR",
|
||||||
|
"US_NASDAQ",
|
||||||
|
"US_NYSE",
|
||||||
|
"US_AMEX",
|
||||||
|
"JP",
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_expand_preserves_unknown_code(self) -> None:
|
||||||
|
assert expand_market_codes(["KR", "UNKNOWN"]) == ["KR", "UNKNOWN"]
|
||||||
|
|||||||
815
tests/test_overseas_broker.py
Normal file
815
tests/test_overseas_broker.py
Normal file
@@ -0,0 +1,815 @@
|
|||||||
|
"""Tests for OverseasBroker — rankings, price, balance, order, and helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_async_cm(mock_resp: AsyncMock) -> MagicMock:
|
||||||
|
"""Create an async context manager that returns mock_resp on __aenter__."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
cm.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return cm
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings() -> Settings:
|
||||||
|
"""Provide mock settings with correct default TR_IDs/paths."""
|
||||||
|
return Settings(
|
||||||
|
KIS_APP_KEY="test_key",
|
||||||
|
KIS_APP_SECRET="test_secret",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test_gemini_key",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_broker(mock_settings: Settings) -> KISBroker:
|
||||||
|
"""Provide a mock KIS broker."""
|
||||||
|
broker = KISBroker(mock_settings)
|
||||||
|
broker.get_orderbook = AsyncMock() # type: ignore[method-assign]
|
||||||
|
return broker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def overseas_broker(mock_broker: KISBroker) -> OverseasBroker:
|
||||||
|
"""Provide an OverseasBroker wrapping a mock KISBroker."""
|
||||||
|
return OverseasBroker(mock_broker)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_broker_mocks(overseas_broker: OverseasBroker, mock_session: MagicMock) -> None:
|
||||||
|
"""Wire up common broker mocks."""
|
||||||
|
overseas_broker._broker._rate_limiter.acquire = AsyncMock()
|
||||||
|
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
overseas_broker._broker._auth_headers = AsyncMock(return_value={})
|
||||||
|
|
||||||
|
|
||||||
|
class TestRankingExchangeMap:
|
||||||
|
"""Test exchange code mapping for ranking API."""
|
||||||
|
|
||||||
|
def test_nasd_maps_to_nas(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP["NASD"] == "NAS"
|
||||||
|
|
||||||
|
def test_nyse_maps_to_nys(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP["NYSE"] == "NYS"
|
||||||
|
|
||||||
|
def test_amex_maps_to_ams(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP["AMEX"] == "AMS"
|
||||||
|
|
||||||
|
def test_sehk_maps_to_hks(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP["SEHK"] == "HKS"
|
||||||
|
|
||||||
|
def test_unmapped_exchange_passes_through(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP.get("UNKNOWN", "UNKNOWN") == "UNKNOWN"
|
||||||
|
|
||||||
|
def test_tse_unchanged(self) -> None:
|
||||||
|
assert _RANKING_EXCHANGE_MAP["TSE"] == "TSE"
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigDefaults:
|
||||||
|
"""Test that config defaults match KIS official API specs."""
|
||||||
|
|
||||||
|
def test_fluct_tr_id(self, mock_settings: Settings) -> None:
|
||||||
|
assert mock_settings.OVERSEAS_RANKING_FLUCT_TR_ID == "HHDFS76290000"
|
||||||
|
|
||||||
|
def test_volume_tr_id(self, mock_settings: Settings) -> None:
|
||||||
|
assert mock_settings.OVERSEAS_RANKING_VOLUME_TR_ID == "HHDFS76270000"
|
||||||
|
|
||||||
|
def test_fluct_path(self, mock_settings: Settings) -> None:
|
||||||
|
assert mock_settings.OVERSEAS_RANKING_FLUCT_PATH == "/uapi/overseas-stock/v1/ranking/updown-rate"
|
||||||
|
|
||||||
|
def test_volume_path(self, mock_settings: Settings) -> None:
|
||||||
|
assert mock_settings.OVERSEAS_RANKING_VOLUME_PATH == "/uapi/overseas-stock/v1/ranking/volume-surge"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchOverseasRankings:
|
||||||
|
"""Test fetch_overseas_rankings method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fluctuation_uses_correct_params(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Fluctuation ranking should use HHDFS76290000, updown-rate path, and correct params."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={"output": [{"symb": "AAPL", "name": "Apple"}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._auth_headers = AsyncMock(
|
||||||
|
return_value={"authorization": "Bearer test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("NASD", "fluctuation")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["symb"] == "AAPL"
|
||||||
|
|
||||||
|
call_args = mock_session.get.call_args
|
||||||
|
url = call_args[0][0]
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
|
||||||
|
assert "/uapi/overseas-stock/v1/ranking/updown-rate" in url
|
||||||
|
assert params["EXCD"] == "NAS"
|
||||||
|
assert params["NDAY"] == "0"
|
||||||
|
assert params["GUBN"] == "1"
|
||||||
|
assert params["VOL_RANG"] == "0"
|
||||||
|
|
||||||
|
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_volume_uses_correct_params(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Volume ranking should use HHDFS76270000, volume-surge path, and correct params."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._auth_headers = AsyncMock(
|
||||||
|
return_value={"authorization": "Bearer test"}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("NYSE", "volume")
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
call_args = mock_session.get.call_args
|
||||||
|
url = call_args[0][0]
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
|
||||||
|
assert "/uapi/overseas-stock/v1/ranking/volume-surge" in url
|
||||||
|
assert params["EXCD"] == "NYS"
|
||||||
|
assert params["MIXN"] == "0"
|
||||||
|
assert params["VOL_RANG"] == "0"
|
||||||
|
assert "NDAY" not in params
|
||||||
|
assert "GUBN" not in params
|
||||||
|
|
||||||
|
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76270000")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_404_returns_empty_list(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""HTTP 404 should return empty list (fallback) instead of raising."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 404
|
||||||
|
mock_resp.text = AsyncMock(return_value="Not Found")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("AMEX", "fluctuation")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_non_404_error_raises(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Non-404 HTTP errors should raise ConnectionError."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 500
|
||||||
|
mock_resp.text = AsyncMock(return_value="Internal Server Error")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="500"):
|
||||||
|
await overseas_broker.fetch_overseas_rankings("NASD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_response_returns_empty(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Empty output in response should return empty list."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": []})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ranking_disabled_returns_empty(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""When OVERSEAS_RANKING_ENABLED=False, should return empty immediately."""
|
||||||
|
overseas_broker._broker._settings.OVERSEAS_RANKING_ENABLED = False
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("NASD")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_limit_truncates_results(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Results should be truncated to the specified limit."""
|
||||||
|
rows = [{"symb": f"SYM{i}"} for i in range(20)]
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": rows})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.fetch_overseas_rankings("NASD", limit=5)
|
||||||
|
assert len(result) == 5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_network_error_raises(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""Network errors should raise ConnectionError."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout"))
|
||||||
|
cm.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=cm)
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="Network error"):
|
||||||
|
await overseas_broker.fetch_overseas_rankings("NASD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_code_mapping_applied(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""All major exchanges should use mapped codes in API params."""
|
||||||
|
for original, mapped in [("NASD", "NAS"), ("NYSE", "NYS"), ("AMEX", "AMS")]:
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "X"}]})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
await overseas_broker.fetch_overseas_rankings(original)
|
||||||
|
|
||||||
|
call_params = mock_session.get.call_args[1]["params"]
|
||||||
|
assert call_params["EXCD"] == mapped, f"{original} should map to {mapped}"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOverseasPrice:
|
||||||
|
"""Test get_overseas_price method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_success(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Successful price fetch returns JSON data."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": {"last": "150.00"}})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
|
||||||
|
|
||||||
|
result = await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||||
|
assert result["output"]["last"] == "150.00"
|
||||||
|
|
||||||
|
call_args = mock_session.get.call_args
|
||||||
|
params = call_args[1]["params"]
|
||||||
|
assert params["EXCD"] == "NAS" # NASD → NAS via _PRICE_EXCHANGE_MAP
|
||||||
|
assert params["SYMB"] == "AAPL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Non-200 response should raise ConnectionError."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="get_overseas_price failed"):
|
||||||
|
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Network error should raise ConnectionError."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn refused"))
|
||||||
|
cm.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=cm)
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="Network error"):
|
||||||
|
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOverseasBalance:
|
||||||
|
"""Test get_overseas_balance method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_success(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Successful balance fetch returns JSON data."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output1": [{"pdno": "AAPL"}]})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.get_overseas_balance("NASD")
|
||||||
|
assert result["output1"][0]["pdno"] == "AAPL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Non-200 should raise ConnectionError."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 500
|
||||||
|
mock_resp.text = AsyncMock(return_value="Server Error")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="get_overseas_balance failed"):
|
||||||
|
await overseas_broker.get_overseas_balance("NASD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Network error should raise ConnectionError."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__aenter__ = AsyncMock(side_effect=TimeoutError("timeout"))
|
||||||
|
cm.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=cm)
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="Network error"):
|
||||||
|
await overseas_broker.get_overseas_balance("NYSE")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendOverseasOrder:
|
||||||
|
"""Test send_overseas_order method."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_buy_market_order(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Market buy order should use VTTT1002U and ORD_DVSN=01."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||||
|
|
||||||
|
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10)
|
||||||
|
assert result["rt_cd"] == "0"
|
||||||
|
|
||||||
|
# Verify BUY TR_ID
|
||||||
|
overseas_broker._broker._auth_headers.assert_called_with("VTTT1002U")
|
||||||
|
|
||||||
|
call_args = mock_session.post.call_args
|
||||||
|
body = call_args[1]["json"]
|
||||||
|
assert body["ORD_DVSN"] == "01" # market order
|
||||||
|
assert body["OVRS_ORD_UNPR"] == "0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Limit sell order should use VTTT1001U and ORD_DVSN=00."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||||
|
|
||||||
|
result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0)
|
||||||
|
assert result["rt_cd"] == "0"
|
||||||
|
|
||||||
|
overseas_broker._broker._auth_headers.assert_called_with("VTTT1001U")
|
||||||
|
|
||||||
|
call_args = mock_session.post.call_args
|
||||||
|
body = call_args[1]["json"]
|
||||||
|
assert body["ORD_DVSN"] == "00" # limit order
|
||||||
|
assert body["OVRS_ORD_UNPR"] == "350.0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_order_http_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Non-200 should raise ConnectionError."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="send_overseas_order failed"):
|
||||||
|
await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_order_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
"""Network error should raise ConnectionError."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn reset"))
|
||||||
|
cm.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=cm)
|
||||||
|
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError, match="Network error"):
|
||||||
|
await overseas_broker.send_overseas_order("NASD", "TSLA", "SELL", 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCurrencyCode:
|
||||||
|
"""Test _get_currency_code mapping."""
|
||||||
|
|
||||||
|
def test_us_exchanges(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("NASD") == "USD"
|
||||||
|
assert overseas_broker._get_currency_code("NYSE") == "USD"
|
||||||
|
assert overseas_broker._get_currency_code("AMEX") == "USD"
|
||||||
|
|
||||||
|
def test_japan(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("TSE") == "JPY"
|
||||||
|
|
||||||
|
def test_hong_kong(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("SEHK") == "HKD"
|
||||||
|
|
||||||
|
def test_china(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("SHAA") == "CNY"
|
||||||
|
assert overseas_broker._get_currency_code("SZAA") == "CNY"
|
||||||
|
|
||||||
|
def test_vietnam(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("HNX") == "VND"
|
||||||
|
assert overseas_broker._get_currency_code("HSX") == "VND"
|
||||||
|
|
||||||
|
def test_unknown_defaults_usd(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._get_currency_code("UNKNOWN") == "USD"
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractRankingRows:
|
||||||
|
"""Test _extract_ranking_rows helper."""
|
||||||
|
|
||||||
|
def test_output_key(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
data = {"output": [{"a": 1}, {"b": 2}]}
|
||||||
|
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
||||||
|
|
||||||
|
def test_output1_key(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
data = {"output1": [{"c": 3}]}
|
||||||
|
assert overseas_broker._extract_ranking_rows(data) == [{"c": 3}]
|
||||||
|
|
||||||
|
def test_output2_key(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
data = {"output2": [{"d": 4}]}
|
||||||
|
assert overseas_broker._extract_ranking_rows(data) == [{"d": 4}]
|
||||||
|
|
||||||
|
def test_no_list_returns_empty(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
data = {"output": "not a list"}
|
||||||
|
assert overseas_broker._extract_ranking_rows(data) == []
|
||||||
|
|
||||||
|
def test_empty_data(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
assert overseas_broker._extract_ranking_rows({}) == []
|
||||||
|
|
||||||
|
def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None:
|
||||||
|
data = {"output": [{"a": 1}, "invalid", {"b": 2}]}
|
||||||
|
assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriceExchangeMap:
|
||||||
|
"""Test _PRICE_EXCHANGE_MAP is applied in get_overseas_price (issue #151)."""
|
||||||
|
|
||||||
|
def test_price_map_equals_ranking_map(self) -> None:
|
||||||
|
assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("original,expected", [
|
||||||
|
("NASD", "NAS"),
|
||||||
|
("NYSE", "NYS"),
|
||||||
|
("AMEX", "AMS"),
|
||||||
|
])
|
||||||
|
def test_us_exchange_code_mapping(self, original: str, expected: str) -> None:
|
||||||
|
assert _PRICE_EXCHANGE_MAP[original] == expected
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_overseas_price_sends_mapped_code(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""NASD → NAS must be sent to HHDFS00000300."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output": {"last": "200.00"}})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
_setup_broker_mocks(overseas_broker, mock_session)
|
||||||
|
|
||||||
|
await overseas_broker.get_overseas_price("NASD", "AAPL")
|
||||||
|
|
||||||
|
params = mock_session.get.call_args[1]["params"]
|
||||||
|
assert params["EXCD"] == "NAS"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrderRtCdCheck:
|
||||||
|
"""Test that send_overseas_order checks rt_cd and logs accordingly (issue #151)."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def overseas_broker(self, mock_settings: Settings) -> OverseasBroker:
|
||||||
|
broker = MagicMock(spec=KISBroker)
|
||||||
|
broker._settings = mock_settings
|
||||||
|
broker._account_no = "12345678"
|
||||||
|
broker._product_cd = "01"
|
||||||
|
broker._base_url = "https://openapivts.koreainvestment.com:9443"
|
||||||
|
broker._rate_limiter = AsyncMock()
|
||||||
|
broker._rate_limiter.acquire = AsyncMock()
|
||||||
|
broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
|
||||||
|
broker._get_hash_key = AsyncMock(return_value="hashval")
|
||||||
|
return OverseasBroker(broker)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_success_rt_cd_returns_data(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""rt_cd='0' → order accepted, data returned."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "완료"})
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
|
||||||
|
assert result["rt_cd"] == "0"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_error_rt_cd_returns_data_with_msg(
|
||||||
|
self, overseas_broker: OverseasBroker
|
||||||
|
) -> None:
|
||||||
|
"""rt_cd != '0' → order rejected, data still returned (caller checks rt_cd)."""
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={"rt_cd": "1", "msg1": "주문가능금액이 부족합니다."}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp))
|
||||||
|
overseas_broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10, price=150.0)
|
||||||
|
assert result["rt_cd"] == "1"
|
||||||
|
assert "부족" in result["msg1"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPaperOverseasCash:
|
||||||
|
"""Test PAPER_OVERSEAS_CASH config setting (issue #151)."""
|
||||||
|
|
||||||
|
def test_default_value(self) -> None:
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
assert settings.PAPER_OVERSEAS_CASH == 50000.0
|
||||||
|
|
||||||
|
def test_env_override(self) -> None:
|
||||||
|
import os
|
||||||
|
os.environ["PAPER_OVERSEAS_CASH"] = "25000"
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
assert settings.PAPER_OVERSEAS_CASH == 25000.0
|
||||||
|
del os.environ["PAPER_OVERSEAS_CASH"]
|
||||||
|
|
||||||
|
def test_zero_disables_fallback(self) -> None:
|
||||||
|
import os
|
||||||
|
os.environ["PAPER_OVERSEAS_CASH"] = "0"
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
)
|
||||||
|
assert settings.PAPER_OVERSEAS_CASH == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TR_ID live/paper branching — overseas (issues #201, #203)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_overseas_broker_with_mode(mode: str) -> OverseasBroker:
|
||||||
|
s = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
DB_PATH=":memory:",
|
||||||
|
MODE=mode,
|
||||||
|
)
|
||||||
|
kis = KISBroker(s)
|
||||||
|
kis._access_token = "tok"
|
||||||
|
kis._token_expires_at = float("inf")
|
||||||
|
kis._rate_limiter.acquire = AsyncMock()
|
||||||
|
return OverseasBroker(kis)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOverseasTRIDBranching:
|
||||||
|
"""get_overseas_balance and send_overseas_order must use correct TR_ID."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_overseas_balance_paper_uses_vtts3012r(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("paper")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.get_overseas_balance("NASD")
|
||||||
|
assert "VTTS3012R" in captured
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_overseas_balance_live_uses_ttts3012r(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("live")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": []})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.get_overseas_balance("NASD")
|
||||||
|
assert "TTTS3012R" in captured
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_overseas_order_buy_paper_uses_vttt1002u(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("paper")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
|
||||||
|
assert "VTTT1002U" in captured
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_overseas_order_buy_live_uses_tttt1002u(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("live")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.send_overseas_order("NASD", "AAPL", "BUY", 1)
|
||||||
|
assert "TTTT1002U" in captured
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_overseas_order_sell_paper_uses_vttt1001u(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("paper")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.send_overseas_order("NASD", "AAPL", "SELL", 1)
|
||||||
|
assert "VTTT1001U" in captured
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_overseas_order_sell_live_uses_tttt1006u(self) -> None:
|
||||||
|
broker = _make_overseas_broker_with_mode("live")
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
async def mock_auth_headers(tr_id: str) -> dict:
|
||||||
|
captured.append(tr_id)
|
||||||
|
return {"tr_id": tr_id, "authorization": "Bearer tok"}
|
||||||
|
|
||||||
|
broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
|
||||||
|
broker._broker._get_hash_key = AsyncMock(return_value="h") # type: ignore[method-assign]
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.post = MagicMock(return_value=mock_resp)
|
||||||
|
broker._broker._get_session = MagicMock(return_value=mock_session)
|
||||||
|
|
||||||
|
await broker.send_overseas_order("NASD", "AAPL", "SELL", 1)
|
||||||
|
assert "TTTT1006U" in captured
|
||||||
@@ -164,18 +164,23 @@ class TestGeneratePlaybook:
|
|||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gemini_failure_returns_defensive(self) -> None:
|
async def test_gemini_failure_returns_smart_fallback(self) -> None:
|
||||||
planner = _make_planner()
|
planner = _make_planner()
|
||||||
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
|
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
|
||||||
|
# oversold candidate (signal="oversold", rsi=28.5)
|
||||||
candidates = [_candidate()]
|
candidates = [_candidate()]
|
||||||
|
|
||||||
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
assert pb.default_action == ScenarioAction.HOLD
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
|
# Smart fallback uses NEUTRAL outlook (not NEUTRAL_TO_BEARISH)
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
assert pb.stock_count == 1
|
assert pb.stock_count == 1
|
||||||
# Defensive playbook has stop-loss scenarios
|
# Oversold candidate → first scenario is BUY, second is SELL stop-loss
|
||||||
assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL
|
scenarios = pb.stock_playbooks[0].scenarios
|
||||||
|
assert scenarios[0].action == ScenarioAction.BUY
|
||||||
|
assert scenarios[0].condition.rsi_below == 30
|
||||||
|
assert scenarios[1].action == ScenarioAction.SELL
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
|
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
|
||||||
@@ -657,3 +662,339 @@ class TestDefensivePlaybook:
|
|||||||
assert pb.stock_count == 0
|
assert pb.stock_count == 0
|
||||||
assert pb.market == "US"
|
assert pb.market == "US"
|
||||||
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Smart fallback playbook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmartFallbackPlaybook:
|
||||||
|
"""Tests for _smart_fallback_playbook — rule-based BUY/SELL on Gemini failure."""
|
||||||
|
|
||||||
|
def _make_settings(self) -> Settings:
|
||||||
|
return Settings(
|
||||||
|
KIS_APP_KEY="test",
|
||||||
|
KIS_APP_SECRET="test",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test",
|
||||||
|
RSI_OVERSOLD_THRESHOLD=30,
|
||||||
|
VOL_MULTIPLIER=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_momentum_candidate_gets_buy_on_volume(self) -> None:
|
||||||
|
candidates = [
|
||||||
|
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
|
||||||
|
]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
sp = pb.stock_playbooks[0]
|
||||||
|
assert sp.stock_code == "CHOW"
|
||||||
|
# First scenario: BUY with volume_ratio_above
|
||||||
|
buy_sc = sp.scenarios[0]
|
||||||
|
assert buy_sc.action == ScenarioAction.BUY
|
||||||
|
assert buy_sc.condition.volume_ratio_above == 2.0
|
||||||
|
assert buy_sc.condition.rsi_below is None
|
||||||
|
assert buy_sc.confidence == 80
|
||||||
|
# Second scenario: stop-loss SELL
|
||||||
|
sell_sc = sp.scenarios[1]
|
||||||
|
assert sell_sc.action == ScenarioAction.SELL
|
||||||
|
assert sell_sc.condition.price_change_pct_below == -3.0
|
||||||
|
|
||||||
|
def test_oversold_candidate_gets_buy_on_rsi(self) -> None:
|
||||||
|
candidates = [
|
||||||
|
_candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5)
|
||||||
|
]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "KR", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
sp = pb.stock_playbooks[0]
|
||||||
|
buy_sc = sp.scenarios[0]
|
||||||
|
assert buy_sc.action == ScenarioAction.BUY
|
||||||
|
assert buy_sc.condition.rsi_below == 30
|
||||||
|
assert buy_sc.condition.volume_ratio_above is None
|
||||||
|
|
||||||
|
def test_all_candidates_have_stop_loss_sell(self) -> None:
|
||||||
|
candidates = [
|
||||||
|
_candidate(code="AAA", signal="momentum", volume_ratio=5.0),
|
||||||
|
_candidate(code="BBB", signal="oversold", rsi=25.0),
|
||||||
|
]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_NASDAQ", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pb.stock_count == 2
|
||||||
|
for sp in pb.stock_playbooks:
|
||||||
|
sell_scenarios = [s for s in sp.scenarios if s.action == ScenarioAction.SELL]
|
||||||
|
assert len(sell_scenarios) == 1
|
||||||
|
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
|
||||||
|
assert sell_scenarios[0].condition.price_change_pct_below == -3.0
|
||||||
|
|
||||||
|
def test_market_outlook_is_neutral(self) -> None:
|
||||||
|
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
|
def test_default_action_is_hold(self) -> None:
|
||||||
|
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
|
|
||||||
|
def test_has_global_reduce_all_rule(self) -> None:
|
||||||
|
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(pb.global_rules) == 1
|
||||||
|
rule = pb.global_rules[0]
|
||||||
|
assert rule.action == ScenarioAction.REDUCE_ALL
|
||||||
|
assert "portfolio_pnl_pct" in rule.condition
|
||||||
|
|
||||||
|
def test_empty_candidates_returns_empty_playbook(self) -> None:
|
||||||
|
settings = self._make_settings()
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", [], settings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert pb.stock_count == 0
|
||||||
|
|
||||||
|
def test_vol_multiplier_applied_from_settings(self) -> None:
|
||||||
|
"""VOL_MULTIPLIER=3.0 should set volume_ratio_above=3.0 for momentum."""
|
||||||
|
candidates = [_candidate(signal="momentum", volume_ratio=5.0)]
|
||||||
|
settings = self._make_settings()
|
||||||
|
settings = settings.model_copy(update={"VOL_MULTIPLIER": 3.0})
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "US_AMEX", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
buy_sc = pb.stock_playbooks[0].scenarios[0]
|
||||||
|
assert buy_sc.condition.volume_ratio_above == 3.0
|
||||||
|
|
||||||
|
def test_rsi_oversold_threshold_applied_from_settings(self) -> None:
|
||||||
|
"""RSI_OVERSOLD_THRESHOLD=25 should set rsi_below=25 for oversold."""
|
||||||
|
candidates = [_candidate(signal="oversold", rsi=22.0)]
|
||||||
|
settings = self._make_settings()
|
||||||
|
settings = settings.model_copy(update={"RSI_OVERSOLD_THRESHOLD": 25})
|
||||||
|
|
||||||
|
pb = PreMarketPlanner._smart_fallback_playbook(
|
||||||
|
date(2026, 2, 17), "KR", candidates, settings
|
||||||
|
)
|
||||||
|
|
||||||
|
buy_sc = pb.stock_playbooks[0].scenarios[0]
|
||||||
|
assert buy_sc.condition.rsi_below == 25
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_playbook_uses_smart_fallback_on_gemini_error(self) -> None:
|
||||||
|
"""generate_playbook() should use smart fallback (not defensive) on API failure."""
|
||||||
|
planner = _make_planner()
|
||||||
|
planner._gemini.decide = AsyncMock(side_effect=ConnectionError("429 quota exceeded"))
|
||||||
|
# momentum candidate
|
||||||
|
candidates = [
|
||||||
|
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
|
||||||
|
]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook(
|
||||||
|
"US_AMEX", candidates, today=date(2026, 2, 18)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should NOT be all-SELL defensive; should have BUY for momentum
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
buy_scenarios = [
|
||||||
|
s for s in pb.stock_playbooks[0].scenarios
|
||||||
|
if s.action == ScenarioAction.BUY
|
||||||
|
]
|
||||||
|
assert len(buy_scenarios) == 1
|
||||||
|
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Holdings in prompt (#170)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestHoldingsInPrompt:
|
||||||
|
"""Tests for current_holdings parameter in generate_playbook / _build_prompt."""
|
||||||
|
|
||||||
|
def _make_holdings(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"name": "Samsung",
|
||||||
|
"qty": 10,
|
||||||
|
"entry_price": 71000.0,
|
||||||
|
"unrealized_pnl_pct": 2.3,
|
||||||
|
"holding_days": 3,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_build_prompt_includes_holdings_section(self) -> None:
|
||||||
|
"""Prompt should contain a Current Holdings section when holdings are given."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" in prompt
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "+2.30%" in prompt
|
||||||
|
assert "보유 3일" in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_no_holdings_omits_section(self) -> None:
|
||||||
|
"""Prompt should NOT contain a Current Holdings section when holdings=None."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" not in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_empty_holdings_omits_section(self) -> None:
|
||||||
|
"""Empty list should also omit the holdings section."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "## Current Holdings" not in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_holdings_instruction_included(self) -> None:
|
||||||
|
"""Prompt should include instruction to generate scenarios for held stocks."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
prompt = planner._build_prompt(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
context_data={},
|
||||||
|
self_market_scorecard=None,
|
||||||
|
cross_market=None,
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "SELL/HOLD" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_playbook_passes_holdings_to_prompt(self) -> None:
|
||||||
|
"""generate_playbook should pass current_holdings through to the prompt."""
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
holdings = self._make_holdings()
|
||||||
|
|
||||||
|
# Capture the actual prompt sent to Gemini
|
||||||
|
captured_prompts: list[str] = []
|
||||||
|
original_decide = planner._gemini.decide
|
||||||
|
|
||||||
|
async def capture_and_call(data: dict) -> TradeDecision:
|
||||||
|
captured_prompts.append(data.get("prompt_override", ""))
|
||||||
|
return await original_decide(data)
|
||||||
|
|
||||||
|
planner._gemini.decide = capture_and_call # type: ignore[method-assign]
|
||||||
|
|
||||||
|
await planner.generate_playbook(
|
||||||
|
"KR", candidates, today=date(2026, 2, 8), current_holdings=holdings
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(captured_prompts) == 1
|
||||||
|
assert "## Current Holdings" in captured_prompts[0]
|
||||||
|
assert "005930" in captured_prompts[0]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_holdings_stock_allowed_in_parse_response(self) -> None:
|
||||||
|
"""Holdings stocks not in candidates list should be accepted in the response."""
|
||||||
|
holding_code = "000660" # Not in candidates
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930", # candidate
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "oversold",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stock_code": holding_code, # holding only
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"price_change_pct_below": -2.0},
|
||||||
|
"action": "SELL",
|
||||||
|
"confidence": 90,
|
||||||
|
"rationale": "stop-loss",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||||
|
candidates = [_candidate()] # only 005930
|
||||||
|
holdings = [
|
||||||
|
{
|
||||||
|
"stock_code": holding_code,
|
||||||
|
"name": "SK Hynix",
|
||||||
|
"qty": 5,
|
||||||
|
"entry_price": 180000.0,
|
||||||
|
"unrealized_pnl_pct": -1.5,
|
||||||
|
"holding_days": 7,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook(
|
||||||
|
"KR",
|
||||||
|
candidates,
|
||||||
|
today=date(2026, 2, 8),
|
||||||
|
current_holdings=holdings,
|
||||||
|
)
|
||||||
|
|
||||||
|
codes = [sp.stock_code for sp in pb.stock_playbooks]
|
||||||
|
assert "005930" in codes
|
||||||
|
assert holding_code in codes
|
||||||
|
|||||||
@@ -440,3 +440,135 @@ class TestEvaluate:
|
|||||||
assert result.action == ScenarioAction.BUY
|
assert result.action == ScenarioAction.BUY
|
||||||
assert result.match_details["rsi"] == 25.0
|
assert result.match_details["rsi"] == 25.0
|
||||||
assert isinstance(result.match_details["rsi"], float)
|
assert isinstance(result.match_details["rsi"], float)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Position-aware condition tests (#171)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPositionAwareConditions:
|
||||||
|
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_unrealized_pnl_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
|
||||||
|
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should match when position held longer than threshold."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_above_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_above should NOT match when position held shorter."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_matches(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should match when position held fewer days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
|
||||||
|
|
||||||
|
def test_evaluate_condition_holding_days_below_no_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""holding_days_below should NOT match when held more days."""
|
||||||
|
condition = StockCondition(holding_days_below=3)
|
||||||
|
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
|
||||||
|
|
||||||
|
def test_combined_pnl_and_holding_days(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Combined position-aware conditions should AND-evaluate correctly."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
holding_days_above=5,
|
||||||
|
)
|
||||||
|
# Both met → match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
|
||||||
|
) is True
|
||||||
|
# Only pnl met → no match
|
||||||
|
assert engine.evaluate_condition(
|
||||||
|
condition,
|
||||||
|
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
|
||||||
|
) is False
|
||||||
|
|
||||||
|
def test_missing_unrealized_pnl_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing unrealized_pnl_pct key should not match the condition."""
|
||||||
|
condition = StockCondition(unrealized_pnl_pct_above=3.0)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_missing_holding_days_does_not_match(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""Missing holding_days key should not match the condition."""
|
||||||
|
condition = StockCondition(holding_days_above=5)
|
||||||
|
assert engine.evaluate_condition(condition, {}) is False
|
||||||
|
|
||||||
|
def test_match_details_includes_position_fields(
|
||||||
|
self, engine: ScenarioEngine
|
||||||
|
) -> None:
|
||||||
|
"""match_details should include position fields when condition specifies them."""
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(unrealized_pnl_pct_above=3.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=90,
|
||||||
|
rationale="Take profit",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"unrealized_pnl_pct": 5.0},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result.action == ScenarioAction.SELL
|
||||||
|
assert "unrealized_pnl_pct" in result.match_details
|
||||||
|
assert result.match_details["unrealized_pnl_pct"] == 5.0
|
||||||
|
|
||||||
|
def test_position_conditions_parse_from_planner(self) -> None:
|
||||||
|
"""StockCondition should accept and store new fields from JSON parsing."""
|
||||||
|
condition = StockCondition(
|
||||||
|
unrealized_pnl_pct_above=3.0,
|
||||||
|
unrealized_pnl_pct_below=None,
|
||||||
|
holding_days_above=5,
|
||||||
|
holding_days_below=None,
|
||||||
|
)
|
||||||
|
assert condition.unrealized_pnl_pct_above == 3.0
|
||||||
|
assert condition.holding_days_above == 5
|
||||||
|
assert condition.has_any_condition() is True
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
||||||
from src.analysis.volatility import VolatilityAnalyzer
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
from src.broker.kis_api import KISBroker
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.broker.overseas import OverseasBroker
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
@@ -43,61 +44,70 @@ def scanner(mock_broker: MagicMock, mock_settings: Settings) -> SmartVolatilityS
|
|||||||
analyzer = VolatilityAnalyzer()
|
analyzer = VolatilityAnalyzer()
|
||||||
return SmartVolatilityScanner(
|
return SmartVolatilityScanner(
|
||||||
broker=mock_broker,
|
broker=mock_broker,
|
||||||
|
overseas_broker=None,
|
||||||
volatility_analyzer=analyzer,
|
volatility_analyzer=analyzer,
|
||||||
settings=mock_settings,
|
settings=mock_settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_overseas_broker() -> MagicMock:
|
||||||
|
"""Create mock overseas broker."""
|
||||||
|
broker = MagicMock(spec=OverseasBroker)
|
||||||
|
broker.get_overseas_price = AsyncMock()
|
||||||
|
broker.fetch_overseas_rankings = AsyncMock(return_value=[])
|
||||||
|
return broker
|
||||||
|
|
||||||
|
|
||||||
class TestSmartVolatilityScanner:
|
class TestSmartVolatilityScanner:
|
||||||
"""Test suite for SmartVolatilityScanner."""
|
"""Test suite for SmartVolatilityScanner."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_finds_oversold_candidates(
|
async def test_scan_domestic_prefers_volatility_with_liquidity_bonus(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that scanner identifies oversold stocks with high volume."""
|
"""Domestic scan should score by volatility first and volume rank second."""
|
||||||
# Mock rankings
|
fluctuation_rows = [
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
|
||||||
{
|
{
|
||||||
"stock_code": "005930",
|
"stock_code": "005930",
|
||||||
"name": "Samsung",
|
"name": "Samsung",
|
||||||
"price": 70000,
|
"price": 70000,
|
||||||
"volume": 5000000,
|
"volume": 5000000,
|
||||||
"change_rate": -3.5,
|
"change_rate": -5.0,
|
||||||
"volume_increase_rate": 250,
|
"volume_increase_rate": 250,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"stock_code": "035420",
|
||||||
|
"name": "NAVER",
|
||||||
|
"price": 250000,
|
||||||
|
"volume": 3000000,
|
||||||
|
"change_rate": 3.0,
|
||||||
|
"volume_increase_rate": 200,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
volume_rows = [
|
||||||
|
{"stock_code": "035420", "name": "NAVER", "price": 250000, "volume": 3000000},
|
||||||
|
{"stock_code": "005930", "name": "Samsung", "price": 70000, "volume": 5000000},
|
||||||
|
]
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, volume_rows]
|
||||||
|
mock_broker.get_daily_prices.return_value = [
|
||||||
|
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
|
||||||
|
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Mock daily prices - trending down (oversold)
|
|
||||||
prices = []
|
|
||||||
for i in range(20):
|
|
||||||
prices.append({
|
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 75000 - i * 200,
|
|
||||||
"high": 75500 - i * 200,
|
|
||||||
"low": 74500 - i * 200,
|
|
||||||
"close": 75000 - i * 250, # Steady decline
|
|
||||||
"volume": 2000000,
|
|
||||||
})
|
|
||||||
mock_broker.get_daily_prices.return_value = prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
candidates = await scanner.scan()
|
||||||
|
|
||||||
# Should find at least one candidate (depending on exact RSI calculation)
|
assert len(candidates) >= 1
|
||||||
mock_broker.fetch_market_rankings.assert_called_once()
|
# Samsung has higher absolute move, so it should lead despite lower volume rank bonus.
|
||||||
mock_broker.get_daily_prices.assert_called_once_with("005930", days=20)
|
assert candidates[0].stock_code == "005930"
|
||||||
|
assert candidates[0].signal == "oversold"
|
||||||
# If qualified, should have oversold signal
|
|
||||||
if candidates:
|
|
||||||
assert candidates[0].signal in ["oversold", "momentum"]
|
|
||||||
assert candidates[0].volume_ratio >= scanner.vol_multiplier
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_finds_momentum_candidates(
|
async def test_scan_domestic_finds_momentum_candidate(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that scanner identifies momentum stocks with high volume."""
|
"""Positive change should be represented as momentum signal."""
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
fluctuation_rows = [
|
||||||
{
|
{
|
||||||
"stock_code": "035420",
|
"stock_code": "035420",
|
||||||
"name": "NAVER",
|
"name": "NAVER",
|
||||||
@@ -107,124 +117,67 @@ class TestSmartVolatilityScanner:
|
|||||||
"volume_increase_rate": 300,
|
"volume_increase_rate": 300,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
|
||||||
# Mock daily prices - trending up (momentum)
|
mock_broker.get_daily_prices.return_value = [
|
||||||
prices = []
|
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
|
||||||
for i in range(20):
|
{"open": 1, "high": 1, "low": 1, "close": 1, "volume": 1000000},
|
||||||
prices.append({
|
]
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 230000 + i * 500,
|
|
||||||
"high": 231000 + i * 500,
|
|
||||||
"low": 229000 + i * 500,
|
|
||||||
"close": 230500 + i * 500, # Steady rise
|
|
||||||
"volume": 1000000,
|
|
||||||
})
|
|
||||||
mock_broker.get_daily_prices.return_value = prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
candidates = await scanner.scan()
|
||||||
|
|
||||||
mock_broker.fetch_market_rankings.assert_called_once()
|
assert [c.stock_code for c in candidates] == ["035420"]
|
||||||
|
assert candidates[0].signal == "momentum"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_filters_low_volume(
|
async def test_scan_domestic_filters_low_volatility(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that stocks with low volume ratio are filtered out."""
|
"""Domestic scan should drop symbols below volatility threshold."""
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
fluctuation_rows = [
|
||||||
{
|
{
|
||||||
"stock_code": "000660",
|
"stock_code": "000660",
|
||||||
"name": "SK Hynix",
|
"name": "SK Hynix",
|
||||||
"price": 150000,
|
"price": 150000,
|
||||||
"volume": 500000,
|
"volume": 500000,
|
||||||
"change_rate": -5.0,
|
"change_rate": 0.2,
|
||||||
"volume_increase_rate": 50, # Only 50% increase (< 200%)
|
"volume_increase_rate": 50,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
|
||||||
# Low volume
|
mock_broker.get_daily_prices.return_value = [
|
||||||
prices = []
|
{"open": 1, "high": 150100, "low": 149900, "close": 150000, "volume": 1000000},
|
||||||
for i in range(20):
|
{"open": 1, "high": 150100, "low": 149900, "close": 150000, "volume": 1000000},
|
||||||
prices.append({
|
]
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 150000 - i * 100,
|
|
||||||
"high": 151000 - i * 100,
|
|
||||||
"low": 149000 - i * 100,
|
|
||||||
"close": 150000 - i * 150, # Declining (would be oversold)
|
|
||||||
"volume": 1000000, # Current 500k < 2x prev day 1M
|
|
||||||
})
|
|
||||||
mock_broker.get_daily_prices.return_value = prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
candidates = await scanner.scan()
|
||||||
|
|
||||||
# Should be filtered out due to low volume ratio
|
|
||||||
assert len(candidates) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_filters_neutral_rsi(
|
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Test that stocks with neutral RSI are filtered out."""
|
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
|
||||||
{
|
|
||||||
"stock_code": "051910",
|
|
||||||
"name": "LG Chem",
|
|
||||||
"price": 500000,
|
|
||||||
"volume": 3000000,
|
|
||||||
"change_rate": 0.5,
|
|
||||||
"volume_increase_rate": 300, # High volume
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Flat prices (neutral RSI ~50)
|
|
||||||
prices = []
|
|
||||||
for i in range(20):
|
|
||||||
prices.append({
|
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 500000 + (i % 2) * 100, # Small oscillation
|
|
||||||
"high": 500500,
|
|
||||||
"low": 499500,
|
|
||||||
"close": 500000 + (i % 2) * 50,
|
|
||||||
"volume": 1000000,
|
|
||||||
})
|
|
||||||
mock_broker.get_daily_prices.return_value = prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
|
||||||
|
|
||||||
# Should be filtered out (RSI ~50, not < 30 or > 70)
|
|
||||||
assert len(candidates) == 0
|
assert len(candidates) == 0
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_uses_fallback_on_api_error(
|
async def test_scan_uses_fallback_on_api_error(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test fallback to static list when ranking API fails."""
|
"""Domestic scan should remain operational using fallback symbols."""
|
||||||
mock_broker.fetch_market_rankings.side_effect = ConnectionError("API unavailable")
|
mock_broker.fetch_market_rankings.side_effect = [
|
||||||
|
ConnectionError("API unavailable"),
|
||||||
# Fallback stocks should still be analyzed
|
ConnectionError("API unavailable"),
|
||||||
prices = []
|
]
|
||||||
for i in range(20):
|
mock_broker.get_daily_prices.return_value = [
|
||||||
prices.append({
|
{"open": 1, "high": 103, "low": 97, "close": 100, "volume": 1000000},
|
||||||
"date": f"2026020{i:02d}",
|
{"open": 1, "high": 103, "low": 97, "close": 100, "volume": 800000},
|
||||||
"open": 50000 - i * 50,
|
]
|
||||||
"high": 51000 - i * 50,
|
|
||||||
"low": 49000 - i * 50,
|
|
||||||
"close": 50000 - i * 75, # Declining
|
|
||||||
"volume": 1000000,
|
|
||||||
})
|
|
||||||
mock_broker.get_daily_prices.return_value = prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan(fallback_stocks=["005930", "000660"])
|
candidates = await scanner.scan(fallback_stocks=["005930", "000660"])
|
||||||
|
|
||||||
# Should not crash
|
|
||||||
assert isinstance(candidates, list)
|
assert isinstance(candidates, list)
|
||||||
|
assert len(candidates) >= 1
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_scan_returns_top_n_only(
|
async def test_scan_returns_top_n_only(
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test that scan returns at most top_n candidates."""
|
"""Test that scan returns at most top_n candidates."""
|
||||||
# Return many stocks
|
fluctuation_rows = [
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
|
||||||
{
|
{
|
||||||
"stock_code": f"00{i}000",
|
"stock_code": f"00{i}000",
|
||||||
"name": f"Stock{i}",
|
"name": f"Stock{i}",
|
||||||
@@ -235,62 +188,17 @@ class TestSmartVolatilityScanner:
|
|||||||
}
|
}
|
||||||
for i in range(1, 10)
|
for i in range(1, 10)
|
||||||
]
|
]
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = [fluctuation_rows, fluctuation_rows]
|
||||||
# All oversold with high volume
|
mock_broker.get_daily_prices.return_value = [
|
||||||
def make_prices(code: str) -> list[dict]:
|
{"open": 1, "high": 105, "low": 95, "close": 100, "volume": 1000000},
|
||||||
prices = []
|
{"open": 1, "high": 105, "low": 95, "close": 100, "volume": 900000},
|
||||||
for i in range(20):
|
]
|
||||||
prices.append({
|
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 10000 - i * 100,
|
|
||||||
"high": 10500 - i * 100,
|
|
||||||
"low": 9500 - i * 100,
|
|
||||||
"close": 10000 - i * 150,
|
|
||||||
"volume": 1000000,
|
|
||||||
})
|
|
||||||
return prices
|
|
||||||
|
|
||||||
mock_broker.get_daily_prices.side_effect = make_prices
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
candidates = await scanner.scan()
|
||||||
|
|
||||||
# Should respect top_n limit (3)
|
# Should respect top_n limit (3)
|
||||||
assert len(candidates) <= scanner.top_n
|
assert len(candidates) <= scanner.top_n
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_scan_skips_insufficient_price_history(
|
|
||||||
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
|
||||||
) -> None:
|
|
||||||
"""Test that stocks with insufficient history are skipped."""
|
|
||||||
mock_broker.fetch_market_rankings.return_value = [
|
|
||||||
{
|
|
||||||
"stock_code": "005930",
|
|
||||||
"name": "Samsung",
|
|
||||||
"price": 70000,
|
|
||||||
"volume": 5000000,
|
|
||||||
"change_rate": -5.0,
|
|
||||||
"volume_increase_rate": 300,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Only 5 days of data (need 15+ for RSI)
|
|
||||||
mock_broker.get_daily_prices.return_value = [
|
|
||||||
{
|
|
||||||
"date": f"2026020{i:02d}",
|
|
||||||
"open": 70000,
|
|
||||||
"high": 71000,
|
|
||||||
"low": 69000,
|
|
||||||
"close": 70000,
|
|
||||||
"volume": 2000000,
|
|
||||||
}
|
|
||||||
for i in range(5)
|
|
||||||
]
|
|
||||||
|
|
||||||
candidates = await scanner.scan()
|
|
||||||
|
|
||||||
# Should skip due to insufficient data
|
|
||||||
assert len(candidates) == 0
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_stock_codes(
|
async def test_get_stock_codes(
|
||||||
self, scanner: SmartVolatilityScanner
|
self, scanner: SmartVolatilityScanner
|
||||||
@@ -323,6 +231,160 @@ class TestSmartVolatilityScanner:
|
|||||||
|
|
||||||
assert codes == ["005930", "035420"]
|
assert codes == ["005930", "035420"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_overseas_uses_dynamic_symbols(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
|
||||||
|
) -> None:
|
||||||
|
"""Overseas scan should use provided dynamic universe symbols."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
scanner = SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
mock_overseas_broker.get_overseas_price.side_effect = [
|
||||||
|
{"output": {"last": "210.5", "rate": "1.6", "tvol": "1500000"}},
|
||||||
|
{"output": {"last": "330.1", "rate": "0.2", "tvol": "900000"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates = await scanner.scan(
|
||||||
|
market=market,
|
||||||
|
fallback_stocks=["AAPL", "MSFT"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [c.stock_code for c in candidates] == ["AAPL"]
|
||||||
|
assert candidates[0].signal == "momentum"
|
||||||
|
assert candidates[0].price == 210.5
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_overseas_uses_ranking_api_first(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
|
||||||
|
) -> None:
|
||||||
|
"""Overseas scan should prioritize ranking API when available."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
scanner = SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
mock_overseas_broker.fetch_overseas_rankings.return_value = [
|
||||||
|
{"symb": "NVDA", "last": "780.2", "rate": "2.4", "tvol": "1200000"},
|
||||||
|
{"symb": "MSFT", "last": "420.0", "rate": "0.3", "tvol": "900000"},
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates = await scanner.scan(market=market, fallback_stocks=["AAPL", "TSLA"])
|
||||||
|
|
||||||
|
assert mock_overseas_broker.fetch_overseas_rankings.call_count >= 1
|
||||||
|
mock_overseas_broker.get_overseas_price.assert_not_called()
|
||||||
|
assert [c.stock_code for c in candidates] == ["NVDA"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_overseas_without_symbols_returns_empty(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
|
||||||
|
) -> None:
|
||||||
|
"""Overseas scan should return empty list when no symbol universe exists."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
scanner = SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
candidates = await scanner.scan(market=market, fallback_stocks=[])
|
||||||
|
|
||||||
|
assert candidates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_overseas_picks_high_intraday_range_even_with_low_change(
|
||||||
|
self, mock_broker: MagicMock, mock_overseas_broker: MagicMock, mock_settings: Settings
|
||||||
|
) -> None:
|
||||||
|
"""Volatility selection should consider intraday range, not only change rate."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
scanner = SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
# change rate is tiny, but high-low range is large (15%).
|
||||||
|
mock_overseas_broker.fetch_overseas_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"symb": "ABCD",
|
||||||
|
"last": "100",
|
||||||
|
"rate": "0.2",
|
||||||
|
"high": "110",
|
||||||
|
"low": "95",
|
||||||
|
"tvol": "800000",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
candidates = await scanner.scan(market=market, fallback_stocks=[])
|
||||||
|
|
||||||
|
assert [c.stock_code for c in candidates] == ["ABCD"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestImpliedRSIFormula:
|
||||||
|
"""Test the implied_rsi formula in SmartVolatilityScanner (issue #181)."""
|
||||||
|
|
||||||
|
def test_neutral_change_gives_neutral_rsi(self) -> None:
|
||||||
|
"""0% change → implied_rsi = 50 (neutral)."""
|
||||||
|
# formula: 50 + (change_rate * 2.0)
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (0.0 * 2.0)))
|
||||||
|
assert rsi == 50.0
|
||||||
|
|
||||||
|
def test_10pct_change_gives_rsi_70(self) -> None:
|
||||||
|
"""10% upward change → implied_rsi = 70 (momentum signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (10.0 * 2.0)))
|
||||||
|
assert rsi == 70.0
|
||||||
|
|
||||||
|
def test_minus_10pct_gives_rsi_30(self) -> None:
|
||||||
|
"""-10% change → implied_rsi = 30 (oversold signal)."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-10.0 * 2.0)))
|
||||||
|
assert rsi == 30.0
|
||||||
|
|
||||||
|
def test_saturation_at_25pct(self) -> None:
|
||||||
|
"""Saturation occurs at >=25% change (not 12.5% as with old coefficient 4.0)."""
|
||||||
|
rsi_12pct = max(0.0, min(100.0, 50.0 + (12.5 * 2.0)))
|
||||||
|
rsi_25pct = max(0.0, min(100.0, 50.0 + (25.0 * 2.0)))
|
||||||
|
rsi_30pct = max(0.0, min(100.0, 50.0 + (30.0 * 2.0)))
|
||||||
|
# At 12.5% change: RSI = 75 (not 100, unlike old formula)
|
||||||
|
assert rsi_12pct == 75.0
|
||||||
|
# At 25%+ saturation
|
||||||
|
assert rsi_25pct == 100.0
|
||||||
|
assert rsi_30pct == 100.0 # Capped
|
||||||
|
|
||||||
|
def test_negative_saturation(self) -> None:
|
||||||
|
"""Saturation at -25% gives RSI = 0."""
|
||||||
|
rsi = max(0.0, min(100.0, 50.0 + (-25.0 * 2.0)))
|
||||||
|
assert rsi == 0.0
|
||||||
|
|
||||||
|
|
||||||
class TestRSICalculation:
|
class TestRSICalculation:
|
||||||
"""Test RSI calculation in VolatilityAnalyzer."""
|
"""Test RSI calculation in VolatilityAnalyzer."""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.notifications.telegram_client import NotificationPriority, TelegramClient
|
from src.notifications.telegram_client import NotificationFilter, NotificationPriority, TelegramClient
|
||||||
|
|
||||||
|
|
||||||
class TestTelegramClientInit:
|
class TestTelegramClientInit:
|
||||||
@@ -481,3 +481,187 @@ class TestClientCleanup:
|
|||||||
|
|
||||||
# Should not raise exception
|
# Should not raise exception
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationFilter:
|
||||||
|
"""Test granular notification filter behavior."""
|
||||||
|
|
||||||
|
def test_default_filter_allows_all(self) -> None:
|
||||||
|
"""Default NotificationFilter has all flags enabled."""
|
||||||
|
f = NotificationFilter()
|
||||||
|
assert f.trades is True
|
||||||
|
assert f.market_open_close is True
|
||||||
|
assert f.fat_finger is True
|
||||||
|
assert f.system_events is True
|
||||||
|
assert f.playbook is True
|
||||||
|
assert f.scenario_match is True
|
||||||
|
assert f.errors is True
|
||||||
|
|
||||||
|
def test_client_uses_default_filter_when_none_given(self) -> None:
|
||||||
|
"""TelegramClient creates a default NotificationFilter when none provided."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
assert isinstance(client._filter, NotificationFilter)
|
||||||
|
assert client._filter.scenario_match is True
|
||||||
|
|
||||||
|
def test_client_stores_provided_filter(self) -> None:
|
||||||
|
"""TelegramClient stores a custom NotificationFilter."""
|
||||||
|
nf = NotificationFilter(scenario_match=False, trades=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
assert client._filter.scenario_match is False
|
||||||
|
assert client._filter.trades is False
|
||||||
|
assert client._filter.market_open_close is True # default still True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenario_match_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_scenario_matched skips send when scenario_match=False."""
|
||||||
|
nf = NotificationFilter(scenario_match=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_scenario_matched(
|
||||||
|
stock_code="005930", action="BUY", condition_summary="rsi<30", confidence=85.0
|
||||||
|
)
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trades_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_trade_execution skips send when trades=False."""
|
||||||
|
nf = NotificationFilter(trades=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_trade_execution(
|
||||||
|
stock_code="005930", market="KR", action="BUY",
|
||||||
|
quantity=10, price=70000.0, confidence=85.0
|
||||||
|
)
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_market_open_close_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_market_open/close skip send when market_open_close=False."""
|
||||||
|
nf = NotificationFilter(market_open_close=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_market_open("Korea")
|
||||||
|
await client.notify_market_close("Korea", pnl_pct=1.5)
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_circuit_breaker_always_sends_regardless_of_filter(self) -> None:
|
||||||
|
"""notify_circuit_breaker always sends (no filter flag)."""
|
||||||
|
nf = NotificationFilter(
|
||||||
|
trades=False, market_open_close=False, fat_finger=False,
|
||||||
|
system_events=False, playbook=False, scenario_match=False, errors=False,
|
||||||
|
)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await client.notify_circuit_breaker(pnl_pct=-3.5, threshold=-3.0)
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_errors_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_error skips send when errors=False."""
|
||||||
|
nf = NotificationFilter(errors=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_error("TestError", "something went wrong", "KR")
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playbook_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_playbook_generated/failed skip send when playbook=False."""
|
||||||
|
nf = NotificationFilter(playbook=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_playbook_generated("KR", 3, 10, 1200)
|
||||||
|
await client.notify_playbook_failed("KR", "timeout")
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_events_filtered_does_not_send(self) -> None:
|
||||||
|
"""notify_system_start/shutdown skip send when system_events=False."""
|
||||||
|
nf = NotificationFilter(system_events=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_system_start("paper", ["KR"])
|
||||||
|
await client.notify_system_shutdown("Normal shutdown")
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_set_flag_valid_key(self) -> None:
|
||||||
|
"""set_flag returns True and updates field for a known key."""
|
||||||
|
nf = NotificationFilter()
|
||||||
|
assert nf.set_flag("scenario", False) is True
|
||||||
|
assert nf.scenario_match is False
|
||||||
|
|
||||||
|
def test_set_flag_invalid_key(self) -> None:
|
||||||
|
"""set_flag returns False for an unknown key."""
|
||||||
|
nf = NotificationFilter()
|
||||||
|
assert nf.set_flag("unknown_key", False) is False
|
||||||
|
|
||||||
|
def test_as_dict_keys_match_KEYS(self) -> None:
|
||||||
|
"""as_dict() returns every key defined in KEYS."""
|
||||||
|
nf = NotificationFilter()
|
||||||
|
d = nf.as_dict()
|
||||||
|
assert set(d.keys()) == set(NotificationFilter.KEYS.keys())
|
||||||
|
|
||||||
|
def test_set_notification_valid_key(self) -> None:
|
||||||
|
"""TelegramClient.set_notification toggles filter at runtime."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
assert client._filter.scenario_match is True
|
||||||
|
assert client.set_notification("scenario", False) is True
|
||||||
|
assert client._filter.scenario_match is False
|
||||||
|
|
||||||
|
def test_set_notification_all_off(self) -> None:
|
||||||
|
"""set_notification('all', False) disables every filter flag."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
assert client.set_notification("all", False) is True
|
||||||
|
for v in client.filter_status().values():
|
||||||
|
assert v is False
|
||||||
|
|
||||||
|
def test_set_notification_all_on(self) -> None:
|
||||||
|
"""set_notification('all', True) enables every filter flag."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True,
|
||||||
|
notification_filter=NotificationFilter(
|
||||||
|
trades=False, market_open_close=False, scenario_match=False,
|
||||||
|
fat_finger=False, system_events=False, playbook=False, errors=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert client.set_notification("all", True) is True
|
||||||
|
for v in client.filter_status().values():
|
||||||
|
assert v is True
|
||||||
|
|
||||||
|
def test_set_notification_unknown_key(self) -> None:
|
||||||
|
"""set_notification returns False for an unknown key."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
assert client.set_notification("unknown", False) is False
|
||||||
|
|
||||||
|
def test_filter_status_reflects_current_state(self) -> None:
|
||||||
|
"""filter_status() matches the current NotificationFilter state."""
|
||||||
|
nf = NotificationFilter(trades=False, scenario_match=False)
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
|
||||||
|
)
|
||||||
|
status = client.filter_status()
|
||||||
|
assert status["trades"] is False
|
||||||
|
assert status["scenario"] is False
|
||||||
|
assert status["market"] is True
|
||||||
|
|||||||
@@ -682,6 +682,10 @@ class TestBasicCommands:
|
|||||||
"/help - Show available commands\n"
|
"/help - Show available commands\n"
|
||||||
"/status - Trading status (mode, markets, P&L)\n"
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
"/positions - Current holdings\n"
|
"/positions - Current holdings\n"
|
||||||
|
"/report - Daily summary report\n"
|
||||||
|
"/scenarios - Today's playbook scenarios\n"
|
||||||
|
"/review - Recent scorecards\n"
|
||||||
|
"/dashboard - Dashboard URL/status\n"
|
||||||
"/stop - Pause trading\n"
|
"/stop - Pause trading\n"
|
||||||
"/resume - Resume trading"
|
"/resume - Resume trading"
|
||||||
)
|
)
|
||||||
@@ -707,10 +711,106 @@ class TestBasicCommands:
|
|||||||
assert "/help" in payload["text"]
|
assert "/help" in payload["text"]
|
||||||
assert "/status" in payload["text"]
|
assert "/status" in payload["text"]
|
||||||
assert "/positions" in payload["text"]
|
assert "/positions" in payload["text"]
|
||||||
|
assert "/report" in payload["text"]
|
||||||
|
assert "/scenarios" in payload["text"]
|
||||||
|
assert "/review" in payload["text"]
|
||||||
|
assert "/dashboard" in payload["text"]
|
||||||
assert "/stop" in payload["text"]
|
assert "/stop" in payload["text"]
|
||||||
assert "/resume" in payload["text"]
|
assert "/resume" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtendedCommands:
|
||||||
|
"""Test additional bot commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_report_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_report() -> None:
|
||||||
|
await client.send_message("<b>📈 Daily Report</b>\n\nTrades: 1")
|
||||||
|
|
||||||
|
handler.register_command("report", mock_report)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/report"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Daily Report" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenarios_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_scenarios() -> None:
|
||||||
|
await client.send_message("<b>🧠 Today's Scenarios</b>\n\n- AAPL: BUY (85)")
|
||||||
|
|
||||||
|
handler.register_command("scenarios", mock_scenarios)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/scenarios"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Today's Scenarios" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_review_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_review() -> None:
|
||||||
|
await client.send_message("<b>📝 Recent Reviews</b>\n\n- 2026-02-14 KR")
|
||||||
|
|
||||||
|
handler.register_command("review", mock_review)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/review"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Recent Reviews" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dashboard_command(self) -> None:
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
async def mock_dashboard() -> None:
|
||||||
|
await client.send_message("<b>🖥️ Dashboard</b>\n\nURL: http://127.0.0.1:8080")
|
||||||
|
|
||||||
|
handler.register_command("dashboard", mock_dashboard)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
await handler._handle_update(
|
||||||
|
{"update_id": 1, "message": {"chat": {"id": 456}, "text": "/dashboard"}}
|
||||||
|
)
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Dashboard" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
class TestGetUpdates:
|
class TestGetUpdates:
|
||||||
"""Test getUpdates API interaction."""
|
"""Test getUpdates API interaction."""
|
||||||
|
|
||||||
@@ -775,3 +875,139 @@ class TestGetUpdates:
|
|||||||
updates = await handler._get_updates()
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
assert updates == []
|
assert updates == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_409_stops_polling(self) -> None:
|
||||||
|
"""409 Conflict response stops the poller (_running = False) and returns empty list."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
handler._running = True # simulate active poller
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 409
|
||||||
|
mock_resp.text = AsyncMock(
|
||||||
|
return_value='{"ok":false,"error_code":409,"description":"Conflict"}'
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
|
assert updates == []
|
||||||
|
assert handler._running is False # poller stopped
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_loop_exits_after_409(self) -> None:
|
||||||
|
"""_poll_loop exits naturally after _running is set to False by a 409 response."""
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_get_updates_409() -> list[dict]:
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
# Simulate 409 stopping the poller
|
||||||
|
handler._running = False
|
||||||
|
return []
|
||||||
|
|
||||||
|
handler._get_updates = mock_get_updates_409 # type: ignore[method-assign]
|
||||||
|
|
||||||
|
handler._running = True
|
||||||
|
task = _asyncio.create_task(handler._poll_loop())
|
||||||
|
await _asyncio.wait_for(task, timeout=2.0)
|
||||||
|
|
||||||
|
# _get_updates called exactly once, then loop exited
|
||||||
|
assert call_count == 1
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandWithArgs:
|
||||||
|
"""Test register_command_with_args and argument dispatch."""
|
||||||
|
|
||||||
|
def test_register_command_with_args_stored(self) -> None:
|
||||||
|
"""register_command_with_args stores handler in _commands_with_args."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def my_handler(args: list[str]) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler.register_command_with_args("notify", my_handler)
|
||||||
|
assert "notify" in handler._commands_with_args
|
||||||
|
assert handler._commands_with_args["notify"] is my_handler
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_args_handler_receives_arguments(self) -> None:
|
||||||
|
"""Args handler is called with the trailing tokens."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
received: list[list[str]] = []
|
||||||
|
|
||||||
|
async def capture(args: list[str]) -> None:
|
||||||
|
received.append(args)
|
||||||
|
|
||||||
|
handler.register_command_with_args("notify", capture)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": "456"},
|
||||||
|
"text": "/notify scenario off",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert received == [["scenario", "off"]]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_args_handler_takes_priority_over_no_args_handler(self) -> None:
|
||||||
|
"""When both handlers exist for same command, args handler wins."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
no_args_called = []
|
||||||
|
args_called = []
|
||||||
|
|
||||||
|
async def no_args_handler() -> None:
|
||||||
|
no_args_called.append(True)
|
||||||
|
|
||||||
|
async def args_handler(args: list[str]) -> None:
|
||||||
|
args_called.append(args)
|
||||||
|
|
||||||
|
handler.register_command("notify", no_args_handler)
|
||||||
|
handler.register_command_with_args("notify", args_handler)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": "456"},
|
||||||
|
"text": "/notify all off",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert args_called == [["all", "off"]]
|
||||||
|
assert no_args_called == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_args_handler_with_no_trailing_args(self) -> None:
|
||||||
|
"""/notify with no args still dispatches to args handler with empty list."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
received: list[list[str]] = []
|
||||||
|
|
||||||
|
async def capture(args: list[str]) -> None:
|
||||||
|
received.append(args)
|
||||||
|
|
||||||
|
handler.register_command_with_args("notify", capture)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": "456"},
|
||||||
|
"text": "/notify",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert received == [[]]
|
||||||
|
|||||||
Reference in New Issue
Block a user