Compare commits
98 Commits
feature/is
...
feature/is
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d64e072f06 | ||
|
|
b2312fbe01 | ||
|
|
98c4a2413c | ||
| 6fba7c7ae8 | |||
|
|
be695a5d7c | ||
|
|
6471e66d89 | ||
|
|
149039a904 | ||
| 815d675529 | |||
|
|
e8634b93c3 | ||
| f20736fd2a | |||
|
|
7f2f96a819 | ||
| aaa74894dd | |||
|
|
e711d6702a | ||
|
|
d2fc829380 | ||
| de27b1af10 | |||
|
|
7370220497 | ||
| b01dacf328 | |||
|
|
1210c17989 | ||
|
|
9599b188e8 | ||
| c43660a58c | |||
|
|
7fd48c7764 | ||
| a105bb7c1a | |||
|
|
1a34a74232 | ||
| a82a167915 | |||
|
|
7725e7a8de | ||
|
|
f0ae25c533 | ||
| 27f581f17d | |||
|
|
18a098d9a6 | ||
| d2b07326ed | |||
|
|
1c5eadc23b | ||
| 10ff718045 | |||
|
|
0ca3fe9f5d | ||
| 462f8763ab | |||
|
|
57a45a24cb | ||
| a7696568cc | |||
|
|
70701bf73a | ||
| 20dbd94892 | |||
|
|
48a99962e3 | ||
| ee66ecc305 | |||
|
|
065c9daaad | ||
| c76b9d5c15 | |||
|
|
259f9d2e24 | ||
| 8e715c55cd | |||
|
|
0057de4d12 | ||
|
|
71ac59794e | ||
| be04820b00 | |||
| 10b6e34d44 | |||
| 58f1106dbd | |||
| cf5072cced | |||
|
|
702653e52e | ||
|
|
db0d966a6a | ||
|
|
a56adcd342 | ||
|
|
eaf509a895 | ||
|
|
854931bed2 | ||
| 33b5ff5e54 | |||
| 3923d03650 | |||
|
|
c57ccc4bca | ||
|
|
cb2e3fae57 | ||
| 5e4c68c9d8 | |||
|
|
95f540e5df | ||
| 0087a6b20a | |||
|
|
3dfd7c0935 | ||
| 4b2bb25d03 | |||
|
|
881bbb4240 | ||
| 5f7d61748b | |||
|
|
972e71a2f1 | ||
| 614b9939b1 | |||
|
|
6dbc2afbf4 | ||
| 6c96f9ac64 | |||
|
|
ed26915562 | ||
| 628a572c70 | |||
|
|
73e1d0a54e | ||
| b111157dc8 | |||
|
|
8c05448843 | ||
|
|
87556b145e | ||
| 645c761238 | |||
|
|
033d5fcadd | ||
| 128324427f | |||
|
|
61f5aaf4a3 | ||
|
|
4f61d5af8e | ||
|
|
62fd4ff5e1 | ||
| f40f19e735 | |||
|
|
ce952d97b2 | ||
| 53d3637b3e | |||
|
|
ae7195c829 | ||
| ad1f17bb56 | |||
|
|
62b1a1f37a | ||
| 2a80030ceb | |||
|
|
2f9efdad64 | ||
|
|
6551d7af79 | ||
| 7515a5a314 | |||
|
|
254b543c89 | ||
|
|
917b68eb81 | ||
| 2becbddb4a | |||
|
|
05e8986ff5 | ||
| 3c676c2b8d | |||
|
|
3dd222bd3b | ||
| f4e6b609a4 |
17
.env.example
17
.env.example
@@ -16,8 +16,21 @@ CONFIDENCE_THRESHOLD=80
|
|||||||
# Database
|
# Database
|
||||||
DB_PATH=data/trade_logs.db
|
DB_PATH=data/trade_logs.db
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting (requests per second for KIS API)
|
||||||
RATE_LIMIT_RPS=10.0
|
# Reduced to 5.0 to avoid "초당 거래건수 초과" errors (EGW00201)
|
||||||
|
RATE_LIMIT_RPS=5.0
|
||||||
|
|
||||||
# Trading Mode (paper / live)
|
# Trading Mode (paper / live)
|
||||||
MODE=paper
|
MODE=paper
|
||||||
|
|
||||||
|
# External Data APIs (optional — for enhanced decision-making)
|
||||||
|
# NEWS_API_KEY=your_news_api_key_here
|
||||||
|
# NEWS_API_PROVIDER=alphavantage
|
||||||
|
# MARKET_DATA_API_KEY=your_market_data_key_here
|
||||||
|
|
||||||
|
# Telegram Notifications (optional)
|
||||||
|
# Get bot token from @BotFather on Telegram
|
||||||
|
# Get chat ID from @userinfobot or your chat
|
||||||
|
# TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
# TELEGRAM_CHAT_ID=123456789
|
||||||
|
# TELEGRAM_ENABLED=true
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -174,3 +174,7 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Data files (trade logs, databases)
|
||||||
|
# But NOT src/data/ which contains source code
|
||||||
|
data/
|
||||||
|
!src/data/
|
||||||
|
|||||||
269
CLAUDE.md
269
CLAUDE.md
@@ -1,142 +1,171 @@
|
|||||||
# CLAUDE.md
|
# The Ouroboros
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
AI-powered trading agent for global stock markets with self-evolution capabilities.
|
||||||
|
|
||||||
## Git Workflow Policy
|
## Quick Start
|
||||||
|
|
||||||
**CRITICAL: All code changes MUST follow this workflow. Direct pushes to `main` are ABSOLUTELY PROHIBITED.**
|
|
||||||
|
|
||||||
1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written
|
|
||||||
2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}`
|
|
||||||
3. **Implement Changes** — Write code, tests, and documentation on the feature branch
|
|
||||||
4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number
|
|
||||||
5. **Review & Merge** — After approval, merge via PR (squash or merge commit)
|
|
||||||
|
|
||||||
**Never commit directly to `main`.** This policy applies to all changes, no exceptions.
|
|
||||||
|
|
||||||
## Agent Workflow
|
|
||||||
|
|
||||||
**Modern AI development leverages specialized agents for concurrent, efficient task execution.**
|
|
||||||
|
|
||||||
### Parallel Execution Strategy
|
|
||||||
|
|
||||||
Use **git worktree** or **subagents** (via the Task tool) to handle multiple requirements simultaneously:
|
|
||||||
|
|
||||||
- Each task runs in independent context
|
|
||||||
- Parallel branches for concurrent features
|
|
||||||
- Isolated test environments prevent interference
|
|
||||||
- Faster iteration with distributed workload
|
|
||||||
|
|
||||||
### Specialized Agent Roles
|
|
||||||
|
|
||||||
Deploy task-specific agents as needed instead of handling everything in the main conversation:
|
|
||||||
|
|
||||||
- **Conversational Agent** (main) — Interface with user, coordinate other agents
|
|
||||||
- **Ticket Management Agent** — Create/update Gitea issues, track task status
|
|
||||||
- **Design Agent** — Architectural planning, RFC documents, API design
|
|
||||||
- **Code Writing Agent** — Implementation following specs
|
|
||||||
- **Testing Agent** — Write tests, verify coverage, run test suites
|
|
||||||
- **Documentation Agent** — Update docs, docstrings, CLAUDE.md, README
|
|
||||||
- **Review Agent** — Code review, lint checks, security audits
|
|
||||||
- **Custom Agents** — Created dynamically for specialized tasks (performance analysis, migration scripts, etc.)
|
|
||||||
|
|
||||||
### When to Use Agents
|
|
||||||
|
|
||||||
**Prefer spawning specialized agents for:**
|
|
||||||
|
|
||||||
1. Complex multi-file changes requiring exploration
|
|
||||||
2. Tasks with clear, isolated scope (e.g., "write tests for module X")
|
|
||||||
3. Parallel work streams (feature A + bugfix B simultaneously)
|
|
||||||
4. Long-running analysis (codebase search, dependency audit)
|
|
||||||
5. Tasks requiring different contexts (multiple git worktrees)
|
|
||||||
|
|
||||||
**Use the main conversation for:**
|
|
||||||
|
|
||||||
1. User interaction and clarification
|
|
||||||
2. Quick single-file edits
|
|
||||||
3. Coordinating agent work
|
|
||||||
4. High-level decision making
|
|
||||||
|
|
||||||
### Implementation
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Example: Spawn parallel test and documentation agents
|
|
||||||
task_tool(
|
|
||||||
subagent_type="general-purpose",
|
|
||||||
prompt="Write comprehensive tests for src/markets/schedule.py",
|
|
||||||
description="Write schedule tests"
|
|
||||||
)
|
|
||||||
|
|
||||||
task_tool(
|
|
||||||
subagent_type="general-purpose",
|
|
||||||
prompt="Update README.md with global market feature documentation",
|
|
||||||
description="Update README"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `run_in_background=True` for independent tasks that don't block subsequent work.
|
|
||||||
|
|
||||||
## Build & Test Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install all dependencies (production + dev)
|
# Setup
|
||||||
pip install ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your KIS and Gemini API credentials
|
||||||
|
|
||||||
# Run full test suite with coverage
|
# Test
|
||||||
pytest -v --cov=src --cov-report=term-missing
|
pytest -v --cov=src
|
||||||
|
|
||||||
# Run a single test file
|
# Run (paper trading)
|
||||||
pytest tests/test_risk.py -v
|
|
||||||
|
|
||||||
# Run a single test by name
|
|
||||||
pytest tests/test_brain.py -k "test_parse_valid_json" -v
|
|
||||||
|
|
||||||
# Lint
|
|
||||||
ruff check src/ tests/
|
|
||||||
|
|
||||||
# Type check (strict mode, non-blocking in CI)
|
|
||||||
mypy src/ --strict
|
|
||||||
|
|
||||||
# Run the trading agent
|
|
||||||
python -m src.main --mode=paper
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker compose up -d ouroboros # Run agent
|
|
||||||
docker compose --profile test up test # Run tests in container
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Telegram Notifications (Optional)
|
||||||
|
|
||||||
Self-evolving AI trading agent for Korean stock markets (KIS API). The main loop in `src/main.py` orchestrates four components in a 60-second cycle per stock:
|
Get real-time alerts for trades, circuit breakers, and system events via Telegram.
|
||||||
|
|
||||||
1. **Broker** (`src/broker/kis_api.py`) — Async KIS API client with automatic OAuth token refresh, leaky-bucket rate limiter (10 RPS), and POST body hash-key signing. Uses a custom SSL context with disabled hostname verification for the VTS (virtual trading) endpoint due to a known certificate mismatch.
|
### Quick Setup
|
||||||
|
|
||||||
2. **Brain** (`src/brain/gemini_client.py`) — Sends structured prompts to Google Gemini, parses JSON responses into `TradeDecision` objects. Forces HOLD when confidence < threshold (default 80). Falls back to safe HOLD on any parse/API error.
|
1. **Create bot**: Message [@BotFather](https://t.me/BotFather) on Telegram → `/newbot`
|
||||||
|
2. **Get chat ID**: Message [@userinfobot](https://t.me/userinfobot) → `/start`
|
||||||
|
3. **Configure**: Add to `.env`:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
```
|
||||||
|
4. **Test**: Start bot conversation (`/start`), then run the agent
|
||||||
|
|
||||||
3. **Risk Manager** (`src/core/risk_manager.py`) — **READ-ONLY by policy** (see `docs/agents.md`). Circuit breaker halts all trading via `SystemExit` when daily P&L drops below -3.0%. Fat-finger check rejects orders exceeding 30% of available cash.
|
**Full documentation**: [src/notifications/README.md](src/notifications/README.md)
|
||||||
|
|
||||||
4. **Evolution** (`src/evolution/optimizer.py`) — Analyzes high-confidence losing trades from SQLite, asks Gemini to generate new `BaseStrategy` subclasses, validates them by running the full pytest suite, and simulates PR creation.
|
### What You'll Get
|
||||||
|
|
||||||
**Data flow per cycle:** Fetch orderbook + balance → calculate P&L → get Gemini decision → validate with risk manager → execute order → log to SQLite (`src/db.py`).
|
- 🟢 Trade execution alerts (BUY/SELL with confidence)
|
||||||
|
- 🚨 Circuit breaker trips (automatic trading halt)
|
||||||
|
- ⚠️ Fat-finger rejections (oversized orders blocked)
|
||||||
|
- ℹ️ Market open/close notifications
|
||||||
|
- 📝 System startup/shutdown status
|
||||||
|
|
||||||
## Key Constraints (from `docs/agents.md`)
|
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
|
||||||
|
|
||||||
- `core/risk_manager.py` is **READ-ONLY**. Changes require human approval.
|
## Smart Volatility Scanner (Optional)
|
||||||
- Circuit breaker threshold (-3.0%) may only be made stricter, never relaxed.
|
|
||||||
- Fat-finger protection (30% max order size) must always be enforced.
|
|
||||||
- Confidence < 80 **must** force HOLD — this rule cannot be weakened.
|
|
||||||
- All code changes require corresponding tests. Coverage must stay >= 80%.
|
|
||||||
- Generated strategies must pass the full test suite before activation.
|
|
||||||
|
|
||||||
## Configuration
|
Python-first filtering pipeline that reduces Gemini API calls by pre-filtering stocks using technical indicators.
|
||||||
|
|
||||||
Pydantic Settings loaded from `.env` (see `.env.example`). Required vars: `KIS_APP_KEY`, `KIS_APP_SECRET`, `KIS_ACCOUNT_NO` (format `XXXXXXXX-XX`), `GEMINI_API_KEY`. Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.
|
### How It Works
|
||||||
|
|
||||||
## Test Structure
|
1. **Fetch Rankings** — KIS API volume surge rankings (top 30 stocks)
|
||||||
|
2. **Python Filter** — RSI + volume ratio calculations (no AI)
|
||||||
|
- Volume > 200% of previous day
|
||||||
|
- RSI(14) < 30 (oversold) OR RSI(14) > 70 (momentum)
|
||||||
|
3. **AI Judgment** — Only qualified candidates (1-3 stocks) sent to Gemini
|
||||||
|
|
||||||
54 tests across four 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.
|
### Configuration
|
||||||
|
|
||||||
- `test_risk.py` (11) — Circuit breaker boundaries, fat-finger edge cases
|
Add to `.env` (optional, has sensible defaults):
|
||||||
- `test_broker.py` (6) — Token lifecycle, rate limiting, hash keys, network errors
|
```bash
|
||||||
- `test_brain.py` (18) — JSON parsing, confidence threshold, malformed responses, prompt construction
|
RSI_OVERSOLD_THRESHOLD=30 # 0-50, default 30
|
||||||
- `test_market_schedule.py` (19) — Market open/close logic, timezone handling, DST, lunch breaks
|
RSI_MOMENTUM_THRESHOLD=70 # 50-100, default 70
|
||||||
|
VOL_MULTIPLIER=2.0 # Volume threshold (2.0 = 200%)
|
||||||
|
SCANNER_TOP_N=3 # Max candidates per scan
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benefits
|
||||||
|
|
||||||
|
- **Reduces API costs** — Process 1-3 stocks instead of 20-30
|
||||||
|
- **Python-based filtering** — Fast technical analysis before AI
|
||||||
|
- **Evolution-ready** — Selection context logged for strategy optimization
|
||||||
|
- **Fault-tolerant** — Falls back to static watchlist on API failure
|
||||||
|
|
||||||
|
### Realtime Mode Only
|
||||||
|
|
||||||
|
Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchlists for batch efficiency.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[Workflow Guide](docs/workflow.md)** — Git workflow policy and agent-based development
|
||||||
|
- **[Command Reference](docs/commands.md)** — Common failures, build commands, troubleshooting
|
||||||
|
- **[Architecture](docs/architecture.md)** — System design, components, data flow
|
||||||
|
- **[Context Tree](docs/context-tree.md)** — L1-L7 hierarchical memory system
|
||||||
|
- **[Testing](docs/testing.md)** — Test structure, coverage requirements, writing tests
|
||||||
|
- **[Agent Policies](docs/agents.md)** — Prime directives, constraints, prohibited actions
|
||||||
|
- **[Requirements Log](docs/requirements-log.md)** — User requirements and feedback tracking
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
1. **Safety First** — Risk manager is READ-ONLY and enforces circuit breakers
|
||||||
|
2. **Test Everything** — 80% coverage minimum, all changes require tests
|
||||||
|
3. **Issue-Driven Development** — All work goes through Gitea issues → feature branches → PRs
|
||||||
|
4. **Agent Specialization** — Use dedicated agents for design, coding, testing, docs, review
|
||||||
|
|
||||||
|
## Requirements Management
|
||||||
|
|
||||||
|
User requirements and feedback are tracked in [docs/requirements-log.md](docs/requirements-log.md):
|
||||||
|
|
||||||
|
- New requirements are added chronologically with dates
|
||||||
|
- Code changes should reference related requirements
|
||||||
|
- Helps maintain project evolution aligned with user needs
|
||||||
|
- Preserves context across conversations and development cycles
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── analysis/ # Technical analysis (RSI, volatility, smart scanner)
|
||||||
|
├── broker/ # KIS API client (domestic + overseas)
|
||||||
|
├── brain/ # Gemini AI decision engine
|
||||||
|
├── core/ # Risk manager (READ-ONLY)
|
||||||
|
├── evolution/ # Self-improvement optimizer
|
||||||
|
├── markets/ # Market schedules and timezone handling
|
||||||
|
├── notifications/ # Telegram real-time alerts
|
||||||
|
├── db.py # SQLite trade logging
|
||||||
|
├── main.py # Trading loop orchestrator
|
||||||
|
└── config.py # Settings (from .env)
|
||||||
|
|
||||||
|
tests/ # 343 tests across 14 files
|
||||||
|
docs/ # Extended documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest -v --cov=src # Run tests with coverage
|
||||||
|
ruff check src/ tests/ # Lint
|
||||||
|
mypy src/ --strict # Type check
|
||||||
|
|
||||||
|
python -m src.main --mode=paper # Paper trading
|
||||||
|
python -m src.main --mode=live # Live trading (⚠️ real money)
|
||||||
|
|
||||||
|
# Gitea workflow (requires tea CLI)
|
||||||
|
YES="" ~/bin/tea issues create --repo jihoson/The-Ouroboros --title "..." --description "..."
|
||||||
|
YES="" ~/bin/tea pulls create --head feature-branch --base main --title "..." --description "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Markets Supported
|
||||||
|
|
||||||
|
- 🇰🇷 Korea (KRX)
|
||||||
|
- 🇺🇸 United States (NASDAQ, NYSE, AMEX)
|
||||||
|
- 🇯🇵 Japan (TSE)
|
||||||
|
- 🇭🇰 Hong Kong (SEHK)
|
||||||
|
- 🇨🇳 China (Shanghai, Shenzhen)
|
||||||
|
- 🇻🇳 Vietnam (Hanoi, HCM)
|
||||||
|
|
||||||
|
Markets auto-detected based on timezone and enabled in `ENABLED_MARKETS` env variable.
|
||||||
|
|
||||||
|
## Critical Constraints
|
||||||
|
|
||||||
|
⚠️ **Non-Negotiable Rules** (see [docs/agents.md](docs/agents.md)):
|
||||||
|
|
||||||
|
- `src/core/risk_manager.py` is **READ-ONLY** — changes require human approval
|
||||||
|
- Circuit breaker at -3.0% P&L — may only be made **stricter**
|
||||||
|
- Fat-finger protection: max 30% of cash per order — always enforced
|
||||||
|
- Confidence < 80 → force HOLD — cannot be weakened
|
||||||
|
- All code changes → corresponding tests → coverage ≥ 80%
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [docs/workflow.md](docs/workflow.md) for the complete development process.
|
||||||
|
|
||||||
|
**TL;DR:**
|
||||||
|
1. Create issue in Gitea
|
||||||
|
2. Create feature branch: `feature/issue-N-description`
|
||||||
|
3. Implement with tests
|
||||||
|
4. Open PR
|
||||||
|
5. Merge after review
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -29,6 +29,7 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
|
|||||||
| 브로커 | `src/broker/kis_api.py` | KIS API 비동기 래퍼 (토큰 갱신, 레이트 리미터, 해시키) |
|
| 브로커 | `src/broker/kis_api.py` | KIS API 비동기 래퍼 (토큰 갱신, 레이트 리미터, 해시키) |
|
||||||
| 두뇌 | `src/brain/gemini_client.py` | Gemini 프롬프트 구성 및 JSON 응답 파싱 |
|
| 두뇌 | `src/brain/gemini_client.py` | Gemini 프롬프트 구성 및 JSON 응답 파싱 |
|
||||||
| 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 |
|
| 방패 | `src/core/risk_manager.py` | 서킷 브레이커 + 팻 핑거 체크 |
|
||||||
|
| 알림 | `src/notifications/telegram_client.py` | 텔레그램 실시간 거래 알림 (선택사항) |
|
||||||
| 진화 | `src/evolution/optimizer.py` | 실패 패턴 분석 → 새 전략 생성 → 테스트 → PR |
|
| 진화 | `src/evolution/optimizer.py` | 실패 패턴 분석 → 새 전략 생성 → 테스트 → PR |
|
||||||
| DB | `src/db.py` | SQLite 거래 로그 기록 |
|
| DB | `src/db.py` | SQLite 거래 로그 기록 |
|
||||||
|
|
||||||
@@ -75,6 +76,34 @@ python -m src.main --mode=paper
|
|||||||
docker compose up -d ouroboros
|
docker compose up -d ouroboros
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 텔레그램 알림 (선택사항)
|
||||||
|
|
||||||
|
거래 실행, 서킷 브레이커 발동, 시스템 상태 등을 텔레그램으로 실시간 알림 받을 수 있습니다.
|
||||||
|
|
||||||
|
### 빠른 설정
|
||||||
|
|
||||||
|
1. **봇 생성**: 텔레그램에서 [@BotFather](https://t.me/BotFather) 메시지 → `/newbot` 명령
|
||||||
|
2. **채팅 ID 확인**: [@userinfobot](https://t.me/userinfobot) 메시지 → `/start` 명령
|
||||||
|
3. **환경변수 설정**: `.env` 파일에 추가
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
```
|
||||||
|
4. **테스트**: 봇과 대화 시작 (`/start` 전송) 후 에이전트 실행
|
||||||
|
|
||||||
|
**상세 문서**: [src/notifications/README.md](src/notifications/README.md)
|
||||||
|
|
||||||
|
### 알림 종류
|
||||||
|
|
||||||
|
- 🟢 거래 체결 알림 (BUY/SELL + 신뢰도)
|
||||||
|
- 🚨 서킷 브레이커 발동 (자동 거래 중단)
|
||||||
|
- ⚠️ 팻 핑거 차단 (과도한 주문 차단)
|
||||||
|
- ℹ️ 장 시작/종료 알림
|
||||||
|
- 📝 시스템 시작/종료 상태
|
||||||
|
|
||||||
|
**안전장치**: 알림 실패해도 거래는 계속 진행됩니다. 텔레그램 API 오류나 설정 누락이 있어도 거래 시스템은 정상 작동합니다.
|
||||||
|
|
||||||
## 테스트
|
## 테스트
|
||||||
|
|
||||||
35개 테스트가 TDD 방식으로 구현 전에 먼저 작성되었습니다.
|
35개 테스트가 TDD 방식으로 구현 전에 먼저 작성되었습니다.
|
||||||
@@ -104,15 +133,16 @@ The-Ouroboros/
|
|||||||
│ ├── agents.md # AI 에이전트 페르소나 정의
|
│ ├── agents.md # AI 에이전트 페르소나 정의
|
||||||
│ └── skills.md # 사용 가능한 도구 목록
|
│ └── skills.md # 사용 가능한 도구 목록
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── config.py # Pydantic 설정
|
│ ├── config.py # Pydantic 설정
|
||||||
│ ├── logging_config.py # JSON 구조화 로깅
|
│ ├── logging_config.py # JSON 구조화 로깅
|
||||||
│ ├── db.py # SQLite 거래 기록
|
│ ├── db.py # SQLite 거래 기록
|
||||||
│ ├── main.py # 비동기 거래 루프
|
│ ├── main.py # 비동기 거래 루프
|
||||||
│ ├── broker/kis_api.py # KIS API 클라이언트
|
│ ├── broker/kis_api.py # KIS API 클라이언트
|
||||||
│ ├── brain/gemini_client.py # Gemini 의사결정 엔진
|
│ ├── brain/gemini_client.py # Gemini 의사결정 엔진
|
||||||
│ ├── core/risk_manager.py # 리스크 관리
|
│ ├── core/risk_manager.py # 리스크 관리
|
||||||
│ ├── evolution/optimizer.py # 전략 진화 엔진
|
│ ├── notifications/telegram_client.py # 텔레그램 알림
|
||||||
│ └── strategies/base.py # 전략 베이스 클래스
|
│ ├── evolution/optimizer.py # 전략 진화 엔진
|
||||||
|
│ └── strategies/base.py # 전략 베이스 클래스
|
||||||
├── tests/ # TDD 테스트 스위트
|
├── tests/ # TDD 테스트 스위트
|
||||||
├── Dockerfile # 멀티스테이지 빌드
|
├── Dockerfile # 멀티스테이지 빌드
|
||||||
├── docker-compose.yml # 서비스 오케스트레이션
|
├── docker-compose.yml # 서비스 오케스트레이션
|
||||||
|
|||||||
45
docs/agent-constraints.md
Normal file
45
docs/agent-constraints.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Agent Constraints
|
||||||
|
|
||||||
|
This document records **persistent behavioral constraints** for agents working on this repository.
|
||||||
|
It is distinct from `docs/requirements-log.md`, which records **project/product requirements**.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Applies to all AI agents and automation that modify this repo.
|
||||||
|
- Supplements (does not replace) `docs/agents.md` and `docs/workflow.md`.
|
||||||
|
|
||||||
|
## Persistent Rules
|
||||||
|
|
||||||
|
1. **Workflow enforcement**
|
||||||
|
- Follow `docs/workflow.md` for all changes.
|
||||||
|
- Create a Gitea issue before any code or documentation change.
|
||||||
|
- Work on a feature branch `feature/issue-{N}-{short-description}` and open a PR.
|
||||||
|
- Never commit directly to `main`.
|
||||||
|
|
||||||
|
2. **Document-first routing**
|
||||||
|
- When performing work, consult relevant `docs/` files *before* making changes.
|
||||||
|
- Route decisions to the documented policy whenever applicable.
|
||||||
|
- If guidance conflicts, prefer the stricter/safety-first rule and note it in the PR.
|
||||||
|
|
||||||
|
3. **Docs with code**
|
||||||
|
- Any code change must be accompanied by relevant documentation updates.
|
||||||
|
- If no doc update is needed, state the reason explicitly in the PR.
|
||||||
|
|
||||||
|
4. **Session-persistent user constraints**
|
||||||
|
- If the user requests that a behavior should persist across sessions, record it here
|
||||||
|
(or in a dedicated policy doc) and reference it when working.
|
||||||
|
- Keep entries short and concrete, with dates.
|
||||||
|
|
||||||
|
## Change Control
|
||||||
|
|
||||||
|
- Changes to this file follow the same workflow as code changes.
|
||||||
|
- Keep the history chronological and minimize rewording of existing entries.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
### 2026-02-08
|
||||||
|
|
||||||
|
- Always enforce Gitea workflow: issue -> feature branch -> PR before changes.
|
||||||
|
- When work requires guidance, consult the relevant `docs/` policies first.
|
||||||
|
- Any code change must be accompanied by relevant documentation updates.
|
||||||
|
- Persist user constraints across sessions by recording them in this document.
|
||||||
343
docs/architecture.md
Normal file
343
docs/architecture.md
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
# System Architecture
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
|
||||||
|
## Trading Modes
|
||||||
|
|
||||||
|
The system supports two trading frequency modes controlled by the `TRADE_MODE` environment variable:
|
||||||
|
|
||||||
|
### Daily Mode (default)
|
||||||
|
|
||||||
|
Optimized for Gemini Free tier API limits (20 calls/day):
|
||||||
|
|
||||||
|
- **Batch decisions**: 1 API call per market per session
|
||||||
|
- **Fixed schedule**: 4 sessions per day at 6-hour intervals (configurable)
|
||||||
|
- **API efficiency**: Processes all stocks in a market simultaneously
|
||||||
|
- **Use case**: Free tier users, cost-conscious deployments
|
||||||
|
- **Configuration**:
|
||||||
|
```bash
|
||||||
|
TRADE_MODE=daily
|
||||||
|
DAILY_SESSIONS=4 # Sessions per day (1-10)
|
||||||
|
SESSION_INTERVAL_HOURS=6 # Hours between sessions (1-24)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**: With 2 markets (US, KR) and 4 sessions/day = 8 API calls/day (within 20 call limit)
|
||||||
|
|
||||||
|
### Realtime Mode
|
||||||
|
|
||||||
|
High-frequency trading with individual stock analysis:
|
||||||
|
|
||||||
|
- **Per-stock decisions**: 1 API call per stock per cycle
|
||||||
|
- **60-second interval**: Continuous monitoring
|
||||||
|
- **Use case**: Production deployments with Gemini paid tier
|
||||||
|
- **Configuration**:
|
||||||
|
```bash
|
||||||
|
TRADE_MODE=realtime
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: Realtime mode requires Gemini API subscription due to high call volume.
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### 1. Broker (`src/broker/`)
|
||||||
|
|
||||||
|
**KISBroker** (`kis_api.py`) — Async KIS API client for domestic Korean market
|
||||||
|
|
||||||
|
- Automatic OAuth token refresh (valid for 24 hours)
|
||||||
|
- Leaky-bucket rate limiter (10 requests per second)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**OverseasBroker** (`overseas.py`) — KIS overseas stock API wrapper
|
||||||
|
|
||||||
|
- Reuses KISBroker infrastructure (session, token, rate limiter) via composition
|
||||||
|
- Supports 9 global markets: US (NASDAQ/NYSE/AMEX), Japan, Hong Kong, China (Shanghai/Shenzhen), Vietnam (Hanoi/HCM)
|
||||||
|
- Different API endpoints for overseas price/balance/order operations
|
||||||
|
|
||||||
|
**Market Schedule** (`src/markets/schedule.py`) — Timezone-aware market management
|
||||||
|
|
||||||
|
- `MarketInfo` dataclass with timezone, trading hours, lunch breaks
|
||||||
|
- Automatic DST handling via `zoneinfo.ZoneInfo`
|
||||||
|
- `is_market_open()` checks weekends, trading hours, lunch breaks
|
||||||
|
- `get_open_markets()` returns currently active markets
|
||||||
|
- `get_next_market_open()` finds next market to open and when
|
||||||
|
|
||||||
|
**New API Methods** (added in v0.9.0):
|
||||||
|
- `fetch_market_rankings()` — Fetch volume surge rankings from KIS API
|
||||||
|
- `get_daily_prices()` — Fetch OHLCV history for technical analysis
|
||||||
|
|
||||||
|
### 2. Analysis (`src/analysis/`)
|
||||||
|
|
||||||
|
**VolatilityAnalyzer** (`volatility.py`) — Technical indicator calculations
|
||||||
|
|
||||||
|
- ATR (Average True Range) for volatility measurement
|
||||||
|
- RSI (Relative Strength Index) using Wilder's smoothing method
|
||||||
|
- Price change percentages across multiple timeframes
|
||||||
|
- Volume surge ratios and price-volume divergence
|
||||||
|
- Momentum scoring (0-100 scale)
|
||||||
|
- Breakout/breakdown pattern detection
|
||||||
|
|
||||||
|
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
|
||||||
|
|
||||||
|
- **Step 1**: Fetch volume rankings from KIS API (top 30 stocks)
|
||||||
|
- **Step 2**: Calculate RSI and volume ratio for each stock
|
||||||
|
- **Step 3**: Apply filters:
|
||||||
|
- Volume ratio >= `VOL_MULTIPLIER` (default 2.0x previous day)
|
||||||
|
- RSI < `RSI_OVERSOLD_THRESHOLD` (30) OR RSI > `RSI_MOMENTUM_THRESHOLD` (70)
|
||||||
|
- **Step 4**: Score candidates by RSI extremity (60%) + volume surge (40%)
|
||||||
|
- **Step 5**: Return top N candidates (default 3) for AI analysis
|
||||||
|
- **Fallback**: Uses static watchlist if ranking API unavailable
|
||||||
|
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
|
||||||
|
- Fast Python-based filtering before expensive AI judgment
|
||||||
|
- Logs selection context (RSI, volume_ratio, signal, score) for Evolution system
|
||||||
|
|
||||||
|
### 3. Brain (`src/brain/gemini_client.py`)
|
||||||
|
|
||||||
|
**GeminiClient** — AI decision engine powered by Google Gemini
|
||||||
|
|
||||||
|
- Constructs structured prompts from market data
|
||||||
|
- Parses JSON responses into `TradeDecision` objects (`action`, `confidence`, `rationale`)
|
||||||
|
- Forces HOLD when confidence < threshold (default 80)
|
||||||
|
- Falls back to safe HOLD on any parse/API error
|
||||||
|
- Handles markdown-wrapped JSON, malformed responses, invalid actions
|
||||||
|
|
||||||
|
### 4. Risk Manager (`src/core/risk_manager.py`)
|
||||||
|
|
||||||
|
**RiskManager** — Safety circuit breaker and order validation
|
||||||
|
|
||||||
|
⚠️ **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%
|
||||||
|
- Threshold may only be made stricter, never relaxed
|
||||||
|
- Calculated as `(total_eval - purchase_total) / purchase_total * 100`
|
||||||
|
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
|
||||||
|
- Must always be enforced, cannot be disabled
|
||||||
|
|
||||||
|
### 5. Notifications (`src/notifications/telegram_client.py`)
|
||||||
|
|
||||||
|
**TelegramClient** — Real-time event notifications via Telegram Bot API
|
||||||
|
|
||||||
|
- Sends alerts for trades, circuit breakers, fat-finger rejections, system events
|
||||||
|
- Non-blocking: failures are logged but never crash trading
|
||||||
|
- Rate-limited: 1 message/second default to respect Telegram API limits
|
||||||
|
- Auto-disabled when credentials missing
|
||||||
|
- Gracefully handles API errors, network timeouts, invalid tokens
|
||||||
|
|
||||||
|
**Notification Types:**
|
||||||
|
- Trade execution (BUY/SELL with confidence)
|
||||||
|
- Circuit breaker trips (critical alert)
|
||||||
|
- Fat-finger protection triggers (order rejection)
|
||||||
|
- Market open/close events
|
||||||
|
- System startup/shutdown status
|
||||||
|
|
||||||
|
**Setup:** See [src/notifications/README.md](../src/notifications/README.md) for bot creation and configuration.
|
||||||
|
|
||||||
|
### 6. Evolution (`src/evolution/optimizer.py`)
|
||||||
|
|
||||||
|
**StrategyOptimizer** — Self-improvement loop
|
||||||
|
|
||||||
|
- Analyzes high-confidence losing trades from SQLite
|
||||||
|
- Asks Gemini to generate new `BaseStrategy` subclasses
|
||||||
|
- Validates generated strategies by running full pytest suite
|
||||||
|
- Simulates PR creation for human review
|
||||||
|
- Only activates strategies that pass all tests
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Realtime Mode (with Smart Scanner)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Main Loop (60s cycle per market) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Market Schedule Check │
|
||||||
|
│ - Get open markets │
|
||||||
|
│ - Filter by enabled markets │
|
||||||
|
│ - Wait if all closed │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Smart Scanner (Python-first) │
|
||||||
|
│ - Fetch volume rankings (KIS) │
|
||||||
|
│ - Get 20d price history per stock│
|
||||||
|
│ - Calculate RSI(14) + vol ratio │
|
||||||
|
│ - Filter: vol>2x AND RSI extreme │
|
||||||
|
│ - Return top 3 qualified stocks │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ For Each Qualified Candidate │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Broker: Fetch Market Data │
|
||||||
|
│ - Domestic: orderbook + balance │
|
||||||
|
│ - Overseas: price + balance │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Calculate P&L │
|
||||||
|
│ pnl_pct = (eval - cost) / cost │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Brain: Get Decision (AI) │
|
||||||
|
│ - Build prompt with market data │
|
||||||
|
│ - Call Gemini API │
|
||||||
|
│ - Parse JSON response │
|
||||||
|
│ - Return TradeDecision │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Risk Manager: Validate Order │
|
||||||
|
│ - Check circuit breaker │
|
||||||
|
│ - Check fat-finger limit │
|
||||||
|
│ - Raise if validation fails │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Broker: Execute Order │
|
||||||
|
│ - Domestic: send_order() │
|
||||||
|
│ - Overseas: send_overseas_order() │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ Notifications: Send Alert │
|
||||||
|
│ - Trade execution notification │
|
||||||
|
│ - Non-blocking (errors logged) │
|
||||||
|
│ - Rate-limited to 1/sec │
|
||||||
|
└──────────────────┬────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ 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
|
||||||
|
|
||||||
|
**SQLite** (`src/db.py`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- BUY | SELL | HOLD
|
||||||
|
confidence INTEGER NOT NULL, -- 0-100
|
||||||
|
rationale TEXT,
|
||||||
|
quantity INTEGER,
|
||||||
|
price REAL,
|
||||||
|
pnl REAL DEFAULT 0.0,
|
||||||
|
market TEXT DEFAULT 'KR', -- KR | US_NASDAQ | JP | etc.
|
||||||
|
exchange_code TEXT DEFAULT 'KRX', -- KRX | NASD | NYSE | etc.
|
||||||
|
selection_context TEXT -- JSON: {rsi, volume_ratio, signal, score}
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Selection Context** (new in v0.9.0): Stores scanner selection criteria as JSON:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"rsi": 28.5,
|
||||||
|
"volume_ratio": 2.7,
|
||||||
|
"signal": "oversold",
|
||||||
|
"score": 85.2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enables Evolution system to analyze correlation between selection criteria and trade outcomes.
|
||||||
|
|
||||||
|
Auto-migration: Adds `market`, `exchange_code`, and `selection_context` columns if missing for backward compatibility.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Pydantic Settings** (`src/config.py`)
|
||||||
|
|
||||||
|
Loaded from `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Required
|
||||||
|
KIS_APP_KEY=your_app_key
|
||||||
|
KIS_APP_SECRET=your_app_secret
|
||||||
|
KIS_ACCOUNT_NO=XXXXXXXX-XX
|
||||||
|
GEMINI_API_KEY=your_gemini_key
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
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
|
||||||
|
DAILY_SESSIONS=4 # Sessions per day (daily mode only)
|
||||||
|
SESSION_INTERVAL_HOURS=6 # Hours between sessions (daily mode only)
|
||||||
|
|
||||||
|
# Telegram Notifications (optional)
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
|
||||||
|
# Smart Scanner (optional, realtime mode only)
|
||||||
|
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
|
||||||
|
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold
|
||||||
|
VOL_MULTIPLIER=2.0 # Minimum volume ratio (2.0 = 200%)
|
||||||
|
SCANNER_TOP_N=3 # Max qualified candidates per scan
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use in-memory SQLite (`DB_PATH=":memory:"`) and dummy credentials via `tests/conftest.py`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Connection Errors (Broker API)
|
||||||
|
- Retry with exponential backoff (2^attempt seconds)
|
||||||
|
- Max 3 retries per stock
|
||||||
|
- After exhaustion, skip stock and continue with next
|
||||||
|
|
||||||
|
### API Quota Errors (Gemini)
|
||||||
|
- Return safe HOLD decision with confidence=0
|
||||||
|
- Log error but don't crash
|
||||||
|
- Agent continues trading on next cycle
|
||||||
|
|
||||||
|
### Circuit Breaker Tripped
|
||||||
|
- Immediately halt via `SystemExit`
|
||||||
|
- Log critical message
|
||||||
|
- Requires manual intervention to restart
|
||||||
|
|
||||||
|
### Market Closed
|
||||||
|
- Wait until next market opens
|
||||||
|
- Use `get_next_market_open()` to calculate wait time
|
||||||
|
- Sleep until market open time
|
||||||
|
|
||||||
|
### Telegram API Errors
|
||||||
|
- Log warning but continue trading
|
||||||
|
- Missing credentials → auto-disable notifications
|
||||||
|
- Network timeout → skip notification, no retry
|
||||||
|
- Invalid token → log error, trading unaffected
|
||||||
|
- Rate limit exceeded → queued via rate limiter
|
||||||
|
|
||||||
|
**Guarantee**: Notification failures never interrupt trading operations.
|
||||||
156
docs/commands.md
Normal file
156
docs/commands.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Command Reference
|
||||||
|
|
||||||
|
## Common Command Failures
|
||||||
|
|
||||||
|
**Critical: Learn from failures. Never repeat the same failed command without modification.**
|
||||||
|
|
||||||
|
### tea CLI (Gitea Command Line Tool)
|
||||||
|
|
||||||
|
#### ❌ TTY Error - Interactive Confirmation Fails
|
||||||
|
```bash
|
||||||
|
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
||||||
|
# Error: huh: could not open a new TTY: open /dev/tty: no such device or address
|
||||||
|
```
|
||||||
|
**💡 Reason:** tea tries to open `/dev/tty` for interactive confirmation prompts, which is unavailable in non-interactive environments.
|
||||||
|
|
||||||
|
**✅ Solution:** Use `YES=""` environment variable to bypass confirmation
|
||||||
|
```bash
|
||||||
|
YES="" ~/bin/tea issues create --repo jihoson/The-Ouroboros --title "Title" --description "Body"
|
||||||
|
YES="" ~/bin/tea issues edit <number> --repo jihoson/The-Ouroboros --description "Updated body"
|
||||||
|
YES="" ~/bin/tea pulls create --repo jihoson/The-Ouroboros --head feature-branch --base main --title "Title" --description "Body"
|
||||||
|
```
|
||||||
|
|
||||||
|
**📝 Notes:**
|
||||||
|
- Always set default login: `~/bin/tea login default local`
|
||||||
|
- Use `--repo jihoson/The-Ouroboros` when outside repo directory
|
||||||
|
- tea is preferred over direct Gitea API calls for consistency
|
||||||
|
|
||||||
|
#### ❌ Wrong Parameter Name
|
||||||
|
```bash
|
||||||
|
tea issues create --body "text"
|
||||||
|
# Error: flag provided but not defined: -body
|
||||||
|
```
|
||||||
|
**💡 Reason:** Parameter is `--description`, not `--body`.
|
||||||
|
|
||||||
|
**✅ Solution:** Use correct parameter name
|
||||||
|
```bash
|
||||||
|
YES="" ~/bin/tea issues create --description "text"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea API (Direct HTTP Calls)
|
||||||
|
|
||||||
|
#### ❌ Wrong Hostname
|
||||||
|
```bash
|
||||||
|
curl http://gitea.local:3000/api/v1/...
|
||||||
|
# Error: Could not resolve host: gitea.local
|
||||||
|
```
|
||||||
|
**💡 Reason:** Gitea instance runs on `localhost:3000`, not `gitea.local`.
|
||||||
|
|
||||||
|
**✅ Solution:** Use correct hostname (but prefer tea CLI)
|
||||||
|
```bash
|
||||||
|
curl http://localhost:3000/api/v1/repos/jihoson/The-Ouroboros/issues \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title":"...", "body":"..."}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**📝 Notes:**
|
||||||
|
- Prefer `tea` CLI over direct API calls
|
||||||
|
- Only use curl for operations tea doesn't support
|
||||||
|
|
||||||
|
### Git Commands
|
||||||
|
|
||||||
|
#### ❌ User Not Configured
|
||||||
|
```bash
|
||||||
|
git commit -m "message"
|
||||||
|
# Error: Author identity unknown
|
||||||
|
```
|
||||||
|
**💡 Reason:** Git user.name and user.email not set.
|
||||||
|
|
||||||
|
**✅ Solution:** Configure git user
|
||||||
|
```bash
|
||||||
|
git config user.name "agentson"
|
||||||
|
git config user.email "agentson@localhost"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ❌ Permission Denied on Push
|
||||||
|
```bash
|
||||||
|
git push origin branch
|
||||||
|
# Error: User permission denied for writing
|
||||||
|
```
|
||||||
|
**💡 Reason:** Repository access token lacks write permissions or user lacks repo write access.
|
||||||
|
|
||||||
|
**✅ Solution:**
|
||||||
|
1. Verify user has write access to repository (admin grants this)
|
||||||
|
2. Ensure git credential has correct token with `write:repository` scope
|
||||||
|
3. Check remote URL uses correct authentication
|
||||||
|
|
||||||
|
### Python/Pytest
|
||||||
|
|
||||||
|
#### ❌ Module Import Error
|
||||||
|
```bash
|
||||||
|
pytest tests/test_foo.py
|
||||||
|
# ModuleNotFoundError: No module named 'src'
|
||||||
|
```
|
||||||
|
**💡 Reason:** Package not installed in development mode.
|
||||||
|
|
||||||
|
**✅ Solution:** Install package with dev dependencies
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ❌ Async Test Hangs
|
||||||
|
```python
|
||||||
|
async def test_something(): # Hangs forever
|
||||||
|
result = await async_function()
|
||||||
|
```
|
||||||
|
**💡 Reason:** Missing pytest-asyncio or wrong configuration.
|
||||||
|
|
||||||
|
**✅ Solution:** Already configured in pyproject.toml
|
||||||
|
```toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
```
|
||||||
|
No decorator needed for async tests.
|
||||||
|
|
||||||
|
## Build & Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install all dependencies (production + dev)
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
|
||||||
|
# Run full test suite with coverage
|
||||||
|
pytest -v --cov=src --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
pytest tests/test_risk.py -v
|
||||||
|
|
||||||
|
# Run a single test by name
|
||||||
|
pytest tests/test_brain.py -k "test_parse_valid_json" -v
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check src/ tests/
|
||||||
|
|
||||||
|
# Type check (strict mode, non-blocking in CI)
|
||||||
|
mypy src/ --strict
|
||||||
|
|
||||||
|
# Run the trading agent
|
||||||
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker compose up -d ouroboros # Run agent
|
||||||
|
docker compose --profile test up test # Run tests in container
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create .env file from example
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your credentials
|
||||||
|
# Required: KIS_APP_KEY, KIS_APP_SECRET, KIS_ACCOUNT_NO, GEMINI_API_KEY
|
||||||
|
|
||||||
|
# Verify configuration
|
||||||
|
python -c "from src.config import Settings; print(Settings())"
|
||||||
|
```
|
||||||
338
docs/context-tree.md
Normal file
338
docs/context-tree.md
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
# Context Tree: Multi-Layered Memory Management
|
||||||
|
|
||||||
|
The context tree implements **Pillar 2** of The Ouroboros: hierarchical memory management across 7 time horizons, from real-time market data to generational trading wisdom.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Instead of a flat memory structure, The Ouroboros maintains a **7-tier context tree** where each layer represents a different time horizon and level of abstraction:
|
||||||
|
|
||||||
|
```
|
||||||
|
L1 (Legacy) ← Cumulative wisdom across generations
|
||||||
|
↑
|
||||||
|
L2 (Annual) ← Yearly performance metrics
|
||||||
|
↑
|
||||||
|
L3 (Quarterly) ← Quarterly strategy adjustments
|
||||||
|
↑
|
||||||
|
L4 (Monthly) ← Monthly portfolio rebalancing
|
||||||
|
↑
|
||||||
|
L5 (Weekly) ← Weekly stock selection
|
||||||
|
↑
|
||||||
|
L6 (Daily) ← Daily trade logs
|
||||||
|
↑
|
||||||
|
L7 (Real-time) ← Live market data
|
||||||
|
```
|
||||||
|
|
||||||
|
Data flows **bottom-up**: real-time trades aggregate into daily summaries, which roll up to weekly, then monthly, quarterly, annual, and finally into permanent legacy knowledge.
|
||||||
|
|
||||||
|
## The 7 Layers
|
||||||
|
|
||||||
|
### L7: Real-time
|
||||||
|
**Retention**: 7 days
|
||||||
|
**Timeframe format**: `YYYY-MM-DD` (same-day)
|
||||||
|
**Content**: Current positions, live quotes, orderbook snapshots, tick-by-tick volatility
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Immediate execution decisions
|
||||||
|
- Stop-loss triggers
|
||||||
|
- Real-time P&L tracking
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `current_position_{stock_code}`: Current holdings
|
||||||
|
- `live_price_{stock_code}`: Latest quote
|
||||||
|
- `volatility_5m_{stock_code}`: 5-minute rolling volatility
|
||||||
|
|
||||||
|
### L6: Daily
|
||||||
|
**Retention**: 90 days
|
||||||
|
**Timeframe format**: `YYYY-MM-DD`
|
||||||
|
**Content**: Daily trade logs, end-of-day P&L, market summaries, decision accuracy
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Daily performance review
|
||||||
|
- Identify patterns in recent trading
|
||||||
|
- Backtest strategy adjustments
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `total_pnl`: Daily profit/loss
|
||||||
|
- `trade_count`: Number of trades
|
||||||
|
- `win_rate`: Percentage of profitable trades
|
||||||
|
- `avg_confidence`: Average Gemini confidence
|
||||||
|
|
||||||
|
### L5: Weekly
|
||||||
|
**Retention**: 1 year
|
||||||
|
**Timeframe format**: `YYYY-Www` (ISO week, e.g., `2026-W06`)
|
||||||
|
**Content**: Weekly stock selection, sector rotation, volatility regime classification
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Weekly strategy adjustment
|
||||||
|
- Sector momentum tracking
|
||||||
|
- Identify hot/cold markets
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `weekly_pnl`: Week's total P&L
|
||||||
|
- `top_performers`: Best-performing stocks
|
||||||
|
- `sector_focus`: Dominant sectors
|
||||||
|
- `avg_confidence`: Weekly average confidence
|
||||||
|
|
||||||
|
### L4: Monthly
|
||||||
|
**Retention**: 2 years
|
||||||
|
**Timeframe format**: `YYYY-MM`
|
||||||
|
**Content**: Monthly portfolio rebalancing, risk exposure analysis, drawdown recovery
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Monthly performance reporting
|
||||||
|
- Risk exposure adjustment
|
||||||
|
- Correlation analysis
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `monthly_pnl`: Month's total P&L
|
||||||
|
- `sharpe_ratio`: Risk-adjusted return
|
||||||
|
- `max_drawdown`: Largest peak-to-trough decline
|
||||||
|
- `rebalancing_notes`: Manual insights
|
||||||
|
|
||||||
|
### L3: Quarterly
|
||||||
|
**Retention**: 3 years
|
||||||
|
**Timeframe format**: `YYYY-Qn` (e.g., `2026-Q1`)
|
||||||
|
**Content**: Quarterly strategy pivots, market phase detection (bull/bear/sideways), macro regime changes
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Strategic pivots (e.g., growth → value)
|
||||||
|
- Macro regime classification
|
||||||
|
- Long-term pattern recognition
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `quarterly_pnl`: Quarter's total P&L
|
||||||
|
- `market_phase`: Bull/Bear/Sideways
|
||||||
|
- `strategy_adjustments`: Major changes made
|
||||||
|
- `lessons_learned`: Key insights
|
||||||
|
|
||||||
|
### L2: Annual
|
||||||
|
**Retention**: 10 years
|
||||||
|
**Timeframe format**: `YYYY`
|
||||||
|
**Content**: Yearly returns, Sharpe ratio, max drawdown, win rate, strategy effectiveness
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Annual performance review
|
||||||
|
- Multi-year trend analysis
|
||||||
|
- Strategy benchmarking
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `annual_pnl`: Year's total P&L
|
||||||
|
- `sharpe_ratio`: Annual risk-adjusted return
|
||||||
|
- `win_rate`: Yearly win percentage
|
||||||
|
- `best_strategy`: Most successful strategy
|
||||||
|
- `worst_mistake`: Biggest lesson learned
|
||||||
|
|
||||||
|
### L1: Legacy
|
||||||
|
**Retention**: Forever
|
||||||
|
**Timeframe format**: `LEGACY` (single timeframe)
|
||||||
|
**Content**: Cumulative trading history, core principles, generational wisdom
|
||||||
|
|
||||||
|
**Use cases**:
|
||||||
|
- Long-term philosophy
|
||||||
|
- Foundational rules
|
||||||
|
- Lessons that transcend market cycles
|
||||||
|
|
||||||
|
**Example keys**:
|
||||||
|
- `total_pnl`: All-time profit/loss
|
||||||
|
- `years_traded`: Trading longevity
|
||||||
|
- `avg_annual_pnl`: Long-term average return
|
||||||
|
- `core_principles`: Immutable trading rules
|
||||||
|
- `greatest_trades`: Hall of fame
|
||||||
|
- `never_again`: Permanent warnings
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Setting Context
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.context import ContextLayer, ContextStore
|
||||||
|
from src.db import init_db
|
||||||
|
|
||||||
|
conn = init_db("data/ouroboros.db")
|
||||||
|
store = ContextStore(conn)
|
||||||
|
|
||||||
|
# Store daily P&L
|
||||||
|
store.set_context(
|
||||||
|
layer=ContextLayer.L6_DAILY,
|
||||||
|
timeframe="2026-02-04",
|
||||||
|
key="total_pnl",
|
||||||
|
value=1234.56
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store weekly insight
|
||||||
|
store.set_context(
|
||||||
|
layer=ContextLayer.L5_WEEKLY,
|
||||||
|
timeframe="2026-W06",
|
||||||
|
key="top_performers",
|
||||||
|
value=["005930", "000660", "035720"] # JSON-serializable
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store legacy wisdom
|
||||||
|
store.set_context(
|
||||||
|
layer=ContextLayer.L1_LEGACY,
|
||||||
|
timeframe="LEGACY",
|
||||||
|
key="core_principles",
|
||||||
|
value=[
|
||||||
|
"Cut losses fast",
|
||||||
|
"Let winners run",
|
||||||
|
"Never average down on losing positions"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Retrieving Context
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Get a specific value
|
||||||
|
pnl = store.get_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl")
|
||||||
|
# Returns: 1234.56
|
||||||
|
|
||||||
|
# Get all keys for a timeframe
|
||||||
|
daily_summary = store.get_all_contexts(ContextLayer.L6_DAILY, "2026-02-04")
|
||||||
|
# Returns: {"total_pnl": 1234.56, "trade_count": 10, "win_rate": 60.0, ...}
|
||||||
|
|
||||||
|
# Get all data for a layer (any timeframe)
|
||||||
|
all_daily = store.get_all_contexts(ContextLayer.L6_DAILY)
|
||||||
|
# Returns: {"total_pnl": 1234.56, "trade_count": 10, ...} (latest timeframes first)
|
||||||
|
|
||||||
|
# Get the latest timeframe
|
||||||
|
latest = store.get_latest_timeframe(ContextLayer.L6_DAILY)
|
||||||
|
# Returns: "2026-02-04"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatic Aggregation
|
||||||
|
|
||||||
|
The `ContextAggregator` rolls up data from lower to higher layers:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.context.aggregator import ContextAggregator
|
||||||
|
|
||||||
|
aggregator = ContextAggregator(conn)
|
||||||
|
|
||||||
|
# Aggregate daily metrics from trades
|
||||||
|
aggregator.aggregate_daily_from_trades("2026-02-04")
|
||||||
|
|
||||||
|
# Roll up weekly from daily
|
||||||
|
aggregator.aggregate_weekly_from_daily("2026-W06")
|
||||||
|
|
||||||
|
# Roll up all layers at once (bottom-up)
|
||||||
|
aggregator.run_all_aggregations()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Aggregation schedule** (recommended):
|
||||||
|
- **L7 → L6**: Every midnight (daily rollup)
|
||||||
|
- **L6 → L5**: Every Sunday (weekly rollup)
|
||||||
|
- **L5 → L4**: First day of each month (monthly rollup)
|
||||||
|
- **L4 → L3**: First day of quarter (quarterly rollup)
|
||||||
|
- **L3 → L2**: January 1st (annual rollup)
|
||||||
|
- **L2 → L1**: On demand (major milestones)
|
||||||
|
|
||||||
|
### Context Cleanup
|
||||||
|
|
||||||
|
Expired contexts are automatically deleted based on retention policies:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Manual cleanup
|
||||||
|
deleted = store.cleanup_expired_contexts()
|
||||||
|
# Returns: {ContextLayer.L7_REALTIME: 42, ContextLayer.L6_DAILY: 15, ...}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Retention policies** (defined in `src/context/layer.py`):
|
||||||
|
- L1: Forever
|
||||||
|
- L2: 10 years
|
||||||
|
- L3: 3 years
|
||||||
|
- L4: 2 years
|
||||||
|
- L5: 1 year
|
||||||
|
- L6: 90 days
|
||||||
|
- L7: 7 days
|
||||||
|
|
||||||
|
## Integration with Gemini Brain
|
||||||
|
|
||||||
|
The context tree provides hierarchical memory for decision-making:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
|
||||||
|
# Build prompt with multi-layer context
|
||||||
|
def build_enhanced_prompt(stock_code: str, store: ContextStore) -> str:
|
||||||
|
# L7: Real-time data
|
||||||
|
current_price = store.get_context(ContextLayer.L7_REALTIME, "2026-02-04", f"live_price_{stock_code}")
|
||||||
|
|
||||||
|
# L6: Recent daily performance
|
||||||
|
yesterday_pnl = store.get_context(ContextLayer.L6_DAILY, "2026-02-03", "total_pnl")
|
||||||
|
|
||||||
|
# L5: Weekly trend
|
||||||
|
weekly_data = store.get_all_contexts(ContextLayer.L5_WEEKLY, "2026-W06")
|
||||||
|
|
||||||
|
# L1: Core principles
|
||||||
|
principles = store.get_context(ContextLayer.L1_LEGACY, "LEGACY", "core_principles")
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
Analyze {stock_code} for trading decision.
|
||||||
|
|
||||||
|
Current price: {current_price}
|
||||||
|
Yesterday's P&L: {yesterday_pnl}
|
||||||
|
This week: {weekly_data}
|
||||||
|
|
||||||
|
Core principles:
|
||||||
|
{chr(10).join(f'- {p}' for p in principles)}
|
||||||
|
|
||||||
|
Decision (BUY/SELL/HOLD):
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Context storage
|
||||||
|
CREATE TABLE contexts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
layer TEXT NOT NULL, -- L1_LEGACY, L2_ANNUAL, ..., L7_REALTIME
|
||||||
|
timeframe TEXT NOT NULL, -- "LEGACY", "2026", "2026-Q1", "2026-02", "2026-W06", "2026-02-04"
|
||||||
|
key TEXT NOT NULL, -- "total_pnl", "win_rate", "core_principles", etc.
|
||||||
|
value TEXT NOT NULL, -- JSON-serialized value
|
||||||
|
created_at TEXT NOT NULL, -- ISO 8601 timestamp
|
||||||
|
updated_at TEXT NOT NULL, -- ISO 8601 timestamp
|
||||||
|
UNIQUE(layer, timeframe, key)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Layer metadata
|
||||||
|
CREATE TABLE context_metadata (
|
||||||
|
layer TEXT PRIMARY KEY,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
retention_days INTEGER, -- NULL = keep forever
|
||||||
|
aggregation_source TEXT -- Parent layer for rollup
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices for fast queries
|
||||||
|
CREATE INDEX idx_contexts_layer ON contexts(layer);
|
||||||
|
CREATE INDEX idx_contexts_timeframe ON contexts(timeframe);
|
||||||
|
CREATE INDEX idx_contexts_updated ON contexts(updated_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Write to leaf layers only** — Never manually write to L1-L5; let aggregation populate them
|
||||||
|
2. **Aggregate regularly** — Schedule aggregation jobs to keep higher layers fresh
|
||||||
|
3. **Query specific timeframes** — Use `get_context(layer, timeframe, key)` for precise retrieval
|
||||||
|
4. **Clean up periodically** — Run `cleanup_expired_contexts()` weekly to free space
|
||||||
|
5. **Preserve L1 forever** — Legacy wisdom should never expire
|
||||||
|
6. **Use JSON-serializable values** — Store dicts, lists, strings, numbers (not custom objects)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
See `tests/test_context.py` for comprehensive test coverage (18 tests, 100% coverage on context modules).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_context.py -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- **Implementation**: `src/context/`
|
||||||
|
- `layer.py`: Layer definitions and metadata
|
||||||
|
- `store.py`: CRUD operations
|
||||||
|
- `aggregator.py`: Bottom-up aggregation logic
|
||||||
|
- **Database**: `src/db.py` (table initialization)
|
||||||
|
- **Tests**: `tests/test_context.py`
|
||||||
|
- **Related**: Pillar 2 (Multi-layered Context Management)
|
||||||
348
docs/disaster_recovery.md
Normal file
348
docs/disaster_recovery.md
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
# Disaster Recovery Guide
|
||||||
|
|
||||||
|
Complete guide for backing up and restoring The Ouroboros trading system.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Backup Strategy](#backup-strategy)
|
||||||
|
- [Creating Backups](#creating-backups)
|
||||||
|
- [Restoring from Backup](#restoring-from-backup)
|
||||||
|
- [Health Monitoring](#health-monitoring)
|
||||||
|
- [Export Formats](#export-formats)
|
||||||
|
- [RTO/RPO](#rtorpo)
|
||||||
|
- [Testing Recovery](#testing-recovery)
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
The system implements a 3-tier backup retention policy:
|
||||||
|
|
||||||
|
| Policy | Frequency | Retention | Purpose |
|
||||||
|
|--------|-----------|-----------|---------|
|
||||||
|
| **Daily** | Every day | 30 days | Quick recovery from recent issues |
|
||||||
|
| **Weekly** | Sunday | 1 year | Medium-term historical analysis |
|
||||||
|
| **Monthly** | 1st of month | Forever | Long-term archival |
|
||||||
|
|
||||||
|
### Storage Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
data/backups/
|
||||||
|
├── daily/ # Last 30 days
|
||||||
|
├── weekly/ # Last 52 weeks
|
||||||
|
└── monthly/ # Forever (cold storage)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating Backups
|
||||||
|
|
||||||
|
### Automated Backups (Recommended)
|
||||||
|
|
||||||
|
Set up a cron job to run daily:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Edit crontab
|
||||||
|
crontab -e
|
||||||
|
|
||||||
|
# Run backup at 2 AM every day
|
||||||
|
0 2 * * * cd /path/to/The-Ouroboros && ./scripts/backup.sh >> logs/backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Backups
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run backup script
|
||||||
|
./scripts/backup.sh
|
||||||
|
|
||||||
|
# Or use Python directly
|
||||||
|
python3 -c "
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler, BackupPolicy
|
||||||
|
|
||||||
|
scheduler = BackupScheduler('data/trade_logs.db', Path('data/backups'))
|
||||||
|
metadata = scheduler.create_backup(BackupPolicy.DAILY, verify=True)
|
||||||
|
print(f'Backup created: {metadata.file_path}')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Export to Other Formats
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.exporter import BackupExporter, ExportFormat
|
||||||
|
|
||||||
|
exporter = BackupExporter('data/trade_logs.db')
|
||||||
|
results = exporter.export_all(
|
||||||
|
Path('exports'),
|
||||||
|
formats=[ExportFormat.JSON, ExportFormat.CSV],
|
||||||
|
compress=True
|
||||||
|
)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Restoring from Backup
|
||||||
|
|
||||||
|
### Interactive Restoration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/restore.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
1. List available backups
|
||||||
|
2. Ask you to select one
|
||||||
|
3. Create a safety backup of current database
|
||||||
|
4. Restore the selected backup
|
||||||
|
5. Verify database integrity
|
||||||
|
|
||||||
|
### Manual Restoration
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler
|
||||||
|
|
||||||
|
scheduler = BackupScheduler('data/trade_logs.db', Path('data/backups'))
|
||||||
|
|
||||||
|
# List backups
|
||||||
|
backups = scheduler.list_backups()
|
||||||
|
for backup in backups:
|
||||||
|
print(f"{backup.timestamp}: {backup.file_path}")
|
||||||
|
|
||||||
|
# Restore specific backup
|
||||||
|
scheduler.restore_backup(backups[0], verify=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Health Monitoring
|
||||||
|
|
||||||
|
### Check System Health
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
monitor = HealthMonitor('data/trade_logs.db', Path('data/backups'))
|
||||||
|
|
||||||
|
# Run all checks
|
||||||
|
report = monitor.get_health_report()
|
||||||
|
print(f"Overall status: {report['overall_status']}")
|
||||||
|
|
||||||
|
# Individual checks
|
||||||
|
checks = monitor.run_all_checks()
|
||||||
|
for name, result in checks.items():
|
||||||
|
print(f"{name}: {result.status.value} - {result.message}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
The system monitors:
|
||||||
|
|
||||||
|
- **Database Health**: Accessibility, integrity, size
|
||||||
|
- **Disk Space**: Available storage (alerts if < 10 GB)
|
||||||
|
- **Backup Recency**: Ensures backups are < 25 hours old
|
||||||
|
|
||||||
|
### Health Status Levels
|
||||||
|
|
||||||
|
- **HEALTHY**: All systems operational
|
||||||
|
- **DEGRADED**: Warning condition (e.g., low disk space)
|
||||||
|
- **UNHEALTHY**: Critical issue (e.g., database corrupted, no backups)
|
||||||
|
|
||||||
|
## Export Formats
|
||||||
|
|
||||||
|
### JSON (Human-Readable)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"export_timestamp": "2024-01-15T10:30:00Z",
|
||||||
|
"record_count": 150,
|
||||||
|
"trades": [
|
||||||
|
{
|
||||||
|
"timestamp": "2024-01-15T09:00:00Z",
|
||||||
|
"stock_code": "005930",
|
||||||
|
"action": "BUY",
|
||||||
|
"quantity": 10,
|
||||||
|
"price": 70000.0,
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "Strong momentum",
|
||||||
|
"pnl": 0.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CSV (Analysis Tools)
|
||||||
|
|
||||||
|
Compatible with Excel, pandas, R:
|
||||||
|
|
||||||
|
```csv
|
||||||
|
timestamp,stock_code,action,quantity,price,confidence,rationale,pnl
|
||||||
|
2024-01-15T09:00:00Z,005930,BUY,10,70000.0,85,Strong momentum,0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parquet (Big Data)
|
||||||
|
|
||||||
|
Columnar format for Spark, DuckDB:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pandas as pd
|
||||||
|
df = pd.read_parquet('exports/trades_20240115.parquet')
|
||||||
|
```
|
||||||
|
|
||||||
|
## RTO/RPO
|
||||||
|
|
||||||
|
### Recovery Time Objective (RTO)
|
||||||
|
|
||||||
|
**Target: < 5 minutes**
|
||||||
|
|
||||||
|
Time to restore trading operations:
|
||||||
|
1. Identify backup to restore (1 min)
|
||||||
|
2. Run restore script (2 min)
|
||||||
|
3. Verify database integrity (1 min)
|
||||||
|
4. Restart trading system (1 min)
|
||||||
|
|
||||||
|
### Recovery Point Objective (RPO)
|
||||||
|
|
||||||
|
**Target: < 24 hours**
|
||||||
|
|
||||||
|
Maximum acceptable data loss:
|
||||||
|
- Daily backups ensure ≤ 24-hour data loss
|
||||||
|
- For critical periods, run backups more frequently
|
||||||
|
|
||||||
|
## Testing Recovery
|
||||||
|
|
||||||
|
### Quarterly Recovery Test
|
||||||
|
|
||||||
|
Perform full disaster recovery test every quarter:
|
||||||
|
|
||||||
|
1. **Create test backup**
|
||||||
|
```bash
|
||||||
|
./scripts/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Simulate disaster** (use test database)
|
||||||
|
```bash
|
||||||
|
cp data/trade_logs.db data/trade_logs_test.db
|
||||||
|
rm data/trade_logs_test.db # Simulate data loss
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restore from backup**
|
||||||
|
```bash
|
||||||
|
DB_PATH=data/trade_logs_test.db ./scripts/restore.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify data integrity**
|
||||||
|
```python
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('data/trade_logs_test.db')
|
||||||
|
cursor = conn.execute('SELECT COUNT(*) FROM trades')
|
||||||
|
print(f"Restored {cursor.fetchone()[0]} trades")
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Document results** in `logs/recovery_test_YYYYMMDD.md`
|
||||||
|
|
||||||
|
### Backup Verification
|
||||||
|
|
||||||
|
Always verify backups after creation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler
|
||||||
|
|
||||||
|
scheduler = BackupScheduler('data/trade_logs.db', Path('data/backups'))
|
||||||
|
|
||||||
|
# Create and verify
|
||||||
|
metadata = scheduler.create_backup(BackupPolicy.DAILY, verify=True)
|
||||||
|
print(f"Checksum: {metadata.checksum}") # Should not be None
|
||||||
|
```
|
||||||
|
|
||||||
|
## Emergency Procedures
|
||||||
|
|
||||||
|
### Database Corrupted
|
||||||
|
|
||||||
|
1. Stop trading system immediately
|
||||||
|
2. Check most recent backup age: `ls -lht data/backups/daily/`
|
||||||
|
3. Restore: `./scripts/restore.sh`
|
||||||
|
4. Verify: Run health check
|
||||||
|
5. Resume trading
|
||||||
|
|
||||||
|
### Disk Full
|
||||||
|
|
||||||
|
1. Check disk space: `df -h`
|
||||||
|
2. Clean old backups: Run cleanup manually
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler
|
||||||
|
scheduler = BackupScheduler('data/trade_logs.db', Path('data/backups'))
|
||||||
|
scheduler.cleanup_old_backups()
|
||||||
|
```
|
||||||
|
3. Consider archiving old monthly backups to external storage
|
||||||
|
4. Increase disk space if needed
|
||||||
|
|
||||||
|
### Lost All Backups
|
||||||
|
|
||||||
|
If local backups are lost:
|
||||||
|
1. Check if exports exist in `exports/` directory
|
||||||
|
2. Reconstruct database from CSV/JSON exports
|
||||||
|
3. If no exports: Check broker API for trade history
|
||||||
|
4. Manual reconstruction as last resort
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Test Restores Regularly**: Don't wait for disaster
|
||||||
|
2. **Monitor Disk Space**: Set up alerts at 80% usage
|
||||||
|
3. **Keep Multiple Generations**: Never delete all backups at once
|
||||||
|
4. **Verify Checksums**: Always verify backup integrity
|
||||||
|
5. **Document Changes**: Update this guide when backup strategy changes
|
||||||
|
6. **Off-Site Storage**: Consider external backup for monthly archives
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Backup Script Fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database file permissions
|
||||||
|
ls -l data/trade_logs.db
|
||||||
|
|
||||||
|
# Check disk space
|
||||||
|
df -h data/
|
||||||
|
|
||||||
|
# Run backup manually with debug
|
||||||
|
python3 -c "
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler, BackupPolicy
|
||||||
|
scheduler = BackupScheduler('data/trade_logs.db', Path('data/backups'))
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY, verify=True)
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Fails Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check backup file integrity
|
||||||
|
python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect('data/backups/daily/trade_logs_daily_20240115.db')
|
||||||
|
cursor = conn.execute('PRAGMA integrity_check')
|
||||||
|
print(cursor.fetchone()[0])
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Check Fails
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
monitor = HealthMonitor('data/trade_logs.db', Path('data/backups'))
|
||||||
|
|
||||||
|
# Check each component individually
|
||||||
|
print("Database:", monitor.check_database_health())
|
||||||
|
print("Disk Space:", monitor.check_disk_space())
|
||||||
|
print("Backup Recency:", monitor.check_backup_recency())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For backup/recovery issues:
|
||||||
|
- Check logs: `logs/backup.log`
|
||||||
|
- Review health status: Run health monitor
|
||||||
|
- Raise issue on GitHub if automated recovery fails
|
||||||
66
docs/requirements-log.md
Normal file
66
docs/requirements-log.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# Requirements Log
|
||||||
|
|
||||||
|
프로젝트 진화를 위한 사용자 요구사항 기록.
|
||||||
|
|
||||||
|
이 문서는 시간순으로 사용자와의 대화에서 나온 요구사항과 피드백을 기록합니다.
|
||||||
|
새로운 요구사항이 있으면 날짜와 함께 추가하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-05
|
||||||
|
|
||||||
|
### API 효율화
|
||||||
|
- Gemini API는 귀중한 자원. 종목별 개별 호출 대신 배치 호출 필요
|
||||||
|
- Free tier 한도(20 calls/day) 고려하여 일일 몇 차례 거래 모드로 전환
|
||||||
|
- 배치 API 호출로 여러 종목을 한 번에 분석
|
||||||
|
|
||||||
|
### 거래 모드
|
||||||
|
- **Daily Mode**: 하루 4회 거래 세션 (6시간 간격) - Free tier 호환
|
||||||
|
- **Realtime Mode**: 60초 간격 실시간 거래 - 유료 구독 필요
|
||||||
|
- `TRADE_MODE` 환경변수로 모드 선택
|
||||||
|
|
||||||
|
### 진화 시스템
|
||||||
|
- 사용자 대화 내용을 문서로 기록하여 향후에도 의도 반영
|
||||||
|
- 프롬프트 품질 검증은 별도 이슈로 다룰 예정
|
||||||
|
|
||||||
|
### 문서화
|
||||||
|
- 시스템 구조, 기능별 설명 등 코드 문서화 항상 신경쓸 것
|
||||||
|
- 새로운 기능 추가 시 관련 문서 업데이트 필수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-02-06
|
||||||
|
|
||||||
|
### Smart Volatility Scanner (Python-First, AI-Last 파이프라인)
|
||||||
|
|
||||||
|
**배경:**
|
||||||
|
- 정적 종목 리스트를 순회하는 방식은 비효율적
|
||||||
|
- KIS API 거래량 순위를 통해 시장 주도주를 자동 탐지해야 함
|
||||||
|
- Gemini API 호출 전에 Python 기반 기술적 분석으로 필터링 필요
|
||||||
|
|
||||||
|
**요구사항:**
|
||||||
|
1. KIS API 거래량 순위 API 통합 (`fetch_market_rankings`)
|
||||||
|
2. 일별 가격 히스토리 API 추가 (`get_daily_prices`)
|
||||||
|
3. RSI(14) 계산 기능 구현 (Wilder's smoothing method)
|
||||||
|
4. 필터 조건:
|
||||||
|
- 거래량 > 전일 대비 200% (VOL_MULTIPLIER)
|
||||||
|
- RSI < 30 (과매도) OR RSI > 70 (모멘텀)
|
||||||
|
5. 상위 1-3개 적격 종목만 Gemini에 전달
|
||||||
|
6. 종목 선정 배경(RSI, volume_ratio, signal, score) 데이터베이스 기록
|
||||||
|
|
||||||
|
**구현 결과:**
|
||||||
|
- `src/analysis/smart_scanner.py`: SmartVolatilityScanner 클래스
|
||||||
|
- `src/analysis/volatility.py`: calculate_rsi() 메서드 추가
|
||||||
|
- `src/broker/kis_api.py`: 2개 신규 API 메서드
|
||||||
|
- `src/db.py`: selection_context 컬럼 추가
|
||||||
|
- 설정 가능한 임계값: RSI_OVERSOLD_THRESHOLD, RSI_MOMENTUM_THRESHOLD, VOL_MULTIPLIER, SCANNER_TOP_N
|
||||||
|
|
||||||
|
**효과:**
|
||||||
|
- Gemini API 호출 20-30개 → 1-3개로 감소
|
||||||
|
- Python 기반 빠른 필터링 → 비용 절감
|
||||||
|
- 선정 기준 추적 → Evolution 시스템 최적화 가능
|
||||||
|
- API 장애 시 정적 watchlist로 자동 전환
|
||||||
|
|
||||||
|
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
|
||||||
|
|
||||||
|
**이슈/PR:** #76, #77
|
||||||
213
docs/testing.md
Normal file
213
docs/testing.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Testing Guidelines
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
**54 tests** across four 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.
|
||||||
|
|
||||||
|
### Test Files
|
||||||
|
|
||||||
|
#### `tests/test_risk.py` (11 tests)
|
||||||
|
- Circuit breaker boundaries
|
||||||
|
- Fat-finger edge cases
|
||||||
|
- P&L calculation edge cases
|
||||||
|
- Order validation logic
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```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
|
||||||
|
- Rate limiting enforcement
|
||||||
|
- Hash key generation
|
||||||
|
- Network error handling
|
||||||
|
- SSL context configuration
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
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
|
||||||
|
- Missing fields handling
|
||||||
|
- Invalid action validation
|
||||||
|
- Confidence threshold enforcement
|
||||||
|
- Empty response handling
|
||||||
|
- Prompt construction for different markets
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```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
|
||||||
|
- Timezone handling (UTC, Asia/Seoul, America/New_York, etc.)
|
||||||
|
- DST (Daylight Saving Time) transitions
|
||||||
|
- Weekend handling
|
||||||
|
- Lunch break logic
|
||||||
|
- Multiple market filtering
|
||||||
|
- Next market open calculation
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```python
|
||||||
|
def test_is_market_open_during_trading_hours():
|
||||||
|
"""Market should be open during regular trading hours."""
|
||||||
|
# KRX: 9:00-15:30 KST, no lunch break
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
trading_time = datetime(2026, 2, 3, 10, 0, tzinfo=ZoneInfo("Asia/Seoul")) # Monday 10:00
|
||||||
|
assert is_market_open(market, trading_time) is True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Coverage Requirements
|
||||||
|
|
||||||
|
**Minimum coverage: 80%**
|
||||||
|
|
||||||
|
Check coverage:
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Test Configuration
|
||||||
|
|
||||||
|
### `pyproject.toml`
|
||||||
|
```toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### `tests/conftest.py`
|
||||||
|
```python
|
||||||
|
@pytest.fixture
|
||||||
|
def settings() -> Settings:
|
||||||
|
"""Provide test settings with safe defaults."""
|
||||||
|
return Settings(
|
||||||
|
KIS_APP_KEY="test_key",
|
||||||
|
KIS_APP_SECRET="test_secret",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test_gemini_key",
|
||||||
|
MODE="paper",
|
||||||
|
DB_PATH=":memory:", # In-memory SQLite
|
||||||
|
CONFIDENCE_THRESHOLD=80,
|
||||||
|
ENABLED_MARKETS="KR",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
- Test files: `test_<module>.py`
|
||||||
|
- Test functions: `test_<feature>_<scenario>()`
|
||||||
|
- Use descriptive names that explain what is being tested
|
||||||
|
|
||||||
|
### Good Test Example
|
||||||
|
```python
|
||||||
|
async def test_send_order_with_market_price(broker, settings):
|
||||||
|
"""Market orders should use price=0 and ORD_DVSN='01'."""
|
||||||
|
# Arrange
|
||||||
|
stock_code = "005930"
|
||||||
|
order_type = "BUY"
|
||||||
|
quantity = 10
|
||||||
|
|
||||||
|
# Act
|
||||||
|
with patch.object(broker._session, 'post') as mock_post:
|
||||||
|
mock_post.return_value.__aenter__.return_value.status = 200
|
||||||
|
mock_post.return_value.__aenter__.return_value.json = AsyncMock(
|
||||||
|
return_value={"rt_cd": "0", "msg1": "OK"}
|
||||||
|
)
|
||||||
|
|
||||||
|
await broker.send_order(stock_code, order_type, quantity, price=0)
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
body = call_args.kwargs['json']
|
||||||
|
assert body['ORD_DVSN'] == '01' # Market order
|
||||||
|
assert body['ORD_UNPR'] == '0' # Price 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Checklist
|
||||||
|
- [ ] Test passes in isolation (`pytest tests/test_foo.py::test_bar -v`)
|
||||||
|
- [ ] Test has clear docstring explaining what it tests
|
||||||
|
- [ ] Arrange-Act-Assert structure
|
||||||
|
- [ ] Uses appropriate fixtures from conftest.py
|
||||||
|
- [ ] Mocks external dependencies (API calls, network)
|
||||||
|
- [ ] Tests edge cases and error conditions
|
||||||
|
- [ ] Doesn't rely on test execution order
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All tests
|
||||||
|
pytest -v
|
||||||
|
|
||||||
|
# Specific file
|
||||||
|
pytest tests/test_risk.py -v
|
||||||
|
|
||||||
|
# Specific test
|
||||||
|
pytest tests/test_brain.py::test_parse_valid_json -v
|
||||||
|
|
||||||
|
# With coverage
|
||||||
|
pytest -v --cov=src --cov-report=term-missing
|
||||||
|
|
||||||
|
# Stop on first failure
|
||||||
|
pytest -x
|
||||||
|
|
||||||
|
# Verbose output with print statements
|
||||||
|
pytest -v -s
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Tests run automatically on:
|
||||||
|
- Every commit to feature branches
|
||||||
|
- Every PR to main
|
||||||
|
- Scheduled daily runs
|
||||||
|
|
||||||
|
**Blocking conditions:**
|
||||||
|
- Test failures → PR blocked
|
||||||
|
- Coverage < 80% → PR blocked (warning only for main.py)
|
||||||
|
|
||||||
|
**Non-blocking:**
|
||||||
|
- `mypy --strict` errors (type hints encouraged but not enforced)
|
||||||
|
- `ruff check` warnings (must be acknowledged)
|
||||||
76
docs/workflow.md
Normal file
76
docs/workflow.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Development Workflow
|
||||||
|
|
||||||
|
## Git Workflow Policy
|
||||||
|
|
||||||
|
**CRITICAL: All code changes MUST follow this workflow. Direct pushes to `main` are ABSOLUTELY PROHIBITED.**
|
||||||
|
|
||||||
|
1. **Create Gitea Issue First** — All features, bug fixes, and policy changes require a Gitea issue before any code is written
|
||||||
|
2. **Create Feature Branch** — Branch from `main` using format `feature/issue-{N}-{short-description}`
|
||||||
|
- After creating the branch, run `git pull origin main` and rebase to ensure the branch is up to date
|
||||||
|
3. **Implement Changes** — Write code, tests, and documentation on the feature branch
|
||||||
|
4. **Create Pull Request** — Submit PR to `main` branch referencing the issue number
|
||||||
|
5. **Review & Merge** — After approval, merge via PR (squash or merge commit)
|
||||||
|
|
||||||
|
**Never commit directly to `main`.** This policy applies to all changes, no exceptions.
|
||||||
|
|
||||||
|
## Agent Workflow
|
||||||
|
|
||||||
|
**Modern AI development leverages specialized agents for concurrent, efficient task execution.**
|
||||||
|
|
||||||
|
### Parallel Execution Strategy
|
||||||
|
|
||||||
|
Use **git worktree** or **subagents** (via the Task tool) to handle multiple requirements simultaneously:
|
||||||
|
|
||||||
|
- Each task runs in independent context
|
||||||
|
- Parallel branches for concurrent features
|
||||||
|
- Isolated test environments prevent interference
|
||||||
|
- Faster iteration with distributed workload
|
||||||
|
|
||||||
|
### Specialized Agent Roles
|
||||||
|
|
||||||
|
Deploy task-specific agents as needed instead of handling everything in the main conversation:
|
||||||
|
|
||||||
|
- **Conversational Agent** (main) — Interface with user, coordinate other agents
|
||||||
|
- **Ticket Management Agent** — Create/update Gitea issues, track task status
|
||||||
|
- **Design Agent** — Architectural planning, RFC documents, API design
|
||||||
|
- **Code Writing Agent** — Implementation following specs
|
||||||
|
- **Testing Agent** — Write tests, verify coverage, run test suites
|
||||||
|
- **Documentation Agent** — Update docs, docstrings, CLAUDE.md, README
|
||||||
|
- **Review Agent** — Code review, lint checks, security audits
|
||||||
|
- **Custom Agents** — Created dynamically for specialized tasks (performance analysis, migration scripts, etc.)
|
||||||
|
|
||||||
|
### When to Use Agents
|
||||||
|
|
||||||
|
**Prefer spawning specialized agents for:**
|
||||||
|
|
||||||
|
1. Complex multi-file changes requiring exploration
|
||||||
|
2. Tasks with clear, isolated scope (e.g., "write tests for module X")
|
||||||
|
3. Parallel work streams (feature A + bugfix B simultaneously)
|
||||||
|
4. Long-running analysis (codebase search, dependency audit)
|
||||||
|
5. Tasks requiring different contexts (multiple git worktrees)
|
||||||
|
|
||||||
|
**Use the main conversation for:**
|
||||||
|
|
||||||
|
1. User interaction and clarification
|
||||||
|
2. Quick single-file edits
|
||||||
|
3. Coordinating agent work
|
||||||
|
4. High-level decision making
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Example: Spawn parallel test and documentation agents
|
||||||
|
task_tool(
|
||||||
|
subagent_type="general-purpose",
|
||||||
|
prompt="Write comprehensive tests for src/markets/schedule.py",
|
||||||
|
description="Write schedule tests"
|
||||||
|
)
|
||||||
|
|
||||||
|
task_tool(
|
||||||
|
subagent_type="general-purpose",
|
||||||
|
prompt="Update README.md with global market feature documentation",
|
||||||
|
description="Update README"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `run_in_background=True` for independent tasks that don't block subsequent work.
|
||||||
@@ -8,6 +8,7 @@ dependencies = [
|
|||||||
"pydantic>=2.5,<3",
|
"pydantic>=2.5,<3",
|
||||||
"pydantic-settings>=2.1,<3",
|
"pydantic-settings>=2.1,<3",
|
||||||
"google-genai>=1.0,<2",
|
"google-genai>=1.0,<2",
|
||||||
|
"scipy>=1.11,<2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
96
scripts/backup.sh
Normal file
96
scripts/backup.sh
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Automated backup script for The Ouroboros trading system
|
||||||
|
# Runs daily/weekly/monthly backups
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DB_PATH="${DB_PATH:-data/trade_logs.db}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-data/backups}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if database exists
|
||||||
|
if [ ! -f "$DB_PATH" ]; then
|
||||||
|
log_error "Database not found: $DB_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
log_info "Starting backup process..."
|
||||||
|
log_info "Database: $DB_PATH"
|
||||||
|
log_info "Backup directory: $BACKUP_DIR"
|
||||||
|
|
||||||
|
# Determine backup policy based on day of week and month
|
||||||
|
DAY_OF_WEEK=$(date +%u) # 1-7 (Monday-Sunday)
|
||||||
|
DAY_OF_MONTH=$(date +%d)
|
||||||
|
|
||||||
|
if [ "$DAY_OF_MONTH" == "01" ]; then
|
||||||
|
POLICY="monthly"
|
||||||
|
log_info "Running MONTHLY backup (first day of month)"
|
||||||
|
elif [ "$DAY_OF_WEEK" == "7" ]; then
|
||||||
|
POLICY="weekly"
|
||||||
|
log_info "Running WEEKLY backup (Sunday)"
|
||||||
|
else
|
||||||
|
POLICY="daily"
|
||||||
|
log_info "Running DAILY backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run Python backup script
|
||||||
|
$PYTHON -c "
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler, BackupPolicy
|
||||||
|
from src.backup.health_monitor import HealthMonitor
|
||||||
|
|
||||||
|
# Create scheduler
|
||||||
|
scheduler = BackupScheduler(
|
||||||
|
db_path='$DB_PATH',
|
||||||
|
backup_dir=Path('$BACKUP_DIR')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
policy = BackupPolicy.$POLICY.upper()
|
||||||
|
metadata = scheduler.create_backup(policy, verify=True)
|
||||||
|
print(f'Backup created: {metadata.file_path}')
|
||||||
|
print(f'Size: {metadata.size_bytes / 1024 / 1024:.2f} MB')
|
||||||
|
print(f'Checksum: {metadata.checksum}')
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
removed = scheduler.cleanup_old_backups()
|
||||||
|
total_removed = sum(removed.values())
|
||||||
|
if total_removed > 0:
|
||||||
|
print(f'Removed {total_removed} old backup(s)')
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
monitor = HealthMonitor('$DB_PATH', Path('$BACKUP_DIR'))
|
||||||
|
status = monitor.get_overall_status()
|
||||||
|
print(f'System health: {status.value}')
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_info "Backup completed successfully"
|
||||||
|
else
|
||||||
|
log_error "Backup failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Backup process finished"
|
||||||
111
scripts/restore.sh
Normal file
111
scripts/restore.sh
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Restore script for The Ouroboros trading system
|
||||||
|
# Restores database from a backup file
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
DB_PATH="${DB_PATH:-data/trade_logs.db}"
|
||||||
|
BACKUP_DIR="${BACKUP_DIR:-data/backups}"
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if backup directory exists
|
||||||
|
if [ ! -d "$BACKUP_DIR" ]; then
|
||||||
|
log_error "Backup directory not found: $BACKUP_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Available backups:"
|
||||||
|
log_info "=================="
|
||||||
|
|
||||||
|
# List available backups
|
||||||
|
$PYTHON -c "
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler
|
||||||
|
|
||||||
|
scheduler = BackupScheduler(
|
||||||
|
db_path='$DB_PATH',
|
||||||
|
backup_dir=Path('$BACKUP_DIR')
|
||||||
|
)
|
||||||
|
|
||||||
|
backups = scheduler.list_backups()
|
||||||
|
|
||||||
|
if not backups:
|
||||||
|
print('No backups found.')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
for i, backup in enumerate(backups, 1):
|
||||||
|
size_mb = backup.size_bytes / 1024 / 1024
|
||||||
|
print(f'{i}. [{backup.policy.value.upper()}] {backup.file_path.name}')
|
||||||
|
print(f' Date: {backup.timestamp.strftime(\"%Y-%m-%d %H:%M:%S UTC\")}')
|
||||||
|
print(f' Size: {size_mb:.2f} MB')
|
||||||
|
print()
|
||||||
|
"
|
||||||
|
|
||||||
|
# Ask user to select backup
|
||||||
|
echo ""
|
||||||
|
read -p "Enter backup number to restore (or 'q' to quit): " BACKUP_NUM
|
||||||
|
|
||||||
|
if [ "$BACKUP_NUM" == "q" ]; then
|
||||||
|
log_info "Restore cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Confirm restoration
|
||||||
|
log_warn "WARNING: This will replace the current database!"
|
||||||
|
log_warn "Current database will be backed up to: ${DB_PATH}.before_restore"
|
||||||
|
read -p "Are you sure you want to continue? (yes/no): " CONFIRM
|
||||||
|
|
||||||
|
if [ "$CONFIRM" != "yes" ]; then
|
||||||
|
log_info "Restore cancelled"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Perform restoration
|
||||||
|
$PYTHON -c "
|
||||||
|
from pathlib import Path
|
||||||
|
from src.backup.scheduler import BackupScheduler
|
||||||
|
|
||||||
|
scheduler = BackupScheduler(
|
||||||
|
db_path='$DB_PATH',
|
||||||
|
backup_dir=Path('$BACKUP_DIR')
|
||||||
|
)
|
||||||
|
|
||||||
|
backups = scheduler.list_backups()
|
||||||
|
backup_index = int('$BACKUP_NUM') - 1
|
||||||
|
|
||||||
|
if backup_index < 0 or backup_index >= len(backups):
|
||||||
|
print('Invalid backup number')
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
selected = backups[backup_index]
|
||||||
|
print(f'Restoring: {selected.file_path.name}')
|
||||||
|
|
||||||
|
scheduler.restore_backup(selected, verify=True)
|
||||||
|
print('Restore completed successfully')
|
||||||
|
"
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_info "Database restored successfully"
|
||||||
|
else
|
||||||
|
log_error "Restore failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
9
src/analysis/__init__.py
Normal file
9
src/analysis/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Technical analysis and market scanning modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.analysis.scanner import MarketScanner
|
||||||
|
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
||||||
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
|
|
||||||
|
__all__ = ["VolatilityAnalyzer", "MarketScanner", "SmartVolatilityScanner", "ScanCandidate"]
|
||||||
244
src/analysis/scanner.py
Normal file
244
src/analysis/scanner.py
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
"""Real-time market scanner for detecting high-momentum stocks.
|
||||||
|
|
||||||
|
Scans all available stocks in a market and ranks by volatility/momentum score.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.analysis.volatility import VolatilityAnalyzer, VolatilityMetrics
|
||||||
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.broker.overseas import OverseasBroker
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.markets.schedule import MarketInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanResult:
|
||||||
|
"""Result from a market scan."""
|
||||||
|
|
||||||
|
market_code: str
|
||||||
|
timestamp: str
|
||||||
|
total_scanned: int
|
||||||
|
top_movers: list[VolatilityMetrics]
|
||||||
|
breakouts: list[str] # Stock codes with breakout patterns
|
||||||
|
breakdowns: list[str] # Stock codes with breakdown patterns
|
||||||
|
|
||||||
|
|
||||||
|
class MarketScanner:
|
||||||
|
"""Scans markets for high-volatility, high-momentum stocks."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
broker: KISBroker,
|
||||||
|
overseas_broker: OverseasBroker,
|
||||||
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
|
context_store: ContextStore,
|
||||||
|
top_n: int = 5,
|
||||||
|
max_concurrent_scans: int = 1,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the market scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
broker: KIS broker instance for domestic market
|
||||||
|
overseas_broker: Overseas broker instance
|
||||||
|
volatility_analyzer: Volatility analyzer instance
|
||||||
|
context_store: Context store for L7 real-time data
|
||||||
|
top_n: Number of top movers to return per market (default 5)
|
||||||
|
max_concurrent_scans: Max concurrent stock scans (default 1, fully serialized)
|
||||||
|
"""
|
||||||
|
self.broker = broker
|
||||||
|
self.overseas_broker = overseas_broker
|
||||||
|
self.analyzer = volatility_analyzer
|
||||||
|
self.context_store = context_store
|
||||||
|
self.top_n = top_n
|
||||||
|
self._scan_semaphore = asyncio.Semaphore(max_concurrent_scans)
|
||||||
|
|
||||||
|
async def scan_stock(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
market: MarketInfo,
|
||||||
|
) -> VolatilityMetrics | None:
|
||||||
|
"""Scan a single stock for volatility metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock code to scan
|
||||||
|
market: Market information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
VolatilityMetrics if successful, None on error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if market.is_domestic:
|
||||||
|
orderbook = await self.broker.get_orderbook(stock_code)
|
||||||
|
else:
|
||||||
|
# For overseas, we need to adapt the price data structure
|
||||||
|
price_data = await self.overseas_broker.get_overseas_price(
|
||||||
|
market.exchange_code, stock_code
|
||||||
|
)
|
||||||
|
# Convert to orderbook-like structure
|
||||||
|
orderbook = {
|
||||||
|
"output1": {
|
||||||
|
"stck_prpr": price_data.get("output", {}).get("last", "0") or "0",
|
||||||
|
"acml_vol": price_data.get("output", {}).get("tvol", "0") or "0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# For now, use empty price history (would need real historical data)
|
||||||
|
# In production, this would fetch from a time-series database or API
|
||||||
|
price_history: dict[str, Any] = {
|
||||||
|
"high": [],
|
||||||
|
"low": [],
|
||||||
|
"close": [],
|
||||||
|
"volume": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics = self.analyzer.analyze(stock_code, orderbook, price_history)
|
||||||
|
|
||||||
|
# Store in L7 real-time layer
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
timeframe = datetime.now(UTC).isoformat()
|
||||||
|
self.context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"{market.code}_{stock_code}_volatility",
|
||||||
|
{
|
||||||
|
"price": metrics.current_price,
|
||||||
|
"atr": metrics.atr,
|
||||||
|
"price_change_1m": metrics.price_change_1m,
|
||||||
|
"volume_surge": metrics.volume_surge,
|
||||||
|
"momentum_score": metrics.momentum_score,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to scan %s (%s): %s", stock_code, market.code, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def scan_market(
|
||||||
|
self,
|
||||||
|
market: MarketInfo,
|
||||||
|
stock_codes: list[str],
|
||||||
|
) -> ScanResult:
|
||||||
|
"""Scan all stocks in a market and rank by momentum.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market to scan
|
||||||
|
stock_codes: List of stock codes to scan
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScanResult with ranked stocks
|
||||||
|
"""
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
logger.info("Scanning %s market (%d stocks)", market.name, len(stock_codes))
|
||||||
|
|
||||||
|
# Scan stocks with bounded concurrency to prevent API rate limit burst
|
||||||
|
async def _bounded_scan(code: str) -> VolatilityMetrics | None:
|
||||||
|
async with self._scan_semaphore:
|
||||||
|
return await self.scan_stock(code, market)
|
||||||
|
|
||||||
|
tasks = [_bounded_scan(code) for code in stock_codes]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
# Filter out failures and sort by momentum score
|
||||||
|
valid_metrics = [m for m in results if m is not None]
|
||||||
|
valid_metrics.sort(key=lambda m: m.momentum_score, reverse=True)
|
||||||
|
|
||||||
|
# Get top N movers
|
||||||
|
top_movers = valid_metrics[: self.top_n]
|
||||||
|
|
||||||
|
# Detect breakouts and breakdowns
|
||||||
|
breakouts = [
|
||||||
|
m.stock_code for m in valid_metrics if self.analyzer.is_breakout(m)
|
||||||
|
]
|
||||||
|
breakdowns = [
|
||||||
|
m.stock_code for m in valid_metrics if self.analyzer.is_breakdown(m)
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"%s scan complete: %d scanned, top momentum=%.1f, %d breakouts, %d breakdowns",
|
||||||
|
market.name,
|
||||||
|
len(valid_metrics),
|
||||||
|
top_movers[0].momentum_score if top_movers else 0.0,
|
||||||
|
len(breakouts),
|
||||||
|
len(breakdowns),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store scan results in L7
|
||||||
|
timeframe = datetime.now(UTC).isoformat()
|
||||||
|
self.context_store.set_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
timeframe,
|
||||||
|
f"{market.code}_scan_result",
|
||||||
|
{
|
||||||
|
"total_scanned": len(valid_metrics),
|
||||||
|
"top_movers": [m.stock_code for m in top_movers],
|
||||||
|
"breakouts": breakouts,
|
||||||
|
"breakdowns": breakdowns,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return ScanResult(
|
||||||
|
market_code=market.code,
|
||||||
|
timestamp=timeframe,
|
||||||
|
total_scanned=len(valid_metrics),
|
||||||
|
top_movers=top_movers,
|
||||||
|
breakouts=breakouts,
|
||||||
|
breakdowns=breakdowns,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_updated_watchlist(
|
||||||
|
self,
|
||||||
|
current_watchlist: list[str],
|
||||||
|
scan_result: ScanResult,
|
||||||
|
max_replacements: int = 2,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Update watchlist by replacing laggards with leaders.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_watchlist: Current watchlist
|
||||||
|
scan_result: Recent scan result
|
||||||
|
max_replacements: Maximum stocks to replace per scan
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated watchlist with leaders
|
||||||
|
"""
|
||||||
|
# Keep stocks that are in top movers
|
||||||
|
top_codes = [m.stock_code for m in scan_result.top_movers]
|
||||||
|
keepers = [code for code in current_watchlist if code in top_codes]
|
||||||
|
|
||||||
|
# Add new leaders not in current watchlist
|
||||||
|
new_leaders = [code for code in top_codes if code not in current_watchlist]
|
||||||
|
|
||||||
|
# Limit replacements
|
||||||
|
new_leaders = new_leaders[:max_replacements]
|
||||||
|
|
||||||
|
# Create updated watchlist
|
||||||
|
updated = keepers + new_leaders
|
||||||
|
|
||||||
|
# If we removed too many, backfill from current watchlist
|
||||||
|
if len(updated) < len(current_watchlist):
|
||||||
|
backfill = [
|
||||||
|
code for code in current_watchlist
|
||||||
|
if code not in updated
|
||||||
|
][: len(current_watchlist) - len(updated)]
|
||||||
|
updated.extend(backfill)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Watchlist updated: %d kept, %d new leaders, %d total",
|
||||||
|
len(keepers),
|
||||||
|
len(new_leaders),
|
||||||
|
len(updated),
|
||||||
|
)
|
||||||
|
|
||||||
|
return updated
|
||||||
192
src/analysis/smart_scanner.py
Normal file
192
src/analysis/smart_scanner.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""Smart Volatility Scanner with RSI and volume filters.
|
||||||
|
|
||||||
|
Fetches market rankings from KIS API and applies technical filters
|
||||||
|
to identify high-probability trading candidates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScanCandidate:
|
||||||
|
"""A qualified candidate from the smart scanner."""
|
||||||
|
|
||||||
|
stock_code: str
|
||||||
|
name: str
|
||||||
|
price: float
|
||||||
|
volume: float
|
||||||
|
volume_ratio: float # Current volume / previous day volume
|
||||||
|
rsi: float
|
||||||
|
signal: str # "oversold" or "momentum"
|
||||||
|
score: float # Composite score for ranking
|
||||||
|
|
||||||
|
|
||||||
|
class SmartVolatilityScanner:
|
||||||
|
"""Scans market rankings and applies RSI/volume filters.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Fetch volume rankings from KIS API
|
||||||
|
2. For each ranked stock, fetch daily prices
|
||||||
|
3. Calculate RSI and volume ratio
|
||||||
|
4. Apply filters: volume > VOL_MULTIPLIER AND (RSI < 30 OR RSI > 70)
|
||||||
|
5. Return top N qualified candidates
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
broker: KISBroker,
|
||||||
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
|
settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the smart scanner.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
broker: KIS broker for API calls
|
||||||
|
volatility_analyzer: Analyzer for RSI calculation
|
||||||
|
settings: Application settings
|
||||||
|
"""
|
||||||
|
self.broker = broker
|
||||||
|
self.analyzer = volatility_analyzer
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
# Extract scanner settings
|
||||||
|
self.rsi_oversold = settings.RSI_OVERSOLD_THRESHOLD
|
||||||
|
self.rsi_momentum = settings.RSI_MOMENTUM_THRESHOLD
|
||||||
|
self.vol_multiplier = settings.VOL_MULTIPLIER
|
||||||
|
self.top_n = settings.SCANNER_TOP_N
|
||||||
|
|
||||||
|
async def scan(
|
||||||
|
self,
|
||||||
|
fallback_stocks: list[str] | None = None,
|
||||||
|
) -> list[ScanCandidate]:
|
||||||
|
"""Execute smart scan and return qualified candidates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fallback_stocks: Stock codes to use if ranking API fails
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ScanCandidate, sorted by score, up to top_n items
|
||||||
|
"""
|
||||||
|
# Step 1: Fetch rankings
|
||||||
|
try:
|
||||||
|
rankings = await self.broker.fetch_market_rankings(
|
||||||
|
ranking_type="volume",
|
||||||
|
limit=30, # Fetch more than needed for filtering
|
||||||
|
)
|
||||||
|
logger.info("Fetched %d stocks from volume rankings", len(rankings))
|
||||||
|
except ConnectionError as exc:
|
||||||
|
logger.warning("Ranking API failed, using fallback: %s", exc)
|
||||||
|
if fallback_stocks:
|
||||||
|
# Create minimal ranking data for fallback
|
||||||
|
rankings = [
|
||||||
|
{
|
||||||
|
"stock_code": code,
|
||||||
|
"name": code,
|
||||||
|
"price": 0,
|
||||||
|
"volume": 0,
|
||||||
|
"change_rate": 0,
|
||||||
|
"volume_increase_rate": 0,
|
||||||
|
}
|
||||||
|
for code in fallback_stocks
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Step 2: Analyze each stock
|
||||||
|
candidates: list[ScanCandidate] = []
|
||||||
|
|
||||||
|
for stock in rankings:
|
||||||
|
stock_code = stock["stock_code"]
|
||||||
|
if not stock_code:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch daily prices for RSI calculation
|
||||||
|
daily_prices = await self.broker.get_daily_prices(stock_code, days=20)
|
||||||
|
|
||||||
|
if len(daily_prices) < 15: # Need at least 14+1 for RSI
|
||||||
|
logger.debug("Insufficient price history for %s", stock_code)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate RSI
|
||||||
|
close_prices = [p["close"] for p in daily_prices]
|
||||||
|
rsi = self.analyzer.calculate_rsi(close_prices, period=14)
|
||||||
|
|
||||||
|
# Calculate volume ratio (today vs previous day avg)
|
||||||
|
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(
|
||||||
|
ScanCandidate(
|
||||||
|
stock_code=stock_code,
|
||||||
|
name=stock.get("name", stock_code),
|
||||||
|
price=stock.get("price", daily_prices[-1]["close"]),
|
||||||
|
volume=current_volume,
|
||||||
|
volume_ratio=volume_ratio,
|
||||||
|
rsi=rsi,
|
||||||
|
signal=signal,
|
||||||
|
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:
|
||||||
|
logger.warning("Failed to analyze %s: %s", stock_code, exc)
|
||||||
|
continue
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error analyzing %s: %s", stock_code, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Sort by score and return top N
|
||||||
|
candidates.sort(key=lambda c: c.score, reverse=True)
|
||||||
|
return candidates[: self.top_n]
|
||||||
|
|
||||||
|
def get_stock_codes(self, candidates: list[ScanCandidate]) -> list[str]:
|
||||||
|
"""Extract stock codes from candidates for watchlist update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
candidates: List of scan candidates
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of stock codes
|
||||||
|
"""
|
||||||
|
return [c.stock_code for c in candidates]
|
||||||
373
src/analysis/volatility.py
Normal file
373
src/analysis/volatility.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"""Volatility and momentum analysis for stock selection.
|
||||||
|
|
||||||
|
Calculates ATR, price change percentages, volume surges, and price-volume divergence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VolatilityMetrics:
|
||||||
|
"""Volatility and momentum metrics for a stock."""
|
||||||
|
|
||||||
|
stock_code: str
|
||||||
|
current_price: float
|
||||||
|
atr: float # Average True Range (14 periods)
|
||||||
|
price_change_1m: float # 1-minute price change %
|
||||||
|
price_change_5m: float # 5-minute price change %
|
||||||
|
price_change_15m: float # 15-minute price change %
|
||||||
|
volume_surge: float # Volume vs average (ratio)
|
||||||
|
pv_divergence: float # Price-volume divergence score
|
||||||
|
momentum_score: float # Combined momentum score (0-100)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"VolatilityMetrics({self.stock_code}: "
|
||||||
|
f"price={self.current_price:.2f}, "
|
||||||
|
f"atr={self.atr:.2f}, "
|
||||||
|
f"1m={self.price_change_1m:.2f}%, "
|
||||||
|
f"vol_surge={self.volume_surge:.2f}x, "
|
||||||
|
f"momentum={self.momentum_score:.1f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VolatilityAnalyzer:
|
||||||
|
"""Analyzes stock volatility and momentum for leader detection."""
|
||||||
|
|
||||||
|
def __init__(self, min_volume_surge: float = 2.0, min_price_change: float = 1.0) -> None:
|
||||||
|
"""Initialize the volatility analyzer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_volume_surge: Minimum volume surge ratio (default 2x average)
|
||||||
|
min_price_change: Minimum price change % for breakout (default 1%)
|
||||||
|
"""
|
||||||
|
self.min_volume_surge = min_volume_surge
|
||||||
|
self.min_price_change = min_price_change
|
||||||
|
|
||||||
|
def calculate_atr(
|
||||||
|
self,
|
||||||
|
high_prices: list[float],
|
||||||
|
low_prices: list[float],
|
||||||
|
close_prices: list[float],
|
||||||
|
period: int = 14,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate Average True Range (ATR).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
high_prices: List of high prices (most recent last)
|
||||||
|
low_prices: List of low prices (most recent last)
|
||||||
|
close_prices: List of close prices (most recent last)
|
||||||
|
period: ATR period (default 14)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ATR value
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
len(high_prices) < period + 1
|
||||||
|
or len(low_prices) < period + 1
|
||||||
|
or len(close_prices) < period + 1
|
||||||
|
):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
true_ranges: list[float] = []
|
||||||
|
for i in range(1, len(high_prices)):
|
||||||
|
high = high_prices[i]
|
||||||
|
low = low_prices[i]
|
||||||
|
prev_close = close_prices[i - 1]
|
||||||
|
|
||||||
|
tr = max(
|
||||||
|
high - low,
|
||||||
|
abs(high - prev_close),
|
||||||
|
abs(low - prev_close),
|
||||||
|
)
|
||||||
|
true_ranges.append(tr)
|
||||||
|
|
||||||
|
if len(true_ranges) < period:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Simple Moving Average of True Range
|
||||||
|
recent_tr = true_ranges[-period:]
|
||||||
|
return sum(recent_tr) / len(recent_tr)
|
||||||
|
|
||||||
|
def calculate_price_change(
|
||||||
|
self, current_price: float, past_price: float
|
||||||
|
) -> float:
|
||||||
|
"""Calculate price change percentage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_price: Current price
|
||||||
|
past_price: Past price to compare against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Price change percentage
|
||||||
|
"""
|
||||||
|
if past_price == 0:
|
||||||
|
return 0.0
|
||||||
|
return ((current_price - past_price) / past_price) * 100
|
||||||
|
|
||||||
|
def calculate_volume_surge(
|
||||||
|
self, current_volume: float, avg_volume: float
|
||||||
|
) -> float:
|
||||||
|
"""Calculate volume surge ratio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_volume: Current volume
|
||||||
|
avg_volume: Average volume
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Volume surge ratio (current / average)
|
||||||
|
"""
|
||||||
|
if avg_volume == 0:
|
||||||
|
return 1.0
|
||||||
|
return current_volume / avg_volume
|
||||||
|
|
||||||
|
def calculate_rsi(
|
||||||
|
self,
|
||||||
|
close_prices: list[float],
|
||||||
|
period: int = 14,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate Relative Strength Index (RSI) using Wilder's smoothing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
close_prices: List of closing prices (oldest to newest, minimum period+1 values)
|
||||||
|
period: RSI period (default 14)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RSI value between 0 and 100, or 50.0 (neutral) if insufficient data
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> analyzer = VolatilityAnalyzer()
|
||||||
|
>>> prices = [100 - i * 0.5 for i in range(20)] # Downtrend
|
||||||
|
>>> rsi = analyzer.calculate_rsi(prices)
|
||||||
|
>>> assert rsi < 50 # Oversold territory
|
||||||
|
"""
|
||||||
|
if len(close_prices) < period + 1:
|
||||||
|
return 50.0 # Neutral RSI if insufficient data
|
||||||
|
|
||||||
|
# Calculate price changes
|
||||||
|
changes = [close_prices[i] - close_prices[i - 1] for i in range(1, len(close_prices))]
|
||||||
|
|
||||||
|
# Separate gains and losses
|
||||||
|
gains = [max(0.0, change) for change in changes]
|
||||||
|
losses = [max(0.0, -change) for change in changes]
|
||||||
|
|
||||||
|
# Calculate initial average gain/loss (simple average for first period)
|
||||||
|
avg_gain = sum(gains[:period]) / period
|
||||||
|
avg_loss = sum(losses[:period]) / period
|
||||||
|
|
||||||
|
# Apply Wilder's smoothing for remaining periods
|
||||||
|
for i in range(period, len(changes)):
|
||||||
|
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
|
||||||
|
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
|
||||||
|
|
||||||
|
# Calculate RS and RSI
|
||||||
|
if avg_loss == 0:
|
||||||
|
return 100.0 # All gains, maximum RSI
|
||||||
|
|
||||||
|
rs = avg_gain / avg_loss
|
||||||
|
rsi = 100 - (100 / (1 + rs))
|
||||||
|
|
||||||
|
return rsi
|
||||||
|
|
||||||
|
def calculate_pv_divergence(
|
||||||
|
self,
|
||||||
|
price_change: float,
|
||||||
|
volume_surge: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate price-volume divergence score.
|
||||||
|
|
||||||
|
Positive divergence: Price up + Volume up = bullish
|
||||||
|
Negative divergence: Price up + Volume down = bearish
|
||||||
|
Neutral: Price/volume move together moderately
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_change: Price change percentage
|
||||||
|
volume_surge: Volume surge ratio
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Divergence score (-100 to +100)
|
||||||
|
"""
|
||||||
|
# Normalize volume surge to -1 to +1 scale (1.0 = neutral)
|
||||||
|
volume_signal = (volume_surge - 1.0) * 10 # Scale for sensitivity
|
||||||
|
|
||||||
|
# Calculate divergence
|
||||||
|
# Positive: price and volume move in same direction
|
||||||
|
# Negative: price and volume move in opposite directions
|
||||||
|
if price_change > 0 and volume_surge > 1.0:
|
||||||
|
# Bullish: price up, volume up
|
||||||
|
return min(100.0, price_change * volume_signal)
|
||||||
|
elif price_change < 0 and volume_surge < 1.0:
|
||||||
|
# Bearish confirmation: price down, volume down
|
||||||
|
return max(-100.0, price_change * volume_signal)
|
||||||
|
elif price_change > 0 and volume_surge < 1.0:
|
||||||
|
# Bearish divergence: price up but volume low (weak rally)
|
||||||
|
return -abs(price_change) * 0.5
|
||||||
|
elif price_change < 0 and volume_surge > 1.0:
|
||||||
|
# Selling pressure: price down, volume up
|
||||||
|
return price_change * volume_signal
|
||||||
|
else:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def calculate_momentum_score(
|
||||||
|
self,
|
||||||
|
price_change_1m: float,
|
||||||
|
price_change_5m: float,
|
||||||
|
price_change_15m: float,
|
||||||
|
volume_surge: float,
|
||||||
|
atr: float,
|
||||||
|
current_price: float,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate combined momentum score (0-100).
|
||||||
|
|
||||||
|
Weights:
|
||||||
|
- 1m change: 40%
|
||||||
|
- 5m change: 30%
|
||||||
|
- 15m change: 20%
|
||||||
|
- Volume surge: 10%
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_change_1m: 1-minute price change %
|
||||||
|
price_change_5m: 5-minute price change %
|
||||||
|
price_change_15m: 15-minute price change %
|
||||||
|
volume_surge: Volume surge ratio
|
||||||
|
atr: Average True Range
|
||||||
|
current_price: Current price
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Momentum score (0-100)
|
||||||
|
"""
|
||||||
|
# Weight recent changes more heavily
|
||||||
|
weighted_change = (
|
||||||
|
price_change_1m * 0.4 +
|
||||||
|
price_change_5m * 0.3 +
|
||||||
|
price_change_15m * 0.2
|
||||||
|
)
|
||||||
|
|
||||||
|
# Volume contribution (normalized to 0-10 scale)
|
||||||
|
volume_contribution = min(10.0, (volume_surge - 1.0) * 5.0)
|
||||||
|
|
||||||
|
# Volatility bonus: higher ATR = higher potential (normalized)
|
||||||
|
volatility_bonus = 0.0
|
||||||
|
if current_price > 0:
|
||||||
|
atr_pct = (atr / current_price) * 100
|
||||||
|
volatility_bonus = min(10.0, atr_pct)
|
||||||
|
|
||||||
|
# Combine scores
|
||||||
|
raw_score = weighted_change + volume_contribution + volatility_bonus
|
||||||
|
|
||||||
|
# Normalize to 0-100 scale
|
||||||
|
# Assume typical momentum range is -10 to +30
|
||||||
|
normalized = ((raw_score + 10) / 40) * 100
|
||||||
|
|
||||||
|
return max(0.0, min(100.0, normalized))
|
||||||
|
|
||||||
|
def analyze(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
orderbook_data: dict[str, Any],
|
||||||
|
price_history: dict[str, Any],
|
||||||
|
) -> VolatilityMetrics:
|
||||||
|
"""Analyze volatility and momentum for a stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock code
|
||||||
|
orderbook_data: Current orderbook/quote data
|
||||||
|
price_history: Historical price and volume data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
VolatilityMetrics with calculated indicators
|
||||||
|
"""
|
||||||
|
# Extract current data from orderbook
|
||||||
|
output1 = orderbook_data.get("output1", {})
|
||||||
|
current_price = float(output1.get("stck_prpr", 0))
|
||||||
|
current_volume = float(output1.get("acml_vol", 0))
|
||||||
|
|
||||||
|
# Extract historical data
|
||||||
|
high_prices = price_history.get("high", [])
|
||||||
|
low_prices = price_history.get("low", [])
|
||||||
|
close_prices = price_history.get("close", [])
|
||||||
|
volumes = price_history.get("volume", [])
|
||||||
|
|
||||||
|
# Calculate ATR
|
||||||
|
atr = self.calculate_atr(high_prices, low_prices, close_prices)
|
||||||
|
|
||||||
|
# Calculate price changes (use historical data if available)
|
||||||
|
price_change_1m = 0.0
|
||||||
|
price_change_5m = 0.0
|
||||||
|
price_change_15m = 0.0
|
||||||
|
|
||||||
|
if len(close_prices) > 0:
|
||||||
|
if len(close_prices) >= 1:
|
||||||
|
price_change_1m = self.calculate_price_change(
|
||||||
|
current_price, close_prices[-1]
|
||||||
|
)
|
||||||
|
if len(close_prices) >= 5:
|
||||||
|
price_change_5m = self.calculate_price_change(
|
||||||
|
current_price, close_prices[-5]
|
||||||
|
)
|
||||||
|
if len(close_prices) >= 15:
|
||||||
|
price_change_15m = self.calculate_price_change(
|
||||||
|
current_price, close_prices[-15]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate volume surge
|
||||||
|
avg_volume = sum(volumes) / len(volumes) if volumes else current_volume
|
||||||
|
volume_surge = self.calculate_volume_surge(current_volume, avg_volume)
|
||||||
|
|
||||||
|
# Calculate price-volume divergence
|
||||||
|
pv_divergence = self.calculate_pv_divergence(price_change_1m, volume_surge)
|
||||||
|
|
||||||
|
# Calculate momentum score
|
||||||
|
momentum_score = self.calculate_momentum_score(
|
||||||
|
price_change_1m,
|
||||||
|
price_change_5m,
|
||||||
|
price_change_15m,
|
||||||
|
volume_surge,
|
||||||
|
atr,
|
||||||
|
current_price,
|
||||||
|
)
|
||||||
|
|
||||||
|
return VolatilityMetrics(
|
||||||
|
stock_code=stock_code,
|
||||||
|
current_price=current_price,
|
||||||
|
atr=atr,
|
||||||
|
price_change_1m=price_change_1m,
|
||||||
|
price_change_5m=price_change_5m,
|
||||||
|
price_change_15m=price_change_15m,
|
||||||
|
volume_surge=volume_surge,
|
||||||
|
pv_divergence=pv_divergence,
|
||||||
|
momentum_score=momentum_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_breakout(self, metrics: VolatilityMetrics) -> bool:
|
||||||
|
"""Determine if a stock is experiencing a breakout.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: Volatility metrics for the stock
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if breakout conditions are met
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
metrics.price_change_1m >= self.min_price_change
|
||||||
|
and metrics.volume_surge >= self.min_volume_surge
|
||||||
|
and metrics.pv_divergence > 0 # Bullish divergence
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_breakdown(self, metrics: VolatilityMetrics) -> bool:
|
||||||
|
"""Determine if a stock is experiencing a breakdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: Volatility metrics for the stock
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if breakdown conditions are met
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
metrics.price_change_1m <= -self.min_price_change
|
||||||
|
and metrics.volume_surge >= self.min_volume_surge
|
||||||
|
and metrics.pv_divergence < 0 # Bearish divergence
|
||||||
|
)
|
||||||
21
src/backup/__init__.py
Normal file
21
src/backup/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""Backup and disaster recovery system for long-term sustainability.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- Automated database backups (daily, weekly, monthly)
|
||||||
|
- Multi-format exports (JSON, CSV, Parquet)
|
||||||
|
- Cloud storage integration (S3-compatible)
|
||||||
|
- Health monitoring and alerts
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.backup.exporter import BackupExporter, ExportFormat
|
||||||
|
from src.backup.scheduler import BackupScheduler, BackupPolicy
|
||||||
|
from src.backup.cloud_storage import CloudStorage, S3Config
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BackupExporter",
|
||||||
|
"ExportFormat",
|
||||||
|
"BackupScheduler",
|
||||||
|
"BackupPolicy",
|
||||||
|
"CloudStorage",
|
||||||
|
"S3Config",
|
||||||
|
]
|
||||||
274
src/backup/cloud_storage.py
Normal file
274
src/backup/cloud_storage.py
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
"""Cloud storage integration for off-site backups.
|
||||||
|
|
||||||
|
Supports S3-compatible storage providers:
|
||||||
|
- AWS S3
|
||||||
|
- MinIO
|
||||||
|
- Backblaze B2
|
||||||
|
- DigitalOcean Spaces
|
||||||
|
- Cloudflare R2
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class S3Config:
|
||||||
|
"""Configuration for S3-compatible storage."""
|
||||||
|
|
||||||
|
endpoint_url: str | None # None for AWS S3, custom URL for others
|
||||||
|
access_key: str
|
||||||
|
secret_key: str
|
||||||
|
bucket_name: str
|
||||||
|
region: str = "us-east-1"
|
||||||
|
use_ssl: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CloudStorage:
|
||||||
|
"""Upload backups to S3-compatible cloud storage."""
|
||||||
|
|
||||||
|
def __init__(self, config: S3Config) -> None:
|
||||||
|
"""Initialize cloud storage client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: S3 configuration
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If boto3 is not installed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import boto3
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"boto3 is required for cloud storage. Install with: pip install boto3"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.config = config
|
||||||
|
self.client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=config.endpoint_url,
|
||||||
|
aws_access_key_id=config.access_key,
|
||||||
|
aws_secret_access_key=config.secret_key,
|
||||||
|
region_name=config.region,
|
||||||
|
use_ssl=config.use_ssl,
|
||||||
|
)
|
||||||
|
|
||||||
|
def upload_file(
|
||||||
|
self,
|
||||||
|
file_path: Path,
|
||||||
|
object_key: str | None = None,
|
||||||
|
metadata: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Upload a file to cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Local file to upload
|
||||||
|
object_key: S3 object key (default: filename)
|
||||||
|
metadata: Optional metadata to attach
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
S3 object key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If file doesn't exist
|
||||||
|
Exception: If upload fails
|
||||||
|
"""
|
||||||
|
if not file_path.exists():
|
||||||
|
raise FileNotFoundError(f"File not found: {file_path}")
|
||||||
|
|
||||||
|
if object_key is None:
|
||||||
|
object_key = file_path.name
|
||||||
|
|
||||||
|
extra_args: dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Add server-side encryption
|
||||||
|
extra_args["ServerSideEncryption"] = "AES256"
|
||||||
|
|
||||||
|
# Add metadata if provided
|
||||||
|
if metadata:
|
||||||
|
extra_args["Metadata"] = metadata
|
||||||
|
|
||||||
|
logger.info("Uploading %s to s3://%s/%s", file_path.name, self.config.bucket_name, object_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.upload_file(
|
||||||
|
str(file_path),
|
||||||
|
self.config.bucket_name,
|
||||||
|
object_key,
|
||||||
|
ExtraArgs=extra_args,
|
||||||
|
)
|
||||||
|
logger.info("Upload successful: %s", object_key)
|
||||||
|
return object_key
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Upload failed: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def download_file(self, object_key: str, local_path: Path) -> Path:
|
||||||
|
"""Download a file from cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_key: S3 object key
|
||||||
|
local_path: Local destination path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to downloaded file
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If download fails
|
||||||
|
"""
|
||||||
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info("Downloading s3://%s/%s to %s", self.config.bucket_name, object_key, local_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.download_file(
|
||||||
|
self.config.bucket_name,
|
||||||
|
object_key,
|
||||||
|
str(local_path),
|
||||||
|
)
|
||||||
|
logger.info("Download successful: %s", local_path)
|
||||||
|
return local_path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Download failed: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_files(self, prefix: str = "") -> list[dict[str, Any]]:
|
||||||
|
"""List files in cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: Filter by object key prefix
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file metadata dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = self.client.list_objects_v2(
|
||||||
|
Bucket=self.config.bucket_name,
|
||||||
|
Prefix=prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
if "Contents" not in response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
files = []
|
||||||
|
for obj in response["Contents"]:
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"key": obj["Key"],
|
||||||
|
"size_bytes": obj["Size"],
|
||||||
|
"last_modified": obj["LastModified"],
|
||||||
|
"etag": obj["ETag"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return files
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to list files: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def delete_file(self, object_key: str) -> None:
|
||||||
|
"""Delete a file from cloud storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
object_key: S3 object key
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If deletion fails
|
||||||
|
"""
|
||||||
|
logger.info("Deleting s3://%s/%s", self.config.bucket_name, object_key)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.delete_object(
|
||||||
|
Bucket=self.config.bucket_name,
|
||||||
|
Key=object_key,
|
||||||
|
)
|
||||||
|
logger.info("Deletion successful: %s", object_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Deletion failed: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_storage_stats(self) -> dict[str, Any]:
|
||||||
|
"""Get cloud storage statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with storage stats
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
files = self.list_files()
|
||||||
|
|
||||||
|
total_size = sum(f["size_bytes"] for f in files)
|
||||||
|
total_count = len(files)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_files": total_count,
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"total_size_mb": total_size / 1024 / 1024,
|
||||||
|
"total_size_gb": total_size / 1024 / 1024 / 1024,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to get storage stats: %s", exc)
|
||||||
|
return {
|
||||||
|
"error": str(exc),
|
||||||
|
"total_files": 0,
|
||||||
|
"total_size_bytes": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def verify_connection(self) -> bool:
|
||||||
|
"""Verify connection to cloud storage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if connection is successful
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.client.head_bucket(Bucket=self.config.bucket_name)
|
||||||
|
logger.info("Cloud storage connection verified")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Cloud storage connection failed: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_bucket_if_not_exists(self) -> None:
|
||||||
|
"""Create storage bucket if it doesn't exist.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If bucket creation fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.client.head_bucket(Bucket=self.config.bucket_name)
|
||||||
|
logger.info("Bucket already exists: %s", self.config.bucket_name)
|
||||||
|
except self.client.exceptions.NoSuchBucket:
|
||||||
|
logger.info("Creating bucket: %s", self.config.bucket_name)
|
||||||
|
if self.config.region == "us-east-1":
|
||||||
|
# us-east-1 requires special handling
|
||||||
|
self.client.create_bucket(Bucket=self.config.bucket_name)
|
||||||
|
else:
|
||||||
|
self.client.create_bucket(
|
||||||
|
Bucket=self.config.bucket_name,
|
||||||
|
CreateBucketConfiguration={"LocationConstraint": self.config.region},
|
||||||
|
)
|
||||||
|
logger.info("Bucket created successfully")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to verify/create bucket: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def enable_versioning(self) -> None:
|
||||||
|
"""Enable versioning on the bucket.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
Exception: If versioning enablement fails
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.client.put_bucket_versioning(
|
||||||
|
Bucket=self.config.bucket_name,
|
||||||
|
VersioningConfiguration={"Status": "Enabled"},
|
||||||
|
)
|
||||||
|
logger.info("Versioning enabled for bucket: %s", self.config.bucket_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to enable versioning: %s", exc)
|
||||||
|
raise
|
||||||
326
src/backup/exporter.py
Normal file
326
src/backup/exporter.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""Multi-format database exporter for backups.
|
||||||
|
|
||||||
|
Supports JSON, CSV, and Parquet formats for different use cases:
|
||||||
|
- JSON: Human-readable, easy to inspect
|
||||||
|
- CSV: Analysis tools (Excel, pandas)
|
||||||
|
- Parquet: Big data tools (Spark, DuckDB)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportFormat(str, Enum):
|
||||||
|
"""Supported export formats."""
|
||||||
|
|
||||||
|
JSON = "json"
|
||||||
|
CSV = "csv"
|
||||||
|
PARQUET = "parquet"
|
||||||
|
|
||||||
|
|
||||||
|
class BackupExporter:
|
||||||
|
"""Export database to multiple formats."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str) -> None:
|
||||||
|
"""Initialize the exporter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database
|
||||||
|
"""
|
||||||
|
self.db_path = db_path
|
||||||
|
|
||||||
|
def export_all(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
formats: list[ExportFormat] | None = None,
|
||||||
|
compress: bool = True,
|
||||||
|
incremental_since: datetime | None = None,
|
||||||
|
) -> dict[ExportFormat, Path]:
|
||||||
|
"""Export database to multiple formats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory to write export files
|
||||||
|
formats: List of formats to export (default: all)
|
||||||
|
compress: Whether to gzip compress exports
|
||||||
|
incremental_since: Only export records after this timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping format to output file path
|
||||||
|
"""
|
||||||
|
if formats is None:
|
||||||
|
formats = [ExportFormat.JSON, ExportFormat.CSV, ExportFormat.PARQUET]
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
results: dict[ExportFormat, Path] = {}
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
try:
|
||||||
|
output_file = self._export_format(
|
||||||
|
fmt, output_dir, timestamp, compress, incremental_since
|
||||||
|
)
|
||||||
|
results[fmt] = output_file
|
||||||
|
logger.info("Exported to %s: %s", fmt.value, output_file)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to export to %s: %s", fmt.value, exc)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
def _export_format(
|
||||||
|
self,
|
||||||
|
fmt: ExportFormat,
|
||||||
|
output_dir: Path,
|
||||||
|
timestamp: str,
|
||||||
|
compress: bool,
|
||||||
|
incremental_since: datetime | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export to a specific format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fmt: Export format
|
||||||
|
output_dir: Output directory
|
||||||
|
timestamp: Timestamp string for filename
|
||||||
|
compress: Whether to compress
|
||||||
|
incremental_since: Incremental export cutoff
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to output file
|
||||||
|
"""
|
||||||
|
if fmt == ExportFormat.JSON:
|
||||||
|
return self._export_json(output_dir, timestamp, compress, incremental_since)
|
||||||
|
elif fmt == ExportFormat.CSV:
|
||||||
|
return self._export_csv(output_dir, timestamp, compress, incremental_since)
|
||||||
|
elif fmt == ExportFormat.PARQUET:
|
||||||
|
return self._export_parquet(
|
||||||
|
output_dir, timestamp, compress, incremental_since
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported format: {fmt}")
|
||||||
|
|
||||||
|
def _get_trades(
|
||||||
|
self, incremental_since: datetime | None = None
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch trades from database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
incremental_since: Only fetch trades after this timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of trade records
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
if incremental_since:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT * FROM trades WHERE timestamp > ?",
|
||||||
|
(incremental_since.isoformat(),),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute("SELECT * FROM trades")
|
||||||
|
|
||||||
|
trades = [dict(row) for row in cursor.fetchall()]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return trades
|
||||||
|
|
||||||
|
def _export_json(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
timestamp: str,
|
||||||
|
compress: bool,
|
||||||
|
incremental_since: datetime | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export to JSON format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Output directory
|
||||||
|
timestamp: Timestamp for filename
|
||||||
|
compress: Whether to gzip
|
||||||
|
incremental_since: Incremental cutoff
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to output file
|
||||||
|
"""
|
||||||
|
trades = self._get_trades(incremental_since)
|
||||||
|
|
||||||
|
filename = f"trades_{timestamp}.json"
|
||||||
|
if compress:
|
||||||
|
filename += ".gz"
|
||||||
|
|
||||||
|
output_file = output_dir / filename
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"export_timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"incremental_since": (
|
||||||
|
incremental_since.isoformat() if incremental_since else None
|
||||||
|
),
|
||||||
|
"record_count": len(trades),
|
||||||
|
"trades": trades,
|
||||||
|
}
|
||||||
|
|
||||||
|
if compress:
|
||||||
|
with gzip.open(output_file, "wt", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
def _export_csv(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
timestamp: str,
|
||||||
|
compress: bool,
|
||||||
|
incremental_since: datetime | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export to CSV format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Output directory
|
||||||
|
timestamp: Timestamp for filename
|
||||||
|
compress: Whether to gzip
|
||||||
|
incremental_since: Incremental cutoff
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to output file
|
||||||
|
"""
|
||||||
|
trades = self._get_trades(incremental_since)
|
||||||
|
|
||||||
|
filename = f"trades_{timestamp}.csv"
|
||||||
|
if compress:
|
||||||
|
filename += ".gz"
|
||||||
|
|
||||||
|
output_file = output_dir / filename
|
||||||
|
|
||||||
|
if not trades:
|
||||||
|
# Write empty CSV with headers
|
||||||
|
if compress:
|
||||||
|
with gzip.open(output_file, "wt", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
"timestamp",
|
||||||
|
"stock_code",
|
||||||
|
"action",
|
||||||
|
"quantity",
|
||||||
|
"price",
|
||||||
|
"confidence",
|
||||||
|
"rationale",
|
||||||
|
"pnl",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
with open(output_file, "w", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow(
|
||||||
|
[
|
||||||
|
"timestamp",
|
||||||
|
"stock_code",
|
||||||
|
"action",
|
||||||
|
"quantity",
|
||||||
|
"price",
|
||||||
|
"confidence",
|
||||||
|
"rationale",
|
||||||
|
"pnl",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
# Get column names from first trade
|
||||||
|
fieldnames = list(trades[0].keys())
|
||||||
|
|
||||||
|
if compress:
|
||||||
|
with gzip.open(output_file, "wt", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(trades)
|
||||||
|
else:
|
||||||
|
with open(output_file, "w", encoding="utf-8", newline="") as f:
|
||||||
|
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(trades)
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
def _export_parquet(
|
||||||
|
self,
|
||||||
|
output_dir: Path,
|
||||||
|
timestamp: str,
|
||||||
|
compress: bool,
|
||||||
|
incremental_since: datetime | None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export to Parquet format.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Output directory
|
||||||
|
timestamp: Timestamp for filename
|
||||||
|
compress: Whether to compress (Parquet has built-in compression)
|
||||||
|
incremental_since: Incremental cutoff
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to output file
|
||||||
|
"""
|
||||||
|
trades = self._get_trades(incremental_since)
|
||||||
|
|
||||||
|
filename = f"trades_{timestamp}.parquet"
|
||||||
|
output_file = output_dir / filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyarrow as pa
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError(
|
||||||
|
"pyarrow is required for Parquet export. "
|
||||||
|
"Install with: pip install pyarrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to pyarrow table
|
||||||
|
table = pa.Table.from_pylist(trades)
|
||||||
|
|
||||||
|
# Write with compression
|
||||||
|
compression = "gzip" if compress else "none"
|
||||||
|
pq.write_table(table, output_file, compression=compression)
|
||||||
|
|
||||||
|
return output_file
|
||||||
|
|
||||||
|
def get_export_stats(self) -> dict[str, Any]:
|
||||||
|
"""Get statistics about exportable data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with data statistics
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self.db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
stats = {}
|
||||||
|
|
||||||
|
# Total trades
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM trades")
|
||||||
|
stats["total_trades"] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Date range
|
||||||
|
cursor.execute("SELECT MIN(timestamp), MAX(timestamp) FROM trades")
|
||||||
|
min_date, max_date = cursor.fetchone()
|
||||||
|
stats["date_range"] = {"earliest": min_date, "latest": max_date}
|
||||||
|
|
||||||
|
# Database size
|
||||||
|
cursor.execute("SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()")
|
||||||
|
stats["db_size_bytes"] = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return stats
|
||||||
282
src/backup/health_monitor.py
Normal file
282
src/backup/health_monitor.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Health monitoring for backup system.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
- Database accessibility and integrity
|
||||||
|
- Disk space availability
|
||||||
|
- Backup success/failure tracking
|
||||||
|
- Self-healing capabilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthStatus(str, Enum):
|
||||||
|
"""Health check status."""
|
||||||
|
|
||||||
|
HEALTHY = "healthy"
|
||||||
|
DEGRADED = "degraded"
|
||||||
|
UNHEALTHY = "unhealthy"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthCheckResult:
|
||||||
|
"""Result of a health check."""
|
||||||
|
|
||||||
|
status: HealthStatus
|
||||||
|
message: str
|
||||||
|
details: dict[str, Any] | None = None
|
||||||
|
timestamp: datetime | None = None
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.timestamp is None:
|
||||||
|
self.timestamp = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class HealthMonitor:
|
||||||
|
"""Monitor system health and backup status."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
backup_dir: Path,
|
||||||
|
min_disk_space_gb: float = 10.0,
|
||||||
|
max_backup_age_hours: int = 25, # Daily backups should be < 25 hours old
|
||||||
|
) -> None:
|
||||||
|
"""Initialize health monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database
|
||||||
|
backup_dir: Backup directory
|
||||||
|
min_disk_space_gb: Minimum required disk space in GB
|
||||||
|
max_backup_age_hours: Maximum acceptable backup age in hours
|
||||||
|
"""
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.backup_dir = backup_dir
|
||||||
|
self.min_disk_space_bytes = int(min_disk_space_gb * 1024 * 1024 * 1024)
|
||||||
|
self.max_backup_age = timedelta(hours=max_backup_age_hours)
|
||||||
|
|
||||||
|
def check_database_health(self) -> HealthCheckResult:
|
||||||
|
"""Check database accessibility and integrity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthCheckResult
|
||||||
|
"""
|
||||||
|
# Check if database exists
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Database not found: {self.db_path}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if database is accessible
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Run integrity check
|
||||||
|
cursor.execute("PRAGMA integrity_check")
|
||||||
|
result = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
if result != "ok":
|
||||||
|
conn.close()
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Database integrity check failed: {result}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get database size
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT page_count * page_size FROM pragma_page_count(), pragma_page_size()"
|
||||||
|
)
|
||||||
|
db_size = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Get row counts
|
||||||
|
cursor.execute("SELECT COUNT(*) FROM trades")
|
||||||
|
trade_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message="Database is healthy",
|
||||||
|
details={
|
||||||
|
"size_bytes": db_size,
|
||||||
|
"size_mb": db_size / 1024 / 1024,
|
||||||
|
"trade_count": trade_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Database access error: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_disk_space(self) -> HealthCheckResult:
|
||||||
|
"""Check available disk space.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthCheckResult
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stat = shutil.disk_usage(self.backup_dir)
|
||||||
|
|
||||||
|
free_gb = stat.free / 1024 / 1024 / 1024
|
||||||
|
total_gb = stat.total / 1024 / 1024 / 1024
|
||||||
|
used_percent = (stat.used / stat.total) * 100
|
||||||
|
|
||||||
|
if stat.free < self.min_disk_space_bytes:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Low disk space: {free_gb:.2f} GB free (minimum: {self.min_disk_space_bytes / 1024 / 1024 / 1024:.2f} GB)",
|
||||||
|
details={
|
||||||
|
"free_gb": free_gb,
|
||||||
|
"total_gb": total_gb,
|
||||||
|
"used_percent": used_percent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif stat.free < self.min_disk_space_bytes * 2:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.DEGRADED,
|
||||||
|
message=f"Disk space low: {free_gb:.2f} GB free",
|
||||||
|
details={
|
||||||
|
"free_gb": free_gb,
|
||||||
|
"total_gb": total_gb,
|
||||||
|
"used_percent": used_percent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"Disk space healthy: {free_gb:.2f} GB free",
|
||||||
|
details={
|
||||||
|
"free_gb": free_gb,
|
||||||
|
"total_gb": total_gb,
|
||||||
|
"used_percent": used_percent,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message=f"Failed to check disk space: {exc}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_backup_recency(self) -> HealthCheckResult:
|
||||||
|
"""Check if backups are recent enough.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthCheckResult
|
||||||
|
"""
|
||||||
|
daily_dir = self.backup_dir / "daily"
|
||||||
|
|
||||||
|
if not daily_dir.exists():
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.DEGRADED,
|
||||||
|
message="Daily backup directory not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find most recent backup
|
||||||
|
backups = sorted(daily_dir.glob("*.db"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||||
|
|
||||||
|
if not backups:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.UNHEALTHY,
|
||||||
|
message="No daily backups found",
|
||||||
|
)
|
||||||
|
|
||||||
|
most_recent = backups[0]
|
||||||
|
mtime = datetime.fromtimestamp(most_recent.stat().st_mtime, tz=UTC)
|
||||||
|
age = datetime.now(UTC) - mtime
|
||||||
|
|
||||||
|
if age > self.max_backup_age:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.DEGRADED,
|
||||||
|
message=f"Most recent backup is {age.total_seconds() / 3600:.1f} hours old",
|
||||||
|
details={
|
||||||
|
"backup_file": most_recent.name,
|
||||||
|
"age_hours": age.total_seconds() / 3600,
|
||||||
|
"threshold_hours": self.max_backup_age.total_seconds() / 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return HealthCheckResult(
|
||||||
|
status=HealthStatus.HEALTHY,
|
||||||
|
message=f"Recent backup found ({age.total_seconds() / 3600:.1f} hours old)",
|
||||||
|
details={
|
||||||
|
"backup_file": most_recent.name,
|
||||||
|
"age_hours": age.total_seconds() / 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_all_checks(self) -> dict[str, HealthCheckResult]:
|
||||||
|
"""Run all health checks.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping check name to result
|
||||||
|
"""
|
||||||
|
checks = {
|
||||||
|
"database": self.check_database_health(),
|
||||||
|
"disk_space": self.check_disk_space(),
|
||||||
|
"backup_recency": self.check_backup_recency(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Log results
|
||||||
|
for check_name, result in checks.items():
|
||||||
|
if result.status == HealthStatus.UNHEALTHY:
|
||||||
|
logger.error("[%s] %s: %s", check_name, result.status.value, result.message)
|
||||||
|
elif result.status == HealthStatus.DEGRADED:
|
||||||
|
logger.warning("[%s] %s: %s", check_name, result.status.value, result.message)
|
||||||
|
else:
|
||||||
|
logger.info("[%s] %s: %s", check_name, result.status.value, result.message)
|
||||||
|
|
||||||
|
return checks
|
||||||
|
|
||||||
|
def get_overall_status(self) -> HealthStatus:
|
||||||
|
"""Get overall system health status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthStatus (worst status from all checks)
|
||||||
|
"""
|
||||||
|
checks = self.run_all_checks()
|
||||||
|
|
||||||
|
# Return worst status
|
||||||
|
if any(c.status == HealthStatus.UNHEALTHY for c in checks.values()):
|
||||||
|
return HealthStatus.UNHEALTHY
|
||||||
|
elif any(c.status == HealthStatus.DEGRADED for c in checks.values()):
|
||||||
|
return HealthStatus.DEGRADED
|
||||||
|
else:
|
||||||
|
return HealthStatus.HEALTHY
|
||||||
|
|
||||||
|
def get_health_report(self) -> dict[str, Any]:
|
||||||
|
"""Get comprehensive health report.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with health report
|
||||||
|
"""
|
||||||
|
checks = self.run_all_checks()
|
||||||
|
overall = self.get_overall_status()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"overall_status": overall.value,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"checks": {
|
||||||
|
name: {
|
||||||
|
"status": result.status.value,
|
||||||
|
"message": result.message,
|
||||||
|
"details": result.details,
|
||||||
|
}
|
||||||
|
for name, result in checks.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
336
src/backup/scheduler.py
Normal file
336
src/backup/scheduler.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""Backup scheduler for automated database backups.
|
||||||
|
|
||||||
|
Implements backup policies:
|
||||||
|
- Daily: Keep for 30 days (hot storage)
|
||||||
|
- Weekly: Keep for 1 year (warm storage)
|
||||||
|
- Monthly: Keep forever (cold storage)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupPolicy(str, Enum):
|
||||||
|
"""Backup retention policies."""
|
||||||
|
|
||||||
|
DAILY = "daily"
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupMetadata:
|
||||||
|
"""Metadata for a backup."""
|
||||||
|
|
||||||
|
timestamp: datetime
|
||||||
|
policy: BackupPolicy
|
||||||
|
file_path: Path
|
||||||
|
size_bytes: int
|
||||||
|
checksum: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BackupScheduler:
|
||||||
|
"""Manage automated database backups with retention policies."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
db_path: str,
|
||||||
|
backup_dir: Path,
|
||||||
|
daily_retention_days: int = 30,
|
||||||
|
weekly_retention_days: int = 365,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the backup scheduler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to SQLite database
|
||||||
|
backup_dir: Root directory for backups
|
||||||
|
daily_retention_days: Days to keep daily backups
|
||||||
|
weekly_retention_days: Days to keep weekly backups
|
||||||
|
"""
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.backup_dir = backup_dir
|
||||||
|
self.daily_retention = timedelta(days=daily_retention_days)
|
||||||
|
self.weekly_retention = timedelta(days=weekly_retention_days)
|
||||||
|
|
||||||
|
# Create policy-specific directories
|
||||||
|
self.daily_dir = backup_dir / "daily"
|
||||||
|
self.weekly_dir = backup_dir / "weekly"
|
||||||
|
self.monthly_dir = backup_dir / "monthly"
|
||||||
|
|
||||||
|
for d in [self.daily_dir, self.weekly_dir, self.monthly_dir]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_backup(
|
||||||
|
self, policy: BackupPolicy, verify: bool = True
|
||||||
|
) -> BackupMetadata:
|
||||||
|
"""Create a database backup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
policy: Backup policy (daily/weekly/monthly)
|
||||||
|
verify: Whether to verify backup integrity
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
BackupMetadata object
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If database doesn't exist
|
||||||
|
OSError: If backup fails
|
||||||
|
"""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found: {self.db_path}")
|
||||||
|
|
||||||
|
timestamp = datetime.now(UTC)
|
||||||
|
backup_filename = self._get_backup_filename(timestamp, policy)
|
||||||
|
|
||||||
|
# Determine output directory
|
||||||
|
if policy == BackupPolicy.DAILY:
|
||||||
|
output_dir = self.daily_dir
|
||||||
|
elif policy == BackupPolicy.WEEKLY:
|
||||||
|
output_dir = self.weekly_dir
|
||||||
|
else: # MONTHLY
|
||||||
|
output_dir = self.monthly_dir
|
||||||
|
|
||||||
|
backup_path = output_dir / backup_filename
|
||||||
|
|
||||||
|
# Create backup (copy database file)
|
||||||
|
logger.info("Creating %s backup: %s", policy.value, backup_path)
|
||||||
|
shutil.copy2(self.db_path, backup_path)
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
size_bytes = backup_path.stat().st_size
|
||||||
|
|
||||||
|
# Verify backup if requested
|
||||||
|
checksum = None
|
||||||
|
if verify:
|
||||||
|
checksum = self._verify_backup(backup_path)
|
||||||
|
|
||||||
|
metadata = BackupMetadata(
|
||||||
|
timestamp=timestamp,
|
||||||
|
policy=policy,
|
||||||
|
file_path=backup_path,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
checksum=checksum,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Backup created: %s (%.2f MB)",
|
||||||
|
backup_path.name,
|
||||||
|
size_bytes / 1024 / 1024,
|
||||||
|
)
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _get_backup_filename(self, timestamp: datetime, policy: BackupPolicy) -> str:
|
||||||
|
"""Generate backup filename.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timestamp: Backup timestamp
|
||||||
|
policy: Backup policy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filename string
|
||||||
|
"""
|
||||||
|
ts_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"trade_logs_{policy.value}_{ts_str}.db"
|
||||||
|
|
||||||
|
def _verify_backup(self, backup_path: Path) -> str:
|
||||||
|
"""Verify backup integrity using SQLite integrity check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup_path: Path to backup file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Checksum string (MD5 hash)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If integrity check fails
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
# Integrity check
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(backup_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("PRAGMA integrity_check")
|
||||||
|
result = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if result != "ok":
|
||||||
|
raise RuntimeError(f"Integrity check failed: {result}")
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
raise RuntimeError(f"Failed to verify backup: {exc}")
|
||||||
|
|
||||||
|
# Calculate MD5 checksum
|
||||||
|
md5 = hashlib.md5()
|
||||||
|
with open(backup_path, "rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(8192), b""):
|
||||||
|
md5.update(chunk)
|
||||||
|
|
||||||
|
return md5.hexdigest()
|
||||||
|
|
||||||
|
def cleanup_old_backups(self) -> dict[BackupPolicy, int]:
|
||||||
|
"""Remove backups older than retention policies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping policy to number of backups removed
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
removed_counts: dict[BackupPolicy, int] = {}
|
||||||
|
|
||||||
|
# Daily backups: remove older than retention
|
||||||
|
removed_counts[BackupPolicy.DAILY] = self._cleanup_directory(
|
||||||
|
self.daily_dir, now - self.daily_retention
|
||||||
|
)
|
||||||
|
|
||||||
|
# Weekly backups: remove older than retention
|
||||||
|
removed_counts[BackupPolicy.WEEKLY] = self._cleanup_directory(
|
||||||
|
self.weekly_dir, now - self.weekly_retention
|
||||||
|
)
|
||||||
|
|
||||||
|
# Monthly backups: never remove (kept forever)
|
||||||
|
removed_counts[BackupPolicy.MONTHLY] = 0
|
||||||
|
|
||||||
|
total = sum(removed_counts.values())
|
||||||
|
if total > 0:
|
||||||
|
logger.info("Cleaned up %d old backup(s)", total)
|
||||||
|
|
||||||
|
return removed_counts
|
||||||
|
|
||||||
|
def _cleanup_directory(self, directory: Path, cutoff: datetime) -> int:
|
||||||
|
"""Remove backups older than cutoff date.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
directory: Directory to clean
|
||||||
|
cutoff: Remove files older than this
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of files removed
|
||||||
|
"""
|
||||||
|
removed = 0
|
||||||
|
|
||||||
|
for backup_file in directory.glob("*.db"):
|
||||||
|
# Get file modification time
|
||||||
|
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime, tz=UTC)
|
||||||
|
|
||||||
|
if mtime < cutoff:
|
||||||
|
logger.debug("Removing old backup: %s", backup_file.name)
|
||||||
|
backup_file.unlink()
|
||||||
|
removed += 1
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
def list_backups(
|
||||||
|
self, policy: BackupPolicy | None = None
|
||||||
|
) -> list[BackupMetadata]:
|
||||||
|
"""List available backups.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
policy: Filter by policy (None for all)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of BackupMetadata objects
|
||||||
|
"""
|
||||||
|
backups: list[BackupMetadata] = []
|
||||||
|
|
||||||
|
policies_to_check = (
|
||||||
|
[policy] if policy else [BackupPolicy.DAILY, BackupPolicy.WEEKLY, BackupPolicy.MONTHLY]
|
||||||
|
)
|
||||||
|
|
||||||
|
for pol in policies_to_check:
|
||||||
|
if pol == BackupPolicy.DAILY:
|
||||||
|
directory = self.daily_dir
|
||||||
|
elif pol == BackupPolicy.WEEKLY:
|
||||||
|
directory = self.weekly_dir
|
||||||
|
else:
|
||||||
|
directory = self.monthly_dir
|
||||||
|
|
||||||
|
for backup_file in sorted(directory.glob("*.db")):
|
||||||
|
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime, tz=UTC)
|
||||||
|
size = backup_file.stat().st_size
|
||||||
|
|
||||||
|
backups.append(
|
||||||
|
BackupMetadata(
|
||||||
|
timestamp=mtime,
|
||||||
|
policy=pol,
|
||||||
|
file_path=backup_file,
|
||||||
|
size_bytes=size,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort by timestamp (newest first)
|
||||||
|
backups.sort(key=lambda b: b.timestamp, reverse=True)
|
||||||
|
|
||||||
|
return backups
|
||||||
|
|
||||||
|
def get_backup_stats(self) -> dict[str, Any]:
|
||||||
|
"""Get backup statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with backup stats
|
||||||
|
"""
|
||||||
|
stats: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for policy in BackupPolicy:
|
||||||
|
if policy == BackupPolicy.DAILY:
|
||||||
|
directory = self.daily_dir
|
||||||
|
elif policy == BackupPolicy.WEEKLY:
|
||||||
|
directory = self.weekly_dir
|
||||||
|
else:
|
||||||
|
directory = self.monthly_dir
|
||||||
|
|
||||||
|
backups = list(directory.glob("*.db"))
|
||||||
|
total_size = sum(b.stat().st_size for b in backups)
|
||||||
|
|
||||||
|
stats[policy.value] = {
|
||||||
|
"count": len(backups),
|
||||||
|
"total_size_bytes": total_size,
|
||||||
|
"total_size_mb": total_size / 1024 / 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def restore_backup(self, backup_metadata: BackupMetadata, verify: bool = True) -> None:
|
||||||
|
"""Restore database from backup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
backup_metadata: Backup to restore
|
||||||
|
verify: Whether to verify restored database
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If backup file doesn't exist
|
||||||
|
RuntimeError: If verification fails
|
||||||
|
"""
|
||||||
|
if not backup_metadata.file_path.exists():
|
||||||
|
raise FileNotFoundError(f"Backup not found: {backup_metadata.file_path}")
|
||||||
|
|
||||||
|
# Create backup of current database
|
||||||
|
if self.db_path.exists():
|
||||||
|
backup_current = self.db_path.with_suffix(".db.before_restore")
|
||||||
|
logger.info("Backing up current database to: %s", backup_current)
|
||||||
|
shutil.copy2(self.db_path, backup_current)
|
||||||
|
|
||||||
|
# Restore backup
|
||||||
|
logger.info("Restoring backup: %s", backup_metadata.file_path.name)
|
||||||
|
shutil.copy2(backup_metadata.file_path, self.db_path)
|
||||||
|
|
||||||
|
# Verify restored database
|
||||||
|
if verify:
|
||||||
|
try:
|
||||||
|
self._verify_backup(self.db_path)
|
||||||
|
logger.info("Backup restored and verified successfully")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
# Restore failed, revert to backup
|
||||||
|
if backup_current.exists():
|
||||||
|
logger.error("Restore verification failed, reverting: %s", exc)
|
||||||
|
shutil.copy2(backup_current, self.db_path)
|
||||||
|
raise
|
||||||
293
src/brain/cache.py
Normal file
293
src/brain/cache.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Response caching system for reducing redundant LLM calls.
|
||||||
|
|
||||||
|
This module provides caching for common trading scenarios:
|
||||||
|
- TTL-based cache invalidation
|
||||||
|
- Cache key based on market conditions
|
||||||
|
- Cache hit rate monitoring
|
||||||
|
- Special handling for HOLD decisions in quiet markets
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from src.brain.gemini_client import TradeDecision
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CacheEntry:
|
||||||
|
"""Cached decision with metadata."""
|
||||||
|
|
||||||
|
decision: "TradeDecision"
|
||||||
|
cached_at: float # Unix timestamp
|
||||||
|
hit_count: int = 0
|
||||||
|
market_data_hash: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CacheMetrics:
|
||||||
|
"""Metrics for cache performance monitoring."""
|
||||||
|
|
||||||
|
total_requests: int = 0
|
||||||
|
cache_hits: int = 0
|
||||||
|
cache_misses: int = 0
|
||||||
|
evictions: int = 0
|
||||||
|
total_entries: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hit_rate(self) -> float:
|
||||||
|
"""Calculate cache hit rate."""
|
||||||
|
if self.total_requests == 0:
|
||||||
|
return 0.0
|
||||||
|
return self.cache_hits / self.total_requests
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert metrics to dictionary."""
|
||||||
|
return {
|
||||||
|
"total_requests": self.total_requests,
|
||||||
|
"cache_hits": self.cache_hits,
|
||||||
|
"cache_misses": self.cache_misses,
|
||||||
|
"hit_rate": self.hit_rate,
|
||||||
|
"evictions": self.evictions,
|
||||||
|
"total_entries": self.total_entries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionCache:
|
||||||
|
"""TTL-based cache for trade decisions."""
|
||||||
|
|
||||||
|
def __init__(self, ttl_seconds: int = 300, max_size: int = 1000) -> None:
|
||||||
|
"""Initialize the decision cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ttl_seconds: Time-to-live for cache entries in seconds (default: 5 minutes)
|
||||||
|
max_size: Maximum number of cache entries
|
||||||
|
"""
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
self.max_size = max_size
|
||||||
|
self._cache: dict[str, CacheEntry] = {}
|
||||||
|
self._metrics = CacheMetrics()
|
||||||
|
|
||||||
|
def _generate_cache_key(self, market_data: dict[str, Any]) -> str:
|
||||||
|
"""Generate cache key from market data.
|
||||||
|
|
||||||
|
Key is based on:
|
||||||
|
- Stock code
|
||||||
|
- Current price (rounded to reduce sensitivity)
|
||||||
|
- Market conditions (orderbook snapshot)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cache key string
|
||||||
|
"""
|
||||||
|
# Extract key components
|
||||||
|
stock_code = market_data.get("stock_code", "UNKNOWN")
|
||||||
|
current_price = market_data.get("current_price", 0)
|
||||||
|
|
||||||
|
# Round price to reduce sensitivity (cache hits for similar prices)
|
||||||
|
# For prices > 1000, round to nearest 10
|
||||||
|
# For prices < 1000, round to nearest 1
|
||||||
|
if current_price > 1000:
|
||||||
|
price_rounded = round(current_price / 10) * 10
|
||||||
|
else:
|
||||||
|
price_rounded = round(current_price)
|
||||||
|
|
||||||
|
# Include orderbook snapshot (if available)
|
||||||
|
orderbook_key = ""
|
||||||
|
if "orderbook" in market_data and market_data["orderbook"]:
|
||||||
|
ob = market_data["orderbook"]
|
||||||
|
# Just use bid/ask spread as indicator
|
||||||
|
if "bid" in ob and "ask" in ob and ob["bid"] and ob["ask"]:
|
||||||
|
bid_price = ob["bid"][0].get("price", 0) if ob["bid"] else 0
|
||||||
|
ask_price = ob["ask"][0].get("price", 0) if ob["ask"] else 0
|
||||||
|
spread = ask_price - bid_price
|
||||||
|
orderbook_key = f"_spread{spread}"
|
||||||
|
|
||||||
|
# Generate cache key
|
||||||
|
key_str = f"{stock_code}_{price_rounded}{orderbook_key}"
|
||||||
|
|
||||||
|
return key_str
|
||||||
|
|
||||||
|
def _generate_market_hash(self, market_data: dict[str, Any]) -> str:
|
||||||
|
"""Generate hash of full market data for invalidation checks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Hash string
|
||||||
|
"""
|
||||||
|
# Create stable JSON representation
|
||||||
|
stable_json = json.dumps(market_data, sort_keys=True, ensure_ascii=False)
|
||||||
|
return hashlib.md5(stable_json.encode()).hexdigest()
|
||||||
|
|
||||||
|
def get(self, market_data: dict[str, Any]) -> TradeDecision | None:
|
||||||
|
"""Retrieve cached decision if valid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cached TradeDecision if valid, None otherwise
|
||||||
|
"""
|
||||||
|
self._metrics.total_requests += 1
|
||||||
|
|
||||||
|
cache_key = self._generate_cache_key(market_data)
|
||||||
|
|
||||||
|
if cache_key not in self._cache:
|
||||||
|
self._metrics.cache_misses += 1
|
||||||
|
return None
|
||||||
|
|
||||||
|
entry = self._cache[cache_key]
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
# Check TTL
|
||||||
|
if current_time - entry.cached_at > self.ttl_seconds:
|
||||||
|
# Expired
|
||||||
|
del self._cache[cache_key]
|
||||||
|
self._metrics.cache_misses += 1
|
||||||
|
self._metrics.evictions += 1
|
||||||
|
logger.debug("Cache expired for key: %s", cache_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Cache hit
|
||||||
|
entry.hit_count += 1
|
||||||
|
self._metrics.cache_hits += 1
|
||||||
|
logger.debug("Cache hit for key: %s (hits: %d)", cache_key, entry.hit_count)
|
||||||
|
|
||||||
|
return entry.decision
|
||||||
|
|
||||||
|
def set(
|
||||||
|
self,
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
decision: TradeDecision,
|
||||||
|
) -> None:
|
||||||
|
"""Store decision in cache.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary
|
||||||
|
decision: TradeDecision to cache
|
||||||
|
"""
|
||||||
|
cache_key = self._generate_cache_key(market_data)
|
||||||
|
market_hash = self._generate_market_hash(market_data)
|
||||||
|
|
||||||
|
# Enforce max size (evict oldest if full)
|
||||||
|
if len(self._cache) >= self.max_size:
|
||||||
|
# Find oldest entry
|
||||||
|
oldest_key = min(self._cache.keys(), key=lambda k: self._cache[k].cached_at)
|
||||||
|
del self._cache[oldest_key]
|
||||||
|
self._metrics.evictions += 1
|
||||||
|
logger.debug("Cache full, evicted key: %s", oldest_key)
|
||||||
|
|
||||||
|
# Store entry
|
||||||
|
entry = CacheEntry(
|
||||||
|
decision=decision,
|
||||||
|
cached_at=time.time(),
|
||||||
|
market_data_hash=market_hash,
|
||||||
|
)
|
||||||
|
self._cache[cache_key] = entry
|
||||||
|
self._metrics.total_entries = len(self._cache)
|
||||||
|
|
||||||
|
logger.debug("Cached decision for key: %s", cache_key)
|
||||||
|
|
||||||
|
def invalidate(self, stock_code: str | None = None) -> int:
|
||||||
|
"""Invalidate cache entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Specific stock code to invalidate, or None for all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries invalidated
|
||||||
|
"""
|
||||||
|
if stock_code is None:
|
||||||
|
# Clear all
|
||||||
|
count = len(self._cache)
|
||||||
|
self._cache.clear()
|
||||||
|
self._metrics.evictions += count
|
||||||
|
self._metrics.total_entries = 0
|
||||||
|
logger.info("Invalidated all cache entries (%d)", count)
|
||||||
|
return count
|
||||||
|
|
||||||
|
# Invalidate specific stock
|
||||||
|
keys_to_remove = [k for k in self._cache.keys() if k.startswith(f"{stock_code}_")]
|
||||||
|
count = len(keys_to_remove)
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
self._metrics.evictions += count
|
||||||
|
self._metrics.total_entries = len(self._cache)
|
||||||
|
logger.info("Invalidated %d cache entries for stock: %s", count, stock_code)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def cleanup_expired(self) -> int:
|
||||||
|
"""Remove expired entries from cache.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of entries removed
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
expired_keys = [
|
||||||
|
k
|
||||||
|
for k, v in self._cache.items()
|
||||||
|
if current_time - v.cached_at > self.ttl_seconds
|
||||||
|
]
|
||||||
|
|
||||||
|
count = len(expired_keys)
|
||||||
|
for key in expired_keys:
|
||||||
|
del self._cache[key]
|
||||||
|
|
||||||
|
self._metrics.evictions += count
|
||||||
|
self._metrics.total_entries = len(self._cache)
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
logger.debug("Cleaned up %d expired cache entries", count)
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
def get_metrics(self) -> CacheMetrics:
|
||||||
|
"""Get current cache metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CacheMetrics object with current statistics
|
||||||
|
"""
|
||||||
|
return self._metrics
|
||||||
|
|
||||||
|
def reset_metrics(self) -> None:
|
||||||
|
"""Reset cache metrics."""
|
||||||
|
self._metrics = CacheMetrics(total_entries=len(self._cache))
|
||||||
|
logger.info("Cache metrics reset")
|
||||||
|
|
||||||
|
def should_cache_decision(self, decision: TradeDecision) -> bool:
|
||||||
|
"""Determine if a decision should be cached.
|
||||||
|
|
||||||
|
HOLD decisions with low confidence are good candidates for caching,
|
||||||
|
as they're likely to recur in quiet markets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision: TradeDecision to evaluate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if decision should be cached
|
||||||
|
"""
|
||||||
|
# Cache HOLD decisions (common in quiet markets)
|
||||||
|
if decision.action == "HOLD":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Cache high-confidence decisions (stable signals)
|
||||||
|
if decision.confidence >= 90:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Don't cache low-confidence BUY/SELL (volatile signals)
|
||||||
|
return False
|
||||||
296
src/brain/context_selector.py
Normal file
296
src/brain/context_selector.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
"""Smart context selection for optimizing token usage.
|
||||||
|
|
||||||
|
This module implements intelligent selection of context layers (L1-L7) based on
|
||||||
|
decision type and market conditions:
|
||||||
|
- L7 (real-time) for normal trading decisions
|
||||||
|
- L6-L5 (daily/weekly) for strategic decisions
|
||||||
|
- L4-L1 (monthly/legacy) only for major events or policy changes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionType(str, Enum):
|
||||||
|
"""Type of trading decision being made."""
|
||||||
|
|
||||||
|
NORMAL = "normal" # Regular trade decision
|
||||||
|
STRATEGIC = "strategic" # Strategy adjustment
|
||||||
|
MAJOR_EVENT = "major_event" # Portfolio rebalancing, policy change
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ContextSelection:
|
||||||
|
"""Selected context layers and their relevance scores."""
|
||||||
|
|
||||||
|
layers: list[ContextLayer]
|
||||||
|
relevance_scores: dict[ContextLayer, float]
|
||||||
|
total_score: float
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSelector:
|
||||||
|
"""Selects optimal context layers to minimize token usage."""
|
||||||
|
|
||||||
|
def __init__(self, store: ContextStore) -> None:
|
||||||
|
"""Initialize the context selector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: ContextStore instance for retrieving context data
|
||||||
|
"""
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def select_layers(
|
||||||
|
self,
|
||||||
|
decision_type: DecisionType = DecisionType.NORMAL,
|
||||||
|
include_realtime: bool = True,
|
||||||
|
) -> list[ContextLayer]:
|
||||||
|
"""Select context layers based on decision type.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- NORMAL: L7 (real-time) only
|
||||||
|
- STRATEGIC: L7 + L6 + L5 (real-time + daily + weekly)
|
||||||
|
- MAJOR_EVENT: All layers L1-L7
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_type: Type of decision being made
|
||||||
|
include_realtime: Whether to include L7 real-time data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of context layers to use (ordered by priority)
|
||||||
|
"""
|
||||||
|
if decision_type == DecisionType.NORMAL:
|
||||||
|
# Normal trading: only real-time data
|
||||||
|
return [ContextLayer.L7_REALTIME] if include_realtime else []
|
||||||
|
|
||||||
|
elif decision_type == DecisionType.STRATEGIC:
|
||||||
|
# Strategic decisions: real-time + recent history
|
||||||
|
layers = []
|
||||||
|
if include_realtime:
|
||||||
|
layers.append(ContextLayer.L7_REALTIME)
|
||||||
|
layers.extend([ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY])
|
||||||
|
return layers
|
||||||
|
|
||||||
|
else: # MAJOR_EVENT
|
||||||
|
# Major events: all layers for comprehensive context
|
||||||
|
layers = []
|
||||||
|
if include_realtime:
|
||||||
|
layers.append(ContextLayer.L7_REALTIME)
|
||||||
|
layers.extend(
|
||||||
|
[
|
||||||
|
ContextLayer.L6_DAILY,
|
||||||
|
ContextLayer.L5_WEEKLY,
|
||||||
|
ContextLayer.L4_MONTHLY,
|
||||||
|
ContextLayer.L3_QUARTERLY,
|
||||||
|
ContextLayer.L2_ANNUAL,
|
||||||
|
ContextLayer.L1_LEGACY,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return layers
|
||||||
|
|
||||||
|
def score_layer_relevance(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
decision_type: DecisionType,
|
||||||
|
current_time: datetime | None = None,
|
||||||
|
) -> float:
|
||||||
|
"""Calculate relevance score for a context layer.
|
||||||
|
|
||||||
|
Relevance is based on:
|
||||||
|
1. Decision type (normal, strategic, major event)
|
||||||
|
2. Layer recency (L7 > L6 > ... > L1)
|
||||||
|
3. Data availability
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: Context layer to score
|
||||||
|
decision_type: Type of decision being made
|
||||||
|
current_time: Current time (defaults to now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Relevance score (0.0 to 1.0)
|
||||||
|
"""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Base scores by decision type
|
||||||
|
base_scores = {
|
||||||
|
DecisionType.NORMAL: {
|
||||||
|
ContextLayer.L7_REALTIME: 1.0,
|
||||||
|
ContextLayer.L6_DAILY: 0.1,
|
||||||
|
ContextLayer.L5_WEEKLY: 0.05,
|
||||||
|
ContextLayer.L4_MONTHLY: 0.01,
|
||||||
|
ContextLayer.L3_QUARTERLY: 0.0,
|
||||||
|
ContextLayer.L2_ANNUAL: 0.0,
|
||||||
|
ContextLayer.L1_LEGACY: 0.0,
|
||||||
|
},
|
||||||
|
DecisionType.STRATEGIC: {
|
||||||
|
ContextLayer.L7_REALTIME: 0.9,
|
||||||
|
ContextLayer.L6_DAILY: 0.8,
|
||||||
|
ContextLayer.L5_WEEKLY: 0.7,
|
||||||
|
ContextLayer.L4_MONTHLY: 0.3,
|
||||||
|
ContextLayer.L3_QUARTERLY: 0.2,
|
||||||
|
ContextLayer.L2_ANNUAL: 0.1,
|
||||||
|
ContextLayer.L1_LEGACY: 0.05,
|
||||||
|
},
|
||||||
|
DecisionType.MAJOR_EVENT: {
|
||||||
|
ContextLayer.L7_REALTIME: 0.7,
|
||||||
|
ContextLayer.L6_DAILY: 0.7,
|
||||||
|
ContextLayer.L5_WEEKLY: 0.7,
|
||||||
|
ContextLayer.L4_MONTHLY: 0.8,
|
||||||
|
ContextLayer.L3_QUARTERLY: 0.8,
|
||||||
|
ContextLayer.L2_ANNUAL: 0.9,
|
||||||
|
ContextLayer.L1_LEGACY: 1.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
score = base_scores[decision_type].get(layer, 0.0)
|
||||||
|
|
||||||
|
# Check data availability
|
||||||
|
latest_timeframe = self.store.get_latest_timeframe(layer)
|
||||||
|
if latest_timeframe is None:
|
||||||
|
# No data available - reduce score significantly
|
||||||
|
score *= 0.1
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
def select_with_scoring(
|
||||||
|
self,
|
||||||
|
decision_type: DecisionType = DecisionType.NORMAL,
|
||||||
|
min_score: float = 0.5,
|
||||||
|
) -> ContextSelection:
|
||||||
|
"""Select context layers with relevance scoring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_type: Type of decision being made
|
||||||
|
min_score: Minimum relevance score to include a layer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ContextSelection with selected layers and scores
|
||||||
|
"""
|
||||||
|
all_layers = [
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
ContextLayer.L6_DAILY,
|
||||||
|
ContextLayer.L5_WEEKLY,
|
||||||
|
ContextLayer.L4_MONTHLY,
|
||||||
|
ContextLayer.L3_QUARTERLY,
|
||||||
|
ContextLayer.L2_ANNUAL,
|
||||||
|
ContextLayer.L1_LEGACY,
|
||||||
|
]
|
||||||
|
|
||||||
|
scores = {
|
||||||
|
layer: self.score_layer_relevance(layer, decision_type) for layer in all_layers
|
||||||
|
}
|
||||||
|
|
||||||
|
# Filter by minimum score
|
||||||
|
selected_layers = [layer for layer, score in scores.items() if score >= min_score]
|
||||||
|
|
||||||
|
# Sort by score (descending)
|
||||||
|
selected_layers.sort(key=lambda layer: scores[layer], reverse=True)
|
||||||
|
|
||||||
|
total_score = sum(scores[layer] for layer in selected_layers)
|
||||||
|
|
||||||
|
return ContextSelection(
|
||||||
|
layers=selected_layers,
|
||||||
|
relevance_scores=scores,
|
||||||
|
total_score=total_score,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(
|
||||||
|
self,
|
||||||
|
layers: list[ContextLayer],
|
||||||
|
max_items_per_layer: int = 10,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Retrieve context data for selected layers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layers: List of context layers to retrieve
|
||||||
|
max_items_per_layer: Maximum number of items per layer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with context data organized by layer
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for layer in layers:
|
||||||
|
# Get latest timeframe for this layer
|
||||||
|
latest_timeframe = self.store.get_latest_timeframe(layer)
|
||||||
|
if latest_timeframe:
|
||||||
|
# Get all contexts for latest timeframe
|
||||||
|
contexts = self.store.get_all_contexts(layer, latest_timeframe)
|
||||||
|
|
||||||
|
# Limit number of items
|
||||||
|
if len(contexts) > max_items_per_layer:
|
||||||
|
# Keep only first N items
|
||||||
|
contexts = dict(list(contexts.items())[:max_items_per_layer])
|
||||||
|
|
||||||
|
result[layer.value] = contexts
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def estimate_context_tokens(self, context_data: dict[str, Any]) -> int:
|
||||||
|
"""Estimate total tokens for context data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context_data: Context data dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from src.brain.prompt_optimizer import PromptOptimizer
|
||||||
|
|
||||||
|
# Serialize to JSON and estimate tokens
|
||||||
|
json_str = json.dumps(context_data, ensure_ascii=False)
|
||||||
|
return PromptOptimizer.estimate_tokens(json_str)
|
||||||
|
|
||||||
|
def optimize_context_for_budget(
|
||||||
|
self,
|
||||||
|
decision_type: DecisionType,
|
||||||
|
max_tokens: int,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Select and retrieve context data within a token budget.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_type: Type of decision being made
|
||||||
|
max_tokens: Maximum token budget for context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optimized context data within budget
|
||||||
|
"""
|
||||||
|
# Start with minimal selection
|
||||||
|
selection = self.select_with_scoring(decision_type, min_score=0.5)
|
||||||
|
|
||||||
|
# Retrieve data
|
||||||
|
context_data = self.get_context_data(selection.layers)
|
||||||
|
|
||||||
|
# Check if within budget
|
||||||
|
estimated_tokens = self.estimate_context_tokens(context_data)
|
||||||
|
|
||||||
|
if estimated_tokens <= max_tokens:
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
# If over budget, progressively reduce
|
||||||
|
# 1. Reduce items per layer
|
||||||
|
for max_items in [5, 3, 1]:
|
||||||
|
context_data = self.get_context_data(selection.layers, max_items)
|
||||||
|
estimated_tokens = self.estimate_context_tokens(context_data)
|
||||||
|
if estimated_tokens <= max_tokens:
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
# 2. Remove lower-priority layers
|
||||||
|
for min_score in [0.6, 0.7, 0.8, 0.9]:
|
||||||
|
selection = self.select_with_scoring(decision_type, min_score=min_score)
|
||||||
|
context_data = self.get_context_data(selection.layers, max_items_per_layer=1)
|
||||||
|
estimated_tokens = self.estimate_context_tokens(context_data)
|
||||||
|
if estimated_tokens <= max_tokens:
|
||||||
|
return context_data
|
||||||
|
|
||||||
|
# Last resort: return only L7 with minimal data
|
||||||
|
return self.get_context_data([ContextLayer.L7_REALTIME], max_items_per_layer=1)
|
||||||
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
Constructs prompts from market data, calls Gemini, and parses structured
|
Constructs prompts from market data, calls Gemini, and parses structured
|
||||||
JSON responses into validated TradeDecision objects.
|
JSON responses into validated TradeDecision objects.
|
||||||
|
|
||||||
|
Includes token efficiency optimizations:
|
||||||
|
- Prompt compression and abbreviation
|
||||||
|
- Response caching for common scenarios
|
||||||
|
- Smart context selection
|
||||||
|
- Token usage tracking and metrics
|
||||||
|
|
||||||
|
Includes external data integration:
|
||||||
|
- News sentiment analysis
|
||||||
|
- Economic calendar events
|
||||||
|
- Market indicators
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,6 +26,11 @@ from typing import Any
|
|||||||
from google import genai
|
from google import genai
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
from src.data.news_api import NewsAPI, NewsSentiment
|
||||||
|
from src.data.economic_calendar import EconomicCalendar
|
||||||
|
from src.data.market_data import MarketData
|
||||||
|
from src.brain.cache import DecisionCache
|
||||||
|
from src.brain.prompt_optimizer import PromptOptimizer
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,23 +44,176 @@ class TradeDecision:
|
|||||||
action: str # "BUY" | "SELL" | "HOLD"
|
action: str # "BUY" | "SELL" | "HOLD"
|
||||||
confidence: int # 0-100
|
confidence: int # 0-100
|
||||||
rationale: str
|
rationale: str
|
||||||
|
token_count: int = 0 # Estimated tokens used
|
||||||
|
cached: bool = False # Whether decision came from cache
|
||||||
|
|
||||||
|
|
||||||
class GeminiClient:
|
class GeminiClient:
|
||||||
"""Wraps the Gemini API for trade decision-making."""
|
"""Wraps the Gemini API for trade decision-making."""
|
||||||
|
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
news_api: NewsAPI | None = None,
|
||||||
|
economic_calendar: EconomicCalendar | None = None,
|
||||||
|
market_data: MarketData | None = None,
|
||||||
|
enable_cache: bool = True,
|
||||||
|
enable_optimization: bool = True,
|
||||||
|
) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._confidence_threshold = settings.CONFIDENCE_THRESHOLD
|
self._confidence_threshold = settings.CONFIDENCE_THRESHOLD
|
||||||
self._client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
self._client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
||||||
self._model_name = settings.GEMINI_MODEL
|
self._model_name = settings.GEMINI_MODEL
|
||||||
|
|
||||||
|
# External data sources (optional)
|
||||||
|
self._news_api = news_api
|
||||||
|
self._economic_calendar = economic_calendar
|
||||||
|
self._market_data = market_data
|
||||||
|
|
||||||
|
# Token efficiency features
|
||||||
|
self._enable_cache = enable_cache
|
||||||
|
self._enable_optimization = enable_optimization
|
||||||
|
self._cache = DecisionCache(ttl_seconds=300) if enable_cache else None
|
||||||
|
self._optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
# Token usage metrics
|
||||||
|
self._total_tokens_used = 0
|
||||||
|
self._total_decisions = 0
|
||||||
|
self._total_cached_decisions = 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# External Data Integration
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _build_external_context(
|
||||||
|
self, stock_code: str, news_sentiment: NewsSentiment | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Build external data context for the prompt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol
|
||||||
|
news_sentiment: Optional pre-fetched news sentiment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string with external data context
|
||||||
|
"""
|
||||||
|
context_parts: list[str] = []
|
||||||
|
|
||||||
|
# News sentiment
|
||||||
|
if news_sentiment is not None:
|
||||||
|
sentiment_str = self._format_news_sentiment(news_sentiment)
|
||||||
|
if sentiment_str:
|
||||||
|
context_parts.append(sentiment_str)
|
||||||
|
elif self._news_api is not None:
|
||||||
|
# Fetch news sentiment if not provided
|
||||||
|
try:
|
||||||
|
sentiment = await self._news_api.get_news_sentiment(stock_code)
|
||||||
|
if sentiment is not None:
|
||||||
|
sentiment_str = self._format_news_sentiment(sentiment)
|
||||||
|
if sentiment_str:
|
||||||
|
context_parts.append(sentiment_str)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to fetch news sentiment: %s", exc)
|
||||||
|
|
||||||
|
# Economic events
|
||||||
|
if self._economic_calendar is not None:
|
||||||
|
events_str = self._format_economic_events(stock_code)
|
||||||
|
if events_str:
|
||||||
|
context_parts.append(events_str)
|
||||||
|
|
||||||
|
# Market indicators
|
||||||
|
if self._market_data is not None:
|
||||||
|
indicators_str = self._format_market_indicators()
|
||||||
|
if indicators_str:
|
||||||
|
context_parts.append(indicators_str)
|
||||||
|
|
||||||
|
if not context_parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "EXTERNAL DATA:\n" + "\n\n".join(context_parts)
|
||||||
|
|
||||||
|
def _format_news_sentiment(self, sentiment: NewsSentiment) -> str:
|
||||||
|
"""Format news sentiment for prompt."""
|
||||||
|
if sentiment.article_count == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Select top 3 most relevant articles
|
||||||
|
top_articles = sentiment.articles[:3]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"News Sentiment: {sentiment.avg_sentiment:.2f} "
|
||||||
|
f"(from {sentiment.article_count} articles)",
|
||||||
|
]
|
||||||
|
|
||||||
|
for i, article in enumerate(top_articles, 1):
|
||||||
|
lines.append(
|
||||||
|
f" {i}. [{article.source}] {article.title} "
|
||||||
|
f"(sentiment: {article.sentiment_score:.2f})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _format_economic_events(self, stock_code: str) -> str:
|
||||||
|
"""Format upcoming economic events for prompt."""
|
||||||
|
if self._economic_calendar is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# Check for upcoming high-impact events
|
||||||
|
upcoming = self._economic_calendar.get_upcoming_events(
|
||||||
|
days_ahead=7, min_impact="HIGH"
|
||||||
|
)
|
||||||
|
|
||||||
|
if upcoming.high_impact_count == 0:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"Upcoming High-Impact Events: {upcoming.high_impact_count} in next 7 days"
|
||||||
|
]
|
||||||
|
|
||||||
|
if upcoming.next_major_event is not None:
|
||||||
|
event = upcoming.next_major_event
|
||||||
|
lines.append(
|
||||||
|
f" Next: {event.name} ({event.event_type}) "
|
||||||
|
f"on {event.datetime.strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for earnings
|
||||||
|
earnings_date = self._economic_calendar.get_earnings_date(stock_code)
|
||||||
|
if earnings_date is not None:
|
||||||
|
lines.append(
|
||||||
|
f" Earnings: {stock_code} on {earnings_date.strftime('%Y-%m-%d')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _format_market_indicators(self) -> str:
|
||||||
|
"""Format market indicators for prompt."""
|
||||||
|
if self._market_data is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
indicators = self._market_data.get_market_indicators()
|
||||||
|
lines = [f"Market Sentiment: {indicators.sentiment.name}"]
|
||||||
|
|
||||||
|
# Add breadth if meaningful
|
||||||
|
if indicators.breadth.advance_decline_ratio != 1.0:
|
||||||
|
lines.append(
|
||||||
|
f"Advance/Decline Ratio: {indicators.breadth.advance_decline_ratio:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to get market indicators: %s", exc)
|
||||||
|
return ""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Prompt Construction
|
# Prompt Construction
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def build_prompt(self, market_data: dict[str, Any]) -> str:
|
async def build_prompt(
|
||||||
"""Build a structured prompt from market data.
|
self, market_data: dict[str, Any], news_sentiment: NewsSentiment | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Build a structured prompt from market data and external sources.
|
||||||
|
|
||||||
The prompt instructs Gemini to return valid JSON with action,
|
The prompt instructs Gemini to return valid JSON with action,
|
||||||
confidence, and rationale fields.
|
confidence, and rationale fields.
|
||||||
@@ -72,6 +241,60 @@ class GeminiClient:
|
|||||||
|
|
||||||
market_info = "\n".join(market_info_lines)
|
market_info = "\n".join(market_info_lines)
|
||||||
|
|
||||||
|
# Add external data context if available
|
||||||
|
external_context = await self._build_external_context(
|
||||||
|
market_data["stock_code"], news_sentiment
|
||||||
|
)
|
||||||
|
if external_context:
|
||||||
|
market_info += f"\n\n{external_context}"
|
||||||
|
|
||||||
|
json_format = (
|
||||||
|
'{"action": "BUY"|"SELL"|"HOLD", '
|
||||||
|
'"confidence": <int 0-100>, "rationale": "<string>"}'
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"You are a professional {market_name} trading analyst.\n"
|
||||||
|
"Analyze the following market data and decide whether to "
|
||||||
|
"BUY, SELL, or HOLD.\n\n"
|
||||||
|
f"{market_info}\n\n"
|
||||||
|
"You MUST respond with ONLY valid JSON in the following format:\n"
|
||||||
|
f"{json_format}\n\n"
|
||||||
|
"Rules:\n"
|
||||||
|
"- action must be exactly one of: BUY, SELL, HOLD\n"
|
||||||
|
"- confidence must be an integer from 0 to 100\n"
|
||||||
|
"- rationale must explain your reasoning concisely\n"
|
||||||
|
"- Do NOT wrap the JSON in markdown code blocks\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_prompt_sync(self, market_data: dict[str, Any]) -> str:
|
||||||
|
"""Synchronous version of build_prompt (for backward compatibility).
|
||||||
|
|
||||||
|
This version does NOT include external data integration.
|
||||||
|
Use async build_prompt() for full functionality.
|
||||||
|
"""
|
||||||
|
market_name = market_data.get("market_name", "Korean stock market")
|
||||||
|
|
||||||
|
# Build market data section dynamically based on available fields
|
||||||
|
market_info_lines = [
|
||||||
|
f"Market: {market_name}",
|
||||||
|
f"Stock Code: {market_data['stock_code']}",
|
||||||
|
f"Current Price: {market_data['current_price']}",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add orderbook if available (domestic markets)
|
||||||
|
if "orderbook" in market_data:
|
||||||
|
market_info_lines.append(
|
||||||
|
f"Orderbook: {json.dumps(market_data['orderbook'], ensure_ascii=False)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add foreigner net if non-zero
|
||||||
|
if market_data.get("foreigner_net", 0) != 0:
|
||||||
|
market_info_lines.append(
|
||||||
|
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
market_info = "\n".join(market_info_lines)
|
||||||
|
|
||||||
json_format = (
|
json_format = (
|
||||||
'{"action": "BUY"|"SELL"|"HOLD", '
|
'{"action": "BUY"|"SELL"|"HOLD", '
|
||||||
'"confidence": <int 0-100>, "rationale": "<string>"}'
|
'"confidence": <int 0-100>, "rationale": "<string>"}'
|
||||||
@@ -152,28 +375,383 @@ class GeminiClient:
|
|||||||
# API Call
|
# API Call
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def decide(self, market_data: dict[str, Any]) -> TradeDecision:
|
async def decide(
|
||||||
"""Build prompt, call Gemini, and return a parsed decision."""
|
self, market_data: dict[str, Any], news_sentiment: NewsSentiment | None = None
|
||||||
prompt = self.build_prompt(market_data)
|
) -> TradeDecision:
|
||||||
logger.info("Requesting trade decision from Gemini")
|
"""Build prompt, call Gemini, and return a parsed decision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary with price, orderbook, etc.
|
||||||
|
news_sentiment: Optional pre-fetched news sentiment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Parsed TradeDecision
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if self._cache:
|
||||||
|
cached_decision = self._cache.get(market_data)
|
||||||
|
if cached_decision:
|
||||||
|
self._total_cached_decisions += 1
|
||||||
|
self._total_decisions += 1
|
||||||
|
logger.info(
|
||||||
|
"Cache hit for decision",
|
||||||
|
extra={
|
||||||
|
"action": cached_decision.action,
|
||||||
|
"confidence": cached_decision.confidence,
|
||||||
|
"cache_hit_rate": self.get_cache_hit_rate(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Return cached decision with cached flag
|
||||||
|
return TradeDecision(
|
||||||
|
action=cached_decision.action,
|
||||||
|
confidence=cached_decision.confidence,
|
||||||
|
rationale=cached_decision.rationale,
|
||||||
|
token_count=0,
|
||||||
|
cached=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build optimized prompt
|
||||||
|
if self._enable_optimization:
|
||||||
|
prompt = self._optimizer.build_compressed_prompt(market_data)
|
||||||
|
else:
|
||||||
|
prompt = await self.build_prompt(market_data, news_sentiment)
|
||||||
|
|
||||||
|
# Estimate tokens
|
||||||
|
token_count = self._optimizer.estimate_tokens(prompt)
|
||||||
|
self._total_tokens_used += token_count
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Requesting trade decision from Gemini",
|
||||||
|
extra={"estimated_tokens": token_count, "optimized": self._enable_optimization},
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await self._client.aio.models.generate_content(
|
response = await self._client.aio.models.generate_content(
|
||||||
model=self._model_name, contents=prompt,
|
model=self._model_name,
|
||||||
|
contents=prompt,
|
||||||
)
|
)
|
||||||
raw = response.text
|
raw = response.text
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Gemini API error: %s", exc)
|
logger.error("Gemini API error: %s", exc)
|
||||||
return TradeDecision(
|
return TradeDecision(
|
||||||
action="HOLD", confidence=0, rationale=f"API error: {exc}"
|
action="HOLD", confidence=0, rationale=f"API error: {exc}", token_count=token_count
|
||||||
)
|
)
|
||||||
|
|
||||||
decision = self.parse_response(raw)
|
decision = self.parse_response(raw)
|
||||||
|
self._total_decisions += 1
|
||||||
|
|
||||||
|
# Add token count to decision
|
||||||
|
decision_with_tokens = TradeDecision(
|
||||||
|
action=decision.action,
|
||||||
|
confidence=decision.confidence,
|
||||||
|
rationale=decision.rationale,
|
||||||
|
token_count=token_count,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cache if appropriate
|
||||||
|
if self._cache and self._cache.should_cache_decision(decision):
|
||||||
|
self._cache.set(market_data, decision)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Gemini decision",
|
"Gemini decision",
|
||||||
extra={
|
extra={
|
||||||
"action": decision.action,
|
"action": decision.action,
|
||||||
"confidence": decision.confidence,
|
"confidence": decision.confidence,
|
||||||
|
"tokens": token_count,
|
||||||
|
"avg_tokens": self.get_avg_tokens_per_decision(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return decision
|
|
||||||
|
return decision_with_tokens
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Token Efficiency Metrics
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_token_metrics(self) -> dict[str, Any]:
|
||||||
|
"""Get token usage metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with token usage statistics
|
||||||
|
"""
|
||||||
|
metrics = {
|
||||||
|
"total_tokens_used": self._total_tokens_used,
|
||||||
|
"total_decisions": self._total_decisions,
|
||||||
|
"total_cached_decisions": self._total_cached_decisions,
|
||||||
|
"avg_tokens_per_decision": self.get_avg_tokens_per_decision(),
|
||||||
|
"cache_hit_rate": self.get_cache_hit_rate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._cache:
|
||||||
|
cache_metrics = self._cache.get_metrics()
|
||||||
|
metrics["cache_metrics"] = cache_metrics.to_dict()
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
def get_avg_tokens_per_decision(self) -> float:
|
||||||
|
"""Calculate average tokens per decision.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Average tokens per decision
|
||||||
|
"""
|
||||||
|
if self._total_decisions == 0:
|
||||||
|
return 0.0
|
||||||
|
return self._total_tokens_used / self._total_decisions
|
||||||
|
|
||||||
|
def get_cache_hit_rate(self) -> float:
|
||||||
|
"""Calculate cache hit rate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cache hit rate (0.0 to 1.0)
|
||||||
|
"""
|
||||||
|
if self._total_decisions == 0:
|
||||||
|
return 0.0
|
||||||
|
return self._total_cached_decisions / self._total_decisions
|
||||||
|
|
||||||
|
def reset_metrics(self) -> None:
|
||||||
|
"""Reset token usage metrics."""
|
||||||
|
self._total_tokens_used = 0
|
||||||
|
self._total_decisions = 0
|
||||||
|
self._total_cached_decisions = 0
|
||||||
|
if self._cache:
|
||||||
|
self._cache.reset_metrics()
|
||||||
|
logger.info("Token metrics reset")
|
||||||
|
|
||||||
|
def get_cache(self) -> DecisionCache | None:
|
||||||
|
"""Get the decision cache instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecisionCache instance or None if caching disabled
|
||||||
|
"""
|
||||||
|
return self._cache
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Batch Decision Making (for daily trading mode)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def decide_batch(
|
||||||
|
self, stocks_data: list[dict[str, Any]]
|
||||||
|
) -> dict[str, TradeDecision]:
|
||||||
|
"""Make decisions for multiple stocks in a single API call.
|
||||||
|
|
||||||
|
This is designed for daily trading mode to minimize API usage
|
||||||
|
when working with Gemini Free tier (20 calls/day limit).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stocks_data: List of market data dictionaries, each with:
|
||||||
|
- stock_code: Stock ticker
|
||||||
|
- current_price: Current price
|
||||||
|
- market_name: Market name (optional)
|
||||||
|
- foreigner_net: Foreigner net buy/sell (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping stock_code to TradeDecision
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> stocks_data = [
|
||||||
|
... {"stock_code": "AAPL", "current_price": 185.5},
|
||||||
|
... {"stock_code": "MSFT", "current_price": 420.0},
|
||||||
|
... ]
|
||||||
|
>>> decisions = await client.decide_batch(stocks_data)
|
||||||
|
>>> decisions["AAPL"].action
|
||||||
|
'BUY'
|
||||||
|
"""
|
||||||
|
if not stocks_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# Build compressed batch prompt
|
||||||
|
market_name = stocks_data[0].get("market_name", "stock market")
|
||||||
|
|
||||||
|
# Format stock data as compact JSON array
|
||||||
|
compact_stocks = []
|
||||||
|
for stock in stocks_data:
|
||||||
|
compact = {
|
||||||
|
"code": stock["stock_code"],
|
||||||
|
"price": stock["current_price"],
|
||||||
|
}
|
||||||
|
if stock.get("foreigner_net", 0) != 0:
|
||||||
|
compact["frgn"] = stock["foreigner_net"]
|
||||||
|
compact_stocks.append(compact)
|
||||||
|
|
||||||
|
data_str = json.dumps(compact_stocks, ensure_ascii=False)
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
f"You are a professional {market_name} trading analyst.\n"
|
||||||
|
"Analyze the following stocks and decide whether to BUY, SELL, or HOLD each one.\n\n"
|
||||||
|
f"Stock Data: {data_str}\n\n"
|
||||||
|
"You MUST respond with ONLY a valid JSON array in this format:\n"
|
||||||
|
'[{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "..."},\n'
|
||||||
|
' {"code": "MSFT", "action": "HOLD", "confidence": 50, "rationale": "..."}, ...]\n\n'
|
||||||
|
"Rules:\n"
|
||||||
|
"- Return one decision object per stock\n"
|
||||||
|
"- action must be exactly: BUY, SELL, or HOLD\n"
|
||||||
|
"- confidence must be 0-100\n"
|
||||||
|
"- rationale should be concise (1-2 sentences)\n"
|
||||||
|
"- Do NOT wrap JSON in markdown code blocks\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Estimate tokens
|
||||||
|
token_count = self._optimizer.estimate_tokens(prompt)
|
||||||
|
self._total_tokens_used += token_count
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Requesting batch decision for %d stocks from Gemini",
|
||||||
|
len(stocks_data),
|
||||||
|
extra={"estimated_tokens": token_count},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await self._client.aio.models.generate_content(
|
||||||
|
model=self._model_name,
|
||||||
|
contents=prompt,
|
||||||
|
)
|
||||||
|
raw = response.text
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Gemini API error in batch decision: %s", exc)
|
||||||
|
# Return HOLD for all stocks on API error
|
||||||
|
return {
|
||||||
|
stock["stock_code"]: TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale=f"API error: {exc}",
|
||||||
|
token_count=token_count,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
for stock in stocks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse batch response
|
||||||
|
return self._parse_batch_response(raw, stocks_data, token_count)
|
||||||
|
|
||||||
|
def _parse_batch_response(
|
||||||
|
self, raw: str, stocks_data: list[dict[str, Any]], token_count: int
|
||||||
|
) -> dict[str, TradeDecision]:
|
||||||
|
"""Parse batch response into a dictionary of decisions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: Raw response from Gemini
|
||||||
|
stocks_data: Original stock data list
|
||||||
|
token_count: Token count for the request
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping stock_code to TradeDecision
|
||||||
|
"""
|
||||||
|
if not raw or not raw.strip():
|
||||||
|
logger.warning("Empty batch response from Gemini — defaulting all to HOLD")
|
||||||
|
return {
|
||||||
|
stock["stock_code"]: TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale="Empty response",
|
||||||
|
token_count=0,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
for stock in stocks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Strip markdown code fences if present
|
||||||
|
cleaned = raw.strip()
|
||||||
|
match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", cleaned, re.DOTALL)
|
||||||
|
if match:
|
||||||
|
cleaned = match.group(1).strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning("Malformed JSON in batch response — defaulting all to HOLD")
|
||||||
|
return {
|
||||||
|
stock["stock_code"]: TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale="Malformed JSON response",
|
||||||
|
token_count=0,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
for stock in stocks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
logger.warning("Batch response is not a JSON array — defaulting all to HOLD")
|
||||||
|
return {
|
||||||
|
stock["stock_code"]: TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale="Invalid response format",
|
||||||
|
token_count=0,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
for stock in stocks_data
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build decision map
|
||||||
|
decisions: dict[str, TradeDecision] = {}
|
||||||
|
stock_codes = {stock["stock_code"] for stock in stocks_data}
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
code = item.get("code")
|
||||||
|
if not code or code not in stock_codes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not all(k in item for k in ("action", "confidence", "rationale")):
|
||||||
|
logger.warning("Missing fields for %s — using HOLD", code)
|
||||||
|
decisions[code] = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale="Missing required fields",
|
||||||
|
token_count=0,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
action = str(item["action"]).upper()
|
||||||
|
if action not in VALID_ACTIONS:
|
||||||
|
logger.warning("Invalid action '%s' for %s — forcing HOLD", action, code)
|
||||||
|
action = "HOLD"
|
||||||
|
|
||||||
|
confidence = int(item["confidence"])
|
||||||
|
rationale = str(item["rationale"])
|
||||||
|
|
||||||
|
# Enforce confidence threshold
|
||||||
|
if confidence < self._confidence_threshold:
|
||||||
|
logger.info(
|
||||||
|
"Confidence %d < threshold %d for %s — forcing HOLD",
|
||||||
|
confidence,
|
||||||
|
self._confidence_threshold,
|
||||||
|
code,
|
||||||
|
)
|
||||||
|
action = "HOLD"
|
||||||
|
|
||||||
|
decisions[code] = TradeDecision(
|
||||||
|
action=action,
|
||||||
|
confidence=confidence,
|
||||||
|
rationale=rationale,
|
||||||
|
token_count=token_count // len(stocks_data), # Split token cost
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
self._total_decisions += 1
|
||||||
|
|
||||||
|
# Fill in missing stocks with HOLD
|
||||||
|
for stock in stocks_data:
|
||||||
|
code = stock["stock_code"]
|
||||||
|
if code not in decisions:
|
||||||
|
logger.warning("No decision for %s in batch response — using HOLD", code)
|
||||||
|
decisions[code] = TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale="Not found in batch response",
|
||||||
|
token_count=0,
|
||||||
|
cached=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Batch decision completed for %d stocks",
|
||||||
|
len(decisions),
|
||||||
|
extra={"tokens": token_count},
|
||||||
|
)
|
||||||
|
|
||||||
|
return decisions
|
||||||
|
|||||||
267
src/brain/prompt_optimizer.py
Normal file
267
src/brain/prompt_optimizer.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""Prompt optimization utilities for reducing token usage.
|
||||||
|
|
||||||
|
This module provides tools to compress prompts while maintaining decision quality:
|
||||||
|
- Token counting
|
||||||
|
- Text compression and abbreviation
|
||||||
|
- Template-based prompts with variable slots
|
||||||
|
- Priority-based context truncation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Abbreviation mapping for common terms
|
||||||
|
ABBREVIATIONS = {
|
||||||
|
"price": "P",
|
||||||
|
"volume": "V",
|
||||||
|
"current": "cur",
|
||||||
|
"previous": "prev",
|
||||||
|
"change": "chg",
|
||||||
|
"percentage": "pct",
|
||||||
|
"market": "mkt",
|
||||||
|
"orderbook": "ob",
|
||||||
|
"foreigner": "fgn",
|
||||||
|
"buy": "B",
|
||||||
|
"sell": "S",
|
||||||
|
"hold": "H",
|
||||||
|
"confidence": "conf",
|
||||||
|
"rationale": "reason",
|
||||||
|
"action": "act",
|
||||||
|
"net": "net",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverse mapping for decompression
|
||||||
|
REVERSE_ABBREVIATIONS = {v: k for k, v in ABBREVIATIONS.items()}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TokenMetrics:
|
||||||
|
"""Metrics about token usage in a prompt."""
|
||||||
|
|
||||||
|
char_count: int
|
||||||
|
word_count: int
|
||||||
|
estimated_tokens: int # Rough estimate: ~4 chars per token
|
||||||
|
compression_ratio: float = 1.0 # Original / Compressed
|
||||||
|
|
||||||
|
|
||||||
|
class PromptOptimizer:
|
||||||
|
"""Optimizes prompts to reduce token usage while maintaining quality."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def estimate_tokens(text: str) -> int:
|
||||||
|
"""Estimate token count for text.
|
||||||
|
|
||||||
|
Uses a simple heuristic: ~4 characters per token for English.
|
||||||
|
This is approximate but sufficient for optimization purposes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to estimate tokens for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Estimated token count
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return 0
|
||||||
|
# Simple estimate: 1 token ≈ 4 characters
|
||||||
|
return max(1, len(text) // 4)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def count_tokens(text: str) -> TokenMetrics:
|
||||||
|
"""Count various metrics for a text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to analyze
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TokenMetrics with character, word, and estimated token counts
|
||||||
|
"""
|
||||||
|
char_count = len(text)
|
||||||
|
word_count = len(text.split())
|
||||||
|
estimated_tokens = PromptOptimizer.estimate_tokens(text)
|
||||||
|
|
||||||
|
return TokenMetrics(
|
||||||
|
char_count=char_count,
|
||||||
|
word_count=word_count,
|
||||||
|
estimated_tokens=estimated_tokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compress_json(data: dict[str, Any]) -> str:
|
||||||
|
"""Compress JSON by removing whitespace.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary to serialize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compact JSON string without whitespace
|
||||||
|
"""
|
||||||
|
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def abbreviate_text(text: str, aggressive: bool = False) -> str:
|
||||||
|
"""Apply abbreviations to reduce text length.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Input text to abbreviate
|
||||||
|
aggressive: If True, apply more aggressive compression
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Abbreviated text
|
||||||
|
"""
|
||||||
|
result = text
|
||||||
|
|
||||||
|
# Apply word-level abbreviations (case-insensitive)
|
||||||
|
for full, abbr in ABBREVIATIONS.items():
|
||||||
|
# Word boundaries to avoid partial replacements
|
||||||
|
pattern = r"\b" + re.escape(full) + r"\b"
|
||||||
|
result = re.sub(pattern, abbr, result, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
if aggressive:
|
||||||
|
# Remove articles and filler words
|
||||||
|
result = re.sub(r"\b(a|an|the)\b", "", result, flags=re.IGNORECASE)
|
||||||
|
result = re.sub(r"\b(is|are|was|were)\b", "", result, flags=re.IGNORECASE)
|
||||||
|
# Collapse multiple spaces
|
||||||
|
result = re.sub(r"\s+", " ", result)
|
||||||
|
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def build_compressed_prompt(
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
include_instructions: bool = True,
|
||||||
|
max_length: int | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a compressed prompt from market data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_data: Market data dictionary with stock info
|
||||||
|
include_instructions: Whether to include full instructions
|
||||||
|
max_length: Maximum character length (truncates if needed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed prompt string
|
||||||
|
"""
|
||||||
|
# Abbreviated market name
|
||||||
|
market_name = market_data.get("market_name", "KR")
|
||||||
|
if "Korea" in market_name:
|
||||||
|
market_name = "KR"
|
||||||
|
elif "United States" in market_name or "US" in market_name:
|
||||||
|
market_name = "US"
|
||||||
|
|
||||||
|
# Core data - always included
|
||||||
|
core_info = {
|
||||||
|
"mkt": market_name,
|
||||||
|
"code": market_data["stock_code"],
|
||||||
|
"P": market_data["current_price"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optional fields
|
||||||
|
if "orderbook" in market_data and market_data["orderbook"]:
|
||||||
|
ob = market_data["orderbook"]
|
||||||
|
# Compress orderbook: keep only top 3 levels
|
||||||
|
compressed_ob = {
|
||||||
|
"bid": ob.get("bid", [])[:3],
|
||||||
|
"ask": ob.get("ask", [])[:3],
|
||||||
|
}
|
||||||
|
core_info["ob"] = compressed_ob
|
||||||
|
|
||||||
|
if market_data.get("foreigner_net", 0) != 0:
|
||||||
|
core_info["fgn_net"] = market_data["foreigner_net"]
|
||||||
|
|
||||||
|
# Compress to JSON
|
||||||
|
data_str = PromptOptimizer.compress_json(core_info)
|
||||||
|
|
||||||
|
if include_instructions:
|
||||||
|
# Minimal instructions
|
||||||
|
prompt = (
|
||||||
|
f"{market_name} trader. Analyze:\n{data_str}\n\n"
|
||||||
|
'Return JSON: {"act":"BUY"|"SELL"|"HOLD","conf":<0-100>,"reason":"<text>"}\n'
|
||||||
|
"Rules: act=BUY/SELL/HOLD, conf=0-100, reason=concise. No markdown."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Data only (for cached contexts where instructions are known)
|
||||||
|
prompt = data_str
|
||||||
|
|
||||||
|
# Truncate if needed
|
||||||
|
if max_length and len(prompt) > max_length:
|
||||||
|
prompt = prompt[:max_length] + "..."
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def truncate_context(
|
||||||
|
context: dict[str, Any],
|
||||||
|
max_tokens: int,
|
||||||
|
priority_keys: list[str] | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Truncate context data to fit within token budget.
|
||||||
|
|
||||||
|
Keeps high-priority keys first, then truncates less important data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Context dictionary to truncate
|
||||||
|
max_tokens: Maximum token budget
|
||||||
|
priority_keys: List of keys to keep (in order of priority)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Truncated context dictionary
|
||||||
|
"""
|
||||||
|
if not context:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if priority_keys is None:
|
||||||
|
priority_keys = []
|
||||||
|
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
current_tokens = 0
|
||||||
|
|
||||||
|
# Add priority keys first
|
||||||
|
for key in priority_keys:
|
||||||
|
if key in context:
|
||||||
|
value_str = json.dumps(context[key])
|
||||||
|
tokens = PromptOptimizer.estimate_tokens(value_str)
|
||||||
|
|
||||||
|
if current_tokens + tokens <= max_tokens:
|
||||||
|
result[key] = context[key]
|
||||||
|
current_tokens += tokens
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Add remaining keys if space available
|
||||||
|
for key, value in context.items():
|
||||||
|
if key in result:
|
||||||
|
continue
|
||||||
|
|
||||||
|
value_str = json.dumps(value)
|
||||||
|
tokens = PromptOptimizer.estimate_tokens(value_str)
|
||||||
|
|
||||||
|
if current_tokens + tokens <= max_tokens:
|
||||||
|
result[key] = value
|
||||||
|
current_tokens += tokens
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_compression_ratio(original: str, compressed: str) -> float:
|
||||||
|
"""Calculate compression ratio between original and compressed text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
original: Original text
|
||||||
|
compressed: Compressed text
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compression ratio (original_tokens / compressed_tokens)
|
||||||
|
"""
|
||||||
|
original_tokens = PromptOptimizer.estimate_tokens(original)
|
||||||
|
compressed_tokens = PromptOptimizer.estimate_tokens(compressed)
|
||||||
|
|
||||||
|
if compressed_tokens == 0:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
return original_tokens / compressed_tokens
|
||||||
@@ -55,6 +55,9 @@ class KISBroker:
|
|||||||
self._session: aiohttp.ClientSession | None = None
|
self._session: aiohttp.ClientSession | None = None
|
||||||
self._access_token: str | None = None
|
self._access_token: str | None = None
|
||||||
self._token_expires_at: float = 0.0
|
self._token_expires_at: float = 0.0
|
||||||
|
self._token_lock = asyncio.Lock()
|
||||||
|
self._last_refresh_attempt: float = 0.0
|
||||||
|
self._refresh_cooldown: float = 60.0 # Seconds (matches KIS 1/minute limit)
|
||||||
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
|
self._rate_limiter = LeakyBucket(settings.RATE_LIMIT_RPS)
|
||||||
|
|
||||||
def _get_session(self) -> aiohttp.ClientSession:
|
def _get_session(self) -> aiohttp.ClientSession:
|
||||||
@@ -80,30 +83,54 @@ class KISBroker:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
async def _ensure_token(self) -> str:
|
async def _ensure_token(self) -> str:
|
||||||
"""Return a valid access token, refreshing if expired."""
|
"""Return a valid access token, refreshing if expired.
|
||||||
|
|
||||||
|
Uses a lock to prevent concurrent token refresh attempts that would
|
||||||
|
hit the API's 1-per-minute rate limit (EGW00133).
|
||||||
|
"""
|
||||||
|
# Fast path: check without lock
|
||||||
now = asyncio.get_event_loop().time()
|
now = asyncio.get_event_loop().time()
|
||||||
if self._access_token and now < self._token_expires_at:
|
if self._access_token and now < self._token_expires_at:
|
||||||
return self._access_token
|
return self._access_token
|
||||||
|
|
||||||
logger.info("Refreshing KIS access token")
|
# Slow path: acquire lock and refresh
|
||||||
session = self._get_session()
|
async with self._token_lock:
|
||||||
url = f"{self._base_url}/oauth2/tokenP"
|
# Re-check after acquiring lock (another coroutine may have refreshed)
|
||||||
body = {
|
now = asyncio.get_event_loop().time()
|
||||||
"grant_type": "client_credentials",
|
if self._access_token and now < self._token_expires_at:
|
||||||
"appkey": self._app_key,
|
return self._access_token
|
||||||
"appsecret": self._app_secret,
|
|
||||||
}
|
|
||||||
|
|
||||||
async with session.post(url, json=body) as resp:
|
# Check cooldown period (prevents hitting EGW00133: 1/minute limit)
|
||||||
if resp.status != 200:
|
time_since_last_attempt = now - self._last_refresh_attempt
|
||||||
text = await resp.text()
|
if time_since_last_attempt < self._refresh_cooldown:
|
||||||
raise ConnectionError(f"Token refresh failed ({resp.status}): {text}")
|
remaining = self._refresh_cooldown - time_since_last_attempt
|
||||||
data = await resp.json()
|
error_msg = (
|
||||||
|
f"Token refresh on cooldown. "
|
||||||
|
f"Retry in {remaining:.1f}s (KIS allows 1/minute)"
|
||||||
|
)
|
||||||
|
logger.warning(error_msg)
|
||||||
|
raise ConnectionError(error_msg)
|
||||||
|
|
||||||
self._access_token = data["access_token"]
|
logger.info("Refreshing KIS access token")
|
||||||
self._token_expires_at = now + data.get("expires_in", 86400) - 60 # 1-min buffer
|
self._last_refresh_attempt = now
|
||||||
logger.info("Token refreshed successfully")
|
session = self._get_session()
|
||||||
return self._access_token
|
url = f"{self._base_url}/oauth2/tokenP"
|
||||||
|
body = {
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"appkey": self._app_key,
|
||||||
|
"appsecret": self._app_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session.post(url, json=body) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ConnectionError(f"Token refresh failed ({resp.status}): {text}")
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
self._access_token = data["access_token"]
|
||||||
|
self._token_expires_at = now + data.get("expires_in", 86400) - 60 # 1-min buffer
|
||||||
|
logger.info("Token refreshed successfully")
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hash Key (required for POST bodies)
|
# Hash Key (required for POST bodies)
|
||||||
@@ -111,6 +138,7 @@ class KISBroker:
|
|||||||
|
|
||||||
async def _get_hash_key(self, body: dict[str, Any]) -> str:
|
async def _get_hash_key(self, body: dict[str, Any]) -> str:
|
||||||
"""Request a hash key from KIS for POST request body signing."""
|
"""Request a hash key from KIS for POST request body signing."""
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
session = self._get_session()
|
session = self._get_session()
|
||||||
url = f"{self._base_url}/uapi/hashkey"
|
url = f"{self._base_url}/uapi/hashkey"
|
||||||
headers = {
|
headers = {
|
||||||
@@ -252,3 +280,153 @@ class KISBroker:
|
|||||||
return data
|
return data
|
||||||
except (TimeoutError, aiohttp.ClientError) as exc:
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
raise ConnectionError(f"Network error sending order: {exc}") from exc
|
raise ConnectionError(f"Network error sending order: {exc}") from exc
|
||||||
|
|
||||||
|
async def fetch_market_rankings(
|
||||||
|
self,
|
||||||
|
ranking_type: str = "volume",
|
||||||
|
limit: int = 30,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch market rankings from KIS API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ranking_type: Type of ranking ("volume" or "fluctuation")
|
||||||
|
limit: Maximum number of results to return
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of stock data dicts with keys: stock_code, name, price, volume,
|
||||||
|
change_rate, volume_increase_rate
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If API request fails
|
||||||
|
"""
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
|
session = self._get_session()
|
||||||
|
|
||||||
|
# TR_ID for volume ranking
|
||||||
|
tr_id = "FHPST01710000" if ranking_type == "volume" else "FHPST01710100"
|
||||||
|
headers = await self._auth_headers(tr_id)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J", # Stock/ETF/ETN
|
||||||
|
"FID_COND_SCR_DIV_CODE": "20001", # Volume surge
|
||||||
|
"FID_INPUT_ISCD": "0000", # All stocks
|
||||||
|
"FID_DIV_CLS_CODE": "0", # All types
|
||||||
|
"FID_BLNG_CLS_CODE": "0",
|
||||||
|
"FID_TRGT_CLS_CODE": "111111111",
|
||||||
|
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
||||||
|
"FID_INPUT_PRICE_1": "0",
|
||||||
|
"FID_INPUT_PRICE_2": "0",
|
||||||
|
"FID_VOL_CNT": "0",
|
||||||
|
"FID_INPUT_DATE_1": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/volume-rank"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ConnectionError(
|
||||||
|
f"fetch_market_rankings failed ({resp.status}): {text}"
|
||||||
|
)
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
# Parse response - output is a list of ranked stocks
|
||||||
|
def _safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
rankings = []
|
||||||
|
for item in data.get("output", [])[:limit]:
|
||||||
|
rankings.append({
|
||||||
|
"stock_code": item.get("mksc_shrn_iscd", ""),
|
||||||
|
"name": item.get("hts_kor_isnm", ""),
|
||||||
|
"price": _safe_float(item.get("stck_prpr", "0")),
|
||||||
|
"volume": _safe_float(item.get("acml_vol", "0")),
|
||||||
|
"change_rate": _safe_float(item.get("prdy_ctrt", "0")),
|
||||||
|
"volume_increase_rate": _safe_float(item.get("vol_inrt", "0")),
|
||||||
|
})
|
||||||
|
return rankings
|
||||||
|
|
||||||
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
|
raise ConnectionError(f"Network error fetching rankings: {exc}") from exc
|
||||||
|
|
||||||
|
async def get_daily_prices(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
days: int = 20,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch daily OHLCV price history for a stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: 6-digit stock code
|
||||||
|
days: Number of trading days to fetch (default 20 for RSI calculation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of daily price dicts with keys: date, open, high, low, close, volume
|
||||||
|
Sorted oldest to newest
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ConnectionError: If API request fails
|
||||||
|
"""
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
|
session = self._get_session()
|
||||||
|
|
||||||
|
headers = await self._auth_headers("FHKST03010100")
|
||||||
|
|
||||||
|
# Calculate date range (today and N days ago)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
end_date = datetime.now().strftime("%Y%m%d")
|
||||||
|
start_date = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d")
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": stock_code,
|
||||||
|
"FID_INPUT_DATE_1": start_date,
|
||||||
|
"FID_INPUT_DATE_2": end_date,
|
||||||
|
"FID_PERIOD_DIV_CODE": "D", # Daily
|
||||||
|
"FID_ORG_ADJ_PRC": "0", # Adjusted price
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f"{self._base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=headers, params=params) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ConnectionError(
|
||||||
|
f"get_daily_prices failed ({resp.status}): {text}"
|
||||||
|
)
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
# Parse response
|
||||||
|
def _safe_float(value: str | float | None, default: float = 0.0) -> float:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
prices = []
|
||||||
|
for item in data.get("output2", []):
|
||||||
|
prices.append({
|
||||||
|
"date": item.get("stck_bsop_date", ""),
|
||||||
|
"open": _safe_float(item.get("stck_oprc", "0")),
|
||||||
|
"high": _safe_float(item.get("stck_hgpr", "0")),
|
||||||
|
"low": _safe_float(item.get("stck_lwpr", "0")),
|
||||||
|
"close": _safe_float(item.get("stck_clpr", "0")),
|
||||||
|
"volume": _safe_float(item.get("acml_vol", "0")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort oldest to newest (KIS returns newest first)
|
||||||
|
prices.reverse()
|
||||||
|
|
||||||
|
return prices[:days] # Return only requested number of days
|
||||||
|
|
||||||
|
except (TimeoutError, aiohttp.ClientError) as exc:
|
||||||
|
raise ConnectionError(f"Network error fetching daily prices: {exc}") from exc
|
||||||
|
|||||||
@@ -19,22 +19,69 @@ class Settings(BaseSettings):
|
|||||||
GEMINI_API_KEY: str
|
GEMINI_API_KEY: str
|
||||||
GEMINI_MODEL: str = "gemini-pro"
|
GEMINI_MODEL: str = "gemini-pro"
|
||||||
|
|
||||||
|
# External Data APIs (optional — for data-driven decisions)
|
||||||
|
NEWS_API_KEY: str | None = None
|
||||||
|
NEWS_API_PROVIDER: str = "alphavantage" # "alphavantage" or "newsapi"
|
||||||
|
MARKET_DATA_API_KEY: str | None = None
|
||||||
|
|
||||||
|
# Legacy field names (for backward compatibility)
|
||||||
|
ALPHA_VANTAGE_API_KEY: str | None = None
|
||||||
|
NEWSAPI_KEY: str | None = None
|
||||||
|
|
||||||
# Risk Management
|
# Risk Management
|
||||||
CIRCUIT_BREAKER_PCT: float = Field(default=-3.0, le=0.0)
|
CIRCUIT_BREAKER_PCT: float = Field(default=-3.0, le=0.0)
|
||||||
FAT_FINGER_PCT: float = Field(default=30.0, gt=0.0, le=100.0)
|
FAT_FINGER_PCT: float = Field(default=30.0, gt=0.0, le=100.0)
|
||||||
CONFIDENCE_THRESHOLD: int = Field(default=80, ge=0, le=100)
|
CONFIDENCE_THRESHOLD: int = Field(default=80, ge=0, le=100)
|
||||||
|
|
||||||
|
# Smart Scanner Configuration
|
||||||
|
RSI_OVERSOLD_THRESHOLD: int = Field(default=30, ge=0, le=50)
|
||||||
|
RSI_MOMENTUM_THRESHOLD: int = Field(default=70, ge=50, le=100)
|
||||||
|
VOL_MULTIPLIER: float = Field(default=2.0, gt=1.0, le=10.0)
|
||||||
|
SCANNER_TOP_N: int = Field(default=3, ge=1, le=10)
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DB_PATH: str = "data/trade_logs.db"
|
DB_PATH: str = "data/trade_logs.db"
|
||||||
|
|
||||||
# Rate Limiting (requests per second for KIS API)
|
# Rate Limiting (requests per second for KIS API)
|
||||||
RATE_LIMIT_RPS: float = 10.0
|
# Conservative limit to avoid EGW00201 "초당 거래건수 초과" errors.
|
||||||
|
# KIS API real limit is ~2 RPS; 2.0 provides maximum safety.
|
||||||
|
RATE_LIMIT_RPS: float = 2.0
|
||||||
|
|
||||||
# Trading mode
|
# Trading mode
|
||||||
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
MODE: str = Field(default="paper", pattern="^(paper|live)$")
|
||||||
|
|
||||||
|
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
|
||||||
|
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
|
||||||
|
DAILY_SESSIONS: int = Field(default=4, ge=1, le=10)
|
||||||
|
SESSION_INTERVAL_HOURS: int = Field(default=6, ge=1, le=24)
|
||||||
|
|
||||||
|
# Pre-Market Planner
|
||||||
|
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)
|
||||||
|
MAX_SCENARIOS_PER_STOCK: int = Field(default=5, ge=1, le=10)
|
||||||
|
PLANNER_TIMEOUT_SECONDS: int = Field(default=60, ge=10, le=300)
|
||||||
|
DEFENSIVE_PLAYBOOK_ON_FAILURE: bool = True
|
||||||
|
RESCAN_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=900)
|
||||||
|
|
||||||
# Market selection (comma-separated market codes)
|
# Market selection (comma-separated market codes)
|
||||||
ENABLED_MARKETS: str = "KR"
|
ENABLED_MARKETS: str = "KR,US"
|
||||||
|
|
||||||
|
# Backup and Disaster Recovery (optional)
|
||||||
|
BACKUP_ENABLED: bool = True
|
||||||
|
BACKUP_DIR: str = "data/backups"
|
||||||
|
S3_ENDPOINT_URL: str | None = None # For MinIO, Backblaze B2, etc.
|
||||||
|
S3_ACCESS_KEY: str | None = None
|
||||||
|
S3_SECRET_KEY: str | None = None
|
||||||
|
S3_BUCKET_NAME: str | None = None
|
||||||
|
S3_REGION: str = "us-east-1"
|
||||||
|
|
||||||
|
# Telegram Notifications (optional)
|
||||||
|
TELEGRAM_BOT_TOKEN: str | None = None
|
||||||
|
TELEGRAM_CHAT_ID: str | None = None
|
||||||
|
TELEGRAM_ENABLED: bool = True
|
||||||
|
|
||||||
|
# Telegram Commands (optional)
|
||||||
|
TELEGRAM_COMMANDS_ENABLED: bool = True
|
||||||
|
TELEGRAM_POLLING_INTERVAL: float = 1.0 # seconds
|
||||||
|
|
||||||
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
|
||||||
|
|
||||||
|
|||||||
10
src/context/__init__.py
Normal file
10
src/context/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
"""Multi-layered context management system for trading decisions.
|
||||||
|
|
||||||
|
The context tree implements Pillar 2: hierarchical memory management across
|
||||||
|
7 time horizons, from real-time quotes to generational wisdom.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
|
||||||
|
__all__ = ["ContextLayer", "ContextStore"]
|
||||||
250
src/context/aggregator.py
Normal file
250
src/context/aggregator.py
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
"""Context aggregation logic for rolling up data from lower to higher layers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
|
||||||
|
|
||||||
|
class ContextAggregator:
|
||||||
|
"""Aggregates context data from lower (finer) to higher (coarser) layers."""
|
||||||
|
|
||||||
|
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Initialize the aggregator with a database connection."""
|
||||||
|
self.conn = conn
|
||||||
|
self.store = ContextStore(conn)
|
||||||
|
|
||||||
|
def aggregate_daily_from_trades(self, date: str | None = None) -> None:
|
||||||
|
"""Aggregate L6 (daily) context from trades table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
date: Date in YYYY-MM-DD format. If None, uses today.
|
||||||
|
"""
|
||||||
|
if date is None:
|
||||||
|
date = datetime.now(UTC).date().isoformat()
|
||||||
|
|
||||||
|
# Calculate daily metrics from trades
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as trade_count,
|
||||||
|
SUM(CASE WHEN action = 'BUY' THEN 1 ELSE 0 END) as buys,
|
||||||
|
SUM(CASE WHEN action = 'SELL' THEN 1 ELSE 0 END) as sells,
|
||||||
|
SUM(CASE WHEN action = 'HOLD' THEN 1 ELSE 0 END) as holds,
|
||||||
|
AVG(confidence) as avg_confidence,
|
||||||
|
SUM(pnl) as total_pnl,
|
||||||
|
COUNT(DISTINCT stock_code) as unique_stocks,
|
||||||
|
SUM(CASE WHEN pnl > 0 THEN 1 ELSE 0 END) as wins,
|
||||||
|
SUM(CASE WHEN pnl < 0 THEN 1 ELSE 0 END) as losses
|
||||||
|
FROM trades
|
||||||
|
WHERE DATE(timestamp) = ?
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row and row[0] > 0: # At least one trade
|
||||||
|
trade_count, buys, sells, holds, avg_conf, total_pnl, stocks, wins, losses = row
|
||||||
|
|
||||||
|
# Store daily metrics in L6
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "trade_count", trade_count)
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "buys", buys)
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "sells", sells)
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "holds", holds)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L6_DAILY, date, "avg_confidence", round(avg_conf, 2)
|
||||||
|
)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L6_DAILY, date, "total_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "unique_stocks", stocks)
|
||||||
|
win_rate = round(wins / max(wins + losses, 1) * 100, 2)
|
||||||
|
self.store.set_context(ContextLayer.L6_DAILY, date, "win_rate", win_rate)
|
||||||
|
|
||||||
|
def aggregate_weekly_from_daily(self, week: str | None = None) -> None:
|
||||||
|
"""Aggregate L5 (weekly) context from L6 (daily).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
week: Week in YYYY-Www format (ISO week). If None, uses current week.
|
||||||
|
"""
|
||||||
|
if week is None:
|
||||||
|
week = datetime.now(UTC).strftime("%Y-W%V")
|
||||||
|
|
||||||
|
# Get all daily contexts for this week
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT key, value FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe LIKE ?
|
||||||
|
""",
|
||||||
|
(ContextLayer.L6_DAILY.value, f"{week[:4]}-%"), # All days in the year
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by key and collect all values
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
daily_data: dict[str, list[Any]] = defaultdict(list)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
daily_data[row[0]].append(json.loads(row[1]))
|
||||||
|
|
||||||
|
if daily_data:
|
||||||
|
# Sum all PnL values
|
||||||
|
if "total_pnl" in daily_data:
|
||||||
|
total_pnl = sum(daily_data["total_pnl"])
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L5_WEEKLY, week, "weekly_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Average all confidence values
|
||||||
|
if "avg_confidence" in daily_data:
|
||||||
|
conf_values = daily_data["avg_confidence"]
|
||||||
|
avg_conf = sum(conf_values) / len(conf_values)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L5_WEEKLY, week, "avg_confidence", round(avg_conf, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def aggregate_monthly_from_weekly(self, month: str | None = None) -> None:
|
||||||
|
"""Aggregate L4 (monthly) context from L5 (weekly).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
month: Month in YYYY-MM format. If None, uses current month.
|
||||||
|
"""
|
||||||
|
if month is None:
|
||||||
|
month = datetime.now(UTC).strftime("%Y-%m")
|
||||||
|
|
||||||
|
# Get all weekly contexts for this month
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT key, value FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe LIKE ?
|
||||||
|
""",
|
||||||
|
(ContextLayer.L5_WEEKLY.value, f"{month[:4]}-W%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Group by key and collect all values
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
weekly_data: dict[str, list[Any]] = defaultdict(list)
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
weekly_data[row[0]].append(json.loads(row[1]))
|
||||||
|
|
||||||
|
if weekly_data:
|
||||||
|
# Sum all weekly PnL values
|
||||||
|
if "weekly_pnl" in weekly_data:
|
||||||
|
total_pnl = sum(weekly_data["weekly_pnl"])
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L4_MONTHLY, month, "monthly_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def aggregate_quarterly_from_monthly(self, quarter: str | None = None) -> None:
|
||||||
|
"""Aggregate L3 (quarterly) context from L4 (monthly).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
quarter: Quarter in YYYY-Qn format. If None, uses current quarter.
|
||||||
|
"""
|
||||||
|
if quarter is None:
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
q = (now.month - 1) // 3 + 1
|
||||||
|
quarter = f"{now.year}-Q{q}"
|
||||||
|
|
||||||
|
# Get all monthly contexts for this quarter
|
||||||
|
# Q1: 01-03, Q2: 04-06, Q3: 07-09, Q4: 10-12
|
||||||
|
q_num = int(quarter.split("-Q")[1])
|
||||||
|
months = [f"{quarter[:4]}-{m:02d}" for m in range((q_num - 1) * 3 + 1, q_num * 3 + 1)]
|
||||||
|
|
||||||
|
total_pnl = 0.0
|
||||||
|
for month in months:
|
||||||
|
monthly_pnl = self.store.get_context(
|
||||||
|
ContextLayer.L4_MONTHLY, month, "monthly_pnl"
|
||||||
|
)
|
||||||
|
if monthly_pnl is not None:
|
||||||
|
total_pnl += monthly_pnl
|
||||||
|
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L3_QUARTERLY, quarter, "quarterly_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def aggregate_annual_from_quarterly(self, year: str | None = None) -> None:
|
||||||
|
"""Aggregate L2 (annual) context from L3 (quarterly).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
year: Year in YYYY format. If None, uses current year.
|
||||||
|
"""
|
||||||
|
if year is None:
|
||||||
|
year = str(datetime.now(UTC).year)
|
||||||
|
|
||||||
|
# Get all quarterly contexts for this year
|
||||||
|
total_pnl = 0.0
|
||||||
|
for q in range(1, 5):
|
||||||
|
quarter = f"{year}-Q{q}"
|
||||||
|
quarterly_pnl = self.store.get_context(
|
||||||
|
ContextLayer.L3_QUARTERLY, quarter, "quarterly_pnl"
|
||||||
|
)
|
||||||
|
if quarterly_pnl is not None:
|
||||||
|
total_pnl += quarterly_pnl
|
||||||
|
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L2_ANNUAL, year, "annual_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
|
||||||
|
def aggregate_legacy_from_annual(self) -> None:
|
||||||
|
"""Aggregate L1 (legacy) context from all L2 (annual) data."""
|
||||||
|
# Get all annual PnL
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe, value FROM contexts
|
||||||
|
WHERE layer = ? AND key = ?
|
||||||
|
ORDER BY timeframe
|
||||||
|
""",
|
||||||
|
(ContextLayer.L2_ANNUAL.value, "annual_pnl"),
|
||||||
|
)
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
annual_data = [(row[0], json.loads(row[1])) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if annual_data:
|
||||||
|
total_pnl = sum(pnl for _, pnl in annual_data)
|
||||||
|
years_traded = len(annual_data)
|
||||||
|
avg_annual_pnl = total_pnl / years_traded
|
||||||
|
|
||||||
|
# Store in L1 (single "LEGACY" timeframe)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L1_LEGACY, "LEGACY", "total_pnl", round(total_pnl, 2)
|
||||||
|
)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L1_LEGACY, "LEGACY", "years_traded", years_traded
|
||||||
|
)
|
||||||
|
self.store.set_context(
|
||||||
|
ContextLayer.L1_LEGACY,
|
||||||
|
"LEGACY",
|
||||||
|
"avg_annual_pnl",
|
||||||
|
round(avg_annual_pnl, 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
def run_all_aggregations(self) -> None:
|
||||||
|
"""Run all aggregations from L7 to L1 (bottom-up)."""
|
||||||
|
# L7 (trades) → L6 (daily)
|
||||||
|
self.aggregate_daily_from_trades()
|
||||||
|
|
||||||
|
# L6 (daily) → L5 (weekly)
|
||||||
|
self.aggregate_weekly_from_daily()
|
||||||
|
|
||||||
|
# L5 (weekly) → L4 (monthly)
|
||||||
|
self.aggregate_monthly_from_weekly()
|
||||||
|
|
||||||
|
# L4 (monthly) → L3 (quarterly)
|
||||||
|
self.aggregate_quarterly_from_monthly()
|
||||||
|
|
||||||
|
# L3 (quarterly) → L2 (annual)
|
||||||
|
self.aggregate_annual_from_quarterly()
|
||||||
|
|
||||||
|
# L2 (annual) → L1 (legacy)
|
||||||
|
self.aggregate_legacy_from_annual()
|
||||||
75
src/context/layer.py
Normal file
75
src/context/layer.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""Context layer definitions for multi-tier memory management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class ContextLayer(str, Enum):
|
||||||
|
"""7-tier context hierarchy from real-time to generational."""
|
||||||
|
|
||||||
|
L1_LEGACY = "L1_LEGACY" # Cumulative/generational wisdom
|
||||||
|
L2_ANNUAL = "L2_ANNUAL" # Yearly performance
|
||||||
|
L3_QUARTERLY = "L3_QUARTERLY" # Quarterly strategy adjustments
|
||||||
|
L4_MONTHLY = "L4_MONTHLY" # Monthly rebalancing
|
||||||
|
L5_WEEKLY = "L5_WEEKLY" # Weekly stock selection
|
||||||
|
L6_DAILY = "L6_DAILY" # Daily trade logs
|
||||||
|
L7_REALTIME = "L7_REALTIME" # Real-time market data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LayerMetadata:
|
||||||
|
"""Metadata for each context layer."""
|
||||||
|
|
||||||
|
layer: ContextLayer
|
||||||
|
description: str
|
||||||
|
retention_days: int | None # None = keep forever
|
||||||
|
aggregation_source: ContextLayer | None # Parent layer for aggregation
|
||||||
|
|
||||||
|
|
||||||
|
# Layer configuration
|
||||||
|
LAYER_CONFIG: dict[ContextLayer, LayerMetadata] = {
|
||||||
|
ContextLayer.L1_LEGACY: LayerMetadata(
|
||||||
|
layer=ContextLayer.L1_LEGACY,
|
||||||
|
description="Cumulative trading history and core lessons learned across generations",
|
||||||
|
retention_days=None, # Keep forever
|
||||||
|
aggregation_source=ContextLayer.L2_ANNUAL,
|
||||||
|
),
|
||||||
|
ContextLayer.L2_ANNUAL: LayerMetadata(
|
||||||
|
layer=ContextLayer.L2_ANNUAL,
|
||||||
|
description="Yearly returns, Sharpe ratio, max drawdown, win rate",
|
||||||
|
retention_days=365 * 10, # 10 years
|
||||||
|
aggregation_source=ContextLayer.L3_QUARTERLY,
|
||||||
|
),
|
||||||
|
ContextLayer.L3_QUARTERLY: LayerMetadata(
|
||||||
|
layer=ContextLayer.L3_QUARTERLY,
|
||||||
|
description="Quarterly strategy adjustments, market phase detection, sector rotation",
|
||||||
|
retention_days=365 * 3, # 3 years
|
||||||
|
aggregation_source=ContextLayer.L4_MONTHLY,
|
||||||
|
),
|
||||||
|
ContextLayer.L4_MONTHLY: LayerMetadata(
|
||||||
|
layer=ContextLayer.L4_MONTHLY,
|
||||||
|
description="Monthly portfolio rebalancing, risk exposure, drawdown recovery",
|
||||||
|
retention_days=365 * 2, # 2 years
|
||||||
|
aggregation_source=ContextLayer.L5_WEEKLY,
|
||||||
|
),
|
||||||
|
ContextLayer.L5_WEEKLY: LayerMetadata(
|
||||||
|
layer=ContextLayer.L5_WEEKLY,
|
||||||
|
description="Weekly stock selection, sector focus, volatility regime",
|
||||||
|
retention_days=365, # 1 year
|
||||||
|
aggregation_source=ContextLayer.L6_DAILY,
|
||||||
|
),
|
||||||
|
ContextLayer.L6_DAILY: LayerMetadata(
|
||||||
|
layer=ContextLayer.L6_DAILY,
|
||||||
|
description="Daily trade logs, P&L, market summaries, decision accuracy",
|
||||||
|
retention_days=90, # 90 days
|
||||||
|
aggregation_source=ContextLayer.L7_REALTIME,
|
||||||
|
),
|
||||||
|
ContextLayer.L7_REALTIME: LayerMetadata(
|
||||||
|
layer=ContextLayer.L7_REALTIME,
|
||||||
|
description="Real-time positions, quotes, orderbook, volatility, live P&L",
|
||||||
|
retention_days=7, # 7 days (real-time data is ephemeral)
|
||||||
|
aggregation_source=None, # No aggregation source (leaf layer)
|
||||||
|
),
|
||||||
|
}
|
||||||
193
src/context/store.py
Normal file
193
src/context/store.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""Context storage and retrieval for the 7-tier memory system."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.context.layer import LAYER_CONFIG, ContextLayer
|
||||||
|
|
||||||
|
|
||||||
|
class ContextStore:
|
||||||
|
"""Manages context data across the 7-tier hierarchy."""
|
||||||
|
|
||||||
|
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Initialize the context store with a database connection."""
|
||||||
|
self.conn = conn
|
||||||
|
self._init_metadata()
|
||||||
|
|
||||||
|
def _init_metadata(self) -> None:
|
||||||
|
"""Initialize context_metadata table with layer configurations."""
|
||||||
|
for config in LAYER_CONFIG.values():
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO context_metadata
|
||||||
|
(layer, description, retention_days, aggregation_source)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
config.layer.value,
|
||||||
|
config.description,
|
||||||
|
config.retention_days,
|
||||||
|
config.aggregation_source.value if config.aggregation_source else None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def set_context(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
timeframe: str,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Set a context value for a given layer and timeframe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: The context layer (L1-L7)
|
||||||
|
timeframe: Time identifier (e.g., "2026", "2026-Q1", "2026-01",
|
||||||
|
"2026-W05", "2026-02-04")
|
||||||
|
key: Context key (e.g., "sharpe_ratio", "win_rate", "lesson_learned")
|
||||||
|
value: Context value (will be JSON-serialized)
|
||||||
|
"""
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
value_json = json.dumps(value)
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(layer, timeframe, key)
|
||||||
|
DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(layer.value, timeframe, key, value_json, now, now),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_context(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
timeframe: str,
|
||||||
|
key: str,
|
||||||
|
) -> Any | None:
|
||||||
|
"""Get a context value for a given layer and timeframe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: The context layer (L1-L7)
|
||||||
|
timeframe: Time identifier
|
||||||
|
key: Context key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The context value (deserialized from JSON), or None if not found
|
||||||
|
"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT value FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe = ? AND key = ?
|
||||||
|
""",
|
||||||
|
(layer.value, timeframe, key),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
return json.loads(row[0])
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_all_contexts(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
timeframe: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get all context values for a given layer and optional timeframe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: The context layer (L1-L7)
|
||||||
|
timeframe: Optional time identifier filter
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of key-value pairs for the specified layer/timeframe
|
||||||
|
"""
|
||||||
|
if timeframe:
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT key, value FROM contexts
|
||||||
|
WHERE layer = ? AND timeframe = ?
|
||||||
|
ORDER BY key
|
||||||
|
""",
|
||||||
|
(layer.value, timeframe),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT key, value FROM contexts
|
||||||
|
WHERE layer = ?
|
||||||
|
ORDER BY timeframe DESC, key
|
||||||
|
""",
|
||||||
|
(layer.value,),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {row[0]: json.loads(row[1]) for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
def get_latest_timeframe(self, layer: ContextLayer) -> str | None:
|
||||||
|
"""Get the most recent timeframe for a given layer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: The context layer (L1-L7)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The latest timeframe string, or None if no data exists
|
||||||
|
"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT timeframe FROM contexts
|
||||||
|
WHERE layer = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(layer.value,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
def delete_old_contexts(self, layer: ContextLayer, cutoff_date: str) -> int:
|
||||||
|
"""Delete contexts older than the cutoff date for a given layer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: The context layer (L1-L7)
|
||||||
|
cutoff_date: ISO format date string (contexts before this will be deleted)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of rows deleted
|
||||||
|
"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM contexts
|
||||||
|
WHERE layer = ? AND updated_at < ?
|
||||||
|
""",
|
||||||
|
(layer.value, cutoff_date),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def cleanup_expired_contexts(self) -> dict[ContextLayer, int]:
|
||||||
|
"""Delete expired contexts based on retention policies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary mapping layer to number of deleted rows
|
||||||
|
"""
|
||||||
|
deleted_counts: dict[ContextLayer, int] = {}
|
||||||
|
|
||||||
|
for layer, config in LAYER_CONFIG.items():
|
||||||
|
if config.retention_days is None:
|
||||||
|
# Keep forever (e.g., L1_LEGACY)
|
||||||
|
deleted_counts[layer] = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate cutoff date
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=config.retention_days)
|
||||||
|
deleted_counts[layer] = self.delete_old_contexts(layer, cutoff.isoformat())
|
||||||
|
|
||||||
|
return deleted_counts
|
||||||
328
src/context/summarizer.py
Normal file
328
src/context/summarizer.py
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
"""Context summarization for efficient historical data representation.
|
||||||
|
|
||||||
|
This module summarizes old context data instead of including raw details:
|
||||||
|
- Key metrics only (averages, trends, not details)
|
||||||
|
- Rolling window (keep last N days detailed, summarize older)
|
||||||
|
- Aggregate historical data efficiently
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SummaryStats:
|
||||||
|
"""Statistical summary of historical data."""
|
||||||
|
|
||||||
|
count: int
|
||||||
|
mean: float | None = None
|
||||||
|
min: float | None = None
|
||||||
|
max: float | None = None
|
||||||
|
std: float | None = None
|
||||||
|
trend: str | None = None # "up", "down", "flat"
|
||||||
|
|
||||||
|
|
||||||
|
class ContextSummarizer:
|
||||||
|
"""Summarizes historical context data to reduce token usage."""
|
||||||
|
|
||||||
|
def __init__(self, store: ContextStore) -> None:
|
||||||
|
"""Initialize the context summarizer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
store: ContextStore instance for retrieving context data
|
||||||
|
"""
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def summarize_numeric_values(self, values: list[float]) -> SummaryStats:
|
||||||
|
"""Summarize a list of numeric values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: List of numeric values to summarize
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SummaryStats with mean, min, max, std, and trend
|
||||||
|
"""
|
||||||
|
if not values:
|
||||||
|
return SummaryStats(count=0)
|
||||||
|
|
||||||
|
count = len(values)
|
||||||
|
mean = sum(values) / count
|
||||||
|
min_val = min(values)
|
||||||
|
max_val = max(values)
|
||||||
|
|
||||||
|
# Calculate standard deviation
|
||||||
|
if count > 1:
|
||||||
|
variance = sum((x - mean) ** 2 for x in values) / (count - 1)
|
||||||
|
std = variance**0.5
|
||||||
|
else:
|
||||||
|
std = 0.0
|
||||||
|
|
||||||
|
# Determine trend
|
||||||
|
trend = "flat"
|
||||||
|
if count >= 3:
|
||||||
|
# Simple trend: compare first third vs last third
|
||||||
|
first_third = values[: count // 3]
|
||||||
|
last_third = values[-(count // 3) :]
|
||||||
|
first_avg = sum(first_third) / len(first_third)
|
||||||
|
last_avg = sum(last_third) / len(last_third)
|
||||||
|
|
||||||
|
# Trend threshold: 5% change
|
||||||
|
threshold = 0.05 * abs(first_avg) if first_avg != 0 else 0.01
|
||||||
|
|
||||||
|
if last_avg > first_avg + threshold:
|
||||||
|
trend = "up"
|
||||||
|
elif last_avg < first_avg - threshold:
|
||||||
|
trend = "down"
|
||||||
|
|
||||||
|
return SummaryStats(
|
||||||
|
count=count,
|
||||||
|
mean=round(mean, 4),
|
||||||
|
min=round(min_val, 4),
|
||||||
|
max=round(max_val, 4),
|
||||||
|
std=round(std, 4),
|
||||||
|
trend=trend,
|
||||||
|
)
|
||||||
|
|
||||||
|
def summarize_layer(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
start_date: datetime | None = None,
|
||||||
|
end_date: datetime | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Summarize all context data for a layer within a date range.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: Context layer to summarize
|
||||||
|
start_date: Start date (inclusive), None for all
|
||||||
|
end_date: End date (inclusive), None for now
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with summarized metrics
|
||||||
|
"""
|
||||||
|
if end_date is None:
|
||||||
|
end_date = datetime.now(UTC)
|
||||||
|
|
||||||
|
# Get all contexts for this layer
|
||||||
|
all_contexts = self.store.get_all_contexts(layer)
|
||||||
|
|
||||||
|
if not all_contexts:
|
||||||
|
return {"summary": "No data available", "count": 0}
|
||||||
|
|
||||||
|
# Group numeric values by key
|
||||||
|
numeric_data: dict[str, list[float]] = {}
|
||||||
|
text_data: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for key, value in all_contexts.items():
|
||||||
|
# Try to extract numeric values
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
if key not in numeric_data:
|
||||||
|
numeric_data[key] = []
|
||||||
|
numeric_data[key].append(float(value))
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
# Extract numeric fields from dict
|
||||||
|
for subkey, subvalue in value.items():
|
||||||
|
if isinstance(subvalue, (int, float)):
|
||||||
|
full_key = f"{key}.{subkey}"
|
||||||
|
if full_key not in numeric_data:
|
||||||
|
numeric_data[full_key] = []
|
||||||
|
numeric_data[full_key].append(float(subvalue))
|
||||||
|
elif isinstance(value, str):
|
||||||
|
if key not in text_data:
|
||||||
|
text_data[key] = []
|
||||||
|
text_data[key].append(value)
|
||||||
|
|
||||||
|
# Summarize numeric data
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for key, values in numeric_data.items():
|
||||||
|
stats = self.summarize_numeric_values(values)
|
||||||
|
summary[key] = {
|
||||||
|
"count": stats.count,
|
||||||
|
"avg": stats.mean,
|
||||||
|
"range": [stats.min, stats.max],
|
||||||
|
"trend": stats.trend,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Summarize text data (just counts)
|
||||||
|
for key, values in text_data.items():
|
||||||
|
summary[f"{key}_count"] = len(values)
|
||||||
|
|
||||||
|
summary["total_entries"] = len(all_contexts)
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def rolling_window_summary(
|
||||||
|
self,
|
||||||
|
layer: ContextLayer,
|
||||||
|
window_days: int = 30,
|
||||||
|
summarize_older: bool = True,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a rolling window summary.
|
||||||
|
|
||||||
|
Recent data (within window) is kept detailed.
|
||||||
|
Older data is summarized to key metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layer: Context layer to summarize
|
||||||
|
window_days: Number of days to keep detailed
|
||||||
|
summarize_older: Whether to summarize data older than window
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with recent (detailed) and historical (summary) data
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"window_days": window_days,
|
||||||
|
"recent_data": {},
|
||||||
|
"historical_summary": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all contexts
|
||||||
|
all_contexts = self.store.get_all_contexts(layer)
|
||||||
|
|
||||||
|
recent_values: dict[str, list[float]] = {}
|
||||||
|
historical_values: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
for key, value in all_contexts.items():
|
||||||
|
# For simplicity, treat all numeric values
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
# Note: We don't have timestamps in context keys
|
||||||
|
# This is a simplified implementation
|
||||||
|
# In practice, would need to check timeframe field
|
||||||
|
|
||||||
|
# For now, put recent data in window
|
||||||
|
if key not in recent_values:
|
||||||
|
recent_values[key] = []
|
||||||
|
recent_values[key].append(float(value))
|
||||||
|
|
||||||
|
# Detailed recent data
|
||||||
|
result["recent_data"] = {key: values[-10:] for key, values in recent_values.items()}
|
||||||
|
|
||||||
|
# Summarized historical data
|
||||||
|
if summarize_older:
|
||||||
|
for key, values in historical_values.items():
|
||||||
|
stats = self.summarize_numeric_values(values)
|
||||||
|
result["historical_summary"][key] = {
|
||||||
|
"count": stats.count,
|
||||||
|
"avg": stats.mean,
|
||||||
|
"trend": stats.trend,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def aggregate_to_higher_layer(
|
||||||
|
self,
|
||||||
|
source_layer: ContextLayer,
|
||||||
|
target_layer: ContextLayer,
|
||||||
|
metric_key: str,
|
||||||
|
aggregation_func: str = "mean",
|
||||||
|
) -> float | None:
|
||||||
|
"""Aggregate data from source layer to target layer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source_layer: Source context layer (more granular)
|
||||||
|
target_layer: Target context layer (less granular)
|
||||||
|
metric_key: Key of metric to aggregate
|
||||||
|
aggregation_func: Aggregation function ("mean", "sum", "max", "min")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Aggregated value, or None if no data available
|
||||||
|
"""
|
||||||
|
# Get all contexts from source layer
|
||||||
|
source_contexts = self.store.get_all_contexts(source_layer)
|
||||||
|
|
||||||
|
# Extract values for metric_key
|
||||||
|
values = []
|
||||||
|
for key, value in source_contexts.items():
|
||||||
|
if key == metric_key and isinstance(value, (int, float)):
|
||||||
|
values.append(float(value))
|
||||||
|
elif isinstance(value, dict) and metric_key in value:
|
||||||
|
subvalue = value[metric_key]
|
||||||
|
if isinstance(subvalue, (int, float)):
|
||||||
|
values.append(float(subvalue))
|
||||||
|
|
||||||
|
if not values:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Apply aggregation function
|
||||||
|
if aggregation_func == "mean":
|
||||||
|
return sum(values) / len(values)
|
||||||
|
elif aggregation_func == "sum":
|
||||||
|
return sum(values)
|
||||||
|
elif aggregation_func == "max":
|
||||||
|
return max(values)
|
||||||
|
elif aggregation_func == "min":
|
||||||
|
return min(values)
|
||||||
|
else:
|
||||||
|
return sum(values) / len(values) # Default to mean
|
||||||
|
|
||||||
|
def create_compact_summary(
|
||||||
|
self,
|
||||||
|
layers: list[ContextLayer],
|
||||||
|
top_n_metrics: int = 5,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a compact summary across multiple layers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layers: List of context layers to summarize
|
||||||
|
top_n_metrics: Number of top metrics to include per layer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compact summary dictionary
|
||||||
|
"""
|
||||||
|
summary: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for layer in layers:
|
||||||
|
layer_summary = self.summarize_layer(layer)
|
||||||
|
|
||||||
|
# Keep only top N metrics (by count/relevance)
|
||||||
|
metrics = []
|
||||||
|
for key, value in layer_summary.items():
|
||||||
|
if isinstance(value, dict) and "count" in value:
|
||||||
|
metrics.append((key, value, value["count"]))
|
||||||
|
|
||||||
|
# Sort by count (descending)
|
||||||
|
metrics.sort(key=lambda x: x[2], reverse=True)
|
||||||
|
|
||||||
|
# Keep top N
|
||||||
|
top_metrics = {m[0]: m[1] for m in metrics[:top_n_metrics]}
|
||||||
|
|
||||||
|
summary[layer.value] = top_metrics
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def format_summary_for_prompt(self, summary: dict[str, Any]) -> str:
|
||||||
|
"""Format summary for inclusion in a prompt.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
summary: Summary dictionary
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string for prompt
|
||||||
|
"""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
for layer, metrics in summary.items():
|
||||||
|
if not metrics:
|
||||||
|
continue
|
||||||
|
|
||||||
|
lines.append(f"{layer}:")
|
||||||
|
for key, value in metrics.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Format as: key: avg=X, trend=Y
|
||||||
|
parts = []
|
||||||
|
if "avg" in value and value["avg"] is not None:
|
||||||
|
parts.append(f"avg={value['avg']:.2f}")
|
||||||
|
if "trend" in value and value["trend"]:
|
||||||
|
parts.append(f"trend={value['trend']}")
|
||||||
|
if parts:
|
||||||
|
lines.append(f" {key}: {', '.join(parts)}")
|
||||||
|
else:
|
||||||
|
lines.append(f" {key}: {value}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
110
src/core/criticality.py
Normal file
110
src/core/criticality.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
"""Criticality assessment for urgency-based response system.
|
||||||
|
|
||||||
|
Evaluates market conditions to determine response urgency and enable
|
||||||
|
faster reactions in critical situations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class CriticalityLevel(StrEnum):
|
||||||
|
"""Urgency levels for market conditions and trading decisions."""
|
||||||
|
|
||||||
|
CRITICAL = "CRITICAL" # <5s timeout - Emergency response required
|
||||||
|
HIGH = "HIGH" # <30s timeout - Elevated priority
|
||||||
|
NORMAL = "NORMAL" # <60s timeout - Standard processing
|
||||||
|
LOW = "LOW" # No timeout - Batch processing
|
||||||
|
|
||||||
|
|
||||||
|
class CriticalityAssessor:
|
||||||
|
"""Assesses market conditions to determine response criticality level."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
critical_pnl_threshold: float = -2.5,
|
||||||
|
critical_price_change_threshold: float = 5.0,
|
||||||
|
critical_volume_surge_threshold: float = 10.0,
|
||||||
|
high_volatility_threshold: float = 70.0,
|
||||||
|
low_volatility_threshold: float = 30.0,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the criticality assessor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
critical_pnl_threshold: P&L % that triggers CRITICAL (default -2.5%)
|
||||||
|
critical_price_change_threshold: Price change % that triggers CRITICAL
|
||||||
|
(default 5.0% in 1 minute)
|
||||||
|
critical_volume_surge_threshold: Volume surge ratio that triggers CRITICAL
|
||||||
|
(default 10x average)
|
||||||
|
high_volatility_threshold: Volatility score that triggers HIGH
|
||||||
|
(default 70.0)
|
||||||
|
low_volatility_threshold: Volatility score below which is LOW
|
||||||
|
(default 30.0)
|
||||||
|
"""
|
||||||
|
self.critical_pnl_threshold = critical_pnl_threshold
|
||||||
|
self.critical_price_change_threshold = critical_price_change_threshold
|
||||||
|
self.critical_volume_surge_threshold = critical_volume_surge_threshold
|
||||||
|
self.high_volatility_threshold = high_volatility_threshold
|
||||||
|
self.low_volatility_threshold = low_volatility_threshold
|
||||||
|
|
||||||
|
def assess_market_conditions(
|
||||||
|
self,
|
||||||
|
pnl_pct: float,
|
||||||
|
volatility_score: float,
|
||||||
|
volume_surge: float,
|
||||||
|
price_change_1m: float = 0.0,
|
||||||
|
is_market_open: bool = True,
|
||||||
|
) -> CriticalityLevel:
|
||||||
|
"""Assess criticality level based on market conditions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pnl_pct: Current P&L percentage
|
||||||
|
volatility_score: Momentum score from VolatilityAnalyzer (0-100)
|
||||||
|
volume_surge: Volume surge ratio (current / average)
|
||||||
|
price_change_1m: 1-minute price change percentage
|
||||||
|
is_market_open: Whether the market is currently open
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CriticalityLevel indicating required response urgency
|
||||||
|
"""
|
||||||
|
# Market closed or very quiet → LOW priority (batch processing)
|
||||||
|
if not is_market_open or volatility_score < self.low_volatility_threshold:
|
||||||
|
return CriticalityLevel.LOW
|
||||||
|
|
||||||
|
# CRITICAL conditions: immediate action required
|
||||||
|
# 1. P&L near circuit breaker (-2.5% is close to -3.0% breaker)
|
||||||
|
if pnl_pct <= self.critical_pnl_threshold:
|
||||||
|
return CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
# 2. Large sudden price movement (>5% in 1 minute)
|
||||||
|
if abs(price_change_1m) >= self.critical_price_change_threshold:
|
||||||
|
return CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
# 3. Extreme volume surge (>10x average) indicates major event
|
||||||
|
if volume_surge >= self.critical_volume_surge_threshold:
|
||||||
|
return CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
# HIGH priority: elevated volatility requires faster response
|
||||||
|
if volatility_score >= self.high_volatility_threshold:
|
||||||
|
return CriticalityLevel.HIGH
|
||||||
|
|
||||||
|
# NORMAL: standard trading conditions
|
||||||
|
return CriticalityLevel.NORMAL
|
||||||
|
|
||||||
|
def get_timeout(self, level: CriticalityLevel) -> float | None:
|
||||||
|
"""Get timeout in seconds for a given criticality level.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
level: Criticality level
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timeout in seconds, or None for no timeout (LOW priority)
|
||||||
|
"""
|
||||||
|
timeout_map = {
|
||||||
|
CriticalityLevel.CRITICAL: 5.0,
|
||||||
|
CriticalityLevel.HIGH: 30.0,
|
||||||
|
CriticalityLevel.NORMAL: 60.0,
|
||||||
|
CriticalityLevel.LOW: None,
|
||||||
|
}
|
||||||
|
return timeout_map[level]
|
||||||
291
src/core/priority_queue.py
Normal file
291
src/core/priority_queue.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""Priority-based task queue for latency control.
|
||||||
|
|
||||||
|
Implements a thread-safe priority queue with timeout enforcement and metrics tracking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import heapq
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.core.criticality import CriticalityLevel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(order=True)
|
||||||
|
class PriorityTask:
|
||||||
|
"""Task with priority and timestamp for queue ordering."""
|
||||||
|
|
||||||
|
# Lower priority value = higher urgency (CRITICAL=0, HIGH=1, NORMAL=2, LOW=3)
|
||||||
|
priority: int
|
||||||
|
timestamp: float
|
||||||
|
# Task data not used in comparison
|
||||||
|
task_id: str = field(compare=False)
|
||||||
|
task_data: dict[str, Any] = field(compare=False, default_factory=dict)
|
||||||
|
callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(
|
||||||
|
compare=False, default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QueueMetrics:
|
||||||
|
"""Metrics for priority queue performance monitoring."""
|
||||||
|
|
||||||
|
total_enqueued: int = 0
|
||||||
|
total_dequeued: int = 0
|
||||||
|
total_timeouts: int = 0
|
||||||
|
total_errors: int = 0
|
||||||
|
current_size: int = 0
|
||||||
|
# Average wait time per criticality level (in seconds)
|
||||||
|
avg_wait_time: dict[CriticalityLevel, float] = field(default_factory=dict)
|
||||||
|
# P95 wait time per criticality level
|
||||||
|
p95_wait_time: dict[CriticalityLevel, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class PriorityTaskQueue:
|
||||||
|
"""Thread-safe priority queue with timeout enforcement."""
|
||||||
|
|
||||||
|
# Priority mapping for criticality levels
|
||||||
|
PRIORITY_MAP = {
|
||||||
|
CriticalityLevel.CRITICAL: 0,
|
||||||
|
CriticalityLevel.HIGH: 1,
|
||||||
|
CriticalityLevel.NORMAL: 2,
|
||||||
|
CriticalityLevel.LOW: 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, max_size: int = 1000) -> None:
|
||||||
|
"""Initialize the priority task queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_size: Maximum queue size (default 1000)
|
||||||
|
"""
|
||||||
|
self._queue: list[PriorityTask] = []
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._max_size = max_size
|
||||||
|
self._metrics = QueueMetrics()
|
||||||
|
# Track wait times for metrics
|
||||||
|
self._wait_times: dict[CriticalityLevel, list[float]] = {
|
||||||
|
level: [] for level in CriticalityLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
async def enqueue(
|
||||||
|
self,
|
||||||
|
task_id: str,
|
||||||
|
criticality: CriticalityLevel,
|
||||||
|
task_data: dict[str, Any],
|
||||||
|
callback: Callable[[], Coroutine[Any, Any, Any]] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Add a task to the priority queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: Unique identifier for the task
|
||||||
|
criticality: Criticality level determining priority
|
||||||
|
task_data: Data associated with the task
|
||||||
|
callback: Optional async callback to execute
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if enqueued successfully, False if queue is full
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
if len(self._queue) >= self._max_size:
|
||||||
|
logger.warning(
|
||||||
|
"Priority queue full (size=%d), rejecting task %s",
|
||||||
|
len(self._queue),
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
priority = self.PRIORITY_MAP[criticality]
|
||||||
|
timestamp = time.time()
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=priority,
|
||||||
|
timestamp=timestamp,
|
||||||
|
task_id=task_id,
|
||||||
|
task_data=task_data,
|
||||||
|
callback=callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
heapq.heappush(self._queue, task)
|
||||||
|
self._metrics.total_enqueued += 1
|
||||||
|
self._metrics.current_size = len(self._queue)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Enqueued task %s with criticality %s (priority=%d, queue_size=%d)",
|
||||||
|
task_id,
|
||||||
|
criticality.value,
|
||||||
|
priority,
|
||||||
|
len(self._queue),
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def dequeue(self, timeout: float | None = None) -> PriorityTask | None:
|
||||||
|
"""Remove and return the highest priority task from the queue.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for a task (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PriorityTask if available, None if queue is empty or timeout
|
||||||
|
"""
|
||||||
|
start_time = time.time()
|
||||||
|
deadline = start_time + timeout if timeout else None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
async with self._lock:
|
||||||
|
if self._queue:
|
||||||
|
task = heapq.heappop(self._queue)
|
||||||
|
self._metrics.total_dequeued += 1
|
||||||
|
self._metrics.current_size = len(self._queue)
|
||||||
|
|
||||||
|
# Calculate wait time
|
||||||
|
wait_time = time.time() - task.timestamp
|
||||||
|
criticality = self._get_criticality_from_priority(task.priority)
|
||||||
|
self._wait_times[criticality].append(wait_time)
|
||||||
|
self._update_wait_time_metrics()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Dequeued task %s (priority=%d, wait_time=%.2fs, queue_size=%d)",
|
||||||
|
task.task_id,
|
||||||
|
task.priority,
|
||||||
|
wait_time,
|
||||||
|
len(self._queue),
|
||||||
|
)
|
||||||
|
|
||||||
|
return task
|
||||||
|
|
||||||
|
# Queue is empty
|
||||||
|
if deadline and time.time() >= deadline:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Wait a bit before checking again
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
async def execute_with_timeout(
|
||||||
|
self,
|
||||||
|
task: PriorityTask,
|
||||||
|
timeout: float | None,
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a task with timeout enforcement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: Task to execute
|
||||||
|
timeout: Timeout in seconds (None = no timeout)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Result from task callback
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
asyncio.TimeoutError: If task exceeds timeout
|
||||||
|
Exception: Any exception raised by the task callback
|
||||||
|
"""
|
||||||
|
if not task.callback:
|
||||||
|
logger.warning("Task %s has no callback, skipping execution", task.task_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
criticality = self._get_criticality_from_priority(task.priority)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if timeout:
|
||||||
|
result = await asyncio.wait_for(task.callback(), timeout=timeout)
|
||||||
|
else:
|
||||||
|
result = await task.callback()
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"Task %s completed successfully (criticality=%s)",
|
||||||
|
task.task_id,
|
||||||
|
criticality.value,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
self._metrics.total_timeouts += 1
|
||||||
|
logger.error(
|
||||||
|
"Task %s timed out after %.2fs (criticality=%s)",
|
||||||
|
task.task_id,
|
||||||
|
timeout or 0.0,
|
||||||
|
criticality.value,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
self._metrics.total_errors += 1
|
||||||
|
logger.exception(
|
||||||
|
"Task %s failed with error (criticality=%s): %s",
|
||||||
|
task.task_id,
|
||||||
|
criticality.value,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _get_criticality_from_priority(self, priority: int) -> CriticalityLevel:
|
||||||
|
"""Convert priority back to criticality level."""
|
||||||
|
for level, prio in self.PRIORITY_MAP.items():
|
||||||
|
if prio == priority:
|
||||||
|
return level
|
||||||
|
return CriticalityLevel.NORMAL
|
||||||
|
|
||||||
|
def _update_wait_time_metrics(self) -> None:
|
||||||
|
"""Update average and p95 wait time metrics."""
|
||||||
|
for level, times in self._wait_times.items():
|
||||||
|
if not times:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Keep only last 1000 measurements to avoid memory bloat
|
||||||
|
if len(times) > 1000:
|
||||||
|
self._wait_times[level] = times[-1000:]
|
||||||
|
times = self._wait_times[level]
|
||||||
|
|
||||||
|
# Calculate average
|
||||||
|
self._metrics.avg_wait_time[level] = sum(times) / len(times)
|
||||||
|
|
||||||
|
# Calculate P95
|
||||||
|
sorted_times = sorted(times)
|
||||||
|
p95_idx = int(len(sorted_times) * 0.95)
|
||||||
|
self._metrics.p95_wait_time[level] = sorted_times[p95_idx]
|
||||||
|
|
||||||
|
async def get_metrics(self) -> QueueMetrics:
|
||||||
|
"""Get current queue metrics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
QueueMetrics with current statistics
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
return QueueMetrics(
|
||||||
|
total_enqueued=self._metrics.total_enqueued,
|
||||||
|
total_dequeued=self._metrics.total_dequeued,
|
||||||
|
total_timeouts=self._metrics.total_timeouts,
|
||||||
|
total_errors=self._metrics.total_errors,
|
||||||
|
current_size=self._metrics.current_size,
|
||||||
|
avg_wait_time=dict(self._metrics.avg_wait_time),
|
||||||
|
p95_wait_time=dict(self._metrics.p95_wait_time),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def size(self) -> int:
|
||||||
|
"""Get current queue size.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tasks in queue
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
return len(self._queue)
|
||||||
|
|
||||||
|
async def clear(self) -> int:
|
||||||
|
"""Clear all tasks from the queue.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of tasks cleared
|
||||||
|
"""
|
||||||
|
async with self._lock:
|
||||||
|
count = len(self._queue)
|
||||||
|
self._queue.clear()
|
||||||
|
self._metrics.current_size = 0
|
||||||
|
logger.info("Cleared %d tasks from priority queue", count)
|
||||||
|
return count
|
||||||
205
src/data/README.md
Normal file
205
src/data/README.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# External Data Integration
|
||||||
|
|
||||||
|
This module provides objective external data sources to enhance trading decisions beyond just market prices and user input.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
### `news_api.py` - News Sentiment Analysis
|
||||||
|
|
||||||
|
Fetches real-time news for stocks with sentiment scoring.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Alpha Vantage and NewsAPI.org support
|
||||||
|
- Sentiment scoring (-1.0 to +1.0)
|
||||||
|
- 5-minute caching to minimize API quota usage
|
||||||
|
- Graceful fallback when API unavailable
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from src.data.news_api import NewsAPI
|
||||||
|
|
||||||
|
# Initialize with API key
|
||||||
|
news_api = NewsAPI(api_key="your_key", provider="alphavantage")
|
||||||
|
|
||||||
|
# Fetch news sentiment
|
||||||
|
sentiment = await news_api.get_news_sentiment("AAPL")
|
||||||
|
if sentiment:
|
||||||
|
print(f"Average sentiment: {sentiment.avg_sentiment}")
|
||||||
|
for article in sentiment.articles[:3]:
|
||||||
|
print(f"{article.title} ({article.sentiment_score})")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `economic_calendar.py` - Major Economic Events
|
||||||
|
|
||||||
|
Tracks FOMC meetings, GDP releases, CPI, earnings calendars, and other market-moving events.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- High-impact event tracking (FOMC, GDP, CPI)
|
||||||
|
- Earnings calendar per stock
|
||||||
|
- Event proximity checking
|
||||||
|
- Hardcoded major events for 2026 (no API required)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from src.data.economic_calendar import EconomicCalendar
|
||||||
|
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
calendar.load_hardcoded_events()
|
||||||
|
|
||||||
|
# Get upcoming high-impact events
|
||||||
|
upcoming = calendar.get_upcoming_events(days_ahead=7, min_impact="HIGH")
|
||||||
|
print(f"High-impact events: {upcoming.high_impact_count}")
|
||||||
|
|
||||||
|
# Check if near earnings
|
||||||
|
earnings_date = calendar.get_earnings_date("AAPL")
|
||||||
|
if earnings_date:
|
||||||
|
print(f"Next earnings: {earnings_date}")
|
||||||
|
|
||||||
|
# Check for high volatility period
|
||||||
|
if calendar.is_high_volatility_period(hours_ahead=24):
|
||||||
|
print("High-impact event imminent!")
|
||||||
|
```
|
||||||
|
|
||||||
|
### `market_data.py` - Market Indicators
|
||||||
|
|
||||||
|
Provides market breadth, sector performance, and sentiment indicators.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Market sentiment levels (Fear & Greed equivalent)
|
||||||
|
- Market breadth (advancing/declining stocks)
|
||||||
|
- Sector performance tracking
|
||||||
|
- Fear/Greed score calculation
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
from src.data.market_data import MarketData
|
||||||
|
|
||||||
|
market_data = MarketData(api_key="your_key")
|
||||||
|
|
||||||
|
# Get market sentiment
|
||||||
|
sentiment = market_data.get_market_sentiment()
|
||||||
|
print(f"Market sentiment: {sentiment.name}")
|
||||||
|
|
||||||
|
# Get full indicators
|
||||||
|
indicators = market_data.get_market_indicators("US")
|
||||||
|
print(f"Sentiment: {indicators.sentiment.name}")
|
||||||
|
print(f"A/D Ratio: {indicators.breadth.advance_decline_ratio}")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Integration with GeminiClient
|
||||||
|
|
||||||
|
The external data sources are seamlessly integrated into the AI decision engine:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
from src.data.news_api import NewsAPI
|
||||||
|
from src.data.economic_calendar import EconomicCalendar
|
||||||
|
from src.data.market_data import MarketData
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
# Initialize data sources
|
||||||
|
news_api = NewsAPI(api_key=settings.NEWS_API_KEY, provider=settings.NEWS_API_PROVIDER)
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
calendar.load_hardcoded_events()
|
||||||
|
market_data = MarketData(api_key=settings.MARKET_DATA_API_KEY)
|
||||||
|
|
||||||
|
# Create enhanced client
|
||||||
|
client = GeminiClient(
|
||||||
|
settings,
|
||||||
|
news_api=news_api,
|
||||||
|
economic_calendar=calendar,
|
||||||
|
market_data=market_data
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make decision with external context
|
||||||
|
market_data_dict = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market"
|
||||||
|
}
|
||||||
|
|
||||||
|
decision = await client.decide(market_data_dict)
|
||||||
|
```
|
||||||
|
|
||||||
|
The external data is automatically included in the prompt sent to Gemini:
|
||||||
|
|
||||||
|
```
|
||||||
|
Market: US stock market
|
||||||
|
Stock Code: AAPL
|
||||||
|
Current Price: 180.0
|
||||||
|
|
||||||
|
EXTERNAL DATA:
|
||||||
|
News Sentiment: 0.85 (from 10 articles)
|
||||||
|
1. [Reuters] Apple hits record high (sentiment: 0.92)
|
||||||
|
2. [Bloomberg] Strong iPhone sales (sentiment: 0.78)
|
||||||
|
3. [CNBC] Tech sector rallying (sentiment: 0.85)
|
||||||
|
|
||||||
|
Upcoming High-Impact Events: 2 in next 7 days
|
||||||
|
Next: FOMC Meeting (FOMC) on 2026-03-18
|
||||||
|
Earnings: AAPL on 2026-02-10
|
||||||
|
|
||||||
|
Market Sentiment: GREED
|
||||||
|
Advance/Decline Ratio: 2.35
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Add these to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# External Data APIs (optional)
|
||||||
|
NEWS_API_KEY=your_alpha_vantage_key
|
||||||
|
NEWS_API_PROVIDER=alphavantage # or "newsapi"
|
||||||
|
MARKET_DATA_API_KEY=your_market_data_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Recommendations
|
||||||
|
|
||||||
|
### Alpha Vantage (News)
|
||||||
|
- **Free tier:** 5 calls/min, 500 calls/day
|
||||||
|
- **Pros:** Provides sentiment scores, no credit card required
|
||||||
|
- **URL:** https://www.alphavantage.co/
|
||||||
|
|
||||||
|
### NewsAPI.org
|
||||||
|
- **Free tier:** 100 requests/day
|
||||||
|
- **Pros:** Large news coverage, easy to use
|
||||||
|
- **Cons:** No sentiment scores (we use keyword heuristics)
|
||||||
|
- **URL:** https://newsapi.org/
|
||||||
|
|
||||||
|
## Caching Strategy
|
||||||
|
|
||||||
|
To minimize API quota usage:
|
||||||
|
|
||||||
|
1. **News:** 5-minute TTL cache per stock
|
||||||
|
2. **Economic Calendar:** Loaded once at startup (hardcoded events)
|
||||||
|
3. **Market Data:** Fetched per decision (lightweight)
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
The system works gracefully without external data:
|
||||||
|
|
||||||
|
- If no API keys provided → decisions work with just market prices
|
||||||
|
- If API fails → decision continues without external context
|
||||||
|
- If cache expired → attempts refetch, falls back to no data
|
||||||
|
- Errors are logged but never block trading decisions
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
All modules have comprehensive test coverage (81%+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest tests/test_data_integration.py -v --cov=src/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests use mocks to avoid requiring real API keys.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Twitter/X sentiment analysis
|
||||||
|
- Reddit WallStreetBets sentiment
|
||||||
|
- Options flow data
|
||||||
|
- Insider trading activity
|
||||||
|
- Analyst upgrades/downgrades
|
||||||
|
- Real-time economic data APIs
|
||||||
5
src/data/__init__.py
Normal file
5
src/data/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""External data integration for objective decision-making."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["NewsAPI", "EconomicCalendar", "MarketData"]
|
||||||
219
src/data/economic_calendar.py
Normal file
219
src/data/economic_calendar.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Economic calendar integration for major market events.
|
||||||
|
|
||||||
|
Tracks FOMC meetings, GDP releases, CPI, earnings calendars, and other
|
||||||
|
market-moving events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EconomicEvent:
|
||||||
|
"""Single economic event."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
event_type: str # "FOMC", "GDP", "CPI", "EARNINGS", etc.
|
||||||
|
datetime: datetime
|
||||||
|
impact: str # "HIGH", "MEDIUM", "LOW"
|
||||||
|
country: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpcomingEvents:
|
||||||
|
"""Collection of upcoming economic events."""
|
||||||
|
|
||||||
|
events: list[EconomicEvent]
|
||||||
|
high_impact_count: int
|
||||||
|
next_major_event: EconomicEvent | None
|
||||||
|
|
||||||
|
|
||||||
|
class EconomicCalendar:
|
||||||
|
"""Economic calendar with event tracking and impact scoring."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None) -> None:
|
||||||
|
"""Initialize economic calendar.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for calendar provider (None for testing/hardcoded)
|
||||||
|
"""
|
||||||
|
self._api_key = api_key
|
||||||
|
# For now, use hardcoded major events (can be extended with API)
|
||||||
|
self._events: list[EconomicEvent] = []
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_upcoming_events(
|
||||||
|
self, days_ahead: int = 7, min_impact: str = "MEDIUM"
|
||||||
|
) -> UpcomingEvents:
|
||||||
|
"""Get upcoming economic events within specified timeframe.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days_ahead: Number of days to look ahead
|
||||||
|
min_impact: Minimum impact level ("LOW", "MEDIUM", "HIGH")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UpcomingEvents with filtered events
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
end_date = now + timedelta(days=days_ahead)
|
||||||
|
|
||||||
|
# Filter events by timeframe and impact
|
||||||
|
upcoming = [
|
||||||
|
event
|
||||||
|
for event in self._events
|
||||||
|
if now <= event.datetime <= end_date
|
||||||
|
and self._impact_level(event.impact) >= self._impact_level(min_impact)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort by datetime
|
||||||
|
upcoming.sort(key=lambda e: e.datetime)
|
||||||
|
|
||||||
|
# Count high-impact events
|
||||||
|
high_impact_count = sum(1 for e in upcoming if e.impact == "HIGH")
|
||||||
|
|
||||||
|
# Get next major event
|
||||||
|
next_major = None
|
||||||
|
for event in upcoming:
|
||||||
|
if event.impact == "HIGH":
|
||||||
|
next_major = event
|
||||||
|
break
|
||||||
|
|
||||||
|
return UpcomingEvents(
|
||||||
|
events=upcoming,
|
||||||
|
high_impact_count=high_impact_count,
|
||||||
|
next_major_event=next_major,
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_event(self, event: EconomicEvent) -> None:
|
||||||
|
"""Add an economic event to the calendar."""
|
||||||
|
self._events.append(event)
|
||||||
|
|
||||||
|
def clear_events(self) -> None:
|
||||||
|
"""Clear all events (useful for testing)."""
|
||||||
|
self._events.clear()
|
||||||
|
|
||||||
|
def get_earnings_date(self, stock_code: str) -> datetime | None:
|
||||||
|
"""Get next earnings date for a stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Next earnings datetime or None if not found
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
earnings_events = [
|
||||||
|
event
|
||||||
|
for event in self._events
|
||||||
|
if event.event_type == "EARNINGS"
|
||||||
|
and stock_code.upper() in event.name.upper()
|
||||||
|
and event.datetime > now
|
||||||
|
]
|
||||||
|
|
||||||
|
if not earnings_events:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return earliest upcoming earnings
|
||||||
|
earnings_events.sort(key=lambda e: e.datetime)
|
||||||
|
return earnings_events[0].datetime
|
||||||
|
|
||||||
|
def load_hardcoded_events(self) -> None:
|
||||||
|
"""Load hardcoded major economic events for 2026.
|
||||||
|
|
||||||
|
This is a fallback when no API is available.
|
||||||
|
"""
|
||||||
|
# Major FOMC meetings in 2026 (estimated)
|
||||||
|
fomc_dates = [
|
||||||
|
datetime(2026, 3, 18),
|
||||||
|
datetime(2026, 5, 6),
|
||||||
|
datetime(2026, 6, 17),
|
||||||
|
datetime(2026, 7, 29),
|
||||||
|
datetime(2026, 9, 16),
|
||||||
|
datetime(2026, 11, 4),
|
||||||
|
datetime(2026, 12, 16),
|
||||||
|
]
|
||||||
|
|
||||||
|
for date in fomc_dates:
|
||||||
|
self.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="FOMC Meeting",
|
||||||
|
event_type="FOMC",
|
||||||
|
datetime=date,
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Federal Reserve interest rate decision",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Quarterly GDP releases (estimated)
|
||||||
|
gdp_dates = [
|
||||||
|
datetime(2026, 4, 28),
|
||||||
|
datetime(2026, 7, 30),
|
||||||
|
datetime(2026, 10, 29),
|
||||||
|
]
|
||||||
|
|
||||||
|
for date in gdp_dates:
|
||||||
|
self.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="US GDP Release",
|
||||||
|
event_type="GDP",
|
||||||
|
datetime=date,
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Quarterly GDP growth rate",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Monthly CPI releases (12th of each month, estimated)
|
||||||
|
for month in range(1, 13):
|
||||||
|
try:
|
||||||
|
cpi_date = datetime(2026, month, 12)
|
||||||
|
self.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="US CPI Release",
|
||||||
|
event_type="CPI",
|
||||||
|
datetime=cpi_date,
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Consumer Price Index inflation data",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _impact_level(self, impact: str) -> int:
|
||||||
|
"""Convert impact string to numeric level."""
|
||||||
|
levels = {"LOW": 1, "MEDIUM": 2, "HIGH": 3}
|
||||||
|
return levels.get(impact.upper(), 0)
|
||||||
|
|
||||||
|
def is_high_volatility_period(self, hours_ahead: int = 24) -> bool:
|
||||||
|
"""Check if we're near a high-impact event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours_ahead: Number of hours to look ahead
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if high-impact event is imminent
|
||||||
|
"""
|
||||||
|
now = datetime.now()
|
||||||
|
threshold = now + timedelta(hours=hours_ahead)
|
||||||
|
|
||||||
|
for event in self._events:
|
||||||
|
if event.impact == "HIGH" and now <= event.datetime <= threshold:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
198
src/data/market_data.py
Normal file
198
src/data/market_data.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"""Additional market data indicators beyond basic price data.
|
||||||
|
|
||||||
|
Provides market breadth, sector performance, and market sentiment indicators.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketSentiment(Enum):
|
||||||
|
"""Overall market sentiment levels."""
|
||||||
|
|
||||||
|
EXTREME_FEAR = 1
|
||||||
|
FEAR = 2
|
||||||
|
NEUTRAL = 3
|
||||||
|
GREED = 4
|
||||||
|
EXTREME_GREED = 5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SectorPerformance:
|
||||||
|
"""Performance metrics for a market sector."""
|
||||||
|
|
||||||
|
sector_name: str
|
||||||
|
daily_change_pct: float
|
||||||
|
weekly_change_pct: float
|
||||||
|
leader_stock: str # Best performing stock in sector
|
||||||
|
laggard_stock: str # Worst performing stock in sector
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MarketBreadth:
|
||||||
|
"""Market breadth indicators."""
|
||||||
|
|
||||||
|
advancing_stocks: int
|
||||||
|
declining_stocks: int
|
||||||
|
unchanged_stocks: int
|
||||||
|
new_highs: int
|
||||||
|
new_lows: int
|
||||||
|
advance_decline_ratio: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MarketIndicators:
|
||||||
|
"""Aggregated market indicators."""
|
||||||
|
|
||||||
|
sentiment: MarketSentiment
|
||||||
|
breadth: MarketBreadth
|
||||||
|
sector_performance: list[SectorPerformance]
|
||||||
|
vix_level: float | None # Volatility index if available
|
||||||
|
|
||||||
|
|
||||||
|
class MarketData:
|
||||||
|
"""Market data provider for additional indicators."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None) -> None:
|
||||||
|
"""Initialize market data provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for data provider (None for testing)
|
||||||
|
"""
|
||||||
|
self._api_key = api_key
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_market_sentiment(self) -> MarketSentiment:
|
||||||
|
"""Get current market sentiment level.
|
||||||
|
|
||||||
|
This is a simplified version. In production, this would integrate
|
||||||
|
with Fear & Greed Index or similar sentiment indicators.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MarketSentiment enum value
|
||||||
|
"""
|
||||||
|
# Default to neutral when API not available
|
||||||
|
if self._api_key is None:
|
||||||
|
logger.debug("No market data API key — returning NEUTRAL sentiment")
|
||||||
|
return MarketSentiment.NEUTRAL
|
||||||
|
|
||||||
|
# TODO: Integrate with actual sentiment API
|
||||||
|
return MarketSentiment.NEUTRAL
|
||||||
|
|
||||||
|
def get_market_breadth(self, market: str = "US") -> MarketBreadth | None:
|
||||||
|
"""Get market breadth indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code ("US", "KR", etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MarketBreadth object or None if unavailable
|
||||||
|
"""
|
||||||
|
if self._api_key is None:
|
||||||
|
logger.debug("No market data API key — returning None for breadth")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# TODO: Integrate with actual market breadth API
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_sector_performance(
|
||||||
|
self, market: str = "US"
|
||||||
|
) -> list[SectorPerformance]:
|
||||||
|
"""Get sector performance rankings.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code ("US", "KR", etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SectorPerformance objects, sorted by daily change
|
||||||
|
"""
|
||||||
|
if self._api_key is None:
|
||||||
|
logger.debug("No market data API key — returning empty sector list")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# TODO: Integrate with actual sector performance API
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_market_indicators(self, market: str = "US") -> MarketIndicators:
|
||||||
|
"""Get aggregated market indicators.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code ("US", "KR", etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
MarketIndicators with all available data
|
||||||
|
"""
|
||||||
|
sentiment = self.get_market_sentiment()
|
||||||
|
breadth = self.get_market_breadth(market)
|
||||||
|
sectors = self.get_sector_performance(market)
|
||||||
|
|
||||||
|
# Default breadth if unavailable
|
||||||
|
if breadth is None:
|
||||||
|
breadth = MarketBreadth(
|
||||||
|
advancing_stocks=0,
|
||||||
|
declining_stocks=0,
|
||||||
|
unchanged_stocks=0,
|
||||||
|
new_highs=0,
|
||||||
|
new_lows=0,
|
||||||
|
advance_decline_ratio=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return MarketIndicators(
|
||||||
|
sentiment=sentiment,
|
||||||
|
breadth=breadth,
|
||||||
|
sector_performance=sectors,
|
||||||
|
vix_level=None, # TODO: Add VIX integration
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Helper Methods
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def calculate_fear_greed_score(
|
||||||
|
self, breadth: MarketBreadth, vix: float | None = None
|
||||||
|
) -> int:
|
||||||
|
"""Calculate a simple fear/greed score (0-100).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
breadth: Market breadth data
|
||||||
|
vix: VIX level (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score from 0 (extreme fear) to 100 (extreme greed)
|
||||||
|
"""
|
||||||
|
# Start at neutral
|
||||||
|
score = 50
|
||||||
|
|
||||||
|
# Adjust based on advance/decline ratio
|
||||||
|
if breadth.advance_decline_ratio > 1.5:
|
||||||
|
score += 20
|
||||||
|
elif breadth.advance_decline_ratio > 1.0:
|
||||||
|
score += 10
|
||||||
|
elif breadth.advance_decline_ratio < 0.5:
|
||||||
|
score -= 20
|
||||||
|
elif breadth.advance_decline_ratio < 1.0:
|
||||||
|
score -= 10
|
||||||
|
|
||||||
|
# Adjust based on new highs/lows
|
||||||
|
if breadth.new_highs > breadth.new_lows * 2:
|
||||||
|
score += 15
|
||||||
|
elif breadth.new_lows > breadth.new_highs * 2:
|
||||||
|
score -= 15
|
||||||
|
|
||||||
|
# Adjust based on VIX if available
|
||||||
|
if vix is not None:
|
||||||
|
if vix > 30: # High volatility = fear
|
||||||
|
score -= 15
|
||||||
|
elif vix < 15: # Low volatility = complacency/greed
|
||||||
|
score += 10
|
||||||
|
|
||||||
|
# Clamp to 0-100
|
||||||
|
return max(0, min(100, score))
|
||||||
316
src/data/news_api.py
Normal file
316
src/data/news_api.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
"""News API integration with sentiment analysis and caching.
|
||||||
|
|
||||||
|
Fetches real-time news for stocks using free-tier APIs (Alpha Vantage or NewsAPI).
|
||||||
|
Includes 5-minute caching to minimize API quota usage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Cache entries expire after 5 minutes
|
||||||
|
CACHE_TTL_SECONDS = 300
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NewsArticle:
|
||||||
|
"""Single news article with sentiment."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
summary: str
|
||||||
|
source: str
|
||||||
|
published_at: str
|
||||||
|
sentiment_score: float # -1.0 (negative) to +1.0 (positive)
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NewsSentiment:
|
||||||
|
"""Aggregated news sentiment for a stock."""
|
||||||
|
|
||||||
|
stock_code: str
|
||||||
|
articles: list[NewsArticle]
|
||||||
|
avg_sentiment: float # Average sentiment across all articles
|
||||||
|
article_count: int
|
||||||
|
fetched_at: float # Unix timestamp
|
||||||
|
|
||||||
|
|
||||||
|
class NewsAPI:
|
||||||
|
"""News API client with sentiment analysis and caching."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: str | None = None,
|
||||||
|
provider: str = "alphavantage",
|
||||||
|
cache_ttl: int = CACHE_TTL_SECONDS,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize NewsAPI client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: API key for the news provider (None for testing)
|
||||||
|
provider: News provider ("alphavantage" or "newsapi")
|
||||||
|
cache_ttl: Cache time-to-live in seconds
|
||||||
|
"""
|
||||||
|
self._api_key = api_key
|
||||||
|
self._provider = provider
|
||||||
|
self._cache_ttl = cache_ttl
|
||||||
|
self._cache: dict[str, NewsSentiment] = {}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def get_news_sentiment(self, stock_code: str) -> NewsSentiment | None:
|
||||||
|
"""Fetch news sentiment for a stock with caching.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol (e.g., "AAPL", "005930")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NewsSentiment object or None if fetch fails or API unavailable
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
cached = self._get_from_cache(stock_code)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug("News cache hit for %s", stock_code)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# API key required for real requests
|
||||||
|
if self._api_key is None:
|
||||||
|
logger.warning("No news API key provided — returning None")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Fetch from API
|
||||||
|
try:
|
||||||
|
sentiment = await self._fetch_news(stock_code)
|
||||||
|
if sentiment is not None:
|
||||||
|
self._cache[stock_code] = sentiment
|
||||||
|
return sentiment
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to fetch news for %s: %s", stock_code, exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the news cache (useful for testing)."""
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Cache Management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_from_cache(self, stock_code: str) -> NewsSentiment | None:
|
||||||
|
"""Retrieve cached sentiment if not expired."""
|
||||||
|
if stock_code not in self._cache:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cached = self._cache[stock_code]
|
||||||
|
age = time.time() - cached.fetched_at
|
||||||
|
|
||||||
|
if age > self._cache_ttl:
|
||||||
|
logger.debug("News cache expired for %s (age: %.1fs)", stock_code, age)
|
||||||
|
del self._cache[stock_code]
|
||||||
|
return None
|
||||||
|
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# API Fetching
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _fetch_news(self, stock_code: str) -> NewsSentiment | None:
|
||||||
|
"""Fetch news from the provider API."""
|
||||||
|
if self._provider == "alphavantage":
|
||||||
|
return await self._fetch_alphavantage(stock_code)
|
||||||
|
elif self._provider == "newsapi":
|
||||||
|
return await self._fetch_newsapi(stock_code)
|
||||||
|
else:
|
||||||
|
logger.error("Unknown news provider: %s", self._provider)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_alphavantage(self, stock_code: str) -> NewsSentiment | None:
|
||||||
|
"""Fetch news from Alpha Vantage News Sentiment API."""
|
||||||
|
url = "https://www.alphavantage.co/query"
|
||||||
|
params = {
|
||||||
|
"function": "NEWS_SENTIMENT",
|
||||||
|
"tickers": stock_code,
|
||||||
|
"apikey": self._api_key,
|
||||||
|
"limit": 10, # Fetch top 10 articles
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(
|
||||||
|
"Alpha Vantage API error: HTTP %d", resp.status
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
return self._parse_alphavantage_response(stock_code, data)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Alpha Vantage request failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _fetch_newsapi(self, stock_code: str) -> NewsSentiment | None:
|
||||||
|
"""Fetch news from NewsAPI.org."""
|
||||||
|
url = "https://newsapi.org/v2/everything"
|
||||||
|
params = {
|
||||||
|
"q": stock_code,
|
||||||
|
"apiKey": self._api_key,
|
||||||
|
"pageSize": 10,
|
||||||
|
"sortBy": "publishedAt",
|
||||||
|
"language": "en",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=10) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error("NewsAPI error: HTTP %d", resp.status)
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
return self._parse_newsapi_response(stock_code, data)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("NewsAPI request failed: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Response Parsing
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_alphavantage_response(
|
||||||
|
self, stock_code: str, data: dict[str, Any]
|
||||||
|
) -> NewsSentiment | None:
|
||||||
|
"""Parse Alpha Vantage API response."""
|
||||||
|
if "feed" not in data:
|
||||||
|
logger.warning("No 'feed' key in Alpha Vantage response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
articles: list[NewsArticle] = []
|
||||||
|
for item in data["feed"]:
|
||||||
|
# Extract sentiment for this specific ticker
|
||||||
|
ticker_sentiment = self._extract_ticker_sentiment(item, stock_code)
|
||||||
|
|
||||||
|
article = NewsArticle(
|
||||||
|
title=item.get("title", ""),
|
||||||
|
summary=item.get("summary", "")[:200], # Truncate long summaries
|
||||||
|
source=item.get("source", "Unknown"),
|
||||||
|
published_at=item.get("time_published", ""),
|
||||||
|
sentiment_score=ticker_sentiment,
|
||||||
|
url=item.get("url", ""),
|
||||||
|
)
|
||||||
|
articles.append(article)
|
||||||
|
|
||||||
|
if not articles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg_sentiment = sum(a.sentiment_score for a in articles) / len(articles)
|
||||||
|
|
||||||
|
return NewsSentiment(
|
||||||
|
stock_code=stock_code,
|
||||||
|
articles=articles,
|
||||||
|
avg_sentiment=avg_sentiment,
|
||||||
|
article_count=len(articles),
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _extract_ticker_sentiment(
|
||||||
|
self, item: dict[str, Any], stock_code: str
|
||||||
|
) -> float:
|
||||||
|
"""Extract sentiment score for specific ticker from article."""
|
||||||
|
ticker_sentiments = item.get("ticker_sentiment", [])
|
||||||
|
for ts in ticker_sentiments:
|
||||||
|
if ts.get("ticker", "").upper() == stock_code.upper():
|
||||||
|
# Alpha Vantage provides sentiment_score as string
|
||||||
|
score_str = ts.get("ticker_sentiment_score", "0")
|
||||||
|
try:
|
||||||
|
return float(score_str)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Fallback to overall sentiment if ticker-specific not found
|
||||||
|
overall_sentiment = item.get("overall_sentiment_score", "0")
|
||||||
|
try:
|
||||||
|
return float(overall_sentiment)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
def _parse_newsapi_response(
|
||||||
|
self, stock_code: str, data: dict[str, Any]
|
||||||
|
) -> NewsSentiment | None:
|
||||||
|
"""Parse NewsAPI.org response.
|
||||||
|
|
||||||
|
Note: NewsAPI doesn't provide sentiment scores, so we use a
|
||||||
|
simple heuristic based on title keywords.
|
||||||
|
"""
|
||||||
|
if data.get("status") != "ok" or "articles" not in data:
|
||||||
|
logger.warning("Invalid NewsAPI response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
articles: list[NewsArticle] = []
|
||||||
|
for item in data["articles"]:
|
||||||
|
# Simple sentiment heuristic based on keywords
|
||||||
|
sentiment = self._estimate_sentiment_from_text(
|
||||||
|
item.get("title", "") + " " + item.get("description", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
article = NewsArticle(
|
||||||
|
title=item.get("title", ""),
|
||||||
|
summary=item.get("description", "")[:200],
|
||||||
|
source=item.get("source", {}).get("name", "Unknown"),
|
||||||
|
published_at=item.get("publishedAt", ""),
|
||||||
|
sentiment_score=sentiment,
|
||||||
|
url=item.get("url", ""),
|
||||||
|
)
|
||||||
|
articles.append(article)
|
||||||
|
|
||||||
|
if not articles:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg_sentiment = sum(a.sentiment_score for a in articles) / len(articles)
|
||||||
|
|
||||||
|
return NewsSentiment(
|
||||||
|
stock_code=stock_code,
|
||||||
|
articles=articles,
|
||||||
|
avg_sentiment=avg_sentiment,
|
||||||
|
article_count=len(articles),
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _estimate_sentiment_from_text(self, text: str) -> float:
|
||||||
|
"""Simple keyword-based sentiment estimation.
|
||||||
|
|
||||||
|
This is a fallback for APIs that don't provide sentiment scores.
|
||||||
|
Returns a score between -1.0 and +1.0.
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
positive_keywords = [
|
||||||
|
"surge", "jump", "gain", "rise", "soar", "rally", "profit",
|
||||||
|
"growth", "upgrade", "beat", "strong", "bullish", "breakthrough",
|
||||||
|
]
|
||||||
|
negative_keywords = [
|
||||||
|
"plunge", "fall", "drop", "decline", "crash", "loss", "weak",
|
||||||
|
"downgrade", "miss", "bearish", "concern", "risk", "warning",
|
||||||
|
]
|
||||||
|
|
||||||
|
positive_count = sum(1 for kw in positive_keywords if kw in text_lower)
|
||||||
|
negative_count = sum(1 for kw in negative_keywords if kw in text_lower)
|
||||||
|
|
||||||
|
total = positive_count + negative_count
|
||||||
|
if total == 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# Normalize to -1.0 to +1.0 range
|
||||||
|
return (positive_count - negative_count) / total
|
||||||
113
src/db.py
113
src/db.py
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -38,7 +39,94 @@ def init_db(db_path: str) -> sqlite3.Connection:
|
|||||||
conn.execute("ALTER TABLE trades ADD COLUMN market TEXT DEFAULT 'KR'")
|
conn.execute("ALTER TABLE trades ADD COLUMN market TEXT DEFAULT 'KR'")
|
||||||
if "exchange_code" not in columns:
|
if "exchange_code" not in columns:
|
||||||
conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'")
|
conn.execute("ALTER TABLE trades ADD COLUMN exchange_code TEXT DEFAULT 'KRX'")
|
||||||
|
if "selection_context" not in columns:
|
||||||
|
conn.execute("ALTER TABLE trades ADD COLUMN selection_context TEXT")
|
||||||
|
|
||||||
|
# Context tree tables for multi-layered memory management
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS contexts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
layer TEXT NOT NULL,
|
||||||
|
timeframe TEXT NOT NULL,
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE(layer, timeframe, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decision logging table for comprehensive audit trail
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS decision_logs (
|
||||||
|
decision_id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
market TEXT NOT NULL,
|
||||||
|
exchange_code TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
confidence INTEGER NOT NULL,
|
||||||
|
rationale TEXT NOT NULL,
|
||||||
|
context_snapshot TEXT NOT NULL,
|
||||||
|
input_data TEXT NOT NULL,
|
||||||
|
outcome_pnl REAL,
|
||||||
|
outcome_accuracy INTEGER,
|
||||||
|
reviewed INTEGER DEFAULT 0,
|
||||||
|
review_notes TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS context_metadata (
|
||||||
|
layer TEXT PRIMARY KEY,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
retention_days INTEGER,
|
||||||
|
aggregation_source TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Playbook storage for pre-market strategy persistence
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS playbooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
market TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
playbook_json TEXT NOT NULL,
|
||||||
|
generated_at TEXT NOT NULL,
|
||||||
|
token_count INTEGER DEFAULT 0,
|
||||||
|
scenario_count INTEGER DEFAULT 0,
|
||||||
|
match_count INTEGER DEFAULT 0,
|
||||||
|
UNIQUE(date, market)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_playbooks_date ON playbooks(date)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_playbooks_market ON playbooks(market)")
|
||||||
|
|
||||||
|
# Create indices for efficient context queries
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_layer ON contexts(layer)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_timeframe ON contexts(timeframe)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_contexts_updated ON contexts(updated_at)")
|
||||||
|
|
||||||
|
# Create indices for efficient decision log queries
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_decision_logs_timestamp ON decision_logs(timestamp)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_decision_logs_reviewed ON decision_logs(reviewed)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -54,15 +142,33 @@ def log_trade(
|
|||||||
pnl: float = 0.0,
|
pnl: float = 0.0,
|
||||||
market: str = "KR",
|
market: str = "KR",
|
||||||
exchange_code: str = "KRX",
|
exchange_code: str = "KRX",
|
||||||
|
selection_context: dict[str, any] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert a trade record into the database."""
|
"""Insert a trade record into the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Database connection
|
||||||
|
stock_code: Stock code
|
||||||
|
action: Trade action (BUY/SELL/HOLD)
|
||||||
|
confidence: Confidence level (0-100)
|
||||||
|
rationale: AI decision rationale
|
||||||
|
quantity: Number of shares
|
||||||
|
price: Trade price
|
||||||
|
pnl: Profit/loss
|
||||||
|
market: Market code
|
||||||
|
exchange_code: Exchange code
|
||||||
|
selection_context: Scanner selection data (RSI, volume_ratio, signal, score)
|
||||||
|
"""
|
||||||
|
# Serialize selection context to JSON
|
||||||
|
context_json = json.dumps(selection_context) if selection_context else None
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO trades (
|
INSERT INTO trades (
|
||||||
timestamp, stock_code, action, confidence, rationale,
|
timestamp, stock_code, action, confidence, rationale,
|
||||||
quantity, price, pnl, market, exchange_code
|
quantity, price, pnl, market, exchange_code, selection_context
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
datetime.now(UTC).isoformat(),
|
datetime.now(UTC).isoformat(),
|
||||||
@@ -75,6 +181,7 @@ def log_trade(
|
|||||||
pnl,
|
pnl,
|
||||||
market,
|
market,
|
||||||
exchange_code,
|
exchange_code,
|
||||||
|
context_json,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Evolution engine for self-improving trading strategies."""
|
||||||
|
|
||||||
|
from src.evolution.ab_test import ABTester, ABTestResult, StrategyPerformance
|
||||||
|
from src.evolution.optimizer import EvolutionOptimizer
|
||||||
|
from src.evolution.performance_tracker import (
|
||||||
|
PerformanceDashboard,
|
||||||
|
PerformanceTracker,
|
||||||
|
StrategyMetrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EvolutionOptimizer",
|
||||||
|
"ABTester",
|
||||||
|
"ABTestResult",
|
||||||
|
"StrategyPerformance",
|
||||||
|
"PerformanceTracker",
|
||||||
|
"PerformanceDashboard",
|
||||||
|
"StrategyMetrics",
|
||||||
|
]
|
||||||
|
|||||||
220
src/evolution/ab_test.py
Normal file
220
src/evolution/ab_test.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
"""A/B Testing framework for strategy comparison.
|
||||||
|
|
||||||
|
Runs multiple strategies in parallel, tracks their performance,
|
||||||
|
and uses statistical significance testing to determine winners.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import scipy.stats as stats
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StrategyPerformance:
|
||||||
|
"""Performance metrics for a single strategy."""
|
||||||
|
|
||||||
|
strategy_name: str
|
||||||
|
total_trades: int
|
||||||
|
wins: int
|
||||||
|
losses: int
|
||||||
|
total_pnl: float
|
||||||
|
avg_pnl: float
|
||||||
|
win_rate: float
|
||||||
|
sharpe_ratio: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ABTestResult:
|
||||||
|
"""Result of an A/B test between two strategies."""
|
||||||
|
|
||||||
|
strategy_a: str
|
||||||
|
strategy_b: str
|
||||||
|
winner: str | None
|
||||||
|
p_value: float
|
||||||
|
confidence_level: float
|
||||||
|
is_significant: bool
|
||||||
|
performance_a: StrategyPerformance
|
||||||
|
performance_b: StrategyPerformance
|
||||||
|
|
||||||
|
|
||||||
|
class ABTester:
|
||||||
|
"""A/B testing framework for comparing trading strategies."""
|
||||||
|
|
||||||
|
def __init__(self, significance_level: float = 0.05) -> None:
|
||||||
|
"""Initialize A/B tester.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
significance_level: P-value threshold for statistical significance (default 0.05)
|
||||||
|
"""
|
||||||
|
self._significance_level = significance_level
|
||||||
|
|
||||||
|
def calculate_performance(
|
||||||
|
self, trades: list[dict[str, Any]], strategy_name: str
|
||||||
|
) -> StrategyPerformance:
|
||||||
|
"""Calculate performance metrics for a strategy.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trades: List of trade records with pnl values
|
||||||
|
strategy_name: Name of the strategy
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StrategyPerformance object with calculated metrics
|
||||||
|
"""
|
||||||
|
if not trades:
|
||||||
|
return StrategyPerformance(
|
||||||
|
strategy_name=strategy_name,
|
||||||
|
total_trades=0,
|
||||||
|
wins=0,
|
||||||
|
losses=0,
|
||||||
|
total_pnl=0.0,
|
||||||
|
avg_pnl=0.0,
|
||||||
|
win_rate=0.0,
|
||||||
|
sharpe_ratio=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_trades = len(trades)
|
||||||
|
wins = sum(1 for t in trades if t.get("pnl", 0) > 0)
|
||||||
|
losses = sum(1 for t in trades if t.get("pnl", 0) < 0)
|
||||||
|
pnls = [t.get("pnl", 0.0) for t in trades]
|
||||||
|
total_pnl = sum(pnls)
|
||||||
|
avg_pnl = total_pnl / total_trades if total_trades > 0 else 0.0
|
||||||
|
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0.0
|
||||||
|
|
||||||
|
# Calculate Sharpe ratio (risk-adjusted return)
|
||||||
|
sharpe_ratio = None
|
||||||
|
if len(pnls) > 1:
|
||||||
|
mean_return = avg_pnl
|
||||||
|
std_return = (
|
||||||
|
sum((p - mean_return) ** 2 for p in pnls) / (len(pnls) - 1)
|
||||||
|
) ** 0.5
|
||||||
|
if std_return > 0:
|
||||||
|
sharpe_ratio = mean_return / std_return
|
||||||
|
|
||||||
|
return StrategyPerformance(
|
||||||
|
strategy_name=strategy_name,
|
||||||
|
total_trades=total_trades,
|
||||||
|
wins=wins,
|
||||||
|
losses=losses,
|
||||||
|
total_pnl=round(total_pnl, 2),
|
||||||
|
avg_pnl=round(avg_pnl, 2),
|
||||||
|
win_rate=round(win_rate, 2),
|
||||||
|
sharpe_ratio=round(sharpe_ratio, 4) if sharpe_ratio else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def compare_strategies(
|
||||||
|
self,
|
||||||
|
trades_a: list[dict[str, Any]],
|
||||||
|
trades_b: list[dict[str, Any]],
|
||||||
|
strategy_a_name: str = "Strategy A",
|
||||||
|
strategy_b_name: str = "Strategy B",
|
||||||
|
) -> ABTestResult:
|
||||||
|
"""Compare two strategies using statistical testing.
|
||||||
|
|
||||||
|
Uses a two-sample t-test to determine if performance difference is significant.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trades_a: List of trades from strategy A
|
||||||
|
trades_b: List of trades from strategy B
|
||||||
|
strategy_a_name: Name of strategy A
|
||||||
|
strategy_b_name: Name of strategy B
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ABTestResult with comparison details
|
||||||
|
"""
|
||||||
|
perf_a = self.calculate_performance(trades_a, strategy_a_name)
|
||||||
|
perf_b = self.calculate_performance(trades_b, strategy_b_name)
|
||||||
|
|
||||||
|
# Extract PnL arrays for statistical testing
|
||||||
|
pnls_a = [t.get("pnl", 0.0) for t in trades_a]
|
||||||
|
pnls_b = [t.get("pnl", 0.0) for t in trades_b]
|
||||||
|
|
||||||
|
# Perform two-sample t-test
|
||||||
|
if len(pnls_a) > 1 and len(pnls_b) > 1:
|
||||||
|
t_stat, p_value = stats.ttest_ind(pnls_a, pnls_b, equal_var=False)
|
||||||
|
is_significant = p_value < self._significance_level
|
||||||
|
confidence_level = (1 - p_value) * 100
|
||||||
|
else:
|
||||||
|
# Not enough data for statistical test
|
||||||
|
p_value = 1.0
|
||||||
|
is_significant = False
|
||||||
|
confidence_level = 0.0
|
||||||
|
|
||||||
|
# Determine winner based on average PnL
|
||||||
|
winner = None
|
||||||
|
if is_significant:
|
||||||
|
if perf_a.avg_pnl > perf_b.avg_pnl:
|
||||||
|
winner = strategy_a_name
|
||||||
|
elif perf_b.avg_pnl > perf_a.avg_pnl:
|
||||||
|
winner = strategy_b_name
|
||||||
|
|
||||||
|
return ABTestResult(
|
||||||
|
strategy_a=strategy_a_name,
|
||||||
|
strategy_b=strategy_b_name,
|
||||||
|
winner=winner,
|
||||||
|
p_value=round(p_value, 4),
|
||||||
|
confidence_level=round(confidence_level, 2),
|
||||||
|
is_significant=is_significant,
|
||||||
|
performance_a=perf_a,
|
||||||
|
performance_b=perf_b,
|
||||||
|
)
|
||||||
|
|
||||||
|
def should_deploy(
|
||||||
|
self,
|
||||||
|
result: ABTestResult,
|
||||||
|
min_win_rate: float = 60.0,
|
||||||
|
min_trades: int = 20,
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if a winning strategy should be deployed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: A/B test result
|
||||||
|
min_win_rate: Minimum win rate percentage for deployment (default 60%)
|
||||||
|
min_trades: Minimum number of trades required (default 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the winning strategy meets deployment criteria
|
||||||
|
"""
|
||||||
|
if not result.is_significant or result.winner is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get performance of winning strategy
|
||||||
|
if result.winner == result.strategy_a:
|
||||||
|
winning_perf = result.performance_a
|
||||||
|
else:
|
||||||
|
winning_perf = result.performance_b
|
||||||
|
|
||||||
|
# Check deployment criteria
|
||||||
|
has_enough_trades = winning_perf.total_trades >= min_trades
|
||||||
|
has_good_win_rate = winning_perf.win_rate >= min_win_rate
|
||||||
|
is_profitable = winning_perf.avg_pnl > 0
|
||||||
|
|
||||||
|
meets_criteria = has_enough_trades and has_good_win_rate and is_profitable
|
||||||
|
|
||||||
|
if meets_criteria:
|
||||||
|
logger.info(
|
||||||
|
"Strategy '%s' meets deployment criteria: "
|
||||||
|
"win_rate=%.2f%%, trades=%d, avg_pnl=%.2f",
|
||||||
|
result.winner,
|
||||||
|
winning_perf.win_rate,
|
||||||
|
winning_perf.total_trades,
|
||||||
|
winning_perf.avg_pnl,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Strategy '%s' does NOT meet deployment criteria: "
|
||||||
|
"win_rate=%.2f%% (min %.2f%%), trades=%d (min %d), avg_pnl=%.2f",
|
||||||
|
result.winner if result.winner else "unknown",
|
||||||
|
winning_perf.win_rate if result.winner else 0.0,
|
||||||
|
min_win_rate,
|
||||||
|
winning_perf.total_trades if result.winner else 0,
|
||||||
|
min_trades,
|
||||||
|
winning_perf.avg_pnl if result.winner else 0.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
return meets_criteria
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"""Evolution Engine — analyzes trade logs and generates new strategies.
|
"""Evolution Engine — analyzes trade logs and generates new strategies.
|
||||||
|
|
||||||
This module:
|
This module:
|
||||||
1. Reads trade_logs.db to identify failing patterns
|
1. Uses DecisionLogger.get_losing_decisions() to identify failing patterns
|
||||||
2. Asks Gemini to generate a new strategy class
|
2. Analyzes failure patterns by time, market conditions, stock characteristics
|
||||||
3. Runs pytest on the generated file
|
3. Asks Gemini to generate improved strategy recommendations
|
||||||
4. Creates a simulated PR if tests pass
|
4. Generates new strategy classes with enhanced decision-making logic
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import textwrap
|
import textwrap
|
||||||
|
from collections import Counter
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -21,6 +22,8 @@ from typing import Any
|
|||||||
from google import genai
|
from google import genai
|
||||||
|
|
||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
|
from src.db import init_db
|
||||||
|
from src.logging.decision_logger import DecisionLogger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,29 +56,105 @@ class EvolutionOptimizer:
|
|||||||
self._db_path = settings.DB_PATH
|
self._db_path = settings.DB_PATH
|
||||||
self._client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
self._client = genai.Client(api_key=settings.GEMINI_API_KEY)
|
||||||
self._model_name = settings.GEMINI_MODEL
|
self._model_name = settings.GEMINI_MODEL
|
||||||
|
self._conn = init_db(self._db_path)
|
||||||
|
self._decision_logger = DecisionLogger(self._conn)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Analysis
|
# Analysis
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def analyze_failures(self, limit: int = 50) -> list[dict[str, Any]]:
|
def analyze_failures(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
"""Find trades where high confidence led to losses."""
|
"""Find high-confidence decisions that resulted in losses.
|
||||||
conn = sqlite3.connect(self._db_path)
|
|
||||||
conn.row_factory = sqlite3.Row
|
Uses DecisionLogger.get_losing_decisions() to retrieve failures.
|
||||||
try:
|
"""
|
||||||
rows = conn.execute(
|
losing_decisions = self._decision_logger.get_losing_decisions(
|
||||||
"""
|
min_confidence=80, min_loss=-100.0
|
||||||
SELECT stock_code, action, confidence, pnl, rationale, timestamp
|
)
|
||||||
FROM trades
|
|
||||||
WHERE confidence >= 80 AND pnl < 0
|
# Limit results
|
||||||
ORDER BY pnl ASC
|
if len(losing_decisions) > limit:
|
||||||
LIMIT ?
|
losing_decisions = losing_decisions[:limit]
|
||||||
""",
|
|
||||||
(limit,),
|
# Convert to dict format for analysis
|
||||||
).fetchall()
|
failures = []
|
||||||
return [dict(r) for r in rows]
|
for decision in losing_decisions:
|
||||||
finally:
|
failures.append({
|
||||||
conn.close()
|
"decision_id": decision.decision_id,
|
||||||
|
"timestamp": decision.timestamp,
|
||||||
|
"stock_code": decision.stock_code,
|
||||||
|
"market": decision.market,
|
||||||
|
"exchange_code": decision.exchange_code,
|
||||||
|
"action": decision.action,
|
||||||
|
"confidence": decision.confidence,
|
||||||
|
"rationale": decision.rationale,
|
||||||
|
"outcome_pnl": decision.outcome_pnl,
|
||||||
|
"outcome_accuracy": decision.outcome_accuracy,
|
||||||
|
"context_snapshot": decision.context_snapshot,
|
||||||
|
"input_data": decision.input_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
return failures
|
||||||
|
|
||||||
|
def identify_failure_patterns(
|
||||||
|
self, failures: list[dict[str, Any]]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Identify patterns in losing decisions.
|
||||||
|
|
||||||
|
Analyzes:
|
||||||
|
- Time patterns (hour of day, day of week)
|
||||||
|
- Market conditions (volatility, volume)
|
||||||
|
- Stock characteristics (price range, market)
|
||||||
|
- Common failure modes in rationale
|
||||||
|
"""
|
||||||
|
if not failures:
|
||||||
|
return {"pattern_count": 0, "patterns": {}}
|
||||||
|
|
||||||
|
patterns = {
|
||||||
|
"markets": Counter(),
|
||||||
|
"actions": Counter(),
|
||||||
|
"hours": Counter(),
|
||||||
|
"avg_confidence": 0.0,
|
||||||
|
"avg_loss": 0.0,
|
||||||
|
"total_failures": len(failures),
|
||||||
|
}
|
||||||
|
|
||||||
|
total_confidence = 0
|
||||||
|
total_loss = 0.0
|
||||||
|
|
||||||
|
for failure in failures:
|
||||||
|
# Market distribution
|
||||||
|
patterns["markets"][failure.get("market", "UNKNOWN")] += 1
|
||||||
|
|
||||||
|
# Action distribution
|
||||||
|
patterns["actions"][failure.get("action", "UNKNOWN")] += 1
|
||||||
|
|
||||||
|
# Time pattern (extract hour from ISO timestamp)
|
||||||
|
timestamp = failure.get("timestamp", "")
|
||||||
|
if timestamp:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(timestamp)
|
||||||
|
patterns["hours"][dt.hour] += 1
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Aggregate metrics
|
||||||
|
total_confidence += failure.get("confidence", 0)
|
||||||
|
total_loss += failure.get("outcome_pnl", 0.0)
|
||||||
|
|
||||||
|
patterns["avg_confidence"] = (
|
||||||
|
round(total_confidence / len(failures), 2) if failures else 0.0
|
||||||
|
)
|
||||||
|
patterns["avg_loss"] = (
|
||||||
|
round(total_loss / len(failures), 2) if failures else 0.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert Counters to regular dicts for JSON serialization
|
||||||
|
patterns["markets"] = dict(patterns["markets"])
|
||||||
|
patterns["actions"] = dict(patterns["actions"])
|
||||||
|
patterns["hours"] = dict(patterns["hours"])
|
||||||
|
|
||||||
|
return patterns
|
||||||
|
|
||||||
def get_performance_summary(self) -> dict[str, Any]:
|
def get_performance_summary(self) -> dict[str, Any]:
|
||||||
"""Return aggregate performance metrics from trade logs."""
|
"""Return aggregate performance metrics from trade logs."""
|
||||||
@@ -109,14 +188,25 @@ class EvolutionOptimizer:
|
|||||||
async def generate_strategy(self, failures: list[dict[str, Any]]) -> Path | None:
|
async def generate_strategy(self, failures: list[dict[str, Any]]) -> Path | None:
|
||||||
"""Ask Gemini to generate a new strategy based on failure analysis.
|
"""Ask Gemini to generate a new strategy based on failure analysis.
|
||||||
|
|
||||||
|
Integrates failure patterns and market conditions to create improved strategies.
|
||||||
Returns the path to the generated strategy file, or None on failure.
|
Returns the path to the generated strategy file, or None on failure.
|
||||||
"""
|
"""
|
||||||
|
# Identify failure patterns first
|
||||||
|
patterns = self.identify_failure_patterns(failures)
|
||||||
|
|
||||||
prompt = (
|
prompt = (
|
||||||
"You are a quantitative trading strategy developer.\n"
|
"You are a quantitative trading strategy developer.\n"
|
||||||
"Analyze these failed trades and generate an improved strategy.\n\n"
|
"Analyze these failed trades and their patterns, then generate an improved strategy.\n\n"
|
||||||
f"Failed trades:\n{json.dumps(failures, indent=2, default=str)}\n\n"
|
f"Failure Patterns:\n{json.dumps(patterns, indent=2)}\n\n"
|
||||||
"Generate a Python class that inherits from BaseStrategy.\n"
|
f"Sample Failed Trades (first 5):\n"
|
||||||
"The class must have an `evaluate(self, market_data: dict) -> dict` method.\n"
|
f"{json.dumps(failures[:5], indent=2, default=str)}\n\n"
|
||||||
|
"Based on these patterns, generate an improved trading strategy.\n"
|
||||||
|
"The strategy should:\n"
|
||||||
|
"1. Avoid the identified failure patterns\n"
|
||||||
|
"2. Consider market-specific conditions\n"
|
||||||
|
"3. Adjust confidence based on historical performance\n\n"
|
||||||
|
"Generate a Python method body that inherits from BaseStrategy.\n"
|
||||||
|
"The method signature is: evaluate(self, market_data: dict) -> dict\n"
|
||||||
"The method must return a dict with keys: action, confidence, rationale.\n"
|
"The method must return a dict with keys: action, confidence, rationale.\n"
|
||||||
"Respond with ONLY the method body (Python code), no class definition.\n"
|
"Respond with ONLY the method body (Python code), no class definition.\n"
|
||||||
)
|
)
|
||||||
@@ -147,10 +237,15 @@ class EvolutionOptimizer:
|
|||||||
# Indent the body for the class method
|
# Indent the body for the class method
|
||||||
indented_body = textwrap.indent(body, " ")
|
indented_body = textwrap.indent(body, " ")
|
||||||
|
|
||||||
|
# Generate rationale from patterns
|
||||||
|
rationale = f"Auto-evolved from {len(failures)} failures. "
|
||||||
|
rationale += f"Primary failure markets: {list(patterns.get('markets', {}).keys())}. "
|
||||||
|
rationale += f"Average loss: {patterns.get('avg_loss', 0.0)}"
|
||||||
|
|
||||||
content = STRATEGY_TEMPLATE.format(
|
content = STRATEGY_TEMPLATE.format(
|
||||||
name=version,
|
name=version,
|
||||||
timestamp=datetime.now(UTC).isoformat(),
|
timestamp=datetime.now(UTC).isoformat(),
|
||||||
rationale="Auto-evolved from failure analysis",
|
rationale=rationale,
|
||||||
class_name=class_name,
|
class_name=class_name,
|
||||||
body=indented_body.strip(),
|
body=indented_body.strip(),
|
||||||
)
|
)
|
||||||
|
|||||||
303
src/evolution/performance_tracker.py
Normal file
303
src/evolution/performance_tracker.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
"""Performance tracking system for strategy monitoring.
|
||||||
|
|
||||||
|
Tracks win rates, monitors improvement over time,
|
||||||
|
and provides performance metrics dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StrategyMetrics:
|
||||||
|
"""Performance metrics for a strategy over a time period."""
|
||||||
|
|
||||||
|
strategy_name: str
|
||||||
|
period_start: str
|
||||||
|
period_end: str
|
||||||
|
total_trades: int
|
||||||
|
wins: int
|
||||||
|
losses: int
|
||||||
|
holds: int
|
||||||
|
win_rate: float
|
||||||
|
avg_pnl: float
|
||||||
|
total_pnl: float
|
||||||
|
best_trade: float
|
||||||
|
worst_trade: float
|
||||||
|
avg_confidence: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PerformanceDashboard:
|
||||||
|
"""Comprehensive performance dashboard."""
|
||||||
|
|
||||||
|
generated_at: str
|
||||||
|
overall_metrics: StrategyMetrics
|
||||||
|
daily_metrics: list[StrategyMetrics]
|
||||||
|
weekly_metrics: list[StrategyMetrics]
|
||||||
|
improvement_trend: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class PerformanceTracker:
|
||||||
|
"""Tracks and monitors strategy performance over time."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str) -> None:
|
||||||
|
"""Initialize performance tracker.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the trade logs database
|
||||||
|
"""
|
||||||
|
self._db_path = db_path
|
||||||
|
|
||||||
|
def get_strategy_metrics(
|
||||||
|
self,
|
||||||
|
strategy_name: str | None = None,
|
||||||
|
start_date: str | None = None,
|
||||||
|
end_date: str | None = None,
|
||||||
|
) -> StrategyMetrics:
|
||||||
|
"""Get performance metrics for a strategy over a time period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
strategy_name: Name of the strategy (None = all strategies)
|
||||||
|
start_date: Start date in ISO format (None = beginning of time)
|
||||||
|
end_date: End date in ISO format (None = now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
StrategyMetrics object with performance data
|
||||||
|
"""
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Build query with optional filters
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
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,
|
||||||
|
SUM(CASE WHEN action = 'HOLD' THEN 1 ELSE 0 END) as holds,
|
||||||
|
COALESCE(AVG(CASE WHEN pnl IS NOT NULL THEN pnl END), 0) as avg_pnl,
|
||||||
|
COALESCE(SUM(CASE WHEN pnl IS NOT NULL THEN pnl ELSE 0 END), 0) as total_pnl,
|
||||||
|
COALESCE(MAX(pnl), 0) as best_trade,
|
||||||
|
COALESCE(MIN(pnl), 0) as worst_trade,
|
||||||
|
COALESCE(AVG(confidence), 0) as avg_confidence,
|
||||||
|
MIN(timestamp) as period_start,
|
||||||
|
MAX(timestamp) as period_end
|
||||||
|
FROM trades
|
||||||
|
WHERE 1=1
|
||||||
|
"""
|
||||||
|
params: list[Any] = []
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
query += " AND timestamp >= ?"
|
||||||
|
params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
query += " AND timestamp <= ?"
|
||||||
|
params.append(end_date)
|
||||||
|
|
||||||
|
# Note: Currently trades table doesn't have strategy_name column
|
||||||
|
# This is a placeholder for future extension
|
||||||
|
|
||||||
|
row = conn.execute(query, params).fetchone()
|
||||||
|
|
||||||
|
total_trades = row["total_trades"] or 0
|
||||||
|
wins = row["wins"] or 0
|
||||||
|
win_rate = (wins / total_trades * 100) if total_trades > 0 else 0.0
|
||||||
|
|
||||||
|
return StrategyMetrics(
|
||||||
|
strategy_name=strategy_name or "default",
|
||||||
|
period_start=row["period_start"] or "",
|
||||||
|
period_end=row["period_end"] or "",
|
||||||
|
total_trades=total_trades,
|
||||||
|
wins=wins,
|
||||||
|
losses=row["losses"] or 0,
|
||||||
|
holds=row["holds"] or 0,
|
||||||
|
win_rate=round(win_rate, 2),
|
||||||
|
avg_pnl=round(row["avg_pnl"], 2),
|
||||||
|
total_pnl=round(row["total_pnl"], 2),
|
||||||
|
best_trade=round(row["best_trade"], 2),
|
||||||
|
worst_trade=round(row["worst_trade"], 2),
|
||||||
|
avg_confidence=round(row["avg_confidence"], 2),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_daily_metrics(
|
||||||
|
self, days: int = 7, strategy_name: str | None = None
|
||||||
|
) -> list[StrategyMetrics]:
|
||||||
|
"""Get daily performance metrics for the last N days.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
days: Number of days to retrieve (default 7)
|
||||||
|
strategy_name: Name of the strategy (None = all strategies)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of StrategyMetrics, one per day
|
||||||
|
"""
|
||||||
|
metrics = []
|
||||||
|
end_date = datetime.now(UTC)
|
||||||
|
|
||||||
|
for i in range(days):
|
||||||
|
day_end = end_date - timedelta(days=i)
|
||||||
|
day_start = day_end - timedelta(days=1)
|
||||||
|
|
||||||
|
day_metrics = self.get_strategy_metrics(
|
||||||
|
strategy_name=strategy_name,
|
||||||
|
start_date=day_start.isoformat(),
|
||||||
|
end_date=day_end.isoformat(),
|
||||||
|
)
|
||||||
|
metrics.append(day_metrics)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
def get_weekly_metrics(
|
||||||
|
self, weeks: int = 4, strategy_name: str | None = None
|
||||||
|
) -> list[StrategyMetrics]:
|
||||||
|
"""Get weekly performance metrics for the last N weeks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weeks: Number of weeks to retrieve (default 4)
|
||||||
|
strategy_name: Name of the strategy (None = all strategies)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of StrategyMetrics, one per week
|
||||||
|
"""
|
||||||
|
metrics = []
|
||||||
|
end_date = datetime.now(UTC)
|
||||||
|
|
||||||
|
for i in range(weeks):
|
||||||
|
week_end = end_date - timedelta(weeks=i)
|
||||||
|
week_start = week_end - timedelta(weeks=1)
|
||||||
|
|
||||||
|
week_metrics = self.get_strategy_metrics(
|
||||||
|
strategy_name=strategy_name,
|
||||||
|
start_date=week_start.isoformat(),
|
||||||
|
end_date=week_end.isoformat(),
|
||||||
|
)
|
||||||
|
metrics.append(week_metrics)
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
def calculate_improvement_trend(
|
||||||
|
self, metrics_history: list[StrategyMetrics]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Calculate improvement trend from historical metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics_history: List of StrategyMetrics ordered from oldest to newest
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with trend analysis
|
||||||
|
"""
|
||||||
|
if len(metrics_history) < 2:
|
||||||
|
return {
|
||||||
|
"trend": "insufficient_data",
|
||||||
|
"win_rate_change": 0.0,
|
||||||
|
"pnl_change": 0.0,
|
||||||
|
"confidence_change": 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest = metrics_history[0]
|
||||||
|
newest = metrics_history[-1]
|
||||||
|
|
||||||
|
win_rate_change = newest.win_rate - oldest.win_rate
|
||||||
|
pnl_change = newest.avg_pnl - oldest.avg_pnl
|
||||||
|
confidence_change = newest.avg_confidence - oldest.avg_confidence
|
||||||
|
|
||||||
|
# Determine overall trend
|
||||||
|
if win_rate_change > 5.0 and pnl_change > 0:
|
||||||
|
trend = "improving"
|
||||||
|
elif win_rate_change < -5.0 or pnl_change < 0:
|
||||||
|
trend = "declining"
|
||||||
|
else:
|
||||||
|
trend = "stable"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"trend": trend,
|
||||||
|
"win_rate_change": round(win_rate_change, 2),
|
||||||
|
"pnl_change": round(pnl_change, 2),
|
||||||
|
"confidence_change": round(confidence_change, 2),
|
||||||
|
"period_count": len(metrics_history),
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_dashboard(
|
||||||
|
self, strategy_name: str | None = None
|
||||||
|
) -> PerformanceDashboard:
|
||||||
|
"""Generate a comprehensive performance dashboard.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
strategy_name: Name of the strategy (None = all strategies)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PerformanceDashboard with all metrics
|
||||||
|
"""
|
||||||
|
# Get overall metrics
|
||||||
|
overall_metrics = self.get_strategy_metrics(strategy_name=strategy_name)
|
||||||
|
|
||||||
|
# Get daily metrics (last 7 days)
|
||||||
|
daily_metrics = self.get_daily_metrics(days=7, strategy_name=strategy_name)
|
||||||
|
|
||||||
|
# Get weekly metrics (last 4 weeks)
|
||||||
|
weekly_metrics = self.get_weekly_metrics(weeks=4, strategy_name=strategy_name)
|
||||||
|
|
||||||
|
# Calculate improvement trend
|
||||||
|
improvement_trend = self.calculate_improvement_trend(weekly_metrics[::-1])
|
||||||
|
|
||||||
|
return PerformanceDashboard(
|
||||||
|
generated_at=datetime.now(UTC).isoformat(),
|
||||||
|
overall_metrics=overall_metrics,
|
||||||
|
daily_metrics=daily_metrics,
|
||||||
|
weekly_metrics=weekly_metrics,
|
||||||
|
improvement_trend=improvement_trend,
|
||||||
|
)
|
||||||
|
|
||||||
|
def export_dashboard_json(
|
||||||
|
self, dashboard: PerformanceDashboard
|
||||||
|
) -> str:
|
||||||
|
"""Export dashboard as JSON string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dashboard: PerformanceDashboard object
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON string representation
|
||||||
|
"""
|
||||||
|
data = {
|
||||||
|
"generated_at": dashboard.generated_at,
|
||||||
|
"overall_metrics": asdict(dashboard.overall_metrics),
|
||||||
|
"daily_metrics": [asdict(m) for m in dashboard.daily_metrics],
|
||||||
|
"weekly_metrics": [asdict(m) for m in dashboard.weekly_metrics],
|
||||||
|
"improvement_trend": dashboard.improvement_trend,
|
||||||
|
}
|
||||||
|
return json.dumps(data, indent=2)
|
||||||
|
|
||||||
|
def log_dashboard(self, dashboard: PerformanceDashboard) -> None:
|
||||||
|
"""Log dashboard summary to logger.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dashboard: PerformanceDashboard object
|
||||||
|
"""
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("PERFORMANCE DASHBOARD")
|
||||||
|
logger.info("=" * 60)
|
||||||
|
logger.info("Generated: %s", dashboard.generated_at)
|
||||||
|
logger.info("")
|
||||||
|
logger.info("Overall Performance:")
|
||||||
|
logger.info(" Total Trades: %d", dashboard.overall_metrics.total_trades)
|
||||||
|
logger.info(" Win Rate: %.2f%%", dashboard.overall_metrics.win_rate)
|
||||||
|
logger.info(" Average P&L: %.2f", dashboard.overall_metrics.avg_pnl)
|
||||||
|
logger.info(" Total P&L: %.2f", dashboard.overall_metrics.total_pnl)
|
||||||
|
logger.info("")
|
||||||
|
logger.info("Improvement Trend (%s):", dashboard.improvement_trend["trend"])
|
||||||
|
logger.info(" Win Rate Change: %+.2f%%", dashboard.improvement_trend["win_rate_change"])
|
||||||
|
logger.info(" P&L Change: %+.2f", dashboard.improvement_trend["pnl_change"])
|
||||||
|
logger.info("=" * 60)
|
||||||
5
src/logging/__init__.py
Normal file
5
src/logging/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Decision logging and audit trail for trade decisions."""
|
||||||
|
|
||||||
|
from src.logging.decision_logger import DecisionLog, DecisionLogger
|
||||||
|
|
||||||
|
__all__ = ["DecisionLog", "DecisionLogger"]
|
||||||
235
src/logging/decision_logger.py
Normal file
235
src/logging/decision_logger.py
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
"""Decision logging system with context snapshots for comprehensive audit trail."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecisionLog:
|
||||||
|
"""A logged trading decision with context and outcome."""
|
||||||
|
|
||||||
|
decision_id: str
|
||||||
|
timestamp: str
|
||||||
|
stock_code: str
|
||||||
|
market: str
|
||||||
|
exchange_code: str
|
||||||
|
action: str
|
||||||
|
confidence: int
|
||||||
|
rationale: str
|
||||||
|
context_snapshot: dict[str, Any]
|
||||||
|
input_data: dict[str, Any]
|
||||||
|
outcome_pnl: float | None = None
|
||||||
|
outcome_accuracy: int | None = None
|
||||||
|
reviewed: bool = False
|
||||||
|
review_notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionLogger:
|
||||||
|
"""Logs trading decisions with full context for review and evolution."""
|
||||||
|
|
||||||
|
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||||
|
"""Initialize the decision logger with a database connection."""
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
def log_decision(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
market: str,
|
||||||
|
exchange_code: str,
|
||||||
|
action: str,
|
||||||
|
confidence: int,
|
||||||
|
rationale: str,
|
||||||
|
context_snapshot: dict[str, Any],
|
||||||
|
input_data: dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
"""Log a trading decision with full context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock symbol
|
||||||
|
market: Market code (e.g., "KR", "US_NASDAQ")
|
||||||
|
exchange_code: Exchange code (e.g., "KRX", "NASDAQ")
|
||||||
|
action: Trading action (BUY/SELL/HOLD)
|
||||||
|
confidence: Confidence level (0-100)
|
||||||
|
rationale: Reasoning for the decision
|
||||||
|
context_snapshot: L1-L7 context snapshot at decision time
|
||||||
|
input_data: Market data inputs (price, volume, orderbook, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
decision_id: Unique identifier for this decision
|
||||||
|
"""
|
||||||
|
decision_id = str(uuid.uuid4())
|
||||||
|
timestamp = datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO decision_logs (
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
decision_id,
|
||||||
|
timestamp,
|
||||||
|
stock_code,
|
||||||
|
market,
|
||||||
|
exchange_code,
|
||||||
|
action,
|
||||||
|
confidence,
|
||||||
|
rationale,
|
||||||
|
json.dumps(context_snapshot),
|
||||||
|
json.dumps(input_data),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
return decision_id
|
||||||
|
|
||||||
|
def get_unreviewed_decisions(
|
||||||
|
self, min_confidence: int = 80, limit: int | None = None
|
||||||
|
) -> list[DecisionLog]:
|
||||||
|
"""Get unreviewed decisions with high confidence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_confidence: Minimum confidence threshold (default 80)
|
||||||
|
limit: Maximum number of results (None = unlimited)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unreviewed DecisionLog objects
|
||||||
|
"""
|
||||||
|
query = """
|
||||||
|
SELECT
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data,
|
||||||
|
outcome_pnl, outcome_accuracy, reviewed, review_notes
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE reviewed = 0 AND confidence >= ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
"""
|
||||||
|
if limit is not None:
|
||||||
|
query += f" LIMIT {limit}"
|
||||||
|
|
||||||
|
cursor = self.conn.execute(query, (min_confidence,))
|
||||||
|
return [self._row_to_decision_log(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def mark_reviewed(self, decision_id: str, notes: str) -> None:
|
||||||
|
"""Mark a decision as reviewed with notes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: Decision identifier
|
||||||
|
notes: Review notes and insights
|
||||||
|
"""
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE decision_logs
|
||||||
|
SET reviewed = 1, review_notes = ?
|
||||||
|
WHERE decision_id = ?
|
||||||
|
""",
|
||||||
|
(notes, decision_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def update_outcome(
|
||||||
|
self, decision_id: str, pnl: float, accuracy: int
|
||||||
|
) -> None:
|
||||||
|
"""Update the outcome of a decision after trade execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: Decision identifier
|
||||||
|
pnl: Actual profit/loss realized
|
||||||
|
accuracy: 1 if decision was correct, 0 if wrong
|
||||||
|
"""
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE decision_logs
|
||||||
|
SET outcome_pnl = ?, outcome_accuracy = ?
|
||||||
|
WHERE decision_id = ?
|
||||||
|
""",
|
||||||
|
(pnl, accuracy, decision_id),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def get_decision_by_id(self, decision_id: str) -> DecisionLog | None:
|
||||||
|
"""Get a specific decision by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decision_id: Decision identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecisionLog object or None if not found
|
||||||
|
"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data,
|
||||||
|
outcome_pnl, outcome_accuracy, reviewed, review_notes
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE decision_id = ?
|
||||||
|
""",
|
||||||
|
(decision_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return self._row_to_decision_log(row) if row else None
|
||||||
|
|
||||||
|
def get_losing_decisions(
|
||||||
|
self, min_confidence: int = 80, min_loss: float = -100.0
|
||||||
|
) -> list[DecisionLog]:
|
||||||
|
"""Get high-confidence decisions that resulted in losses.
|
||||||
|
|
||||||
|
Useful for identifying patterns in failed predictions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
min_confidence: Minimum confidence threshold (default 80)
|
||||||
|
min_loss: Minimum loss amount (default -100.0, i.e., loss >= 100)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of losing DecisionLog objects
|
||||||
|
"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
decision_id, timestamp, stock_code, market, exchange_code,
|
||||||
|
action, confidence, rationale, context_snapshot, input_data,
|
||||||
|
outcome_pnl, outcome_accuracy, reviewed, review_notes
|
||||||
|
FROM decision_logs
|
||||||
|
WHERE confidence >= ?
|
||||||
|
AND outcome_pnl IS NOT NULL
|
||||||
|
AND outcome_pnl <= ?
|
||||||
|
ORDER BY outcome_pnl ASC
|
||||||
|
""",
|
||||||
|
(min_confidence, min_loss),
|
||||||
|
)
|
||||||
|
return [self._row_to_decision_log(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
def _row_to_decision_log(self, row: tuple[Any, ...]) -> DecisionLog:
|
||||||
|
"""Convert a database row to a DecisionLog object.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
row: Database row tuple
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DecisionLog object
|
||||||
|
"""
|
||||||
|
return DecisionLog(
|
||||||
|
decision_id=row[0],
|
||||||
|
timestamp=row[1],
|
||||||
|
stock_code=row[2],
|
||||||
|
market=row[3],
|
||||||
|
exchange_code=row[4],
|
||||||
|
action=row[5],
|
||||||
|
confidence=row[6],
|
||||||
|
rationale=row[7],
|
||||||
|
context_snapshot=json.loads(row[8]),
|
||||||
|
input_data=json.loads(row[9]),
|
||||||
|
outcome_pnl=row[10],
|
||||||
|
outcome_accuracy=row[11],
|
||||||
|
reviewed=bool(row[12]),
|
||||||
|
review_notes=row[13],
|
||||||
|
)
|
||||||
1146
src/main.py
1146
src/main.py
File diff suppressed because it is too large
Load Diff
350
src/notifications/README.md
Normal file
350
src/notifications/README.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Telegram Notifications
|
||||||
|
|
||||||
|
Real-time trading event notifications via Telegram Bot API.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### 1. Create a Telegram Bot
|
||||||
|
|
||||||
|
1. Open Telegram and message [@BotFather](https://t.me/BotFather)
|
||||||
|
2. Send `/newbot` command
|
||||||
|
3. Follow prompts to name your bot
|
||||||
|
4. Save the **bot token** (looks like `1234567890:ABCdefGHIjklMNOpqrsTUVwxyz`)
|
||||||
|
|
||||||
|
### 2. Get Your Chat ID
|
||||||
|
|
||||||
|
**Option A: Using @userinfobot**
|
||||||
|
1. Message [@userinfobot](https://t.me/userinfobot) on Telegram
|
||||||
|
2. Send `/start`
|
||||||
|
3. Save your numeric **chat ID** (e.g., `123456789`)
|
||||||
|
|
||||||
|
**Option B: Using @RawDataBot**
|
||||||
|
1. Message [@RawDataBot](https://t.me/rawdatabot) on Telegram
|
||||||
|
2. Look for `"id":` in the JSON response
|
||||||
|
3. Save your numeric **chat ID**
|
||||||
|
|
||||||
|
### 3. Configure Environment
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
TELEGRAM_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Test the Bot
|
||||||
|
|
||||||
|
Start a conversation with your bot on Telegram first (send `/start`), then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m src.main --mode=paper
|
||||||
|
```
|
||||||
|
|
||||||
|
You should receive a startup notification.
|
||||||
|
|
||||||
|
## Message Examples
|
||||||
|
|
||||||
|
### Trade Execution
|
||||||
|
```
|
||||||
|
🟢 BUY
|
||||||
|
Symbol: AAPL (United States)
|
||||||
|
Quantity: 10 shares
|
||||||
|
Price: 150.25
|
||||||
|
Confidence: 85%
|
||||||
|
```
|
||||||
|
|
||||||
|
### Circuit Breaker
|
||||||
|
```
|
||||||
|
🚨 CIRCUIT BREAKER TRIPPED
|
||||||
|
P&L: -3.15% (threshold: -3.0%)
|
||||||
|
Trading halted for safety
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fat-Finger Protection
|
||||||
|
```
|
||||||
|
⚠️ Fat-Finger Protection
|
||||||
|
Order rejected: TSLA
|
||||||
|
Attempted: 45.0% of cash
|
||||||
|
Max allowed: 30%
|
||||||
|
Amount: 45,000 / 100,000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Market Open/Close
|
||||||
|
```
|
||||||
|
ℹ️ Market Open
|
||||||
|
Korea trading session started
|
||||||
|
|
||||||
|
ℹ️ Market Close
|
||||||
|
Korea trading session ended
|
||||||
|
📈 P&L: +1.25%
|
||||||
|
```
|
||||||
|
|
||||||
|
### System Status
|
||||||
|
```
|
||||||
|
📝 System Started
|
||||||
|
Mode: PAPER
|
||||||
|
Markets: KRX, NASDAQ
|
||||||
|
|
||||||
|
System Shutdown
|
||||||
|
Normal shutdown
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notification Priorities
|
||||||
|
|
||||||
|
| Priority | Emoji | Use Case |
|
||||||
|
|----------|-------|----------|
|
||||||
|
| LOW | ℹ️ | Market open/close |
|
||||||
|
| MEDIUM | 📊 | Trade execution, system start/stop |
|
||||||
|
| HIGH | ⚠️ | Fat-finger protection, errors |
|
||||||
|
| CRITICAL | 🚨 | Circuit breaker trips |
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
- Default: 1 message per second
|
||||||
|
- Prevents hitting Telegram's global rate limits
|
||||||
|
- Configurable via `rate_limit` parameter
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No notifications received
|
||||||
|
|
||||||
|
1. **Check bot configuration**
|
||||||
|
```bash
|
||||||
|
# Verify env variables are set
|
||||||
|
grep TELEGRAM .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Start conversation with bot**
|
||||||
|
- Open bot in Telegram
|
||||||
|
- Send `/start` command
|
||||||
|
- Bot cannot message users who haven't started a conversation
|
||||||
|
|
||||||
|
3. **Check logs**
|
||||||
|
```bash
|
||||||
|
# Look for Telegram-related errors
|
||||||
|
python -m src.main --mode=paper 2>&1 | grep -i telegram
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify bot token**
|
||||||
|
```bash
|
||||||
|
curl https://api.telegram.org/bot<YOUR_TOKEN>/getMe
|
||||||
|
# Should return bot info (not 401 error)
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Verify chat ID**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.telegram.org/bot<YOUR_TOKEN>/sendMessage \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"chat_id": "<YOUR_CHAT_ID>", "text": "Test"}'
|
||||||
|
# Should send a test message
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notifications delayed
|
||||||
|
|
||||||
|
- Check rate limiter settings
|
||||||
|
- Verify network connection
|
||||||
|
- Look for timeout errors in logs
|
||||||
|
|
||||||
|
### "Chat not found" error
|
||||||
|
|
||||||
|
- Incorrect chat ID
|
||||||
|
- Bot blocked by user
|
||||||
|
- Need to send `/start` to bot first
|
||||||
|
|
||||||
|
### "Unauthorized" error
|
||||||
|
|
||||||
|
- Invalid bot token
|
||||||
|
- Token revoked (regenerate with @BotFather)
|
||||||
|
|
||||||
|
## Graceful Degradation
|
||||||
|
|
||||||
|
The system works without Telegram notifications:
|
||||||
|
|
||||||
|
- Missing credentials → notifications disabled automatically
|
||||||
|
- API errors → logged but trading continues
|
||||||
|
- Network timeouts → trading loop unaffected
|
||||||
|
- Rate limiting → messages queued, trading proceeds
|
||||||
|
|
||||||
|
**Notifications never crash the trading system.**
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit `.env` file with credentials
|
||||||
|
- Bot token grants full bot control
|
||||||
|
- Chat ID is not sensitive (just a number)
|
||||||
|
- Messages are sent over HTTPS
|
||||||
|
- No trading credentials in notifications
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Group Notifications
|
||||||
|
|
||||||
|
1. Add bot to Telegram group
|
||||||
|
2. Get group chat ID (negative number like `-123456789`)
|
||||||
|
3. Use group chat ID in `TELEGRAM_CHAT_ID`
|
||||||
|
|
||||||
|
### Multiple Recipients
|
||||||
|
|
||||||
|
Create multiple bots or use a broadcast group with multiple members.
|
||||||
|
|
||||||
|
### Custom Rate Limits
|
||||||
|
|
||||||
|
Not currently exposed in config, but can be modified in code:
|
||||||
|
|
||||||
|
```python
|
||||||
|
telegram = TelegramClient(
|
||||||
|
bot_token=settings.TELEGRAM_BOT_TOKEN,
|
||||||
|
chat_id=settings.TELEGRAM_CHAT_ID,
|
||||||
|
rate_limit=2.0, # 2 messages per second
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Bidirectional Commands
|
||||||
|
|
||||||
|
Control your trading bot remotely via Telegram commands. The bot not only sends notifications but also accepts commands for real-time control.
|
||||||
|
|
||||||
|
### Available Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `/start` | Welcome message with quick start guide |
|
||||||
|
| `/help` | List all available commands |
|
||||||
|
| `/status` | Current trading status (mode, markets, P&L, circuit breaker) |
|
||||||
|
| `/positions` | View current holdings grouped by market |
|
||||||
|
| `/stop` | Pause all trading operations |
|
||||||
|
| `/resume` | Resume trading operations |
|
||||||
|
|
||||||
|
### Command Examples
|
||||||
|
|
||||||
|
**Check Trading Status**
|
||||||
|
```
|
||||||
|
You: /status
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
📊 Trading Status
|
||||||
|
|
||||||
|
Mode: PAPER
|
||||||
|
Markets: Korea, United States
|
||||||
|
Trading: Active
|
||||||
|
|
||||||
|
Current P&L: +2.50%
|
||||||
|
Circuit Breaker: -3.0%
|
||||||
|
```
|
||||||
|
|
||||||
|
**View Holdings**
|
||||||
|
```
|
||||||
|
You: /positions
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
💼 Current Holdings
|
||||||
|
|
||||||
|
🇰🇷 Korea
|
||||||
|
• 005930: 10 shares @ 70,000
|
||||||
|
• 035420: 5 shares @ 200,000
|
||||||
|
|
||||||
|
🇺🇸 Overseas
|
||||||
|
• AAPL: 15 shares @ 175
|
||||||
|
• TSLA: 8 shares @ 245
|
||||||
|
|
||||||
|
Cash: ₩5,000,000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pause Trading**
|
||||||
|
```
|
||||||
|
You: /stop
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
⏸️ Trading Paused
|
||||||
|
|
||||||
|
All trading operations have been suspended.
|
||||||
|
Use /resume to restart trading.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Resume Trading**
|
||||||
|
```
|
||||||
|
You: /resume
|
||||||
|
|
||||||
|
Bot:
|
||||||
|
▶️ Trading Resumed
|
||||||
|
|
||||||
|
Trading operations have been restarted.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**Chat ID Verification**
|
||||||
|
- Commands are only accepted from the configured `TELEGRAM_CHAT_ID`
|
||||||
|
- Unauthorized users receive no response
|
||||||
|
- Command attempts from wrong chat IDs are logged
|
||||||
|
|
||||||
|
**Authorization Required**
|
||||||
|
- Only the bot owner (chat ID in `.env`) can control trading
|
||||||
|
- No way for unauthorized users to discover or use commands
|
||||||
|
- All command executions are logged for audit
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Add to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Commands are enabled by default
|
||||||
|
TELEGRAM_COMMANDS_ENABLED=true
|
||||||
|
|
||||||
|
# Polling interval (seconds) - how often to check for commands
|
||||||
|
TELEGRAM_POLLING_INTERVAL=1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
To disable commands but keep notifications:
|
||||||
|
```bash
|
||||||
|
TELEGRAM_COMMANDS_ENABLED=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Long Polling**: Bot checks Telegram API every second for new messages
|
||||||
|
2. **Command Parsing**: Messages starting with `/` are parsed as commands
|
||||||
|
3. **Authentication**: Chat ID is verified before executing any command
|
||||||
|
4. **Execution**: Command handler is called with current bot state
|
||||||
|
5. **Response**: Result is sent back via Telegram
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- Command parsing errors → "Unknown command" response
|
||||||
|
- API failures → Graceful degradation, error logged
|
||||||
|
- Invalid state → Appropriate message (e.g., "Trading is already paused")
|
||||||
|
- Trading loop isolation → Command errors never crash trading
|
||||||
|
|
||||||
|
### Troubleshooting Commands
|
||||||
|
|
||||||
|
**Commands not responding**
|
||||||
|
1. Check `TELEGRAM_COMMANDS_ENABLED=true` in `.env`
|
||||||
|
2. Verify you started conversation with `/start`
|
||||||
|
3. Check logs for command handler errors
|
||||||
|
4. Confirm chat ID matches `.env` configuration
|
||||||
|
|
||||||
|
**Wrong chat ID**
|
||||||
|
- Commands from unauthorized chats are silently ignored
|
||||||
|
- Check logs for "unauthorized chat_id" warnings
|
||||||
|
|
||||||
|
**Delayed responses**
|
||||||
|
- Polling interval is 1 second by default
|
||||||
|
- Network latency may add delay
|
||||||
|
- Check `TELEGRAM_POLLING_INTERVAL` setting
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
See `telegram_client.py` for full API documentation.
|
||||||
|
|
||||||
|
### Notification Methods
|
||||||
|
- `notify_trade_execution()` - Trade alerts
|
||||||
|
- `notify_circuit_breaker()` - Emergency stops
|
||||||
|
- `notify_fat_finger()` - Order rejections
|
||||||
|
- `notify_market_open/close()` - Session tracking
|
||||||
|
- `notify_system_start/shutdown()` - Lifecycle events
|
||||||
|
- `notify_error()` - Error alerts
|
||||||
|
|
||||||
|
### Command Handler
|
||||||
|
- `TelegramCommandHandler` - Bidirectional command processing
|
||||||
|
- `register_command()` - Register custom command handlers
|
||||||
|
- `start_polling()` / `stop_polling()` - Lifecycle management
|
||||||
5
src/notifications/__init__.py
Normal file
5
src/notifications/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Real-time notifications for trading events."""
|
||||||
|
|
||||||
|
from src.notifications.telegram_client import TelegramClient
|
||||||
|
|
||||||
|
__all__ = ["TelegramClient"]
|
||||||
582
src/notifications/telegram_client.py
Normal file
582
src/notifications/telegram_client.py
Normal file
@@ -0,0 +1,582 @@
|
|||||||
|
"""Telegram notification client for real-time trading alerts."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationPriority(Enum):
|
||||||
|
"""Priority levels for notifications with emoji indicators."""
|
||||||
|
|
||||||
|
LOW = ("ℹ️", "info")
|
||||||
|
MEDIUM = ("📊", "medium")
|
||||||
|
HIGH = ("⚠️", "warning")
|
||||||
|
CRITICAL = ("🚨", "critical")
|
||||||
|
|
||||||
|
def __init__(self, emoji: str, label: str) -> None:
|
||||||
|
self.emoji = emoji
|
||||||
|
self.label = label
|
||||||
|
|
||||||
|
|
||||||
|
class LeakyBucket:
|
||||||
|
"""Rate limiter using leaky bucket algorithm."""
|
||||||
|
|
||||||
|
def __init__(self, rate: float, capacity: int = 1) -> None:
|
||||||
|
"""
|
||||||
|
Initialize rate limiter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rate: Maximum requests per second
|
||||||
|
capacity: Bucket capacity (burst size)
|
||||||
|
"""
|
||||||
|
self._rate = rate
|
||||||
|
self._capacity = capacity
|
||||||
|
self._tokens = float(capacity)
|
||||||
|
self._last_update = time.monotonic()
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def acquire(self) -> None:
|
||||||
|
"""Wait until a token is available, then consume it."""
|
||||||
|
async with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self._last_update
|
||||||
|
self._tokens = min(self._capacity, self._tokens + elapsed * self._rate)
|
||||||
|
self._last_update = now
|
||||||
|
|
||||||
|
if self._tokens < 1.0:
|
||||||
|
wait_time = (1.0 - self._tokens) / self._rate
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
self._tokens = 0.0
|
||||||
|
else:
|
||||||
|
self._tokens -= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NotificationMessage:
|
||||||
|
"""Internal notification message structure."""
|
||||||
|
|
||||||
|
priority: NotificationPriority
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramClient:
|
||||||
|
"""Telegram Bot API client for sending trading notifications."""
|
||||||
|
|
||||||
|
API_BASE = "https://api.telegram.org/bot{token}"
|
||||||
|
DEFAULT_TIMEOUT = 5.0 # seconds
|
||||||
|
DEFAULT_RATE = 1.0 # messages per second
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot_token: str | None = None,
|
||||||
|
chat_id: str | None = None,
|
||||||
|
enabled: bool = True,
|
||||||
|
rate_limit: float = DEFAULT_RATE,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize Telegram client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_token: Telegram bot token from @BotFather
|
||||||
|
chat_id: Target chat ID (user or group)
|
||||||
|
enabled: Enable/disable notifications globally
|
||||||
|
rate_limit: Maximum messages per second
|
||||||
|
"""
|
||||||
|
self._bot_token = bot_token
|
||||||
|
self._chat_id = chat_id
|
||||||
|
self._enabled = enabled
|
||||||
|
self._rate_limiter = LeakyBucket(rate=rate_limit)
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
logger.info("Telegram notifications disabled via configuration")
|
||||||
|
elif bot_token is None or chat_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"Telegram notifications disabled (missing bot_token or chat_id)"
|
||||||
|
)
|
||||||
|
self._enabled = False
|
||||||
|
else:
|
||||||
|
logger.info("Telegram notifications enabled for chat_id=%s", chat_id)
|
||||||
|
|
||||||
|
def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
"""Get or create aiohttp session."""
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
timeout=aiohttp.ClientTimeout(total=self.DEFAULT_TIMEOUT)
|
||||||
|
)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close HTTP session."""
|
||||||
|
if self._session is not None and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
||||||
|
"""
|
||||||
|
Send a generic text message to Telegram.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Message text to send
|
||||||
|
parse_mode: Parse mode for formatting (HTML or Markdown)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if message was sent successfully, False otherwise
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._rate_limiter.acquire()
|
||||||
|
|
||||||
|
url = f"{self.API_BASE.format(token=self._bot_token)}/sendMessage"
|
||||||
|
payload = {
|
||||||
|
"chat_id": self._chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
session = self._get_session()
|
||||||
|
async with session.post(url, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
logger.error(
|
||||||
|
"Telegram API error (status=%d): %s", resp.status, error_text
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
logger.debug("Telegram message sent: %s", text[:50])
|
||||||
|
return True
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("Telegram message timeout")
|
||||||
|
return False
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
logger.error("Telegram message failed: %s", exc)
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error sending message: %s", exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _send_notification(self, msg: NotificationMessage) -> None:
|
||||||
|
"""
|
||||||
|
Send notification to Telegram with graceful degradation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: Notification message to send
|
||||||
|
"""
|
||||||
|
formatted_message = f"{msg.priority.emoji} {msg.message}"
|
||||||
|
await self.send_message(formatted_message)
|
||||||
|
|
||||||
|
async def notify_trade_execution(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
market: str,
|
||||||
|
action: str,
|
||||||
|
quantity: int,
|
||||||
|
price: float,
|
||||||
|
confidence: float,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify trade execution.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol
|
||||||
|
market: Market name (e.g., "Korea", "United States")
|
||||||
|
action: "BUY" or "SELL"
|
||||||
|
quantity: Number of shares
|
||||||
|
price: Execution price
|
||||||
|
confidence: AI confidence level (0-100)
|
||||||
|
"""
|
||||||
|
emoji = "🟢" if action == "BUY" else "🔴"
|
||||||
|
message = (
|
||||||
|
f"<b>{emoji} {action}</b>\n"
|
||||||
|
f"Symbol: <code>{stock_code}</code> ({market})\n"
|
||||||
|
f"Quantity: {quantity:,} shares\n"
|
||||||
|
f"Price: {price:,.2f}\n"
|
||||||
|
f"Confidence: {confidence:.0f}%"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_market_open(self, market_name: str) -> None:
|
||||||
|
"""
|
||||||
|
Notify market opening.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_name: Name of the market (e.g., "Korea", "United States")
|
||||||
|
"""
|
||||||
|
message = f"<b>Market Open</b>\n{market_name} trading session started"
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.LOW, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_market_close(self, market_name: str, pnl_pct: float) -> None:
|
||||||
|
"""
|
||||||
|
Notify market closing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market_name: Name of the market
|
||||||
|
pnl_pct: Final P&L percentage for the session
|
||||||
|
"""
|
||||||
|
pnl_sign = "+" if pnl_pct >= 0 else ""
|
||||||
|
pnl_emoji = "📈" if pnl_pct >= 0 else "📉"
|
||||||
|
message = (
|
||||||
|
f"<b>Market Close</b>\n"
|
||||||
|
f"{market_name} trading session ended\n"
|
||||||
|
f"{pnl_emoji} P&L: {pnl_sign}{pnl_pct:.2f}%"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.LOW, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_circuit_breaker(
|
||||||
|
self, pnl_pct: float, threshold: float
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify circuit breaker activation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pnl_pct: Current P&L percentage
|
||||||
|
threshold: Circuit breaker threshold
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
f"<b>CIRCUIT BREAKER TRIPPED</b>\n"
|
||||||
|
f"P&L: {pnl_pct:.2f}% (threshold: {threshold:.1f}%)\n"
|
||||||
|
f"Trading halted for safety"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.CRITICAL, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_fat_finger(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
order_amount: float,
|
||||||
|
total_cash: float,
|
||||||
|
max_pct: float,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify fat-finger protection rejection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol
|
||||||
|
order_amount: Attempted order amount
|
||||||
|
total_cash: Total available cash
|
||||||
|
max_pct: Maximum allowed percentage
|
||||||
|
"""
|
||||||
|
attempted_pct = (order_amount / total_cash) * 100 if total_cash > 0 else 0
|
||||||
|
message = (
|
||||||
|
f"<b>Fat-Finger Protection</b>\n"
|
||||||
|
f"Order rejected: <code>{stock_code}</code>\n"
|
||||||
|
f"Attempted: {attempted_pct:.1f}% of cash\n"
|
||||||
|
f"Max allowed: {max_pct:.0f}%\n"
|
||||||
|
f"Amount: {order_amount:,.0f} / {total_cash:,.0f}"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_system_start(
|
||||||
|
self, mode: str, enabled_markets: list[str]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify system startup.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Trading mode ("paper" or "live")
|
||||||
|
enabled_markets: List of enabled market codes
|
||||||
|
"""
|
||||||
|
mode_emoji = "📝" if mode == "paper" else "💰"
|
||||||
|
markets_str = ", ".join(enabled_markets)
|
||||||
|
message = (
|
||||||
|
f"<b>{mode_emoji} System Started</b>\n"
|
||||||
|
f"Mode: {mode.upper()}\n"
|
||||||
|
f"Markets: {markets_str}"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_playbook_generated(
|
||||||
|
self,
|
||||||
|
market: str,
|
||||||
|
stock_count: int,
|
||||||
|
scenario_count: int,
|
||||||
|
token_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify that a daily playbook was generated.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code (e.g., "KR", "US")
|
||||||
|
stock_count: Number of stocks in the playbook
|
||||||
|
scenario_count: Total number of scenarios
|
||||||
|
token_count: Gemini token usage for the playbook
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
f"<b>Playbook Generated</b>\n"
|
||||||
|
f"Market: {market}\n"
|
||||||
|
f"Stocks: {stock_count}\n"
|
||||||
|
f"Scenarios: {scenario_count}\n"
|
||||||
|
f"Tokens: {token_count}"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_scenario_matched(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
action: str,
|
||||||
|
condition_summary: str,
|
||||||
|
confidence: float,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify that a scenario matched for a stock.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stock_code: Stock ticker symbol
|
||||||
|
action: Scenario action (BUY/SELL/HOLD/REDUCE_ALL)
|
||||||
|
condition_summary: Short summary of the matched condition
|
||||||
|
confidence: Scenario confidence (0-100)
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
f"<b>Scenario Matched</b>\n"
|
||||||
|
f"Symbol: <code>{stock_code}</code>\n"
|
||||||
|
f"Action: {action}\n"
|
||||||
|
f"Condition: {condition_summary}\n"
|
||||||
|
f"Confidence: {confidence:.0f}%"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_playbook_failed(self, market: str, reason: str) -> None:
|
||||||
|
"""
|
||||||
|
Notify that playbook generation failed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code (e.g., "KR", "US")
|
||||||
|
reason: Failure reason summary
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
f"<b>Playbook Failed</b>\n"
|
||||||
|
f"Market: {market}\n"
|
||||||
|
f"Reason: {reason[:200]}"
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_system_shutdown(self, reason: str) -> None:
|
||||||
|
"""
|
||||||
|
Notify system shutdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reason: Reason for shutdown (e.g., "Normal shutdown", "Circuit breaker")
|
||||||
|
"""
|
||||||
|
message = f"<b>System Shutdown</b>\n{reason}"
|
||||||
|
priority = (
|
||||||
|
NotificationPriority.CRITICAL
|
||||||
|
if "circuit breaker" in reason.lower()
|
||||||
|
else NotificationPriority.MEDIUM
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=priority, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def notify_error(
|
||||||
|
self, error_type: str, error_msg: str, context: str
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Notify system error.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
error_type: Type of error (e.g., "Connection Error")
|
||||||
|
error_msg: Error message
|
||||||
|
context: Error context (e.g., stock code, market)
|
||||||
|
"""
|
||||||
|
message = (
|
||||||
|
f"<b>Error: {error_type}</b>\n"
|
||||||
|
f"Context: {context}\n"
|
||||||
|
f"Message: {error_msg[:200]}" # Truncate long errors
|
||||||
|
)
|
||||||
|
await self._send_notification(
|
||||||
|
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramCommandHandler:
|
||||||
|
"""Handles incoming Telegram commands via long polling."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, client: TelegramClient, polling_interval: float = 1.0
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Initialize command handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: TelegramClient instance for sending responses
|
||||||
|
polling_interval: Polling interval in seconds
|
||||||
|
"""
|
||||||
|
self._client = client
|
||||||
|
self._polling_interval = polling_interval
|
||||||
|
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
||||||
|
self._last_update_id = 0
|
||||||
|
self._polling_task: asyncio.Task[None] | None = None
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
def register_command(
|
||||||
|
self, command: str, handler: Callable[[], Awaitable[None]]
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Register a command handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command: Command name (without leading slash, e.g., "start")
|
||||||
|
handler: Async function to handle the command
|
||||||
|
"""
|
||||||
|
self._commands[command] = handler
|
||||||
|
logger.debug("Registered command handler: /%s", command)
|
||||||
|
|
||||||
|
async def start_polling(self) -> None:
|
||||||
|
"""Start long polling for commands."""
|
||||||
|
if self._running:
|
||||||
|
logger.warning("Command handler already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._client._enabled:
|
||||||
|
logger.info("Command handler disabled (TelegramClient disabled)")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._polling_task = asyncio.create_task(self._poll_loop())
|
||||||
|
logger.info("Started Telegram command polling")
|
||||||
|
|
||||||
|
async def stop_polling(self) -> None:
|
||||||
|
"""Stop polling and cancel pending tasks."""
|
||||||
|
if not self._running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = False
|
||||||
|
if self._polling_task:
|
||||||
|
self._polling_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._polling_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
logger.info("Stopped Telegram command polling")
|
||||||
|
|
||||||
|
async def _poll_loop(self) -> None:
|
||||||
|
"""Main polling loop that fetches updates."""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
updates = await self._get_updates()
|
||||||
|
for update in updates:
|
||||||
|
await self._handle_update(update)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error in polling loop: %s", exc)
|
||||||
|
|
||||||
|
await asyncio.sleep(self._polling_interval)
|
||||||
|
|
||||||
|
async def _get_updates(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Fetch updates from Telegram API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of update objects
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
url = f"{self._client.API_BASE.format(token=self._client._bot_token)}/getUpdates"
|
||||||
|
payload = {
|
||||||
|
"offset": self._last_update_id + 1,
|
||||||
|
"timeout": int(self._polling_interval),
|
||||||
|
"allowed_updates": ["message"],
|
||||||
|
}
|
||||||
|
|
||||||
|
session = self._client._get_session()
|
||||||
|
async with session.post(url, json=payload) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
error_text = await resp.text()
|
||||||
|
logger.error(
|
||||||
|
"getUpdates API error (status=%d): %s", resp.status, error_text
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = await resp.json()
|
||||||
|
if not data.get("ok"):
|
||||||
|
logger.error("getUpdates returned ok=false: %s", data)
|
||||||
|
return []
|
||||||
|
|
||||||
|
updates = data.get("result", [])
|
||||||
|
if updates:
|
||||||
|
self._last_update_id = updates[-1]["update_id"]
|
||||||
|
|
||||||
|
return updates
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.debug("getUpdates timeout (normal)")
|
||||||
|
return []
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
logger.error("getUpdates failed: %s", exc)
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error in _get_updates: %s", exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _handle_update(self, update: dict) -> None:
|
||||||
|
"""
|
||||||
|
Parse and handle a single update.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
update: Update object from Telegram API
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
message = update.get("message")
|
||||||
|
if not message:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Verify chat_id matches configured chat
|
||||||
|
chat_id = str(message.get("chat", {}).get("id", ""))
|
||||||
|
if chat_id != self._client._chat_id:
|
||||||
|
logger.warning(
|
||||||
|
"Ignoring command from unauthorized chat_id: %s", chat_id
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Extract command text
|
||||||
|
text = message.get("text", "").strip()
|
||||||
|
if not text.startswith("/"):
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse command (remove leading slash and extract command name)
|
||||||
|
command_parts = text[1:].split()
|
||||||
|
if not command_parts:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove @botname suffix if present (for group chats)
|
||||||
|
command_name = command_parts[0].split("@")[0]
|
||||||
|
|
||||||
|
# Execute handler
|
||||||
|
handler = self._commands.get(command_name)
|
||||||
|
if handler:
|
||||||
|
logger.info("Executing command: /%s", command_name)
|
||||||
|
await handler()
|
||||||
|
else:
|
||||||
|
logger.debug("Unknown command: /%s", command_name)
|
||||||
|
await self._client.send_message(
|
||||||
|
f"Unknown command: /{command_name}\nUse /help to see available commands."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Error handling update: %s", exc)
|
||||||
|
# Don't crash the polling loop on handler errors
|
||||||
0
src/strategy/__init__.py
Normal file
0
src/strategy/__init__.py
Normal file
164
src/strategy/models.py
Normal file
164
src/strategy/models.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""Pydantic models for pre-market scenario planning.
|
||||||
|
|
||||||
|
Defines the data contracts for the proactive strategy system:
|
||||||
|
- AI generates DayPlaybook before market open (structured JSON scenarios)
|
||||||
|
- Local ScenarioEngine matches conditions during market hours (no API calls)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioAction(str, Enum):
|
||||||
|
"""Actions that can be taken by scenarios."""
|
||||||
|
|
||||||
|
BUY = "BUY"
|
||||||
|
SELL = "SELL"
|
||||||
|
HOLD = "HOLD"
|
||||||
|
REDUCE_ALL = "REDUCE_ALL"
|
||||||
|
|
||||||
|
|
||||||
|
class MarketOutlook(str, Enum):
|
||||||
|
"""AI's assessment of market direction."""
|
||||||
|
|
||||||
|
BULLISH = "bullish"
|
||||||
|
NEUTRAL_TO_BULLISH = "neutral_to_bullish"
|
||||||
|
NEUTRAL = "neutral"
|
||||||
|
NEUTRAL_TO_BEARISH = "neutral_to_bearish"
|
||||||
|
BEARISH = "bearish"
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybookStatus(str, Enum):
|
||||||
|
"""Lifecycle status of a playbook."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
READY = "ready"
|
||||||
|
FAILED = "failed"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class StockCondition(BaseModel):
|
||||||
|
"""Condition fields for scenario matching (all optional, AND-combined).
|
||||||
|
|
||||||
|
The ScenarioEngine evaluates all non-None fields as AND conditions.
|
||||||
|
A condition matches only if ALL specified fields are satisfied.
|
||||||
|
"""
|
||||||
|
|
||||||
|
rsi_below: float | None = None
|
||||||
|
rsi_above: float | None = None
|
||||||
|
volume_ratio_above: float | None = None
|
||||||
|
volume_ratio_below: float | None = None
|
||||||
|
price_above: float | None = None
|
||||||
|
price_below: float | None = None
|
||||||
|
price_change_pct_above: float | None = None
|
||||||
|
price_change_pct_below: float | None = None
|
||||||
|
|
||||||
|
def has_any_condition(self) -> bool:
|
||||||
|
"""Check if at least one condition field is set."""
|
||||||
|
return any(
|
||||||
|
v is not None
|
||||||
|
for v in (
|
||||||
|
self.rsi_below,
|
||||||
|
self.rsi_above,
|
||||||
|
self.volume_ratio_above,
|
||||||
|
self.volume_ratio_below,
|
||||||
|
self.price_above,
|
||||||
|
self.price_below,
|
||||||
|
self.price_change_pct_above,
|
||||||
|
self.price_change_pct_below,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StockScenario(BaseModel):
|
||||||
|
"""A single condition-action rule for one stock."""
|
||||||
|
|
||||||
|
condition: StockCondition
|
||||||
|
action: ScenarioAction
|
||||||
|
confidence: int = Field(ge=0, le=100)
|
||||||
|
allocation_pct: float = Field(ge=0, le=100, default=10.0)
|
||||||
|
stop_loss_pct: float = Field(le=0, default=-2.0)
|
||||||
|
take_profit_pct: float = Field(ge=0, default=3.0)
|
||||||
|
rationale: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class StockPlaybook(BaseModel):
|
||||||
|
"""All scenarios for a single stock (ordered by priority)."""
|
||||||
|
|
||||||
|
stock_code: str
|
||||||
|
stock_name: str = ""
|
||||||
|
scenarios: list[StockScenario] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class GlobalRule(BaseModel):
|
||||||
|
"""Portfolio-level rule (checked before stock-level scenarios)."""
|
||||||
|
|
||||||
|
condition: str # e.g. "portfolio_pnl_pct < -2.0"
|
||||||
|
action: ScenarioAction
|
||||||
|
rationale: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CrossMarketContext(BaseModel):
|
||||||
|
"""Summary of another market's state for cross-market awareness."""
|
||||||
|
|
||||||
|
market: str # e.g. "US" or "KR"
|
||||||
|
date: str
|
||||||
|
total_pnl: float = 0.0
|
||||||
|
win_rate: float = 0.0
|
||||||
|
index_change_pct: float = 0.0 # e.g. KOSPI or S&P500 change
|
||||||
|
key_events: list[str] = Field(default_factory=list)
|
||||||
|
lessons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class DayPlaybook(BaseModel):
|
||||||
|
"""Complete playbook for a single trading day in a single market.
|
||||||
|
|
||||||
|
Generated by PreMarketPlanner (1 Gemini call per market per day).
|
||||||
|
Consumed by ScenarioEngine during market hours (0 API calls).
|
||||||
|
"""
|
||||||
|
|
||||||
|
date: date
|
||||||
|
market: str # "KR" or "US"
|
||||||
|
market_outlook: MarketOutlook = MarketOutlook.NEUTRAL
|
||||||
|
generated_at: str = "" # ISO timestamp
|
||||||
|
gemini_model: str = ""
|
||||||
|
token_count: int = 0
|
||||||
|
global_rules: list[GlobalRule] = Field(default_factory=list)
|
||||||
|
stock_playbooks: list[StockPlaybook] = Field(default_factory=list)
|
||||||
|
default_action: ScenarioAction = ScenarioAction.HOLD
|
||||||
|
context_summary: dict = Field(default_factory=dict)
|
||||||
|
cross_market: CrossMarketContext | None = None
|
||||||
|
|
||||||
|
@field_validator("stock_playbooks")
|
||||||
|
@classmethod
|
||||||
|
def validate_unique_stocks(cls, v: list[StockPlaybook]) -> list[StockPlaybook]:
|
||||||
|
codes = [pb.stock_code for pb in v]
|
||||||
|
if len(codes) != len(set(codes)):
|
||||||
|
raise ValueError("Duplicate stock codes in playbook")
|
||||||
|
return v
|
||||||
|
|
||||||
|
def get_stock_playbook(self, stock_code: str) -> StockPlaybook | None:
|
||||||
|
"""Find the playbook for a specific stock."""
|
||||||
|
for pb in self.stock_playbooks:
|
||||||
|
if pb.stock_code == stock_code:
|
||||||
|
return pb
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scenario_count(self) -> int:
|
||||||
|
"""Total number of scenarios across all stocks."""
|
||||||
|
return sum(len(pb.scenarios) for pb in self.stock_playbooks)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stock_count(self) -> int:
|
||||||
|
"""Number of stocks with scenarios."""
|
||||||
|
return len(self.stock_playbooks)
|
||||||
|
|
||||||
|
def model_post_init(self, __context: object) -> None:
|
||||||
|
"""Set generated_at if not provided."""
|
||||||
|
if not self.generated_at:
|
||||||
|
self.generated_at = datetime.now(UTC).isoformat()
|
||||||
184
src/strategy/playbook_store.py
Normal file
184
src/strategy/playbook_store.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""Playbook persistence layer — CRUD for DayPlaybook in SQLite.
|
||||||
|
|
||||||
|
Stores and retrieves market-specific daily playbooks with JSON serialization.
|
||||||
|
Designed for the pre-market strategy system (one playbook per market per day).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from src.strategy.models import DayPlaybook, PlaybookStatus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybookStore:
|
||||||
|
"""CRUD operations for DayPlaybook persistence."""
|
||||||
|
|
||||||
|
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||||
|
self._conn = conn
|
||||||
|
|
||||||
|
def save(self, playbook: DayPlaybook) -> int:
|
||||||
|
"""Save or replace a playbook for a given date+market.
|
||||||
|
|
||||||
|
Uses INSERT OR REPLACE to enforce UNIQUE(date, market).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The row id of the inserted/replaced record.
|
||||||
|
"""
|
||||||
|
playbook_json = playbook.model_dump_json()
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO playbooks
|
||||||
|
(date, market, status, playbook_json, generated_at,
|
||||||
|
token_count, scenario_count, match_count)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
playbook.date.isoformat(),
|
||||||
|
playbook.market,
|
||||||
|
PlaybookStatus.READY.value,
|
||||||
|
playbook_json,
|
||||||
|
playbook.generated_at,
|
||||||
|
playbook.token_count,
|
||||||
|
playbook.scenario_count,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
row_id = cursor.lastrowid or 0
|
||||||
|
logger.info(
|
||||||
|
"Saved playbook for %s/%s (%d stocks, %d scenarios)",
|
||||||
|
playbook.date, playbook.market,
|
||||||
|
playbook.stock_count, playbook.scenario_count,
|
||||||
|
)
|
||||||
|
return row_id
|
||||||
|
|
||||||
|
def load(self, target_date: date, market: str) -> DayPlaybook | None:
|
||||||
|
"""Load a playbook for a specific date and market.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DayPlaybook if found, None otherwise.
|
||||||
|
"""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT playbook_json FROM playbooks WHERE date = ? AND market = ?",
|
||||||
|
(target_date.isoformat(), market),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return DayPlaybook.model_validate_json(row[0])
|
||||||
|
|
||||||
|
def get_status(self, target_date: date, market: str) -> PlaybookStatus | None:
|
||||||
|
"""Get the status of a playbook without deserializing the full JSON."""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"SELECT status FROM playbooks WHERE date = ? AND market = ?",
|
||||||
|
(target_date.isoformat(), market),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return PlaybookStatus(row[0])
|
||||||
|
|
||||||
|
def update_status(self, target_date: date, market: str, status: PlaybookStatus) -> bool:
|
||||||
|
"""Update the status of a playbook.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if not found.
|
||||||
|
"""
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"UPDATE playbooks SET status = ? WHERE date = ? AND market = ?",
|
||||||
|
(status.value, target_date.isoformat(), market),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def increment_match_count(self, target_date: date, market: str) -> bool:
|
||||||
|
"""Increment the match_count for tracking scenario hits during the day.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was updated, False if not found.
|
||||||
|
"""
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"UPDATE playbooks SET match_count = match_count + 1 WHERE date = ? AND market = ?",
|
||||||
|
(target_date.isoformat(), market),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
def get_stats(self, target_date: date, market: str) -> dict | None:
|
||||||
|
"""Get playbook stats without full deserialization.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status, token_count, scenario_count, match_count, or None.
|
||||||
|
"""
|
||||||
|
row = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT status, token_count, scenario_count, match_count, generated_at
|
||||||
|
FROM playbooks WHERE date = ? AND market = ?
|
||||||
|
""",
|
||||||
|
(target_date.isoformat(), market),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"status": row[0],
|
||||||
|
"token_count": row[1],
|
||||||
|
"scenario_count": row[2],
|
||||||
|
"match_count": row[3],
|
||||||
|
"generated_at": row[4],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_recent(self, market: str | None = None, limit: int = 7) -> list[dict]:
|
||||||
|
"""List recent playbooks with summary info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Filter by market code. None for all markets.
|
||||||
|
limit: Max number of results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with date, market, status, scenario_count, match_count.
|
||||||
|
"""
|
||||||
|
if market is not None:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, market, status, scenario_count, match_count
|
||||||
|
FROM playbooks WHERE market = ?
|
||||||
|
ORDER BY date DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(market, limit),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = self._conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT date, market, status, scenario_count, match_count
|
||||||
|
FROM playbooks
|
||||||
|
ORDER BY date DESC LIMIT ?
|
||||||
|
""",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"date": row[0],
|
||||||
|
"market": row[1],
|
||||||
|
"status": row[2],
|
||||||
|
"scenario_count": row[3],
|
||||||
|
"match_count": row[4],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def delete(self, target_date: date, market: str) -> bool:
|
||||||
|
"""Delete a playbook.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted, False if not found.
|
||||||
|
"""
|
||||||
|
cursor = self._conn.execute(
|
||||||
|
"DELETE FROM playbooks WHERE date = ? AND market = ?",
|
||||||
|
(target_date.isoformat(), market),
|
||||||
|
)
|
||||||
|
self._conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
419
src/strategy/pre_market_planner.py
Normal file
419
src/strategy/pre_market_planner.py
Normal file
@@ -0,0 +1,419 @@
|
|||||||
|
"""Pre-market planner — generates DayPlaybook via Gemini before market open.
|
||||||
|
|
||||||
|
One Gemini API call per market per day. Candidates come from SmartVolatilityScanner.
|
||||||
|
On failure, returns a defensive playbook (all HOLD, no trades).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import date
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.analysis.smart_scanner import ScanCandidate
|
||||||
|
from src.brain.context_selector import ContextSelector, DecisionType
|
||||||
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
from src.config import Settings
|
||||||
|
from src.context.store import ContextLayer, ContextStore
|
||||||
|
from src.strategy.models import (
|
||||||
|
CrossMarketContext,
|
||||||
|
DayPlaybook,
|
||||||
|
GlobalRule,
|
||||||
|
MarketOutlook,
|
||||||
|
ScenarioAction,
|
||||||
|
StockCondition,
|
||||||
|
StockPlaybook,
|
||||||
|
StockScenario,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Mapping from string to MarketOutlook enum
|
||||||
|
_OUTLOOK_MAP: dict[str, MarketOutlook] = {
|
||||||
|
"bullish": MarketOutlook.BULLISH,
|
||||||
|
"neutral_to_bullish": MarketOutlook.NEUTRAL_TO_BULLISH,
|
||||||
|
"neutral": MarketOutlook.NEUTRAL,
|
||||||
|
"neutral_to_bearish": MarketOutlook.NEUTRAL_TO_BEARISH,
|
||||||
|
"bearish": MarketOutlook.BEARISH,
|
||||||
|
}
|
||||||
|
|
||||||
|
_ACTION_MAP: dict[str, ScenarioAction] = {
|
||||||
|
"BUY": ScenarioAction.BUY,
|
||||||
|
"SELL": ScenarioAction.SELL,
|
||||||
|
"HOLD": ScenarioAction.HOLD,
|
||||||
|
"REDUCE_ALL": ScenarioAction.REDUCE_ALL,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PreMarketPlanner:
|
||||||
|
"""Generates a DayPlaybook by calling Gemini once before market open.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Collect strategic context (L5-L7) + cross-market context
|
||||||
|
2. Build a structured prompt with scan candidates
|
||||||
|
3. Call Gemini for JSON scenario generation
|
||||||
|
4. Parse and validate response into DayPlaybook
|
||||||
|
5. On failure → defensive playbook (HOLD everything)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
gemini_client: GeminiClient,
|
||||||
|
context_store: ContextStore,
|
||||||
|
context_selector: ContextSelector,
|
||||||
|
settings: Settings,
|
||||||
|
) -> None:
|
||||||
|
self._gemini = gemini_client
|
||||||
|
self._context_store = context_store
|
||||||
|
self._context_selector = context_selector
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
async def generate_playbook(
|
||||||
|
self,
|
||||||
|
market: str,
|
||||||
|
candidates: list[ScanCandidate],
|
||||||
|
today: date | None = None,
|
||||||
|
) -> DayPlaybook:
|
||||||
|
"""Generate a DayPlaybook for a market using Gemini.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
market: Market code ("KR" or "US")
|
||||||
|
candidates: Stock candidates from SmartVolatilityScanner
|
||||||
|
today: Override date (defaults to date.today()). Use market-local date.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DayPlaybook with scenarios. Empty/defensive if no candidates or failure.
|
||||||
|
"""
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
logger.info("No candidates for %s — returning empty playbook", market)
|
||||||
|
return self._empty_playbook(today, market)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Gather context
|
||||||
|
context_data = self._gather_context()
|
||||||
|
cross_market = self.build_cross_market_context(market, today)
|
||||||
|
|
||||||
|
# 2. Build prompt
|
||||||
|
prompt = self._build_prompt(market, candidates, context_data, cross_market)
|
||||||
|
|
||||||
|
# 3. Call Gemini
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "PLANNER",
|
||||||
|
"current_price": 0,
|
||||||
|
"prompt_override": prompt,
|
||||||
|
}
|
||||||
|
decision = await self._gemini.decide(market_data)
|
||||||
|
|
||||||
|
# 4. Parse response
|
||||||
|
playbook = self._parse_response(
|
||||||
|
decision.rationale, today, market, candidates, cross_market
|
||||||
|
)
|
||||||
|
playbook_with_tokens = playbook.model_copy(
|
||||||
|
update={"token_count": decision.token_count}
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Generated playbook for %s: %d stocks, %d scenarios, %d tokens",
|
||||||
|
market,
|
||||||
|
playbook_with_tokens.stock_count,
|
||||||
|
playbook_with_tokens.scenario_count,
|
||||||
|
playbook_with_tokens.token_count,
|
||||||
|
)
|
||||||
|
return playbook_with_tokens
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Playbook generation failed for %s", market)
|
||||||
|
if self._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE:
|
||||||
|
return self._defensive_playbook(today, market, candidates)
|
||||||
|
return self._empty_playbook(today, market)
|
||||||
|
|
||||||
|
def build_cross_market_context(
|
||||||
|
self, target_market: str, today: date | None = None,
|
||||||
|
) -> CrossMarketContext | None:
|
||||||
|
"""Build cross-market context from the other market's L6 data.
|
||||||
|
|
||||||
|
KR planner → reads US scorecard from previous night.
|
||||||
|
US planner → reads KR scorecard from today.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
target_market: The market being planned ("KR" or "US")
|
||||||
|
today: Override date (defaults to date.today()). Use market-local date.
|
||||||
|
"""
|
||||||
|
other_market = "US" if target_market == "KR" else "KR"
|
||||||
|
if today is None:
|
||||||
|
today = date.today()
|
||||||
|
timeframe = today.isoformat()
|
||||||
|
|
||||||
|
scorecard_key = f"scorecard_{other_market}"
|
||||||
|
scorecard_data = self._context_store.get_context(
|
||||||
|
ContextLayer.L6_DAILY, timeframe, scorecard_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if scorecard_data is None:
|
||||||
|
logger.debug("No cross-market scorecard found for %s", other_market)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(scorecard_data, str):
|
||||||
|
try:
|
||||||
|
scorecard_data = json.loads(scorecard_data)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(scorecard_data, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return CrossMarketContext(
|
||||||
|
market=other_market,
|
||||||
|
date=timeframe,
|
||||||
|
total_pnl=float(scorecard_data.get("total_pnl", 0.0)),
|
||||||
|
win_rate=float(scorecard_data.get("win_rate", 0.0)),
|
||||||
|
index_change_pct=float(scorecard_data.get("index_change_pct", 0.0)),
|
||||||
|
key_events=scorecard_data.get("key_events", []),
|
||||||
|
lessons=scorecard_data.get("lessons", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _gather_context(self) -> dict[str, Any]:
|
||||||
|
"""Gather strategic context using ContextSelector."""
|
||||||
|
layers = self._context_selector.select_layers(
|
||||||
|
decision_type=DecisionType.STRATEGIC,
|
||||||
|
include_realtime=True,
|
||||||
|
)
|
||||||
|
return self._context_selector.get_context_data(layers, max_items_per_layer=10)
|
||||||
|
|
||||||
|
def _build_prompt(
|
||||||
|
self,
|
||||||
|
market: str,
|
||||||
|
candidates: list[ScanCandidate],
|
||||||
|
context_data: dict[str, Any],
|
||||||
|
cross_market: CrossMarketContext | None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a structured prompt for Gemini to generate scenario JSON."""
|
||||||
|
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
||||||
|
|
||||||
|
candidates_text = "\n".join(
|
||||||
|
f" - {c.stock_code} ({c.name}): price={c.price}, "
|
||||||
|
f"RSI={c.rsi:.1f}, volume_ratio={c.volume_ratio:.1f}, "
|
||||||
|
f"signal={c.signal}, score={c.score:.1f}"
|
||||||
|
for c in candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
cross_market_text = ""
|
||||||
|
if cross_market:
|
||||||
|
cross_market_text = (
|
||||||
|
f"\n## Other Market ({cross_market.market}) Summary\n"
|
||||||
|
f"- P&L: {cross_market.total_pnl:+.2f}%\n"
|
||||||
|
f"- Win Rate: {cross_market.win_rate:.0f}%\n"
|
||||||
|
f"- Index Change: {cross_market.index_change_pct:+.2f}%\n"
|
||||||
|
)
|
||||||
|
if cross_market.lessons:
|
||||||
|
cross_market_text += f"- Lessons: {'; '.join(cross_market.lessons[:3])}\n"
|
||||||
|
|
||||||
|
context_text = ""
|
||||||
|
if context_data:
|
||||||
|
context_text = "\n## Strategic Context\n"
|
||||||
|
for layer_name, layer_data in context_data.items():
|
||||||
|
if layer_data:
|
||||||
|
context_text += f"### {layer_name}\n"
|
||||||
|
for key, value in list(layer_data.items())[:5]:
|
||||||
|
context_text += f" - {key}: {value}\n"
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"You are a pre-market trading strategist for the {market} market.\n"
|
||||||
|
f"Generate structured trading scenarios for today.\n\n"
|
||||||
|
f"## Candidates (from volatility scanner)\n{candidates_text}\n"
|
||||||
|
f"{cross_market_text}"
|
||||||
|
f"{context_text}\n"
|
||||||
|
f"## Instructions\n"
|
||||||
|
f"Return a JSON object with this exact structure:\n"
|
||||||
|
f'{{\n'
|
||||||
|
f' "market_outlook": "bullish|neutral_to_bullish|neutral'
|
||||||
|
f'|neutral_to_bearish|bearish",\n'
|
||||||
|
f' "global_rules": [\n'
|
||||||
|
f' {{"condition": "portfolio_pnl_pct < -2.0",'
|
||||||
|
f' "action": "REDUCE_ALL", "rationale": "..."}}\n'
|
||||||
|
f' ],\n'
|
||||||
|
f' "stocks": [\n'
|
||||||
|
f' {{\n'
|
||||||
|
f' "stock_code": "...",\n'
|
||||||
|
f' "scenarios": [\n'
|
||||||
|
f' {{\n'
|
||||||
|
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0}},\n'
|
||||||
|
f' "action": "BUY|SELL|HOLD",\n'
|
||||||
|
f' "confidence": 85,\n'
|
||||||
|
f' "allocation_pct": 10.0,\n'
|
||||||
|
f' "stop_loss_pct": -2.0,\n'
|
||||||
|
f' "take_profit_pct": 3.0,\n'
|
||||||
|
f' "rationale": "..."\n'
|
||||||
|
f' }}\n'
|
||||||
|
f' ]\n'
|
||||||
|
f' }}\n'
|
||||||
|
f' ]\n'
|
||||||
|
f'}}\n\n'
|
||||||
|
f"Rules:\n"
|
||||||
|
f"- Max {max_scenarios} scenarios per stock\n"
|
||||||
|
f"- Only use stocks from the candidates list\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"- Return ONLY the JSON, no markdown fences or explanation\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_response(
|
||||||
|
self,
|
||||||
|
response_text: str,
|
||||||
|
today: date,
|
||||||
|
market: str,
|
||||||
|
candidates: list[ScanCandidate],
|
||||||
|
cross_market: CrossMarketContext | None,
|
||||||
|
) -> DayPlaybook:
|
||||||
|
"""Parse Gemini's JSON response into a validated DayPlaybook."""
|
||||||
|
cleaned = self._extract_json(response_text)
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
|
||||||
|
valid_codes = {c.stock_code for c in candidates}
|
||||||
|
|
||||||
|
# Parse market outlook
|
||||||
|
outlook_str = data.get("market_outlook", "neutral")
|
||||||
|
market_outlook = _OUTLOOK_MAP.get(outlook_str, MarketOutlook.NEUTRAL)
|
||||||
|
|
||||||
|
# Parse global rules
|
||||||
|
global_rules = []
|
||||||
|
for rule_data in data.get("global_rules", []):
|
||||||
|
action_str = rule_data.get("action", "HOLD")
|
||||||
|
action = _ACTION_MAP.get(action_str, ScenarioAction.HOLD)
|
||||||
|
global_rules.append(
|
||||||
|
GlobalRule(
|
||||||
|
condition=rule_data.get("condition", ""),
|
||||||
|
action=action,
|
||||||
|
rationale=rule_data.get("rationale", ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse stock playbooks
|
||||||
|
stock_playbooks = []
|
||||||
|
max_scenarios = self._settings.MAX_SCENARIOS_PER_STOCK
|
||||||
|
for stock_data in data.get("stocks", []):
|
||||||
|
code = stock_data.get("stock_code", "")
|
||||||
|
if code not in valid_codes:
|
||||||
|
logger.warning("Gemini returned unknown stock %s — skipping", code)
|
||||||
|
continue
|
||||||
|
|
||||||
|
scenarios = []
|
||||||
|
for sc_data in stock_data.get("scenarios", [])[:max_scenarios]:
|
||||||
|
scenario = self._parse_scenario(sc_data)
|
||||||
|
if scenario:
|
||||||
|
scenarios.append(scenario)
|
||||||
|
|
||||||
|
if scenarios:
|
||||||
|
stock_playbooks.append(
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code=code,
|
||||||
|
scenarios=scenarios,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return DayPlaybook(
|
||||||
|
date=today,
|
||||||
|
market=market,
|
||||||
|
market_outlook=market_outlook,
|
||||||
|
global_rules=global_rules,
|
||||||
|
stock_playbooks=stock_playbooks,
|
||||||
|
cross_market=cross_market,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _parse_scenario(self, sc_data: dict) -> StockScenario | None:
|
||||||
|
"""Parse a single scenario from JSON data. Returns None if invalid."""
|
||||||
|
try:
|
||||||
|
cond_data = sc_data.get("condition", {})
|
||||||
|
condition = StockCondition(
|
||||||
|
rsi_below=cond_data.get("rsi_below"),
|
||||||
|
rsi_above=cond_data.get("rsi_above"),
|
||||||
|
volume_ratio_above=cond_data.get("volume_ratio_above"),
|
||||||
|
volume_ratio_below=cond_data.get("volume_ratio_below"),
|
||||||
|
price_above=cond_data.get("price_above"),
|
||||||
|
price_below=cond_data.get("price_below"),
|
||||||
|
price_change_pct_above=cond_data.get("price_change_pct_above"),
|
||||||
|
price_change_pct_below=cond_data.get("price_change_pct_below"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not condition.has_any_condition():
|
||||||
|
logger.warning("Scenario has no conditions — skipping")
|
||||||
|
return None
|
||||||
|
|
||||||
|
action_str = sc_data.get("action", "HOLD")
|
||||||
|
action = _ACTION_MAP.get(action_str, ScenarioAction.HOLD)
|
||||||
|
|
||||||
|
return StockScenario(
|
||||||
|
condition=condition,
|
||||||
|
action=action,
|
||||||
|
confidence=int(sc_data.get("confidence", 50)),
|
||||||
|
allocation_pct=float(sc_data.get("allocation_pct", 10.0)),
|
||||||
|
stop_loss_pct=float(sc_data.get("stop_loss_pct", -2.0)),
|
||||||
|
take_profit_pct=float(sc_data.get("take_profit_pct", 3.0)),
|
||||||
|
rationale=sc_data.get("rationale", ""),
|
||||||
|
)
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning("Failed to parse scenario: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_json(text: str) -> str:
|
||||||
|
"""Extract JSON from response, stripping markdown fences if present."""
|
||||||
|
stripped = text.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
# Remove first line (```json or ```) and last line (```)
|
||||||
|
lines = stripped.split("\n")
|
||||||
|
lines = lines[1:] # Remove opening fence
|
||||||
|
if lines and lines[-1].strip() == "```":
|
||||||
|
lines = lines[:-1]
|
||||||
|
stripped = "\n".join(lines)
|
||||||
|
return stripped.strip()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _empty_playbook(today: date, market: str) -> DayPlaybook:
|
||||||
|
"""Return an empty playbook (no stocks, no scenarios)."""
|
||||||
|
return DayPlaybook(
|
||||||
|
date=today,
|
||||||
|
market=market,
|
||||||
|
market_outlook=MarketOutlook.NEUTRAL,
|
||||||
|
stock_playbooks=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _defensive_playbook(
|
||||||
|
today: date,
|
||||||
|
market: str,
|
||||||
|
candidates: list[ScanCandidate],
|
||||||
|
) -> DayPlaybook:
|
||||||
|
"""Return a defensive playbook — HOLD everything with stop-loss ready."""
|
||||||
|
stock_playbooks = [
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code=c.stock_code,
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(price_change_pct_below=-3.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=90,
|
||||||
|
stop_loss_pct=-3.0,
|
||||||
|
rationale="Defensive stop-loss (planner failure)",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for c in candidates
|
||||||
|
]
|
||||||
|
return DayPlaybook(
|
||||||
|
date=today,
|
||||||
|
market=market,
|
||||||
|
market_outlook=MarketOutlook.NEUTRAL_TO_BEARISH,
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
270
src/strategy/scenario_engine.py
Normal file
270
src/strategy/scenario_engine.py
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
"""Local scenario engine for playbook execution.
|
||||||
|
|
||||||
|
Matches real-time market conditions against pre-defined scenarios
|
||||||
|
without any API calls. Designed for sub-100ms execution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from src.strategy.models import (
|
||||||
|
DayPlaybook,
|
||||||
|
GlobalRule,
|
||||||
|
ScenarioAction,
|
||||||
|
StockCondition,
|
||||||
|
StockScenario,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScenarioMatch:
|
||||||
|
"""Result of matching market conditions against scenarios."""
|
||||||
|
|
||||||
|
stock_code: str
|
||||||
|
matched_scenario: StockScenario | None
|
||||||
|
action: ScenarioAction
|
||||||
|
confidence: int
|
||||||
|
rationale: str
|
||||||
|
global_rule_triggered: GlobalRule | None = None
|
||||||
|
match_details: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
class ScenarioEngine:
|
||||||
|
"""Evaluates playbook scenarios against real-time market data.
|
||||||
|
|
||||||
|
No API calls — pure Python condition matching.
|
||||||
|
|
||||||
|
Expected market_data keys: "rsi", "volume_ratio", "current_price", "price_change_pct".
|
||||||
|
Callers must normalize data source keys to match this contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._warned_keys: set[str] = set()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_float(value: Any) -> float | None:
|
||||||
|
"""Safely cast a value to float. Returns None on failure."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _warn_missing_key(self, key: str) -> None:
|
||||||
|
"""Log a missing-key warning once per key per engine instance."""
|
||||||
|
if key not in self._warned_keys:
|
||||||
|
self._warned_keys.add(key)
|
||||||
|
logger.warning("Condition requires '%s' but key missing from market_data", key)
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
playbook: DayPlaybook,
|
||||||
|
stock_code: str,
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
portfolio_data: dict[str, Any],
|
||||||
|
) -> ScenarioMatch:
|
||||||
|
"""Match market conditions to scenarios and return a decision.
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
1. Check global rules first (portfolio-level circuit breakers)
|
||||||
|
2. Find the StockPlaybook for the given stock_code
|
||||||
|
3. Iterate scenarios in order (first match wins)
|
||||||
|
4. If no match, return playbook.default_action (HOLD)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
playbook: Today's DayPlaybook for this market
|
||||||
|
stock_code: Stock ticker to evaluate
|
||||||
|
market_data: Real-time market data (price, rsi, volume_ratio, etc.)
|
||||||
|
portfolio_data: Portfolio state (pnl_pct, total_cash, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ScenarioMatch with the decision
|
||||||
|
"""
|
||||||
|
# 1. Check global rules
|
||||||
|
triggered_rule = self.check_global_rules(playbook, portfolio_data)
|
||||||
|
if triggered_rule is not None:
|
||||||
|
logger.info(
|
||||||
|
"Global rule triggered for %s: %s -> %s",
|
||||||
|
stock_code,
|
||||||
|
triggered_rule.condition,
|
||||||
|
triggered_rule.action.value,
|
||||||
|
)
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=None,
|
||||||
|
action=triggered_rule.action,
|
||||||
|
confidence=100,
|
||||||
|
rationale=f"Global rule: {triggered_rule.rationale or triggered_rule.condition}",
|
||||||
|
global_rule_triggered=triggered_rule,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Find stock playbook
|
||||||
|
stock_pb = playbook.get_stock_playbook(stock_code)
|
||||||
|
if stock_pb is None:
|
||||||
|
logger.debug("No playbook for %s — defaulting to %s", stock_code, playbook.default_action)
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=None,
|
||||||
|
action=playbook.default_action,
|
||||||
|
confidence=0,
|
||||||
|
rationale=f"No scenarios defined for {stock_code}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Iterate scenarios (first match wins)
|
||||||
|
for scenario in stock_pb.scenarios:
|
||||||
|
if self.evaluate_condition(scenario.condition, market_data):
|
||||||
|
logger.info(
|
||||||
|
"Scenario matched for %s: %s (confidence=%d)",
|
||||||
|
stock_code,
|
||||||
|
scenario.action.value,
|
||||||
|
scenario.confidence,
|
||||||
|
)
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=scenario,
|
||||||
|
action=scenario.action,
|
||||||
|
confidence=scenario.confidence,
|
||||||
|
rationale=scenario.rationale,
|
||||||
|
match_details=self._build_match_details(scenario.condition, market_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. No match — default action
|
||||||
|
logger.debug("No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action)
|
||||||
|
return ScenarioMatch(
|
||||||
|
stock_code=stock_code,
|
||||||
|
matched_scenario=None,
|
||||||
|
action=playbook.default_action,
|
||||||
|
confidence=0,
|
||||||
|
rationale="No scenario conditions met — holding position",
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_global_rules(
|
||||||
|
self,
|
||||||
|
playbook: DayPlaybook,
|
||||||
|
portfolio_data: dict[str, Any],
|
||||||
|
) -> GlobalRule | None:
|
||||||
|
"""Check portfolio-level rules. Returns first triggered rule or None."""
|
||||||
|
for rule in playbook.global_rules:
|
||||||
|
if self._evaluate_global_condition(rule.condition, portfolio_data):
|
||||||
|
return rule
|
||||||
|
return None
|
||||||
|
|
||||||
|
def evaluate_condition(
|
||||||
|
self,
|
||||||
|
condition: StockCondition,
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate all non-None fields in condition as AND.
|
||||||
|
|
||||||
|
Returns True only if ALL specified conditions are met.
|
||||||
|
Empty condition (no fields set) returns False for safety.
|
||||||
|
"""
|
||||||
|
if not condition.has_any_condition():
|
||||||
|
return False
|
||||||
|
|
||||||
|
checks: list[bool] = []
|
||||||
|
|
||||||
|
rsi = self._safe_float(market_data.get("rsi"))
|
||||||
|
if condition.rsi_below is not None or condition.rsi_above is not None:
|
||||||
|
if "rsi" not in market_data:
|
||||||
|
self._warn_missing_key("rsi")
|
||||||
|
if condition.rsi_below is not None:
|
||||||
|
checks.append(rsi is not None and rsi < condition.rsi_below)
|
||||||
|
if condition.rsi_above is not None:
|
||||||
|
checks.append(rsi is not None and rsi > condition.rsi_above)
|
||||||
|
|
||||||
|
volume_ratio = self._safe_float(market_data.get("volume_ratio"))
|
||||||
|
if condition.volume_ratio_above is not None or condition.volume_ratio_below is not None:
|
||||||
|
if "volume_ratio" not in market_data:
|
||||||
|
self._warn_missing_key("volume_ratio")
|
||||||
|
if condition.volume_ratio_above is not None:
|
||||||
|
checks.append(volume_ratio is not None and volume_ratio > condition.volume_ratio_above)
|
||||||
|
if condition.volume_ratio_below is not None:
|
||||||
|
checks.append(volume_ratio is not None and volume_ratio < condition.volume_ratio_below)
|
||||||
|
|
||||||
|
price = self._safe_float(market_data.get("current_price"))
|
||||||
|
if condition.price_above is not None or condition.price_below is not None:
|
||||||
|
if "current_price" not in market_data:
|
||||||
|
self._warn_missing_key("current_price")
|
||||||
|
if condition.price_above is not None:
|
||||||
|
checks.append(price is not None and price > condition.price_above)
|
||||||
|
if condition.price_below is not None:
|
||||||
|
checks.append(price is not None and price < condition.price_below)
|
||||||
|
|
||||||
|
price_change_pct = self._safe_float(market_data.get("price_change_pct"))
|
||||||
|
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
|
||||||
|
if "price_change_pct" not in market_data:
|
||||||
|
self._warn_missing_key("price_change_pct")
|
||||||
|
if condition.price_change_pct_above is not None:
|
||||||
|
checks.append(price_change_pct is not None and price_change_pct > condition.price_change_pct_above)
|
||||||
|
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)
|
||||||
|
|
||||||
|
return len(checks) > 0 and all(checks)
|
||||||
|
|
||||||
|
def _evaluate_global_condition(
|
||||||
|
self,
|
||||||
|
condition_str: str,
|
||||||
|
portfolio_data: dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Evaluate a simple global condition string against portfolio data.
|
||||||
|
|
||||||
|
Supports: "field < value", "field > value", "field <= value", "field >= value"
|
||||||
|
"""
|
||||||
|
parts = condition_str.strip().split()
|
||||||
|
if len(parts) != 3:
|
||||||
|
logger.warning("Invalid global condition format: %s", condition_str)
|
||||||
|
return False
|
||||||
|
|
||||||
|
field_name, operator, value_str = parts
|
||||||
|
try:
|
||||||
|
threshold = float(value_str)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Invalid threshold in condition: %s", condition_str)
|
||||||
|
return False
|
||||||
|
|
||||||
|
actual = portfolio_data.get(field_name)
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
actual_val = float(actual)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if operator == "<":
|
||||||
|
return actual_val < threshold
|
||||||
|
elif operator == ">":
|
||||||
|
return actual_val > threshold
|
||||||
|
elif operator == "<=":
|
||||||
|
return actual_val <= threshold
|
||||||
|
elif operator == ">=":
|
||||||
|
return actual_val >= threshold
|
||||||
|
else:
|
||||||
|
logger.warning("Unknown operator in condition: %s", operator)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _build_match_details(
|
||||||
|
self,
|
||||||
|
condition: StockCondition,
|
||||||
|
market_data: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Build a summary of which conditions matched and their normalized values."""
|
||||||
|
details: dict[str, Any] = {}
|
||||||
|
|
||||||
|
if condition.rsi_below is not None or condition.rsi_above is not None:
|
||||||
|
details["rsi"] = self._safe_float(market_data.get("rsi"))
|
||||||
|
if condition.volume_ratio_above is not None or condition.volume_ratio_below is not None:
|
||||||
|
details["volume_ratio"] = self._safe_float(market_data.get("volume_ratio"))
|
||||||
|
if condition.price_above is not None or condition.price_below is not None:
|
||||||
|
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:
|
||||||
|
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
|
||||||
|
|
||||||
|
return details
|
||||||
365
tests/test_backup.py
Normal file
365
tests/test_backup.py
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
"""Tests for backup and disaster recovery system."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.backup.exporter import BackupExporter, ExportFormat
|
||||||
|
from src.backup.health_monitor import HealthMonitor, HealthStatus
|
||||||
|
from src.backup.scheduler import BackupPolicy, BackupScheduler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_db(tmp_path: Path) -> Path:
|
||||||
|
"""Create a temporary test database."""
|
||||||
|
db_path = tmp_path / "test_trades.db"
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Create trades table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE trades (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
stock_code TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
quantity INTEGER NOT NULL,
|
||||||
|
price REAL NOT NULL,
|
||||||
|
confidence INTEGER NOT NULL,
|
||||||
|
rationale TEXT,
|
||||||
|
pnl REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Insert test data
|
||||||
|
test_trades = [
|
||||||
|
("2024-01-01T10:00:00Z", "005930", "BUY", 10, 70000.0, 85, "Test buy", 0.0),
|
||||||
|
("2024-01-01T11:00:00Z", "005930", "SELL", 10, 71000.0, 90, "Test sell", 10000.0),
|
||||||
|
("2024-01-02T10:00:00Z", "AAPL", "BUY", 5, 180.0, 88, "Tech buy", 0.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
cursor.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO trades (timestamp, stock_code, action, quantity, price, confidence, rationale, pnl)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
test_trades,
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupExporter:
|
||||||
|
"""Test BackupExporter functionality."""
|
||||||
|
|
||||||
|
def test_exporter_init(self, temp_db: Path) -> None:
|
||||||
|
"""Test exporter initialization."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
assert exporter.db_path == str(temp_db)
|
||||||
|
|
||||||
|
def test_export_json(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test JSON export."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
output_dir = tmp_path / "exports"
|
||||||
|
|
||||||
|
results = exporter.export_all(
|
||||||
|
output_dir, formats=[ExportFormat.JSON], compress=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ExportFormat.JSON in results
|
||||||
|
assert results[ExportFormat.JSON].exists()
|
||||||
|
assert results[ExportFormat.JSON].suffix == ".json"
|
||||||
|
|
||||||
|
def test_export_json_compressed(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test compressed JSON export."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
output_dir = tmp_path / "exports"
|
||||||
|
|
||||||
|
results = exporter.export_all(
|
||||||
|
output_dir, formats=[ExportFormat.JSON], compress=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ExportFormat.JSON in results
|
||||||
|
assert results[ExportFormat.JSON].suffix == ".gz"
|
||||||
|
|
||||||
|
def test_export_csv(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test CSV export."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
output_dir = tmp_path / "exports"
|
||||||
|
|
||||||
|
results = exporter.export_all(
|
||||||
|
output_dir, formats=[ExportFormat.CSV], compress=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ExportFormat.CSV in results
|
||||||
|
assert results[ExportFormat.CSV].exists()
|
||||||
|
|
||||||
|
# Verify CSV content
|
||||||
|
with open(results[ExportFormat.CSV], "r") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
assert len(lines) == 4 # Header + 3 rows
|
||||||
|
|
||||||
|
def test_export_all_formats(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test exporting all formats."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
output_dir = tmp_path / "exports"
|
||||||
|
|
||||||
|
# Skip Parquet if pyarrow not available
|
||||||
|
try:
|
||||||
|
import pyarrow # noqa: F401
|
||||||
|
|
||||||
|
formats = [ExportFormat.JSON, ExportFormat.CSV, ExportFormat.PARQUET]
|
||||||
|
except ImportError:
|
||||||
|
formats = [ExportFormat.JSON, ExportFormat.CSV]
|
||||||
|
|
||||||
|
results = exporter.export_all(output_dir, formats=formats, compress=False)
|
||||||
|
|
||||||
|
for fmt in formats:
|
||||||
|
assert fmt in results
|
||||||
|
assert results[fmt].exists()
|
||||||
|
|
||||||
|
def test_incremental_export(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test incremental export."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
output_dir = tmp_path / "exports"
|
||||||
|
|
||||||
|
# Export only trades after Jan 2
|
||||||
|
cutoff = datetime(2024, 1, 2, tzinfo=UTC)
|
||||||
|
results = exporter.export_all(
|
||||||
|
output_dir,
|
||||||
|
formats=[ExportFormat.JSON],
|
||||||
|
compress=False,
|
||||||
|
incremental_since=cutoff,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only have 1 trade (AAPL on Jan 2)
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(results[ExportFormat.JSON], "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert data["record_count"] == 1
|
||||||
|
assert data["trades"][0]["stock_code"] == "AAPL"
|
||||||
|
|
||||||
|
def test_get_export_stats(self, temp_db: Path) -> None:
|
||||||
|
"""Test export statistics."""
|
||||||
|
exporter = BackupExporter(str(temp_db))
|
||||||
|
stats = exporter.get_export_stats()
|
||||||
|
|
||||||
|
assert stats["total_trades"] == 3
|
||||||
|
assert "date_range" in stats
|
||||||
|
assert "db_size_bytes" in stats
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupScheduler:
|
||||||
|
"""Test BackupScheduler functionality."""
|
||||||
|
|
||||||
|
def test_scheduler_init(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test scheduler initialization."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
assert scheduler.db_path == temp_db
|
||||||
|
assert (backup_dir / "daily").exists()
|
||||||
|
assert (backup_dir / "weekly").exists()
|
||||||
|
assert (backup_dir / "monthly").exists()
|
||||||
|
|
||||||
|
def test_create_daily_backup(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test daily backup creation."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
metadata = scheduler.create_backup(BackupPolicy.DAILY, verify=True)
|
||||||
|
|
||||||
|
assert metadata.policy == BackupPolicy.DAILY
|
||||||
|
assert metadata.file_path.exists()
|
||||||
|
assert metadata.size_bytes > 0
|
||||||
|
assert metadata.checksum is not None
|
||||||
|
|
||||||
|
def test_create_weekly_backup(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test weekly backup creation."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
metadata = scheduler.create_backup(BackupPolicy.WEEKLY, verify=False)
|
||||||
|
|
||||||
|
assert metadata.policy == BackupPolicy.WEEKLY
|
||||||
|
assert metadata.file_path.exists()
|
||||||
|
assert metadata.checksum is None # verify=False
|
||||||
|
|
||||||
|
def test_list_backups(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test listing backups."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
scheduler.create_backup(BackupPolicy.WEEKLY)
|
||||||
|
|
||||||
|
backups = scheduler.list_backups()
|
||||||
|
assert len(backups) == 2
|
||||||
|
|
||||||
|
daily_backups = scheduler.list_backups(BackupPolicy.DAILY)
|
||||||
|
assert len(daily_backups) == 1
|
||||||
|
assert daily_backups[0].policy == BackupPolicy.DAILY
|
||||||
|
|
||||||
|
def test_cleanup_old_backups(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test cleanup of old backups."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir, daily_retention_days=0)
|
||||||
|
|
||||||
|
# Create a backup
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
# Cleanup should remove it (0 day retention)
|
||||||
|
removed = scheduler.cleanup_old_backups()
|
||||||
|
assert removed[BackupPolicy.DAILY] >= 1
|
||||||
|
|
||||||
|
def test_backup_stats(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test backup statistics."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
scheduler.create_backup(BackupPolicy.MONTHLY)
|
||||||
|
|
||||||
|
stats = scheduler.get_backup_stats()
|
||||||
|
|
||||||
|
assert stats["daily"]["count"] == 1
|
||||||
|
assert stats["monthly"]["count"] == 1
|
||||||
|
assert stats["daily"]["total_size_bytes"] > 0
|
||||||
|
|
||||||
|
def test_restore_backup(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test backup restoration."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
metadata = scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
# Modify database
|
||||||
|
conn = sqlite3.connect(str(temp_db))
|
||||||
|
conn.execute("DELETE FROM trades")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Restore
|
||||||
|
scheduler.restore_backup(metadata, verify=True)
|
||||||
|
|
||||||
|
# Verify restoration
|
||||||
|
conn = sqlite3.connect(str(temp_db))
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM trades")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert count == 3 # Original 3 trades restored
|
||||||
|
|
||||||
|
|
||||||
|
class TestHealthMonitor:
|
||||||
|
"""Test HealthMonitor functionality."""
|
||||||
|
|
||||||
|
def test_monitor_init(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test monitor initialization."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir)
|
||||||
|
|
||||||
|
assert monitor.db_path == temp_db
|
||||||
|
|
||||||
|
def test_check_database_health_ok(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test database health check (healthy)."""
|
||||||
|
monitor = HealthMonitor(str(temp_db), tmp_path / "backups")
|
||||||
|
result = monitor.check_database_health()
|
||||||
|
|
||||||
|
assert result.status == HealthStatus.HEALTHY
|
||||||
|
assert "healthy" in result.message.lower()
|
||||||
|
assert result.details is not None
|
||||||
|
assert result.details["trade_count"] == 3
|
||||||
|
|
||||||
|
def test_check_database_health_missing(self, tmp_path: Path) -> None:
|
||||||
|
"""Test database health check (missing file)."""
|
||||||
|
non_existent = tmp_path / "missing.db"
|
||||||
|
monitor = HealthMonitor(str(non_existent), tmp_path / "backups")
|
||||||
|
result = monitor.check_database_health()
|
||||||
|
|
||||||
|
assert result.status == HealthStatus.UNHEALTHY
|
||||||
|
assert "not found" in result.message.lower()
|
||||||
|
|
||||||
|
def test_check_disk_space(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test disk space check."""
|
||||||
|
monitor = HealthMonitor(str(temp_db), tmp_path, min_disk_space_gb=0.001)
|
||||||
|
result = monitor.check_disk_space()
|
||||||
|
|
||||||
|
# Should be healthy with minimal requirement
|
||||||
|
assert result.status in [HealthStatus.HEALTHY, HealthStatus.DEGRADED]
|
||||||
|
assert result.details is not None
|
||||||
|
assert "free_gb" in result.details
|
||||||
|
|
||||||
|
def test_check_backup_recency_no_backups(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test backup recency check (no backups)."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
backup_dir.mkdir()
|
||||||
|
(backup_dir / "daily").mkdir()
|
||||||
|
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir)
|
||||||
|
result = monitor.check_backup_recency()
|
||||||
|
|
||||||
|
assert result.status == HealthStatus.UNHEALTHY
|
||||||
|
assert "no" in result.message.lower()
|
||||||
|
|
||||||
|
def test_check_backup_recency_recent(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test backup recency check (recent backup)."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir)
|
||||||
|
result = monitor.check_backup_recency()
|
||||||
|
|
||||||
|
assert result.status == HealthStatus.HEALTHY
|
||||||
|
assert "recent" in result.message.lower()
|
||||||
|
|
||||||
|
def test_run_all_checks(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test running all health checks."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir, min_disk_space_gb=0.001)
|
||||||
|
checks = monitor.run_all_checks()
|
||||||
|
|
||||||
|
assert "database" in checks
|
||||||
|
assert "disk_space" in checks
|
||||||
|
assert "backup_recency" in checks
|
||||||
|
assert checks["database"].status == HealthStatus.HEALTHY
|
||||||
|
|
||||||
|
def test_get_overall_status(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test overall health status."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir, min_disk_space_gb=0.001)
|
||||||
|
status = monitor.get_overall_status()
|
||||||
|
|
||||||
|
assert status in [HealthStatus.HEALTHY, HealthStatus.DEGRADED]
|
||||||
|
|
||||||
|
def test_get_health_report(self, temp_db: Path, tmp_path: Path) -> None:
|
||||||
|
"""Test health report generation."""
|
||||||
|
backup_dir = tmp_path / "backups"
|
||||||
|
scheduler = BackupScheduler(str(temp_db), backup_dir)
|
||||||
|
scheduler.create_backup(BackupPolicy.DAILY)
|
||||||
|
|
||||||
|
monitor = HealthMonitor(str(temp_db), backup_dir, min_disk_space_gb=0.001)
|
||||||
|
report = monitor.get_health_report()
|
||||||
|
|
||||||
|
assert "overall_status" in report
|
||||||
|
assert "timestamp" in report
|
||||||
|
assert "checks" in report
|
||||||
|
assert len(report["checks"]) == 3
|
||||||
@@ -126,7 +126,7 @@ class TestPromptConstruction:
|
|||||||
"orderbook": {"asks": [], "bids": []},
|
"orderbook": {"asks": [], "bids": []},
|
||||||
"foreigner_net": -50000,
|
"foreigner_net": -50000,
|
||||||
}
|
}
|
||||||
prompt = client.build_prompt(market_data)
|
prompt = client.build_prompt_sync(market_data)
|
||||||
assert "005930" in prompt
|
assert "005930" in prompt
|
||||||
|
|
||||||
def test_prompt_contains_price(self, settings):
|
def test_prompt_contains_price(self, settings):
|
||||||
@@ -137,7 +137,7 @@ class TestPromptConstruction:
|
|||||||
"orderbook": {"asks": [], "bids": []},
|
"orderbook": {"asks": [], "bids": []},
|
||||||
"foreigner_net": -50000,
|
"foreigner_net": -50000,
|
||||||
}
|
}
|
||||||
prompt = client.build_prompt(market_data)
|
prompt = client.build_prompt_sync(market_data)
|
||||||
assert "72000" in prompt
|
assert "72000" in prompt
|
||||||
|
|
||||||
def test_prompt_enforces_json_output_format(self, settings):
|
def test_prompt_enforces_json_output_format(self, settings):
|
||||||
@@ -148,7 +148,125 @@ class TestPromptConstruction:
|
|||||||
"orderbook": {"asks": [], "bids": []},
|
"orderbook": {"asks": [], "bids": []},
|
||||||
"foreigner_net": 0,
|
"foreigner_net": 0,
|
||||||
}
|
}
|
||||||
prompt = client.build_prompt(market_data)
|
prompt = client.build_prompt_sync(market_data)
|
||||||
assert "JSON" in prompt
|
assert "JSON" in prompt
|
||||||
assert "action" in prompt
|
assert "action" in prompt
|
||||||
assert "confidence" in prompt
|
assert "confidence" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Batch Decision Making
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBatchDecisionParsing:
|
||||||
|
"""Batch response parser must handle JSON arrays correctly."""
|
||||||
|
|
||||||
|
def test_parse_valid_batch_response(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [
|
||||||
|
{"stock_code": "AAPL", "current_price": 185.5},
|
||||||
|
{"stock_code": "MSFT", "current_price": 420.0},
|
||||||
|
]
|
||||||
|
raw = """[
|
||||||
|
{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "Strong momentum"},
|
||||||
|
{"code": "MSFT", "action": "HOLD", "confidence": 50, "rationale": "Wait for earnings"}
|
||||||
|
]"""
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert len(decisions) == 2
|
||||||
|
assert decisions["AAPL"].action == "BUY"
|
||||||
|
assert decisions["AAPL"].confidence == 85
|
||||||
|
assert decisions["MSFT"].action == "HOLD"
|
||||||
|
assert decisions["MSFT"].confidence == 50
|
||||||
|
|
||||||
|
def test_parse_batch_with_markdown_wrapper(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = """```json
|
||||||
|
[{"code": "AAPL", "action": "BUY", "confidence": 90, "rationale": "Good"}]
|
||||||
|
```"""
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "BUY"
|
||||||
|
assert decisions["AAPL"].confidence == 90
|
||||||
|
|
||||||
|
def test_parse_batch_empty_response_returns_hold_for_all(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [
|
||||||
|
{"stock_code": "AAPL", "current_price": 185.5},
|
||||||
|
{"stock_code": "MSFT", "current_price": 420.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response("", stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert len(decisions) == 2
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
assert decisions["MSFT"].action == "HOLD"
|
||||||
|
|
||||||
|
def test_parse_batch_malformed_json_returns_hold_for_all(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = "This is not JSON"
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
|
||||||
|
def test_parse_batch_not_array_returns_hold_for_all(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = '{"code": "AAPL", "action": "BUY", "confidence": 90, "rationale": "Good"}'
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
|
||||||
|
def test_parse_batch_missing_stock_gets_hold(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [
|
||||||
|
{"stock_code": "AAPL", "current_price": 185.5},
|
||||||
|
{"stock_code": "MSFT", "current_price": 420.0},
|
||||||
|
]
|
||||||
|
# Response only has AAPL, MSFT is missing
|
||||||
|
raw = '[{"code": "AAPL", "action": "BUY", "confidence": 85, "rationale": "Good"}]'
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "BUY"
|
||||||
|
assert decisions["MSFT"].action == "HOLD"
|
||||||
|
assert decisions["MSFT"].confidence == 0
|
||||||
|
|
||||||
|
def test_parse_batch_invalid_action_becomes_hold(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = '[{"code": "AAPL", "action": "YOLO", "confidence": 90, "rationale": "Moon"}]'
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
|
||||||
|
def test_parse_batch_low_confidence_becomes_hold(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = '[{"code": "AAPL", "action": "BUY", "confidence": 65, "rationale": "Weak"}]'
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
assert decisions["AAPL"].confidence == 65
|
||||||
|
|
||||||
|
def test_parse_batch_missing_fields_gets_hold(self, settings):
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
stocks_data = [{"stock_code": "AAPL", "current_price": 185.5}]
|
||||||
|
raw = '[{"code": "AAPL", "action": "BUY"}]' # Missing confidence and rationale
|
||||||
|
|
||||||
|
decisions = client._parse_batch_response(raw, stocks_data, token_count=100)
|
||||||
|
|
||||||
|
assert decisions["AAPL"].action == "HOLD"
|
||||||
|
assert decisions["AAPL"].confidence == 0
|
||||||
|
|||||||
@@ -49,6 +49,110 @@ class TestTokenManagement:
|
|||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_token_refresh_calls_api_once(self, settings):
|
||||||
|
"""Multiple concurrent token requests should only call API once."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
|
||||||
|
# Track how many times the mock API is called
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def create_mock_resp():
|
||||||
|
call_count[0] += 1
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"access_token": "tok_concurrent",
|
||||||
|
"token_type": "Bearer",
|
||||||
|
"expires_in": 86400,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return mock_resp
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=create_mock_resp()):
|
||||||
|
# Launch 5 concurrent token requests
|
||||||
|
tokens = await asyncio.gather(
|
||||||
|
broker._ensure_token(),
|
||||||
|
broker._ensure_token(),
|
||||||
|
broker._ensure_token(),
|
||||||
|
broker._ensure_token(),
|
||||||
|
broker._ensure_token(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# All should get the same token
|
||||||
|
assert all(t == "tok_concurrent" for t in tokens)
|
||||||
|
# API should be called only once (due to lock)
|
||||||
|
assert call_count[0] == 1
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings):
|
||||||
|
"""Token refresh should enforce cooldown after failure (issue #54)."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._refresh_cooldown = 2.0 # Short cooldown for testing
|
||||||
|
|
||||||
|
# First refresh attempt fails with 403 (EGW00133)
|
||||||
|
mock_resp_403 = AsyncMock()
|
||||||
|
mock_resp_403.status = 403
|
||||||
|
mock_resp_403.text = AsyncMock(
|
||||||
|
return_value='{"error_code":"EGW00133","error_description":"접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"}'
|
||||||
|
)
|
||||||
|
mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403)
|
||||||
|
mock_resp_403.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp_403):
|
||||||
|
# First attempt should fail with 403
|
||||||
|
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||||
|
await broker._ensure_token()
|
||||||
|
|
||||||
|
# Second attempt within cooldown should fail with cooldown error
|
||||||
|
with pytest.raises(ConnectionError, match="Token refresh on cooldown"):
|
||||||
|
await broker._ensure_token()
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_refresh_allowed_after_cooldown(self, settings):
|
||||||
|
"""Token refresh should be allowed after cooldown period expires."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._refresh_cooldown = 0.1 # Very short cooldown for testing
|
||||||
|
|
||||||
|
# First attempt fails
|
||||||
|
mock_resp_403 = AsyncMock()
|
||||||
|
mock_resp_403.status = 403
|
||||||
|
mock_resp_403.text = AsyncMock(return_value='{"error_code":"EGW00133"}')
|
||||||
|
mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403)
|
||||||
|
mock_resp_403.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
# Second attempt succeeds
|
||||||
|
mock_resp_200 = AsyncMock()
|
||||||
|
mock_resp_200.status = 200
|
||||||
|
mock_resp_200.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"access_token": "tok_after_cooldown",
|
||||||
|
"expires_in": 86400,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
mock_resp_200.__aenter__ = AsyncMock(return_value=mock_resp_200)
|
||||||
|
mock_resp_200.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp_403):
|
||||||
|
with pytest.raises(ConnectionError, match="Token refresh failed"):
|
||||||
|
await broker._ensure_token()
|
||||||
|
|
||||||
|
# Wait for cooldown to expire
|
||||||
|
await asyncio.sleep(0.15)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp_200):
|
||||||
|
token = await broker._ensure_token()
|
||||||
|
assert token == "tok_after_cooldown"
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Network Error Handling
|
# Network Error Handling
|
||||||
@@ -107,6 +211,38 @@ class TestRateLimiter:
|
|||||||
await broker._rate_limiter.acquire()
|
await broker._rate_limiter.acquire()
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_order_acquires_rate_limiter_twice(self, settings):
|
||||||
|
"""send_order must acquire rate limiter for both hash key and order call."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._access_token = "tok"
|
||||||
|
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
|
||||||
|
|
||||||
|
# Mock hash key response
|
||||||
|
mock_hash_resp = AsyncMock()
|
||||||
|
mock_hash_resp.status = 200
|
||||||
|
mock_hash_resp.json = AsyncMock(return_value={"HASH": "abc123"})
|
||||||
|
mock_hash_resp.__aenter__ = AsyncMock(return_value=mock_hash_resp)
|
||||||
|
mock_hash_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
# Mock order response
|
||||||
|
mock_order_resp = AsyncMock()
|
||||||
|
mock_order_resp.status = 200
|
||||||
|
mock_order_resp.json = AsyncMock(return_value={"rt_cd": "0"})
|
||||||
|
mock_order_resp.__aenter__ = AsyncMock(return_value=mock_order_resp)
|
||||||
|
mock_order_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post", side_effect=[mock_hash_resp, mock_order_resp]
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
broker._rate_limiter, "acquire", new_callable=AsyncMock
|
||||||
|
) as mock_acquire:
|
||||||
|
await broker.send_order("005930", "BUY", 1, 50000)
|
||||||
|
assert mock_acquire.call_count == 2
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Hash Key Generation
|
# Hash Key Generation
|
||||||
@@ -136,3 +272,27 @@ class TestHashKey:
|
|||||||
assert len(hash_key) > 0
|
assert len(hash_key) > 0
|
||||||
|
|
||||||
await broker.close()
|
await broker.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hash_key_acquires_rate_limiter(self, settings):
|
||||||
|
"""_get_hash_key must go through the rate limiter to prevent burst."""
|
||||||
|
broker = KISBroker(settings)
|
||||||
|
broker._access_token = "tok"
|
||||||
|
broker._token_expires_at = asyncio.get_event_loop().time() + 3600
|
||||||
|
|
||||||
|
body = {"CANO": "12345678", "ACNT_PRDT_CD": "01"}
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"HASH": "abc123hash"})
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
with patch.object(
|
||||||
|
broker._rate_limiter, "acquire", new_callable=AsyncMock
|
||||||
|
) as mock_acquire:
|
||||||
|
await broker._get_hash_key(body)
|
||||||
|
mock_acquire.assert_called_once()
|
||||||
|
|
||||||
|
await broker.close()
|
||||||
|
|||||||
350
tests/test_context.py
Normal file
350
tests/test_context.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Tests for the multi-layered context management system."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.context.aggregator import ContextAggregator
|
||||||
|
from src.context.layer import LAYER_CONFIG, ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.db import init_db, log_trade
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_conn() -> sqlite3.Connection:
|
||||||
|
"""Provide an in-memory database connection."""
|
||||||
|
return init_db(":memory:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(db_conn: sqlite3.Connection) -> ContextStore:
|
||||||
|
"""Provide a ContextStore instance."""
|
||||||
|
return ContextStore(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def aggregator(db_conn: sqlite3.Connection) -> ContextAggregator:
|
||||||
|
"""Provide a ContextAggregator instance."""
|
||||||
|
return ContextAggregator(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextStore:
|
||||||
|
"""Test suite for ContextStore CRUD operations."""
|
||||||
|
|
||||||
|
def test_set_and_get_context(self, store: ContextStore) -> None:
|
||||||
|
"""Test setting and retrieving a context value."""
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl", 1234.56)
|
||||||
|
|
||||||
|
value = store.get_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl")
|
||||||
|
assert value == 1234.56
|
||||||
|
|
||||||
|
def test_get_nonexistent_context(self, store: ContextStore) -> None:
|
||||||
|
"""Test retrieving a non-existent context returns None."""
|
||||||
|
value = store.get_context(ContextLayer.L6_DAILY, "2026-02-04", "nonexistent")
|
||||||
|
assert value is None
|
||||||
|
|
||||||
|
def test_update_existing_context(self, store: ContextStore) -> None:
|
||||||
|
"""Test updating an existing context value."""
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl", 100.0)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl", 200.0)
|
||||||
|
|
||||||
|
value = store.get_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl")
|
||||||
|
assert value == 200.0
|
||||||
|
|
||||||
|
def test_get_all_contexts_for_layer(self, store: ContextStore) -> None:
|
||||||
|
"""Test retrieving all contexts for a specific layer."""
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "total_pnl", 100.0)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "trade_count", 10)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "win_rate", 60.5)
|
||||||
|
|
||||||
|
contexts = store.get_all_contexts(ContextLayer.L6_DAILY, "2026-02-04")
|
||||||
|
assert len(contexts) == 3
|
||||||
|
assert contexts["total_pnl"] == 100.0
|
||||||
|
assert contexts["trade_count"] == 10
|
||||||
|
assert contexts["win_rate"] == 60.5
|
||||||
|
|
||||||
|
def test_get_latest_timeframe(self, store: ContextStore) -> None:
|
||||||
|
"""Test getting the most recent timeframe for a layer."""
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "total_pnl", 100.0)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "total_pnl", 200.0)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "total_pnl", 150.0)
|
||||||
|
|
||||||
|
latest = store.get_latest_timeframe(ContextLayer.L6_DAILY)
|
||||||
|
# Latest by updated_at, which should be the last one set
|
||||||
|
assert latest == "2026-02-02"
|
||||||
|
|
||||||
|
def test_delete_old_contexts(
|
||||||
|
self, store: ContextStore, db_conn: sqlite3.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Test deleting contexts older than a cutoff date."""
|
||||||
|
# Insert contexts with specific old timestamps
|
||||||
|
# (bypassing set_context which uses current time)
|
||||||
|
old_date = "2026-01-01T00:00:00+00:00"
|
||||||
|
new_date = "2026-02-01T00:00:00+00:00"
|
||||||
|
|
||||||
|
db_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(ContextLayer.L6_DAILY.value, "2026-01-01", "total_pnl", "100.0", old_date, old_date),
|
||||||
|
)
|
||||||
|
db_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(ContextLayer.L6_DAILY.value, "2026-02-01", "total_pnl", "200.0", new_date, new_date),
|
||||||
|
)
|
||||||
|
db_conn.commit()
|
||||||
|
|
||||||
|
# Delete contexts before 2026-01-15
|
||||||
|
cutoff = "2026-01-15T00:00:00+00:00"
|
||||||
|
deleted = store.delete_old_contexts(ContextLayer.L6_DAILY, cutoff)
|
||||||
|
|
||||||
|
# Should delete the 2026-01-01 context
|
||||||
|
assert deleted == 1
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, "2026-02-01", "total_pnl") == 200.0
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, "2026-01-01", "total_pnl") is None
|
||||||
|
|
||||||
|
def test_cleanup_expired_contexts(
|
||||||
|
self, store: ContextStore, db_conn: sqlite3.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Test automatic cleanup based on retention policies."""
|
||||||
|
# Set old contexts for L7 (7 day retention)
|
||||||
|
old_date = (datetime.now(UTC) - timedelta(days=10)).isoformat()
|
||||||
|
db_conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO contexts (layer, timeframe, key, value, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(ContextLayer.L7_REALTIME.value, "2026-01-01", "price", "100.0", old_date, old_date),
|
||||||
|
)
|
||||||
|
db_conn.commit()
|
||||||
|
|
||||||
|
deleted_counts = store.cleanup_expired_contexts()
|
||||||
|
|
||||||
|
# Should delete the old L7 context (10 days > 7 day retention)
|
||||||
|
assert deleted_counts[ContextLayer.L7_REALTIME] == 1
|
||||||
|
|
||||||
|
# L1 has no retention limit, so nothing should be deleted
|
||||||
|
assert deleted_counts[ContextLayer.L1_LEGACY] == 0
|
||||||
|
|
||||||
|
def test_context_metadata_initialized(
|
||||||
|
self, store: ContextStore, db_conn: sqlite3.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Test that context metadata is properly initialized."""
|
||||||
|
cursor = db_conn.execute("SELECT COUNT(*) FROM context_metadata")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Should have metadata for all 7 layers
|
||||||
|
assert count == 7
|
||||||
|
|
||||||
|
# Verify L1 metadata
|
||||||
|
cursor = db_conn.execute(
|
||||||
|
"SELECT description, retention_days FROM context_metadata WHERE layer = ?",
|
||||||
|
(ContextLayer.L1_LEGACY.value,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert "Cumulative trading history" in row[0]
|
||||||
|
assert row[1] is None # No retention limit for L1
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextAggregator:
|
||||||
|
"""Test suite for ContextAggregator."""
|
||||||
|
|
||||||
|
def test_aggregate_daily_from_trades(
|
||||||
|
self, aggregator: ContextAggregator, db_conn: sqlite3.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Test aggregating daily metrics from trades."""
|
||||||
|
date = "2026-02-04"
|
||||||
|
|
||||||
|
# Create sample trades
|
||||||
|
log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=500)
|
||||||
|
log_trade(db_conn, "000660", "SELL", 90, "Take profit", quantity=5, price=50000, pnl=1500)
|
||||||
|
log_trade(db_conn, "035720", "HOLD", 75, "Wait", quantity=0, price=0, pnl=0)
|
||||||
|
|
||||||
|
# Manually set timestamps to the target date
|
||||||
|
db_conn.execute(
|
||||||
|
f"UPDATE trades SET timestamp = '{date}T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
db_conn.commit()
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_daily_from_trades(date)
|
||||||
|
|
||||||
|
# Verify L6 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "trade_count") == 3
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "buys") == 1
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "sells") == 1
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "holds") == 1
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl") == 2000.0
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "unique_stocks") == 3
|
||||||
|
# 2 wins, 0 losses
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "win_rate") == 100.0
|
||||||
|
|
||||||
|
def test_aggregate_weekly_from_daily(self, aggregator: ContextAggregator) -> None:
|
||||||
|
"""Test aggregating weekly metrics from daily."""
|
||||||
|
week = "2026-W06"
|
||||||
|
|
||||||
|
# Set daily contexts
|
||||||
|
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "total_pnl", 100.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "total_pnl", 200.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "avg_confidence", 80.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "avg_confidence", 85.0)
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_weekly_from_daily(week)
|
||||||
|
|
||||||
|
# Verify L5 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
weekly_pnl = store.get_context(ContextLayer.L5_WEEKLY, week, "weekly_pnl")
|
||||||
|
avg_conf = store.get_context(ContextLayer.L5_WEEKLY, week, "avg_confidence")
|
||||||
|
|
||||||
|
assert weekly_pnl == 300.0
|
||||||
|
assert avg_conf == 82.5
|
||||||
|
|
||||||
|
def test_aggregate_monthly_from_weekly(self, aggregator: ContextAggregator) -> None:
|
||||||
|
"""Test aggregating monthly metrics from weekly."""
|
||||||
|
month = "2026-02"
|
||||||
|
|
||||||
|
# Set weekly contexts
|
||||||
|
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W05", "weekly_pnl", 100.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W06", "weekly_pnl", 200.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W07", "weekly_pnl", 150.0)
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_monthly_from_weekly(month)
|
||||||
|
|
||||||
|
# Verify L4 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
monthly_pnl = store.get_context(ContextLayer.L4_MONTHLY, month, "monthly_pnl")
|
||||||
|
assert monthly_pnl == 450.0
|
||||||
|
|
||||||
|
def test_aggregate_quarterly_from_monthly(self, aggregator: ContextAggregator) -> None:
|
||||||
|
"""Test aggregating quarterly metrics from monthly."""
|
||||||
|
quarter = "2026-Q1"
|
||||||
|
|
||||||
|
# Set monthly contexts for Q1 (Jan, Feb, Mar)
|
||||||
|
aggregator.store.set_context(ContextLayer.L4_MONTHLY, "2026-01", "monthly_pnl", 1000.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L4_MONTHLY, "2026-02", "monthly_pnl", 2000.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L4_MONTHLY, "2026-03", "monthly_pnl", 1500.0)
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_quarterly_from_monthly(quarter)
|
||||||
|
|
||||||
|
# Verify L3 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
quarterly_pnl = store.get_context(ContextLayer.L3_QUARTERLY, quarter, "quarterly_pnl")
|
||||||
|
assert quarterly_pnl == 4500.0
|
||||||
|
|
||||||
|
def test_aggregate_annual_from_quarterly(self, aggregator: ContextAggregator) -> None:
|
||||||
|
"""Test aggregating annual metrics from quarterly."""
|
||||||
|
year = "2026"
|
||||||
|
|
||||||
|
# Set quarterly contexts for all 4 quarters
|
||||||
|
aggregator.store.set_context(ContextLayer.L3_QUARTERLY, "2026-Q1", "quarterly_pnl", 4500.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L3_QUARTERLY, "2026-Q2", "quarterly_pnl", 5000.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L3_QUARTERLY, "2026-Q3", "quarterly_pnl", 4800.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L3_QUARTERLY, "2026-Q4", "quarterly_pnl", 5200.0)
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_annual_from_quarterly(year)
|
||||||
|
|
||||||
|
# Verify L2 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
annual_pnl = store.get_context(ContextLayer.L2_ANNUAL, year, "annual_pnl")
|
||||||
|
assert annual_pnl == 19500.0
|
||||||
|
|
||||||
|
def test_aggregate_legacy_from_annual(self, aggregator: ContextAggregator) -> None:
|
||||||
|
"""Test aggregating legacy metrics from all annual data."""
|
||||||
|
# Set annual contexts for multiple years
|
||||||
|
aggregator.store.set_context(ContextLayer.L2_ANNUAL, "2024", "annual_pnl", 10000.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L2_ANNUAL, "2025", "annual_pnl", 15000.0)
|
||||||
|
aggregator.store.set_context(ContextLayer.L2_ANNUAL, "2026", "annual_pnl", 20000.0)
|
||||||
|
|
||||||
|
# Aggregate
|
||||||
|
aggregator.aggregate_legacy_from_annual()
|
||||||
|
|
||||||
|
# Verify L1 contexts
|
||||||
|
store = aggregator.store
|
||||||
|
total_pnl = store.get_context(ContextLayer.L1_LEGACY, "LEGACY", "total_pnl")
|
||||||
|
years_traded = store.get_context(ContextLayer.L1_LEGACY, "LEGACY", "years_traded")
|
||||||
|
avg_annual_pnl = store.get_context(ContextLayer.L1_LEGACY, "LEGACY", "avg_annual_pnl")
|
||||||
|
|
||||||
|
assert total_pnl == 45000.0
|
||||||
|
assert years_traded == 3
|
||||||
|
assert avg_annual_pnl == 15000.0
|
||||||
|
|
||||||
|
def test_run_all_aggregations(
|
||||||
|
self, aggregator: ContextAggregator, db_conn: sqlite3.Connection
|
||||||
|
) -> None:
|
||||||
|
"""Test running all aggregations from L7 to L1."""
|
||||||
|
date = "2026-02-04"
|
||||||
|
|
||||||
|
# Create sample trades
|
||||||
|
log_trade(db_conn, "005930", "BUY", 85, "Good signal", quantity=10, price=70000, pnl=1000)
|
||||||
|
|
||||||
|
# Set timestamp
|
||||||
|
db_conn.execute(f"UPDATE trades SET timestamp = '{date}T10:00:00+00:00'")
|
||||||
|
db_conn.commit()
|
||||||
|
|
||||||
|
# Run all aggregations
|
||||||
|
aggregator.run_all_aggregations()
|
||||||
|
|
||||||
|
# Verify data exists in each layer
|
||||||
|
store = aggregator.store
|
||||||
|
assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl") == 1000.0
|
||||||
|
current_week = datetime.now(UTC).strftime("%Y-W%V")
|
||||||
|
assert store.get_context(ContextLayer.L5_WEEKLY, current_week, "weekly_pnl") is not None
|
||||||
|
# Further layers depend on time alignment, just verify no crashes
|
||||||
|
|
||||||
|
|
||||||
|
class TestLayerMetadata:
|
||||||
|
"""Test suite for layer metadata configuration."""
|
||||||
|
|
||||||
|
def test_all_layers_have_metadata(self) -> None:
|
||||||
|
"""Test that all 7 layers have metadata defined."""
|
||||||
|
assert len(LAYER_CONFIG) == 7
|
||||||
|
|
||||||
|
for layer in ContextLayer:
|
||||||
|
assert layer in LAYER_CONFIG
|
||||||
|
|
||||||
|
def test_layer_retention_policies(self) -> None:
|
||||||
|
"""Test layer retention policies are correctly configured."""
|
||||||
|
# L1 should have no retention limit
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L1_LEGACY].retention_days is None
|
||||||
|
|
||||||
|
# L7 should have the shortest retention (7 days)
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L7_REALTIME].retention_days == 7
|
||||||
|
|
||||||
|
# L2 should have a long retention (10 years)
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L2_ANNUAL].retention_days == 365 * 10
|
||||||
|
|
||||||
|
def test_layer_aggregation_chain(self) -> None:
|
||||||
|
"""Test that the aggregation chain is properly configured."""
|
||||||
|
# L7 has no source (leaf layer)
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L7_REALTIME].aggregation_source is None
|
||||||
|
|
||||||
|
# L6 aggregates from L7
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L6_DAILY].aggregation_source == ContextLayer.L7_REALTIME
|
||||||
|
|
||||||
|
# L5 aggregates from L6
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L5_WEEKLY].aggregation_source == ContextLayer.L6_DAILY
|
||||||
|
|
||||||
|
# L4 aggregates from L5
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L4_MONTHLY].aggregation_source == ContextLayer.L5_WEEKLY
|
||||||
|
|
||||||
|
# L3 aggregates from L4
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L3_QUARTERLY].aggregation_source == ContextLayer.L4_MONTHLY
|
||||||
|
|
||||||
|
# L2 aggregates from L3
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L2_ANNUAL].aggregation_source == ContextLayer.L3_QUARTERLY
|
||||||
|
|
||||||
|
# L1 aggregates from L2
|
||||||
|
assert LAYER_CONFIG[ContextLayer.L1_LEGACY].aggregation_source == ContextLayer.L2_ANNUAL
|
||||||
673
tests/test_data_integration.py
Normal file
673
tests/test_data_integration.py
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
"""Tests for external data integration (news, economic calendar, market data)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.brain.gemini_client import GeminiClient
|
||||||
|
from src.data.economic_calendar import EconomicCalendar, EconomicEvent
|
||||||
|
from src.data.market_data import MarketBreadth, MarketData, MarketSentiment
|
||||||
|
from src.data.news_api import NewsAPI, NewsArticle, NewsSentiment
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# NewsAPI Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestNewsAPI:
|
||||||
|
"""Test news API integration with caching."""
|
||||||
|
|
||||||
|
def test_news_api_init_without_key(self):
|
||||||
|
"""NewsAPI should initialize without API key for testing."""
|
||||||
|
api = NewsAPI(api_key=None)
|
||||||
|
assert api._api_key is None
|
||||||
|
assert api._provider == "alphavantage"
|
||||||
|
assert api._cache_ttl == 300
|
||||||
|
|
||||||
|
def test_news_api_init_with_custom_settings(self):
|
||||||
|
"""NewsAPI should accept custom provider and cache TTL."""
|
||||||
|
api = NewsAPI(api_key="test_key", provider="newsapi", cache_ttl=600)
|
||||||
|
assert api._api_key == "test_key"
|
||||||
|
assert api._provider == "newsapi"
|
||||||
|
assert api._cache_ttl == 600
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_news_sentiment_without_api_key_returns_none(self):
|
||||||
|
"""Without API key, get_news_sentiment should return None."""
|
||||||
|
api = NewsAPI(api_key=None)
|
||||||
|
result = await api.get_news_sentiment("AAPL")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_hit_returns_cached_sentiment(self):
|
||||||
|
"""Cache hit should return cached sentiment without API call."""
|
||||||
|
api = NewsAPI(api_key="test_key")
|
||||||
|
|
||||||
|
# Manually populate cache
|
||||||
|
cached_sentiment = NewsSentiment(
|
||||||
|
stock_code="AAPL",
|
||||||
|
articles=[],
|
||||||
|
avg_sentiment=0.5,
|
||||||
|
article_count=0,
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
api._cache["AAPL"] = cached_sentiment
|
||||||
|
|
||||||
|
result = await api.get_news_sentiment("AAPL")
|
||||||
|
assert result is cached_sentiment
|
||||||
|
assert result.stock_code == "AAPL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cache_expiry_triggers_refetch(self):
|
||||||
|
"""Expired cache entry should trigger refetch."""
|
||||||
|
api = NewsAPI(api_key="test_key", cache_ttl=1)
|
||||||
|
|
||||||
|
# Add expired cache entry
|
||||||
|
expired_sentiment = NewsSentiment(
|
||||||
|
stock_code="AAPL",
|
||||||
|
articles=[],
|
||||||
|
avg_sentiment=0.5,
|
||||||
|
article_count=0,
|
||||||
|
fetched_at=time.time() - 10, # 10 seconds ago
|
||||||
|
)
|
||||||
|
api._cache["AAPL"] = expired_sentiment
|
||||||
|
|
||||||
|
# Mock the fetch to avoid real API call
|
||||||
|
with patch.object(api, "_fetch_news", new_callable=AsyncMock) as mock_fetch:
|
||||||
|
mock_fetch.return_value = None
|
||||||
|
result = await api.get_news_sentiment("AAPL")
|
||||||
|
|
||||||
|
# Should have attempted refetch since cache expired
|
||||||
|
mock_fetch.assert_called_once_with("AAPL")
|
||||||
|
|
||||||
|
def test_clear_cache(self):
|
||||||
|
"""clear_cache should empty the cache."""
|
||||||
|
api = NewsAPI(api_key="test_key")
|
||||||
|
api._cache["AAPL"] = NewsSentiment(
|
||||||
|
stock_code="AAPL",
|
||||||
|
articles=[],
|
||||||
|
avg_sentiment=0.0,
|
||||||
|
article_count=0,
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
assert len(api._cache) == 1
|
||||||
|
|
||||||
|
api.clear_cache()
|
||||||
|
assert len(api._cache) == 0
|
||||||
|
|
||||||
|
def test_parse_alphavantage_response_with_valid_data(self):
|
||||||
|
"""Should parse Alpha Vantage response correctly."""
|
||||||
|
api = NewsAPI(api_key="test_key", provider="alphavantage")
|
||||||
|
|
||||||
|
mock_response = {
|
||||||
|
"feed": [
|
||||||
|
{
|
||||||
|
"title": "Apple hits new high",
|
||||||
|
"summary": "Apple stock surges to record levels",
|
||||||
|
"source": "Reuters",
|
||||||
|
"time_published": "2026-02-04T10:00:00",
|
||||||
|
"url": "https://example.com/1",
|
||||||
|
"ticker_sentiment": [
|
||||||
|
{"ticker": "AAPL", "ticker_sentiment_score": "0.85"}
|
||||||
|
],
|
||||||
|
"overall_sentiment_score": "0.75",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Market volatility rises",
|
||||||
|
"summary": "Tech stocks face headwinds",
|
||||||
|
"source": "Bloomberg",
|
||||||
|
"time_published": "2026-02-04T09:00:00",
|
||||||
|
"url": "https://example.com/2",
|
||||||
|
"ticker_sentiment": [
|
||||||
|
{"ticker": "AAPL", "ticker_sentiment_score": "-0.3"}
|
||||||
|
],
|
||||||
|
"overall_sentiment_score": "-0.2",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = api._parse_alphavantage_response("AAPL", mock_response)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.stock_code == "AAPL"
|
||||||
|
assert result.article_count == 2
|
||||||
|
assert len(result.articles) == 2
|
||||||
|
assert result.articles[0].title == "Apple hits new high"
|
||||||
|
assert result.articles[0].sentiment_score == 0.85
|
||||||
|
assert result.articles[1].sentiment_score == -0.3
|
||||||
|
# Average: (0.85 - 0.3) / 2 = 0.275
|
||||||
|
assert abs(result.avg_sentiment - 0.275) < 0.01
|
||||||
|
|
||||||
|
def test_parse_alphavantage_response_without_feed_returns_none(self):
|
||||||
|
"""Should return None if 'feed' key is missing."""
|
||||||
|
api = NewsAPI(api_key="test_key", provider="alphavantage")
|
||||||
|
result = api._parse_alphavantage_response("AAPL", {})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_parse_newsapi_response_with_valid_data(self):
|
||||||
|
"""Should parse NewsAPI.org response correctly."""
|
||||||
|
api = NewsAPI(api_key="test_key", provider="newsapi")
|
||||||
|
|
||||||
|
mock_response = {
|
||||||
|
"status": "ok",
|
||||||
|
"articles": [
|
||||||
|
{
|
||||||
|
"title": "Apple stock surges",
|
||||||
|
"description": "Strong earnings beat expectations",
|
||||||
|
"source": {"name": "TechCrunch"},
|
||||||
|
"publishedAt": "2026-02-04T10:00:00Z",
|
||||||
|
"url": "https://example.com/1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tech sector faces risks",
|
||||||
|
"description": "Concerns over market downturn",
|
||||||
|
"source": {"name": "CNBC"},
|
||||||
|
"publishedAt": "2026-02-04T09:00:00Z",
|
||||||
|
"url": "https://example.com/2",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = api._parse_newsapi_response("AAPL", mock_response)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.stock_code == "AAPL"
|
||||||
|
assert result.article_count == 2
|
||||||
|
assert len(result.articles) == 2
|
||||||
|
assert result.articles[0].title == "Apple stock surges"
|
||||||
|
assert result.articles[0].source == "TechCrunch"
|
||||||
|
|
||||||
|
def test_estimate_sentiment_from_text_positive(self):
|
||||||
|
"""Should detect positive sentiment from keywords."""
|
||||||
|
api = NewsAPI()
|
||||||
|
text = "Stock price surges with strong profit growth and upgrade"
|
||||||
|
sentiment = api._estimate_sentiment_from_text(text)
|
||||||
|
assert sentiment > 0.5
|
||||||
|
|
||||||
|
def test_estimate_sentiment_from_text_negative(self):
|
||||||
|
"""Should detect negative sentiment from keywords."""
|
||||||
|
api = NewsAPI()
|
||||||
|
text = "Stock plunges on weak earnings, downgrade warning"
|
||||||
|
sentiment = api._estimate_sentiment_from_text(text)
|
||||||
|
assert sentiment < -0.5
|
||||||
|
|
||||||
|
def test_estimate_sentiment_from_text_neutral(self):
|
||||||
|
"""Should return neutral sentiment without keywords."""
|
||||||
|
api = NewsAPI()
|
||||||
|
text = "Company announces quarterly report"
|
||||||
|
sentiment = api._estimate_sentiment_from_text(text)
|
||||||
|
assert abs(sentiment) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EconomicCalendar Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEconomicCalendar:
|
||||||
|
"""Test economic calendar functionality."""
|
||||||
|
|
||||||
|
def test_economic_calendar_init(self):
|
||||||
|
"""EconomicCalendar should initialize correctly."""
|
||||||
|
calendar = EconomicCalendar(api_key="test_key")
|
||||||
|
assert calendar._api_key == "test_key"
|
||||||
|
assert len(calendar._events) == 0
|
||||||
|
|
||||||
|
def test_add_event(self):
|
||||||
|
"""Should be able to add events to calendar."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
event = EconomicEvent(
|
||||||
|
name="FOMC Meeting",
|
||||||
|
event_type="FOMC",
|
||||||
|
datetime=datetime(2026, 3, 18),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Interest rate decision",
|
||||||
|
)
|
||||||
|
calendar.add_event(event)
|
||||||
|
assert len(calendar._events) == 1
|
||||||
|
assert calendar._events[0].name == "FOMC Meeting"
|
||||||
|
|
||||||
|
def test_get_upcoming_events_filters_by_timeframe(self):
|
||||||
|
"""Should only return events within specified timeframe."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
|
||||||
|
# Add events at different times
|
||||||
|
now = datetime.now()
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="Event Tomorrow",
|
||||||
|
event_type="GDP",
|
||||||
|
datetime=now + timedelta(days=1),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Test event",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="Event Next Month",
|
||||||
|
event_type="CPI",
|
||||||
|
datetime=now + timedelta(days=30),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Test event",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get events for next 7 days
|
||||||
|
upcoming = calendar.get_upcoming_events(days_ahead=7, min_impact="HIGH")
|
||||||
|
assert upcoming.high_impact_count == 1
|
||||||
|
assert upcoming.events[0].name == "Event Tomorrow"
|
||||||
|
|
||||||
|
def test_get_upcoming_events_filters_by_impact(self):
|
||||||
|
"""Should filter events by minimum impact level."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="High Impact Event",
|
||||||
|
event_type="FOMC",
|
||||||
|
datetime=now + timedelta(days=1),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="Low Impact Event",
|
||||||
|
event_type="OTHER",
|
||||||
|
datetime=now + timedelta(days=1),
|
||||||
|
impact="LOW",
|
||||||
|
country="US",
|
||||||
|
description="Test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter for HIGH impact only
|
||||||
|
upcoming = calendar.get_upcoming_events(days_ahead=7, min_impact="HIGH")
|
||||||
|
assert upcoming.high_impact_count == 1
|
||||||
|
assert upcoming.events[0].name == "High Impact Event"
|
||||||
|
|
||||||
|
# Filter for MEDIUM and above (should still get HIGH)
|
||||||
|
upcoming = calendar.get_upcoming_events(days_ahead=7, min_impact="MEDIUM")
|
||||||
|
assert len(upcoming.events) == 1
|
||||||
|
|
||||||
|
# Filter for LOW and above (should get both)
|
||||||
|
upcoming = calendar.get_upcoming_events(days_ahead=7, min_impact="LOW")
|
||||||
|
assert len(upcoming.events) == 2
|
||||||
|
|
||||||
|
def test_get_earnings_date_returns_next_earnings(self):
|
||||||
|
"""Should return next earnings date for a stock."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
earnings_date = now + timedelta(days=5)
|
||||||
|
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="AAPL Earnings",
|
||||||
|
event_type="EARNINGS",
|
||||||
|
datetime=earnings_date,
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Apple quarterly earnings",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = calendar.get_earnings_date("AAPL")
|
||||||
|
assert result == earnings_date
|
||||||
|
|
||||||
|
def test_get_earnings_date_returns_none_if_not_found(self):
|
||||||
|
"""Should return None if no earnings found for stock."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
result = calendar.get_earnings_date("UNKNOWN")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_load_hardcoded_events(self):
|
||||||
|
"""Should load hardcoded major economic events."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
calendar.load_hardcoded_events()
|
||||||
|
|
||||||
|
# Should have multiple events (FOMC, GDP, CPI)
|
||||||
|
assert len(calendar._events) > 10
|
||||||
|
|
||||||
|
# Check for FOMC events
|
||||||
|
fomc_events = [e for e in calendar._events if e.event_type == "FOMC"]
|
||||||
|
assert len(fomc_events) > 0
|
||||||
|
|
||||||
|
# Check for GDP events
|
||||||
|
gdp_events = [e for e in calendar._events if e.event_type == "GDP"]
|
||||||
|
assert len(gdp_events) > 0
|
||||||
|
|
||||||
|
# Check for CPI events
|
||||||
|
cpi_events = [e for e in calendar._events if e.event_type == "CPI"]
|
||||||
|
assert len(cpi_events) == 12 # Monthly CPI releases
|
||||||
|
|
||||||
|
def test_is_high_volatility_period_returns_true_near_high_impact(self):
|
||||||
|
"""Should return True if high-impact event is within threshold."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="FOMC Meeting",
|
||||||
|
event_type="FOMC",
|
||||||
|
datetime=now + timedelta(hours=12),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calendar.is_high_volatility_period(hours_ahead=24) is True
|
||||||
|
|
||||||
|
def test_is_high_volatility_period_returns_false_when_no_events(self):
|
||||||
|
"""Should return False if no high-impact events nearby."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
assert calendar.is_high_volatility_period(hours_ahead=24) is False
|
||||||
|
|
||||||
|
def test_clear_events(self):
|
||||||
|
"""Should clear all events."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="Test",
|
||||||
|
event_type="TEST",
|
||||||
|
datetime=datetime.now(),
|
||||||
|
impact="LOW",
|
||||||
|
country="US",
|
||||||
|
description="Test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert len(calendar._events) == 1
|
||||||
|
|
||||||
|
calendar.clear_events()
|
||||||
|
assert len(calendar._events) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MarketData Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketData:
|
||||||
|
"""Test market data indicators."""
|
||||||
|
|
||||||
|
def test_market_data_init(self):
|
||||||
|
"""MarketData should initialize correctly."""
|
||||||
|
data = MarketData(api_key="test_key")
|
||||||
|
assert data._api_key == "test_key"
|
||||||
|
|
||||||
|
def test_get_market_sentiment_without_api_key_returns_neutral(self):
|
||||||
|
"""Without API key, should return NEUTRAL sentiment."""
|
||||||
|
data = MarketData(api_key=None)
|
||||||
|
sentiment = data.get_market_sentiment()
|
||||||
|
assert sentiment == MarketSentiment.NEUTRAL
|
||||||
|
|
||||||
|
def test_get_market_breadth_without_api_key_returns_none(self):
|
||||||
|
"""Without API key, should return None for breadth."""
|
||||||
|
data = MarketData(api_key=None)
|
||||||
|
breadth = data.get_market_breadth()
|
||||||
|
assert breadth is None
|
||||||
|
|
||||||
|
def test_get_sector_performance_without_api_key_returns_empty(self):
|
||||||
|
"""Without API key, should return empty list."""
|
||||||
|
data = MarketData(api_key=None)
|
||||||
|
sectors = data.get_sector_performance()
|
||||||
|
assert sectors == []
|
||||||
|
|
||||||
|
def test_get_market_indicators_returns_defaults_without_api(self):
|
||||||
|
"""Should return default indicators without API key."""
|
||||||
|
data = MarketData(api_key=None)
|
||||||
|
indicators = data.get_market_indicators()
|
||||||
|
|
||||||
|
assert indicators.sentiment == MarketSentiment.NEUTRAL
|
||||||
|
assert indicators.breadth.advance_decline_ratio == 1.0
|
||||||
|
assert indicators.sector_performance == []
|
||||||
|
assert indicators.vix_level is None
|
||||||
|
|
||||||
|
def test_calculate_fear_greed_score_neutral_baseline(self):
|
||||||
|
"""Should return neutral score (50) for balanced market."""
|
||||||
|
data = MarketData()
|
||||||
|
breadth = MarketBreadth(
|
||||||
|
advancing_stocks=500,
|
||||||
|
declining_stocks=500,
|
||||||
|
unchanged_stocks=100,
|
||||||
|
new_highs=50,
|
||||||
|
new_lows=50,
|
||||||
|
advance_decline_ratio=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
score = data.calculate_fear_greed_score(breadth)
|
||||||
|
assert score == 50
|
||||||
|
|
||||||
|
def test_calculate_fear_greed_score_greedy_market(self):
|
||||||
|
"""Should return high score for greedy market conditions."""
|
||||||
|
data = MarketData()
|
||||||
|
breadth = MarketBreadth(
|
||||||
|
advancing_stocks=800,
|
||||||
|
declining_stocks=200,
|
||||||
|
unchanged_stocks=100,
|
||||||
|
new_highs=100,
|
||||||
|
new_lows=10,
|
||||||
|
advance_decline_ratio=4.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
score = data.calculate_fear_greed_score(breadth, vix=12.0)
|
||||||
|
assert score > 70
|
||||||
|
|
||||||
|
def test_calculate_fear_greed_score_fearful_market(self):
|
||||||
|
"""Should return low score for fearful market conditions."""
|
||||||
|
data = MarketData()
|
||||||
|
breadth = MarketBreadth(
|
||||||
|
advancing_stocks=200,
|
||||||
|
declining_stocks=800,
|
||||||
|
unchanged_stocks=100,
|
||||||
|
new_highs=10,
|
||||||
|
new_lows=100,
|
||||||
|
advance_decline_ratio=0.25,
|
||||||
|
)
|
||||||
|
|
||||||
|
score = data.calculate_fear_greed_score(breadth, vix=35.0)
|
||||||
|
assert score < 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GeminiClient Integration Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeminiClientWithExternalData:
|
||||||
|
"""Test GeminiClient integration with external data sources."""
|
||||||
|
|
||||||
|
def test_gemini_client_accepts_optional_data_sources(self, settings):
|
||||||
|
"""GeminiClient should accept optional external data sources."""
|
||||||
|
news_api = NewsAPI(api_key="test_key")
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
market_data = MarketData()
|
||||||
|
|
||||||
|
client = GeminiClient(
|
||||||
|
settings,
|
||||||
|
news_api=news_api,
|
||||||
|
economic_calendar=calendar,
|
||||||
|
market_data=market_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client._news_api is news_api
|
||||||
|
assert client._economic_calendar is calendar
|
||||||
|
assert client._market_data is market_data
|
||||||
|
|
||||||
|
def test_gemini_client_works_without_external_data(self, settings):
|
||||||
|
"""GeminiClient should work without external data sources."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
assert client._news_api is None
|
||||||
|
assert client._economic_calendar is None
|
||||||
|
assert client._market_data is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_prompt_includes_news_sentiment(self, settings):
|
||||||
|
"""build_prompt should include news sentiment when available."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
sentiment = NewsSentiment(
|
||||||
|
stock_code="AAPL",
|
||||||
|
articles=[
|
||||||
|
NewsArticle(
|
||||||
|
title="Apple hits record high",
|
||||||
|
summary="Strong earnings",
|
||||||
|
source="Reuters",
|
||||||
|
published_at="2026-02-04",
|
||||||
|
sentiment_score=0.85,
|
||||||
|
url="https://example.com",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
avg_sentiment=0.85,
|
||||||
|
article_count=1,
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = await client.build_prompt(market_data, news_sentiment=sentiment)
|
||||||
|
|
||||||
|
assert "AAPL" in prompt
|
||||||
|
assert "180.0" in prompt
|
||||||
|
assert "EXTERNAL DATA" in prompt
|
||||||
|
assert "News Sentiment" in prompt
|
||||||
|
assert "0.85" in prompt
|
||||||
|
assert "Apple hits record high" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_prompt_with_economic_events(self, settings):
|
||||||
|
"""build_prompt should include upcoming economic events."""
|
||||||
|
calendar = EconomicCalendar()
|
||||||
|
now = datetime.now()
|
||||||
|
calendar.add_event(
|
||||||
|
EconomicEvent(
|
||||||
|
name="FOMC Meeting",
|
||||||
|
event_type="FOMC",
|
||||||
|
datetime=now + timedelta(days=2),
|
||||||
|
impact="HIGH",
|
||||||
|
country="US",
|
||||||
|
description="Interest rate decision",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
client = GeminiClient(settings, economic_calendar=calendar)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = await client.build_prompt(market_data)
|
||||||
|
|
||||||
|
assert "EXTERNAL DATA" in prompt
|
||||||
|
assert "High-Impact Events" in prompt
|
||||||
|
assert "FOMC Meeting" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_prompt_with_market_indicators(self, settings):
|
||||||
|
"""build_prompt should include market sentiment indicators."""
|
||||||
|
market_data_provider = MarketData(api_key="test_key")
|
||||||
|
|
||||||
|
# Mock the get_market_indicators to return test data
|
||||||
|
with patch.object(market_data_provider, "get_market_indicators") as mock:
|
||||||
|
mock.return_value = MagicMock(
|
||||||
|
sentiment=MarketSentiment.EXTREME_GREED,
|
||||||
|
breadth=MagicMock(advance_decline_ratio=2.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
client = GeminiClient(settings, market_data=market_data_provider)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = await client.build_prompt(market_data)
|
||||||
|
|
||||||
|
assert "EXTERNAL DATA" in prompt
|
||||||
|
assert "Market Sentiment" in prompt
|
||||||
|
assert "EXTREME_GREED" in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_build_prompt_graceful_when_no_external_data(self, settings):
|
||||||
|
"""build_prompt should work gracefully without external data."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = await client.build_prompt(market_data)
|
||||||
|
|
||||||
|
assert "AAPL" in prompt
|
||||||
|
assert "180.0" in prompt
|
||||||
|
# Should NOT have external data section
|
||||||
|
assert "EXTERNAL DATA" not in prompt
|
||||||
|
|
||||||
|
def test_build_prompt_sync_backward_compatibility(self, settings):
|
||||||
|
"""build_prompt_sync should maintain backward compatibility."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "005930",
|
||||||
|
"current_price": 72000,
|
||||||
|
"orderbook": {"asks": [], "bids": []},
|
||||||
|
"foreigner_net": -50000,
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = client.build_prompt_sync(market_data)
|
||||||
|
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "72000" in prompt
|
||||||
|
assert "JSON" in prompt
|
||||||
|
# Sync version should NOT have external data
|
||||||
|
assert "EXTERNAL DATA" not in prompt
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decide_with_news_sentiment_parameter(self, settings):
|
||||||
|
"""decide should accept optional news_sentiment parameter."""
|
||||||
|
client = GeminiClient(settings)
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 180.0,
|
||||||
|
"market_name": "US stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
sentiment = NewsSentiment(
|
||||||
|
stock_code="AAPL",
|
||||||
|
articles=[],
|
||||||
|
avg_sentiment=0.5,
|
||||||
|
article_count=1,
|
||||||
|
fetched_at=time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock the Gemini API call
|
||||||
|
with patch.object(client._client.aio.models, "generate_content", new_callable=AsyncMock) as mock_gen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.text = '{"action": "BUY", "confidence": 85, "rationale": "Good news"}'
|
||||||
|
mock_gen.return_value = mock_response
|
||||||
|
|
||||||
|
decision = await client.decide(market_data, news_sentiment=sentiment)
|
||||||
|
|
||||||
|
assert decision.action == "BUY"
|
||||||
|
assert decision.confidence == 85
|
||||||
|
mock_gen.assert_called_once()
|
||||||
292
tests/test_decision_logger.py
Normal file
292
tests/test_decision_logger.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""Tests for decision logging and audit trail."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db import init_db
|
||||||
|
from src.logging.decision_logger import DecisionLog, DecisionLogger
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_conn() -> sqlite3.Connection:
|
||||||
|
"""Provide an in-memory database with initialized schema."""
|
||||||
|
conn = init_db(":memory:")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def logger(db_conn: sqlite3.Connection) -> DecisionLogger:
|
||||||
|
"""Provide a DecisionLogger instance."""
|
||||||
|
return DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Connection) -> None:
|
||||||
|
"""Test that log_decision creates a database record."""
|
||||||
|
context_snapshot = {
|
||||||
|
"L1": {"quote": {"price": 100.0, "volume": 1000}},
|
||||||
|
"L2": {"orderbook": {"bid": [99.0], "ask": [101.0]}},
|
||||||
|
}
|
||||||
|
input_data = {"price": 100.0, "volume": 1000, "foreigner_net": 500}
|
||||||
|
|
||||||
|
decision_id = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Strong upward momentum",
|
||||||
|
context_snapshot=context_snapshot,
|
||||||
|
input_data=input_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify decision_id is a valid UUID
|
||||||
|
assert decision_id is not None
|
||||||
|
assert len(decision_id) == 36 # UUID v4 format
|
||||||
|
|
||||||
|
# Verify record exists in database
|
||||||
|
cursor = db_conn.execute(
|
||||||
|
"SELECT decision_id, action, confidence FROM decision_logs WHERE decision_id = ?",
|
||||||
|
(decision_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] == decision_id
|
||||||
|
assert row[1] == "BUY"
|
||||||
|
assert row[2] == 85
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
|
||||||
|
"""Test that context snapshot is stored as JSON."""
|
||||||
|
context_snapshot = {
|
||||||
|
"L1": {"real_time": "data"},
|
||||||
|
"L3": {"daily": "aggregate"},
|
||||||
|
"L7": {"legacy": "wisdom"},
|
||||||
|
}
|
||||||
|
input_data = {"price": 50000.0, "volume": 2000}
|
||||||
|
|
||||||
|
decision_id = logger.log_decision(
|
||||||
|
stock_code="035420",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=75,
|
||||||
|
rationale="Waiting for clearer signal",
|
||||||
|
context_snapshot=context_snapshot,
|
||||||
|
input_data=input_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve and verify context snapshot
|
||||||
|
decision = logger.get_decision_by_id(decision_id)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.context_snapshot == context_snapshot
|
||||||
|
assert decision.input_data == input_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_unreviewed_decisions(logger: DecisionLogger) -> None:
|
||||||
|
"""Test retrieving unreviewed decisions with confidence filter."""
|
||||||
|
# Log multiple decisions with varying confidence
|
||||||
|
logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="High confidence buy",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.log_decision(
|
||||||
|
stock_code="000660",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="SELL",
|
||||||
|
confidence=75,
|
||||||
|
rationale="Low confidence sell",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.log_decision(
|
||||||
|
stock_code="035420",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Medium confidence hold",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get unreviewed decisions with default threshold (80)
|
||||||
|
unreviewed = logger.get_unreviewed_decisions()
|
||||||
|
assert len(unreviewed) == 2 # Only confidence >= 80
|
||||||
|
assert all(d.confidence >= 80 for d in unreviewed)
|
||||||
|
assert all(not d.reviewed for d in unreviewed)
|
||||||
|
|
||||||
|
# Get with lower threshold
|
||||||
|
unreviewed_all = logger.get_unreviewed_decisions(min_confidence=70)
|
||||||
|
assert len(unreviewed_all) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_reviewed(logger: DecisionLogger) -> None:
|
||||||
|
"""Test marking a decision as reviewed."""
|
||||||
|
decision_id = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Test decision",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initially unreviewed
|
||||||
|
decision = logger.get_decision_by_id(decision_id)
|
||||||
|
assert decision is not None
|
||||||
|
assert not decision.reviewed
|
||||||
|
assert decision.review_notes is None
|
||||||
|
|
||||||
|
# Mark as reviewed
|
||||||
|
review_notes = "Good decision, captured bullish momentum correctly"
|
||||||
|
logger.mark_reviewed(decision_id, review_notes)
|
||||||
|
|
||||||
|
# Verify updated
|
||||||
|
decision = logger.get_decision_by_id(decision_id)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.reviewed
|
||||||
|
assert decision.review_notes == review_notes
|
||||||
|
|
||||||
|
# Should not appear in unreviewed list
|
||||||
|
unreviewed = logger.get_unreviewed_decisions()
|
||||||
|
assert all(d.decision_id != decision_id for d in unreviewed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_outcome(logger: DecisionLogger) -> None:
|
||||||
|
"""Test updating decision outcome with P&L and accuracy."""
|
||||||
|
decision_id = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=90,
|
||||||
|
rationale="Expecting price increase",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initially no outcome
|
||||||
|
decision = logger.get_decision_by_id(decision_id)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.outcome_pnl is None
|
||||||
|
assert decision.outcome_accuracy is None
|
||||||
|
|
||||||
|
# Update outcome (profitable trade)
|
||||||
|
logger.update_outcome(decision_id, pnl=5000.0, accuracy=1)
|
||||||
|
|
||||||
|
# Verify updated
|
||||||
|
decision = logger.get_decision_by_id(decision_id)
|
||||||
|
assert decision is not None
|
||||||
|
assert decision.outcome_pnl == 5000.0
|
||||||
|
assert decision.outcome_accuracy == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_losing_decisions(logger: DecisionLogger) -> None:
|
||||||
|
"""Test retrieving high-confidence losing decisions."""
|
||||||
|
# Profitable decision
|
||||||
|
id1 = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Correct prediction",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id1, pnl=3000.0, accuracy=1)
|
||||||
|
|
||||||
|
# High-confidence loss
|
||||||
|
id2 = logger.log_decision(
|
||||||
|
stock_code="000660",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="SELL",
|
||||||
|
confidence=90,
|
||||||
|
rationale="Wrong prediction",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id2, pnl=-2000.0, accuracy=0)
|
||||||
|
|
||||||
|
# Low-confidence loss (should be ignored)
|
||||||
|
id3 = logger.log_decision(
|
||||||
|
stock_code="035420",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=70,
|
||||||
|
rationale="Low confidence, wrong",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id3, pnl=-1500.0, accuracy=0)
|
||||||
|
|
||||||
|
# Get high-confidence losing decisions
|
||||||
|
losers = logger.get_losing_decisions(min_confidence=80, min_loss=-1000.0)
|
||||||
|
assert len(losers) == 1
|
||||||
|
assert losers[0].decision_id == id2
|
||||||
|
assert losers[0].outcome_pnl == -2000.0
|
||||||
|
assert losers[0].confidence == 90
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_decision_by_id_not_found(logger: DecisionLogger) -> None:
|
||||||
|
"""Test that get_decision_by_id returns None for non-existent ID."""
|
||||||
|
decision = logger.get_decision_by_id("non-existent-uuid")
|
||||||
|
assert decision is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreviewed_limit(logger: DecisionLogger) -> None:
|
||||||
|
"""Test that get_unreviewed_decisions respects limit parameter."""
|
||||||
|
# Create 5 unreviewed decisions
|
||||||
|
for i in range(5):
|
||||||
|
logger.log_decision(
|
||||||
|
stock_code=f"00{i}",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=85,
|
||||||
|
rationale=f"Decision {i}",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get only 3
|
||||||
|
unreviewed = logger.get_unreviewed_decisions(limit=3)
|
||||||
|
assert len(unreviewed) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_decision_log_dataclass() -> None:
|
||||||
|
"""Test DecisionLog dataclass creation."""
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
log = DecisionLog(
|
||||||
|
decision_id="test-uuid",
|
||||||
|
timestamp=now,
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Test",
|
||||||
|
context_snapshot={"L1": "data"},
|
||||||
|
input_data={"price": 100.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert log.decision_id == "test-uuid"
|
||||||
|
assert log.action == "BUY"
|
||||||
|
assert log.confidence == 85
|
||||||
|
assert log.reviewed is False
|
||||||
|
assert log.outcome_pnl is None
|
||||||
685
tests/test_evolution.py
Normal file
685
tests/test_evolution.py
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
"""Tests for the Evolution Engine components.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- EvolutionOptimizer: failure analysis and strategy generation
|
||||||
|
- ABTester: A/B testing and statistical comparison
|
||||||
|
- PerformanceTracker: metrics tracking and dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.config import Settings
|
||||||
|
from src.db import init_db, log_trade
|
||||||
|
from src.evolution.ab_test import ABTester
|
||||||
|
from src.evolution.optimizer import EvolutionOptimizer
|
||||||
|
from src.evolution.performance_tracker import (
|
||||||
|
PerformanceDashboard,
|
||||||
|
PerformanceTracker,
|
||||||
|
StrategyMetrics,
|
||||||
|
)
|
||||||
|
from src.logging.decision_logger import DecisionLogger
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_conn() -> sqlite3.Connection:
|
||||||
|
"""Provide an in-memory database with initialized schema."""
|
||||||
|
return init_db(":memory:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def settings() -> Settings:
|
||||||
|
"""Provide test settings."""
|
||||||
|
return Settings(
|
||||||
|
KIS_APP_KEY="test_key",
|
||||||
|
KIS_APP_SECRET="test_secret",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test_gemini_key",
|
||||||
|
GEMINI_MODEL="gemini-pro",
|
||||||
|
DB_PATH=":memory:",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def optimizer(settings: Settings) -> EvolutionOptimizer:
|
||||||
|
"""Provide an EvolutionOptimizer instance."""
|
||||||
|
return EvolutionOptimizer(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def decision_logger(db_conn: sqlite3.Connection) -> DecisionLogger:
|
||||||
|
"""Provide a DecisionLogger instance."""
|
||||||
|
return DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ab_tester() -> ABTester:
|
||||||
|
"""Provide an ABTester instance."""
|
||||||
|
return ABTester(significance_level=0.05)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def performance_tracker(settings: Settings) -> PerformanceTracker:
|
||||||
|
"""Provide a PerformanceTracker instance."""
|
||||||
|
return PerformanceTracker(db_path=":memory:")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# EvolutionOptimizer Tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_failures_uses_decision_logger(optimizer: EvolutionOptimizer) -> None:
|
||||||
|
"""Test that analyze_failures uses DecisionLogger.get_losing_decisions()."""
|
||||||
|
# Add some losing decisions to the database
|
||||||
|
logger = optimizer._decision_logger
|
||||||
|
|
||||||
|
# High-confidence loss
|
||||||
|
id1 = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Expected growth",
|
||||||
|
context_snapshot={"L1": {"price": 70000}},
|
||||||
|
input_data={"price": 70000, "volume": 1000},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id1, pnl=-2000.0, accuracy=0)
|
||||||
|
|
||||||
|
# Another high-confidence loss
|
||||||
|
id2 = logger.log_decision(
|
||||||
|
stock_code="000660",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="SELL",
|
||||||
|
confidence=90,
|
||||||
|
rationale="Expected drop",
|
||||||
|
context_snapshot={"L1": {"price": 100000}},
|
||||||
|
input_data={"price": 100000, "volume": 500},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id2, pnl=-1500.0, accuracy=0)
|
||||||
|
|
||||||
|
# Low-confidence loss (should be ignored)
|
||||||
|
id3 = logger.log_decision(
|
||||||
|
stock_code="035420",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="HOLD",
|
||||||
|
confidence=70,
|
||||||
|
rationale="Uncertain",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id3, pnl=-500.0, accuracy=0)
|
||||||
|
|
||||||
|
# Analyze failures
|
||||||
|
failures = optimizer.analyze_failures(limit=10)
|
||||||
|
|
||||||
|
# Should get 2 failures (confidence >= 80)
|
||||||
|
assert len(failures) == 2
|
||||||
|
assert all(f["confidence"] >= 80 for f in failures)
|
||||||
|
assert all(f["outcome_pnl"] <= -100.0 for f in failures)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_failures_empty_database(optimizer: EvolutionOptimizer) -> None:
|
||||||
|
"""Test analyze_failures with no losing decisions."""
|
||||||
|
failures = optimizer.analyze_failures()
|
||||||
|
assert failures == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_failure_patterns(optimizer: EvolutionOptimizer) -> None:
|
||||||
|
"""Test identification of failure patterns."""
|
||||||
|
failures = [
|
||||||
|
{
|
||||||
|
"decision_id": "1",
|
||||||
|
"timestamp": "2024-01-15T09:30:00+00:00",
|
||||||
|
"stock_code": "005930",
|
||||||
|
"market": "KR",
|
||||||
|
"exchange_code": "KRX",
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "Test",
|
||||||
|
"outcome_pnl": -1000.0,
|
||||||
|
"outcome_accuracy": 0,
|
||||||
|
"context_snapshot": {},
|
||||||
|
"input_data": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision_id": "2",
|
||||||
|
"timestamp": "2024-01-15T14:30:00+00:00",
|
||||||
|
"stock_code": "000660",
|
||||||
|
"market": "KR",
|
||||||
|
"exchange_code": "KRX",
|
||||||
|
"action": "SELL",
|
||||||
|
"confidence": 90,
|
||||||
|
"rationale": "Test",
|
||||||
|
"outcome_pnl": -2000.0,
|
||||||
|
"outcome_accuracy": 0,
|
||||||
|
"context_snapshot": {},
|
||||||
|
"input_data": {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"decision_id": "3",
|
||||||
|
"timestamp": "2024-01-15T09:45:00+00:00",
|
||||||
|
"stock_code": "035420",
|
||||||
|
"market": "US_NASDAQ",
|
||||||
|
"exchange_code": "NASDAQ",
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 80,
|
||||||
|
"rationale": "Test",
|
||||||
|
"outcome_pnl": -500.0,
|
||||||
|
"outcome_accuracy": 0,
|
||||||
|
"context_snapshot": {},
|
||||||
|
"input_data": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
patterns = optimizer.identify_failure_patterns(failures)
|
||||||
|
|
||||||
|
assert patterns["total_failures"] == 3
|
||||||
|
assert patterns["markets"]["KR"] == 2
|
||||||
|
assert patterns["markets"]["US_NASDAQ"] == 1
|
||||||
|
assert patterns["actions"]["BUY"] == 2
|
||||||
|
assert patterns["actions"]["SELL"] == 1
|
||||||
|
assert 9 in patterns["hours"] # 09:30 and 09:45
|
||||||
|
assert 14 in patterns["hours"] # 14:30
|
||||||
|
assert patterns["avg_confidence"] == 85.0
|
||||||
|
assert patterns["avg_loss"] == -1166.67
|
||||||
|
|
||||||
|
|
||||||
|
def test_identify_failure_patterns_empty(optimizer: EvolutionOptimizer) -> None:
|
||||||
|
"""Test pattern identification with no failures."""
|
||||||
|
patterns = optimizer.identify_failure_patterns([])
|
||||||
|
assert patterns["pattern_count"] == 0
|
||||||
|
assert patterns["patterns"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_strategy_creates_file(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
|
||||||
|
"""Test that generate_strategy creates a strategy file."""
|
||||||
|
failures = [
|
||||||
|
{
|
||||||
|
"decision_id": "1",
|
||||||
|
"timestamp": "2024-01-15T09:30:00+00:00",
|
||||||
|
"stock_code": "005930",
|
||||||
|
"market": "KR",
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"outcome_pnl": -1000.0,
|
||||||
|
"context_snapshot": {},
|
||||||
|
"input_data": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock Gemini response
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = """
|
||||||
|
# Simple strategy
|
||||||
|
price = market_data.get("current_price", 0)
|
||||||
|
if price > 50000:
|
||||||
|
return {"action": "BUY", "confidence": 70, "rationale": "Price above threshold"}
|
||||||
|
return {"action": "HOLD", "confidence": 50, "rationale": "Waiting"}
|
||||||
|
"""
|
||||||
|
|
||||||
|
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
|
||||||
|
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
|
||||||
|
strategy_path = await optimizer.generate_strategy(failures)
|
||||||
|
|
||||||
|
assert strategy_path is not None
|
||||||
|
assert strategy_path.exists()
|
||||||
|
assert strategy_path.suffix == ".py"
|
||||||
|
assert "class Strategy_" in strategy_path.read_text()
|
||||||
|
assert "def evaluate" in strategy_path.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None:
|
||||||
|
"""Test that generate_strategy handles Gemini API errors gracefully."""
|
||||||
|
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
optimizer._client.aio.models,
|
||||||
|
"generate_content",
|
||||||
|
side_effect=Exception("API Error"),
|
||||||
|
):
|
||||||
|
strategy_path = await optimizer.generate_strategy(failures)
|
||||||
|
|
||||||
|
assert strategy_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_performance_summary() -> None:
|
||||||
|
"""Test getting performance summary from trades table."""
|
||||||
|
# Create a temporary database with trades
|
||||||
|
import tempfile
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
conn = init_db(tmp_path)
|
||||||
|
log_trade(conn, "005930", "BUY", 85, "Test win", quantity=10, price=70000, pnl=1000.0)
|
||||||
|
log_trade(conn, "000660", "SELL", 90, "Test loss", quantity=5, price=100000, pnl=-500.0)
|
||||||
|
log_trade(conn, "035420", "BUY", 80, "Test win", quantity=8, price=50000, pnl=800.0)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Create settings with temp database path
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="test_key",
|
||||||
|
KIS_APP_SECRET="test_secret",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test_gemini_key",
|
||||||
|
GEMINI_MODEL="gemini-pro",
|
||||||
|
DB_PATH=tmp_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
optimizer = EvolutionOptimizer(settings)
|
||||||
|
summary = optimizer.get_performance_summary()
|
||||||
|
|
||||||
|
assert summary["total_trades"] == 3
|
||||||
|
assert summary["wins"] == 2
|
||||||
|
assert summary["losses"] == 1
|
||||||
|
assert summary["total_pnl"] == 1300.0
|
||||||
|
assert summary["avg_pnl"] == 433.33
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Path(tmp_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_strategy_success(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
|
||||||
|
"""Test strategy validation when tests pass."""
|
||||||
|
strategy_file = tmp_path / "test_strategy.py"
|
||||||
|
strategy_file.write_text("# Valid strategy file")
|
||||||
|
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||||
|
result = optimizer.validate_strategy(strategy_file)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert strategy_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_strategy_failure(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
|
||||||
|
"""Test strategy validation when tests fail."""
|
||||||
|
strategy_file = tmp_path / "test_strategy.py"
|
||||||
|
strategy_file.write_text("# Invalid strategy file")
|
||||||
|
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = Mock(returncode=1, stdout="FAILED", stderr="")
|
||||||
|
result = optimizer.validate_strategy(strategy_file)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
# File should be deleted on failure
|
||||||
|
assert not strategy_file.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# ABTester Tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_performance_basic(ab_tester: ABTester) -> None:
|
||||||
|
"""Test basic performance calculation."""
|
||||||
|
trades = [
|
||||||
|
{"pnl": 1000.0},
|
||||||
|
{"pnl": -500.0},
|
||||||
|
{"pnl": 800.0},
|
||||||
|
{"pnl": 200.0},
|
||||||
|
]
|
||||||
|
|
||||||
|
perf = ab_tester.calculate_performance(trades, "TestStrategy")
|
||||||
|
|
||||||
|
assert perf.strategy_name == "TestStrategy"
|
||||||
|
assert perf.total_trades == 4
|
||||||
|
assert perf.wins == 3
|
||||||
|
assert perf.losses == 1
|
||||||
|
assert perf.total_pnl == 1500.0
|
||||||
|
assert perf.avg_pnl == 375.0
|
||||||
|
assert perf.win_rate == 75.0
|
||||||
|
assert perf.sharpe_ratio is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_performance_empty(ab_tester: ABTester) -> None:
|
||||||
|
"""Test performance calculation with no trades."""
|
||||||
|
perf = ab_tester.calculate_performance([], "EmptyStrategy")
|
||||||
|
|
||||||
|
assert perf.total_trades == 0
|
||||||
|
assert perf.wins == 0
|
||||||
|
assert perf.losses == 0
|
||||||
|
assert perf.total_pnl == 0.0
|
||||||
|
assert perf.avg_pnl == 0.0
|
||||||
|
assert perf.win_rate == 0.0
|
||||||
|
assert perf.sharpe_ratio is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_strategies_significant_difference(ab_tester: ABTester) -> None:
|
||||||
|
"""Test strategy comparison with significant performance difference."""
|
||||||
|
# Strategy A: consistently profitable
|
||||||
|
trades_a = [{"pnl": 1000.0} for _ in range(30)]
|
||||||
|
|
||||||
|
# Strategy B: consistently losing
|
||||||
|
trades_b = [{"pnl": -500.0} for _ in range(30)]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "Strategy A", "Strategy B")
|
||||||
|
|
||||||
|
# scipy returns np.True_ instead of Python bool
|
||||||
|
assert bool(result.is_significant) is True
|
||||||
|
assert result.winner == "Strategy A"
|
||||||
|
assert result.p_value < 0.05
|
||||||
|
assert result.performance_a.avg_pnl > result.performance_b.avg_pnl
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_strategies_no_difference(ab_tester: ABTester) -> None:
|
||||||
|
"""Test strategy comparison with no significant difference."""
|
||||||
|
# Both strategies have similar performance
|
||||||
|
trades_a = [{"pnl": 100.0}, {"pnl": -50.0}, {"pnl": 80.0}]
|
||||||
|
trades_b = [{"pnl": 90.0}, {"pnl": -60.0}, {"pnl": 85.0}]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "Strategy A", "Strategy B")
|
||||||
|
|
||||||
|
# With small samples and similar performance, likely not significant
|
||||||
|
assert result.winner is None or not result.is_significant
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deploy_meets_criteria(ab_tester: ABTester) -> None:
|
||||||
|
"""Test deployment decision when criteria are met."""
|
||||||
|
# Create a winning result that meets criteria
|
||||||
|
trades_a = [{"pnl": 1000.0} for _ in range(25)] # 100% win rate
|
||||||
|
trades_b = [{"pnl": -500.0} for _ in range(25)]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "Winner", "Loser")
|
||||||
|
|
||||||
|
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
|
||||||
|
|
||||||
|
assert should_deploy is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deploy_insufficient_trades(ab_tester: ABTester) -> None:
|
||||||
|
"""Test deployment decision with insufficient trades."""
|
||||||
|
trades_a = [{"pnl": 1000.0} for _ in range(10)] # Only 10 trades
|
||||||
|
trades_b = [{"pnl": -500.0} for _ in range(10)]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "Winner", "Loser")
|
||||||
|
|
||||||
|
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
|
||||||
|
|
||||||
|
assert should_deploy is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deploy_low_win_rate(ab_tester: ABTester) -> None:
|
||||||
|
"""Test deployment decision with low win rate."""
|
||||||
|
# Mix of wins and losses, below 60% win rate
|
||||||
|
trades_a = [{"pnl": 100.0}] * 10 + [{"pnl": -100.0}] * 15 # 40% win rate
|
||||||
|
trades_b = [{"pnl": -500.0} for _ in range(25)]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "LowWinner", "Loser")
|
||||||
|
|
||||||
|
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
|
||||||
|
|
||||||
|
assert should_deploy is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_deploy_not_significant(ab_tester: ABTester) -> None:
|
||||||
|
"""Test deployment decision when difference is not significant."""
|
||||||
|
# Use more varied data to ensure statistical insignificance
|
||||||
|
trades_a = [{"pnl": 100.0}, {"pnl": -50.0}] * 12 + [{"pnl": 100.0}]
|
||||||
|
trades_b = [{"pnl": 95.0}, {"pnl": -45.0}] * 12 + [{"pnl": 95.0}]
|
||||||
|
|
||||||
|
result = ab_tester.compare_strategies(trades_a, trades_b, "A", "B")
|
||||||
|
|
||||||
|
should_deploy = ab_tester.should_deploy(result, min_win_rate=60.0, min_trades=20)
|
||||||
|
|
||||||
|
# Not significant or not profitable enough
|
||||||
|
# Even if significant, win rate is 50% which is below 60% threshold
|
||||||
|
assert should_deploy is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# PerformanceTracker Tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_strategy_metrics(db_conn: sqlite3.Connection) -> None:
|
||||||
|
"""Test getting strategy metrics."""
|
||||||
|
# Add some trades
|
||||||
|
log_trade(db_conn, "005930", "BUY", 85, "Win 1", quantity=10, price=70000, pnl=1000.0)
|
||||||
|
log_trade(db_conn, "000660", "SELL", 90, "Loss 1", quantity=5, price=100000, pnl=-500.0)
|
||||||
|
log_trade(db_conn, "035420", "BUY", 80, "Win 2", quantity=8, price=50000, pnl=800.0)
|
||||||
|
log_trade(db_conn, "005930", "HOLD", 75, "Hold", quantity=0, price=70000, pnl=0.0)
|
||||||
|
|
||||||
|
tracker = PerformanceTracker(db_path=":memory:")
|
||||||
|
# Manually set connection for testing
|
||||||
|
tracker._db_path = db_conn
|
||||||
|
|
||||||
|
# Need to use the same connection
|
||||||
|
with patch("sqlite3.connect", return_value=db_conn):
|
||||||
|
metrics = tracker.get_strategy_metrics()
|
||||||
|
|
||||||
|
assert metrics.total_trades == 4
|
||||||
|
assert metrics.wins == 2
|
||||||
|
assert metrics.losses == 1
|
||||||
|
assert metrics.holds == 1
|
||||||
|
assert metrics.win_rate == 50.0
|
||||||
|
assert metrics.total_pnl == 1300.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_improvement_trend_improving(performance_tracker: PerformanceTracker) -> None:
|
||||||
|
"""Test improvement trend calculation for improving strategy."""
|
||||||
|
metrics = [
|
||||||
|
StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-01",
|
||||||
|
period_end="2024-01-07",
|
||||||
|
total_trades=10,
|
||||||
|
wins=5,
|
||||||
|
losses=5,
|
||||||
|
holds=0,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_pnl=100.0,
|
||||||
|
total_pnl=1000.0,
|
||||||
|
best_trade=500.0,
|
||||||
|
worst_trade=-300.0,
|
||||||
|
avg_confidence=75.0,
|
||||||
|
),
|
||||||
|
StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-08",
|
||||||
|
period_end="2024-01-14",
|
||||||
|
total_trades=10,
|
||||||
|
wins=7,
|
||||||
|
losses=3,
|
||||||
|
holds=0,
|
||||||
|
win_rate=70.0,
|
||||||
|
avg_pnl=200.0,
|
||||||
|
total_pnl=2000.0,
|
||||||
|
best_trade=600.0,
|
||||||
|
worst_trade=-200.0,
|
||||||
|
avg_confidence=80.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
trend = performance_tracker.calculate_improvement_trend(metrics)
|
||||||
|
|
||||||
|
assert trend["trend"] == "improving"
|
||||||
|
assert trend["win_rate_change"] == 20.0
|
||||||
|
assert trend["pnl_change"] == 100.0
|
||||||
|
assert trend["confidence_change"] == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_improvement_trend_declining(performance_tracker: PerformanceTracker) -> None:
|
||||||
|
"""Test improvement trend calculation for declining strategy."""
|
||||||
|
metrics = [
|
||||||
|
StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-01",
|
||||||
|
period_end="2024-01-07",
|
||||||
|
total_trades=10,
|
||||||
|
wins=7,
|
||||||
|
losses=3,
|
||||||
|
holds=0,
|
||||||
|
win_rate=70.0,
|
||||||
|
avg_pnl=200.0,
|
||||||
|
total_pnl=2000.0,
|
||||||
|
best_trade=600.0,
|
||||||
|
worst_trade=-200.0,
|
||||||
|
avg_confidence=80.0,
|
||||||
|
),
|
||||||
|
StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-08",
|
||||||
|
period_end="2024-01-14",
|
||||||
|
total_trades=10,
|
||||||
|
wins=4,
|
||||||
|
losses=6,
|
||||||
|
holds=0,
|
||||||
|
win_rate=40.0,
|
||||||
|
avg_pnl=-50.0,
|
||||||
|
total_pnl=-500.0,
|
||||||
|
best_trade=300.0,
|
||||||
|
worst_trade=-400.0,
|
||||||
|
avg_confidence=70.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
trend = performance_tracker.calculate_improvement_trend(metrics)
|
||||||
|
|
||||||
|
assert trend["trend"] == "declining"
|
||||||
|
assert trend["win_rate_change"] == -30.0
|
||||||
|
assert trend["pnl_change"] == -250.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_improvement_trend_insufficient_data(performance_tracker: PerformanceTracker) -> None:
|
||||||
|
"""Test improvement trend with insufficient data."""
|
||||||
|
metrics = [
|
||||||
|
StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-01",
|
||||||
|
period_end="2024-01-07",
|
||||||
|
total_trades=10,
|
||||||
|
wins=5,
|
||||||
|
losses=5,
|
||||||
|
holds=0,
|
||||||
|
win_rate=50.0,
|
||||||
|
avg_pnl=100.0,
|
||||||
|
total_pnl=1000.0,
|
||||||
|
best_trade=500.0,
|
||||||
|
worst_trade=-300.0,
|
||||||
|
avg_confidence=75.0,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
trend = performance_tracker.calculate_improvement_trend(metrics)
|
||||||
|
|
||||||
|
assert trend["trend"] == "insufficient_data"
|
||||||
|
assert trend["win_rate_change"] == 0.0
|
||||||
|
assert trend["pnl_change"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_dashboard_json(performance_tracker: PerformanceTracker) -> None:
|
||||||
|
"""Test exporting dashboard as JSON."""
|
||||||
|
overall_metrics = StrategyMetrics(
|
||||||
|
strategy_name="test",
|
||||||
|
period_start="2024-01-01",
|
||||||
|
period_end="2024-01-31",
|
||||||
|
total_trades=100,
|
||||||
|
wins=60,
|
||||||
|
losses=40,
|
||||||
|
holds=10,
|
||||||
|
win_rate=60.0,
|
||||||
|
avg_pnl=150.0,
|
||||||
|
total_pnl=15000.0,
|
||||||
|
best_trade=1000.0,
|
||||||
|
worst_trade=-500.0,
|
||||||
|
avg_confidence=80.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
dashboard = PerformanceDashboard(
|
||||||
|
generated_at=datetime.now(UTC).isoformat(),
|
||||||
|
overall_metrics=overall_metrics,
|
||||||
|
daily_metrics=[],
|
||||||
|
weekly_metrics=[],
|
||||||
|
improvement_trend={"trend": "improving", "win_rate_change": 10.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
json_output = performance_tracker.export_dashboard_json(dashboard)
|
||||||
|
|
||||||
|
# Verify it's valid JSON
|
||||||
|
data = json.loads(json_output)
|
||||||
|
assert "generated_at" in data
|
||||||
|
assert "overall_metrics" in data
|
||||||
|
assert data["overall_metrics"]["total_trades"] == 100
|
||||||
|
assert data["overall_metrics"]["win_rate"] == 60.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_dashboard() -> None:
|
||||||
|
"""Test generating a complete dashboard."""
|
||||||
|
# Create tracker with temp database
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
|
||||||
|
# Initialize with data
|
||||||
|
conn = init_db(tmp_path)
|
||||||
|
log_trade(conn, "005930", "BUY", 85, "Win", quantity=10, price=70000, pnl=1000.0)
|
||||||
|
log_trade(conn, "000660", "SELL", 90, "Loss", quantity=5, price=100000, pnl=-500.0)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
tracker = PerformanceTracker(db_path=tmp_path)
|
||||||
|
dashboard = tracker.generate_dashboard()
|
||||||
|
|
||||||
|
assert isinstance(dashboard, PerformanceDashboard)
|
||||||
|
assert dashboard.overall_metrics.total_trades == 2
|
||||||
|
assert len(dashboard.daily_metrics) == 7
|
||||||
|
assert len(dashboard.weekly_metrics) == 4
|
||||||
|
assert "trend" in dashboard.improvement_trend
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
Path(tmp_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Integration Tests
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_full_evolution_pipeline(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
|
||||||
|
"""Test the complete evolution pipeline."""
|
||||||
|
# Add losing decisions
|
||||||
|
logger = optimizer._decision_logger
|
||||||
|
id1 = logger.log_decision(
|
||||||
|
stock_code="005930",
|
||||||
|
market="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
action="BUY",
|
||||||
|
confidence=85,
|
||||||
|
rationale="Expected growth",
|
||||||
|
context_snapshot={},
|
||||||
|
input_data={},
|
||||||
|
)
|
||||||
|
logger.update_outcome(id1, pnl=-2000.0, accuracy=0)
|
||||||
|
|
||||||
|
# Mock Gemini and subprocess
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.text = 'return {"action": "HOLD", "confidence": 50, "rationale": "Test"}'
|
||||||
|
|
||||||
|
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
|
||||||
|
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
|
||||||
|
with patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
|
||||||
|
|
||||||
|
result = await optimizer.evolve()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert "title" in result
|
||||||
|
assert "branch" in result
|
||||||
|
assert "status" in result
|
||||||
558
tests/test_latency_control.py
Normal file
558
tests/test_latency_control.py
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
"""Tests for latency control system (criticality assessment and priority queue)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.core.criticality import CriticalityAssessor, CriticalityLevel
|
||||||
|
from src.core.priority_queue import PriorityTask, PriorityTaskQueue
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CriticalityAssessor Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCriticalityAssessor:
|
||||||
|
"""Test suite for criticality assessment logic."""
|
||||||
|
|
||||||
|
def test_market_closed_returns_low(self) -> None:
|
||||||
|
"""Market closed should return LOW priority."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=False,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.LOW
|
||||||
|
|
||||||
|
def test_very_low_volatility_returns_low(self) -> None:
|
||||||
|
"""Very low volatility should return LOW priority."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=20.0, # Below 30.0 threshold
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.LOW
|
||||||
|
|
||||||
|
def test_critical_pnl_threshold_triggered(self) -> None:
|
||||||
|
"""P&L below -2.5% should trigger CRITICAL."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=-2.6, # Below -2.5% threshold
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_critical_pnl_at_circuit_breaker_proximity(self) -> None:
|
||||||
|
"""P&L at exactly -2.5% (near -3.0% breaker) should be CRITICAL."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=-2.5,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_critical_price_change_positive(self) -> None:
|
||||||
|
"""Large positive price change (>5%) should trigger CRITICAL."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
price_change_1m=5.5, # Above 5.0% threshold
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_critical_price_change_negative(self) -> None:
|
||||||
|
"""Large negative price change (<-5%) should trigger CRITICAL."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
price_change_1m=-6.0, # Below -5.0% threshold
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_critical_volume_surge(self) -> None:
|
||||||
|
"""Extreme volume surge (>10x) should trigger CRITICAL."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=12.0, # Above 10.0x threshold
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_high_volatility_returns_high(self) -> None:
|
||||||
|
"""High volatility score should return HIGH priority."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=75.0, # Above 70.0 threshold
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.HIGH
|
||||||
|
|
||||||
|
def test_normal_conditions_return_normal(self) -> None:
|
||||||
|
"""Normal market conditions should return NORMAL priority."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.5,
|
||||||
|
volatility_score=50.0, # Between 30-70
|
||||||
|
volume_surge=1.5,
|
||||||
|
price_change_1m=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.NORMAL
|
||||||
|
|
||||||
|
def test_custom_thresholds(self) -> None:
|
||||||
|
"""Custom thresholds should be respected."""
|
||||||
|
assessor = CriticalityAssessor(
|
||||||
|
critical_pnl_threshold=-1.0,
|
||||||
|
critical_price_change_threshold=3.0,
|
||||||
|
critical_volume_surge_threshold=5.0,
|
||||||
|
high_volatility_threshold=60.0,
|
||||||
|
low_volatility_threshold=20.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test custom P&L threshold
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=-1.1,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
# Test custom price change threshold
|
||||||
|
level = assessor.assess_market_conditions(
|
||||||
|
pnl_pct=0.0,
|
||||||
|
volatility_score=50.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
price_change_1m=3.5,
|
||||||
|
is_market_open=True,
|
||||||
|
)
|
||||||
|
assert level == CriticalityLevel.CRITICAL
|
||||||
|
|
||||||
|
def test_get_timeout_returns_correct_values(self) -> None:
|
||||||
|
"""Timeout values should match specification."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
|
||||||
|
assert assessor.get_timeout(CriticalityLevel.CRITICAL) == 5.0
|
||||||
|
assert assessor.get_timeout(CriticalityLevel.HIGH) == 30.0
|
||||||
|
assert assessor.get_timeout(CriticalityLevel.NORMAL) == 60.0
|
||||||
|
assert assessor.get_timeout(CriticalityLevel.LOW) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PriorityTaskQueue Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPriorityTaskQueue:
|
||||||
|
"""Test suite for priority queue implementation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enqueue_task(self) -> None:
|
||||||
|
"""Tasks should be enqueued successfully."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
success = await queue.enqueue(
|
||||||
|
task_id="test-1",
|
||||||
|
criticality=CriticalityLevel.NORMAL,
|
||||||
|
task_data={"action": "test"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert await queue.size() == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enqueue_rejects_when_full(self) -> None:
|
||||||
|
"""Queue should reject tasks when full."""
|
||||||
|
queue = PriorityTaskQueue(max_size=2)
|
||||||
|
|
||||||
|
# Fill the queue
|
||||||
|
await queue.enqueue("task-1", CriticalityLevel.NORMAL, {})
|
||||||
|
await queue.enqueue("task-2", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
# Third task should be rejected
|
||||||
|
success = await queue.enqueue("task-3", CriticalityLevel.NORMAL, {})
|
||||||
|
assert success is False
|
||||||
|
assert await queue.size() == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dequeue_returns_highest_priority(self) -> None:
|
||||||
|
"""Dequeue should return highest priority task first."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Enqueue tasks in reverse priority order
|
||||||
|
await queue.enqueue("low", CriticalityLevel.LOW, {"priority": 3})
|
||||||
|
await queue.enqueue("normal", CriticalityLevel.NORMAL, {"priority": 2})
|
||||||
|
await queue.enqueue("high", CriticalityLevel.HIGH, {"priority": 1})
|
||||||
|
await queue.enqueue("critical", CriticalityLevel.CRITICAL, {"priority": 0})
|
||||||
|
|
||||||
|
# Dequeue should return CRITICAL first
|
||||||
|
task = await queue.dequeue(timeout=1.0)
|
||||||
|
assert task is not None
|
||||||
|
assert task.task_id == "critical"
|
||||||
|
assert task.priority == 0
|
||||||
|
|
||||||
|
# Then HIGH
|
||||||
|
task = await queue.dequeue(timeout=1.0)
|
||||||
|
assert task is not None
|
||||||
|
assert task.task_id == "high"
|
||||||
|
assert task.priority == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dequeue_fifo_within_same_priority(self) -> None:
|
||||||
|
"""Tasks with same priority should be FIFO."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Enqueue multiple tasks with same priority
|
||||||
|
await queue.enqueue("task-1", CriticalityLevel.NORMAL, {})
|
||||||
|
await asyncio.sleep(0.01) # Small delay to ensure different timestamps
|
||||||
|
await queue.enqueue("task-2", CriticalityLevel.NORMAL, {})
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
await queue.enqueue("task-3", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
# Should dequeue in FIFO order
|
||||||
|
task1 = await queue.dequeue(timeout=1.0)
|
||||||
|
task2 = await queue.dequeue(timeout=1.0)
|
||||||
|
task3 = await queue.dequeue(timeout=1.0)
|
||||||
|
|
||||||
|
assert task1 is not None and task1.task_id == "task-1"
|
||||||
|
assert task2 is not None and task2.task_id == "task-2"
|
||||||
|
assert task3 is not None and task3.task_id == "task-3"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dequeue_returns_none_when_empty(self) -> None:
|
||||||
|
"""Dequeue should return None when queue is empty after timeout."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
task = await queue.dequeue(timeout=0.1)
|
||||||
|
assert task is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_with_timeout_success(self) -> None:
|
||||||
|
"""Task execution should succeed within timeout."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Create a simple async callback
|
||||||
|
async def test_callback() -> str:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=test_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await queue.execute_with_timeout(task, timeout=1.0)
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_with_timeout_raises_timeout_error(self) -> None:
|
||||||
|
"""Task execution should raise TimeoutError if exceeds timeout."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Create a slow async callback
|
||||||
|
async def slow_callback() -> str:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return "too slow"
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=slow_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.TimeoutError):
|
||||||
|
await queue.execute_with_timeout(task, timeout=0.1)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_with_timeout_propagates_exceptions(self) -> None:
|
||||||
|
"""Task execution should propagate exceptions from callback."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Create a failing async callback
|
||||||
|
async def failing_callback() -> None:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=failing_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Test error"):
|
||||||
|
await queue.execute_with_timeout(task, timeout=1.0)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_execute_without_timeout(self) -> None:
|
||||||
|
"""Task execution should work without timeout (LOW priority)."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
async def test_callback() -> str:
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return "success"
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=3,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=test_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await queue.execute_with_timeout(task, timeout=None)
|
||||||
|
assert result == "success"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_metrics(self) -> None:
|
||||||
|
"""Queue should track metrics correctly."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Enqueue and dequeue some tasks
|
||||||
|
await queue.enqueue("task-1", CriticalityLevel.CRITICAL, {})
|
||||||
|
await queue.enqueue("task-2", CriticalityLevel.HIGH, {})
|
||||||
|
await queue.enqueue("task-3", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
await queue.dequeue(timeout=1.0)
|
||||||
|
await queue.dequeue(timeout=1.0)
|
||||||
|
|
||||||
|
metrics = await queue.get_metrics()
|
||||||
|
|
||||||
|
assert metrics.total_enqueued == 3
|
||||||
|
assert metrics.total_dequeued == 2
|
||||||
|
assert metrics.current_size == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wait_time_metrics(self) -> None:
|
||||||
|
"""Queue should track wait times per criticality level."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Enqueue tasks with different criticality
|
||||||
|
await queue.enqueue("critical-1", CriticalityLevel.CRITICAL, {})
|
||||||
|
await asyncio.sleep(0.05) # Add some wait time
|
||||||
|
|
||||||
|
await queue.dequeue(timeout=1.0)
|
||||||
|
|
||||||
|
metrics = await queue.get_metrics()
|
||||||
|
|
||||||
|
# Should have wait time metrics for CRITICAL
|
||||||
|
assert CriticalityLevel.CRITICAL in metrics.avg_wait_time
|
||||||
|
assert metrics.avg_wait_time[CriticalityLevel.CRITICAL] > 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_clear_queue(self) -> None:
|
||||||
|
"""Clear should remove all tasks from queue."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
await queue.enqueue("task-1", CriticalityLevel.NORMAL, {})
|
||||||
|
await queue.enqueue("task-2", CriticalityLevel.NORMAL, {})
|
||||||
|
await queue.enqueue("task-3", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
cleared = await queue.clear()
|
||||||
|
|
||||||
|
assert cleared == 3
|
||||||
|
assert await queue.size() == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_enqueue_dequeue(self) -> None:
|
||||||
|
"""Queue should handle concurrent operations safely."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Concurrent enqueue operations
|
||||||
|
async def enqueue_tasks() -> None:
|
||||||
|
for i in range(10):
|
||||||
|
await queue.enqueue(
|
||||||
|
f"task-{i}",
|
||||||
|
CriticalityLevel.NORMAL,
|
||||||
|
{"index": i},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Concurrent dequeue operations
|
||||||
|
async def dequeue_tasks() -> list[str]:
|
||||||
|
tasks = []
|
||||||
|
for _ in range(10):
|
||||||
|
task = await queue.dequeue(timeout=1.0)
|
||||||
|
if task:
|
||||||
|
tasks.append(task.task_id)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
# Run both concurrently
|
||||||
|
enqueue_task = asyncio.create_task(enqueue_tasks())
|
||||||
|
dequeue_task = asyncio.create_task(dequeue_tasks())
|
||||||
|
|
||||||
|
await enqueue_task
|
||||||
|
dequeued_ids = await dequeue_task
|
||||||
|
|
||||||
|
# All tasks should be processed
|
||||||
|
assert len(dequeued_ids) == 10
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_metric_tracking(self) -> None:
|
||||||
|
"""Queue should track timeout occurrences."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
async def slow_callback() -> str:
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return "too slow"
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=slow_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await queue.execute_with_timeout(task, timeout=0.1)
|
||||||
|
except TimeoutError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
metrics = await queue.get_metrics()
|
||||||
|
assert metrics.total_timeouts == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_error_metric_tracking(self) -> None:
|
||||||
|
"""Queue should track execution errors."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
async def failing_callback() -> None:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0,
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="test",
|
||||||
|
task_data={},
|
||||||
|
callback=failing_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await queue.execute_with_timeout(task, timeout=1.0)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
metrics = await queue.get_metrics()
|
||||||
|
assert metrics.total_errors == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestLatencyControlIntegration:
|
||||||
|
"""Integration tests for criticality assessment and priority queue."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_critical_task_bypass_queue(self) -> None:
|
||||||
|
"""CRITICAL tasks should bypass lower priority tasks."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Add normal priority tasks
|
||||||
|
await queue.enqueue("normal-1", CriticalityLevel.NORMAL, {})
|
||||||
|
await queue.enqueue("normal-2", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
# Add critical task (should jump to front)
|
||||||
|
await queue.enqueue("critical", CriticalityLevel.CRITICAL, {})
|
||||||
|
|
||||||
|
# Dequeue should return critical first
|
||||||
|
task = await queue.dequeue(timeout=1.0)
|
||||||
|
assert task is not None
|
||||||
|
assert task.task_id == "critical"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_enforcement_by_criticality(self) -> None:
|
||||||
|
"""Timeout enforcement should match criticality level."""
|
||||||
|
assessor = CriticalityAssessor()
|
||||||
|
|
||||||
|
# CRITICAL should have 5s timeout
|
||||||
|
critical_timeout = assessor.get_timeout(CriticalityLevel.CRITICAL)
|
||||||
|
assert critical_timeout == 5.0
|
||||||
|
|
||||||
|
# HIGH should have 30s timeout
|
||||||
|
high_timeout = assessor.get_timeout(CriticalityLevel.HIGH)
|
||||||
|
assert high_timeout == 30.0
|
||||||
|
|
||||||
|
# NORMAL should have 60s timeout
|
||||||
|
normal_timeout = assessor.get_timeout(CriticalityLevel.NORMAL)
|
||||||
|
assert normal_timeout == 60.0
|
||||||
|
|
||||||
|
# LOW should have no timeout
|
||||||
|
low_timeout = assessor.get_timeout(CriticalityLevel.LOW)
|
||||||
|
assert low_timeout is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fast_path_execution_for_critical(self) -> None:
|
||||||
|
"""CRITICAL tasks should complete quickly."""
|
||||||
|
queue = PriorityTaskQueue()
|
||||||
|
|
||||||
|
# Create a fast callback simulating fast-path execution
|
||||||
|
async def fast_path_callback() -> str:
|
||||||
|
# Simulate simplified decision flow
|
||||||
|
await asyncio.sleep(0.01) # Very fast execution
|
||||||
|
return "fast_path_complete"
|
||||||
|
|
||||||
|
task = PriorityTask(
|
||||||
|
priority=0, # CRITICAL
|
||||||
|
timestamp=0.0,
|
||||||
|
task_id="critical-fast",
|
||||||
|
task_data={},
|
||||||
|
callback=fast_path_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
start = time.time()
|
||||||
|
result = await queue.execute_with_timeout(task, timeout=5.0)
|
||||||
|
elapsed = time.time() - start
|
||||||
|
|
||||||
|
assert result == "fast_path_complete"
|
||||||
|
assert elapsed < 5.0 # Should complete well under CRITICAL timeout
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_graceful_degradation_when_queue_full(self) -> None:
|
||||||
|
"""System should gracefully handle full queue."""
|
||||||
|
queue = PriorityTaskQueue(max_size=2)
|
||||||
|
|
||||||
|
# Fill the queue
|
||||||
|
await queue.enqueue("task-1", CriticalityLevel.NORMAL, {})
|
||||||
|
await queue.enqueue("task-2", CriticalityLevel.NORMAL, {})
|
||||||
|
|
||||||
|
# Try to add more tasks
|
||||||
|
success = await queue.enqueue("task-3", CriticalityLevel.NORMAL, {})
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
# Queue should still function
|
||||||
|
task = await queue.dequeue(timeout=1.0)
|
||||||
|
assert task is not None
|
||||||
|
|
||||||
|
# Now we can add another task
|
||||||
|
success = await queue.enqueue("task-4", CriticalityLevel.NORMAL, {})
|
||||||
|
assert success is True
|
||||||
1051
tests/test_main.py
Normal file
1051
tests/test_main.py
Normal file
File diff suppressed because it is too large
Load Diff
289
tests/test_playbook_store.py
Normal file
289
tests/test_playbook_store.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""Tests for playbook persistence (PlaybookStore + DB schema)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.db import init_db
|
||||||
|
from src.strategy.models import (
|
||||||
|
DayPlaybook,
|
||||||
|
GlobalRule,
|
||||||
|
MarketOutlook,
|
||||||
|
PlaybookStatus,
|
||||||
|
ScenarioAction,
|
||||||
|
StockCondition,
|
||||||
|
StockPlaybook,
|
||||||
|
StockScenario,
|
||||||
|
)
|
||||||
|
from src.strategy.playbook_store import PlaybookStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn():
|
||||||
|
"""Create an in-memory DB with schema."""
|
||||||
|
connection = init_db(":memory:")
|
||||||
|
yield connection
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(conn) -> PlaybookStore:
|
||||||
|
return PlaybookStore(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_playbook(
|
||||||
|
target_date: date = date(2026, 2, 8),
|
||||||
|
market: str = "KR",
|
||||||
|
outlook: MarketOutlook = MarketOutlook.NEUTRAL,
|
||||||
|
stock_codes: list[str] | None = None,
|
||||||
|
) -> DayPlaybook:
|
||||||
|
"""Create a test playbook with sensible defaults."""
|
||||||
|
if stock_codes is None:
|
||||||
|
stock_codes = ["005930"]
|
||||||
|
return DayPlaybook(
|
||||||
|
date=target_date,
|
||||||
|
market=market,
|
||||||
|
market_outlook=outlook,
|
||||||
|
token_count=150,
|
||||||
|
stock_playbooks=[
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code=code,
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=30.0),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
rationale=f"Oversold bounce for {code}",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for code in stock_codes
|
||||||
|
],
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
rationale="Near circuit breaker",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchema:
|
||||||
|
def test_playbooks_table_exists(self, conn) -> None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='playbooks'"
|
||||||
|
).fetchone()
|
||||||
|
assert row is not None
|
||||||
|
|
||||||
|
def test_unique_constraint(self, store: PlaybookStore) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
store.save(pb)
|
||||||
|
# Saving again for same date+market should replace, not error
|
||||||
|
pb2 = _make_playbook(stock_codes=["005930", "000660"])
|
||||||
|
store.save(pb2)
|
||||||
|
loaded = store.load(date(2026, 2, 8), "KR")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.stock_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save / Load
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSaveLoad:
|
||||||
|
def test_save_and_load(self, store: PlaybookStore) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
row_id = store.save(pb)
|
||||||
|
assert row_id > 0
|
||||||
|
|
||||||
|
loaded = store.load(date(2026, 2, 8), "KR")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.date == date(2026, 2, 8)
|
||||||
|
assert loaded.market == "KR"
|
||||||
|
assert loaded.stock_count == 1
|
||||||
|
assert loaded.scenario_count == 1
|
||||||
|
|
||||||
|
def test_load_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
result = store.load(date(2026, 1, 1), "KR")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_save_preserves_all_fields(self, store: PlaybookStore) -> None:
|
||||||
|
pb = _make_playbook(
|
||||||
|
outlook=MarketOutlook.BULLISH,
|
||||||
|
stock_codes=["005930", "AAPL"],
|
||||||
|
)
|
||||||
|
store.save(pb)
|
||||||
|
loaded = store.load(date(2026, 2, 8), "KR")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.market_outlook == MarketOutlook.BULLISH
|
||||||
|
assert loaded.stock_count == 2
|
||||||
|
assert loaded.global_rules[0].action == ScenarioAction.REDUCE_ALL
|
||||||
|
assert loaded.token_count == 150
|
||||||
|
|
||||||
|
def test_save_different_markets(self, store: PlaybookStore) -> None:
|
||||||
|
kr = _make_playbook(market="KR")
|
||||||
|
us = _make_playbook(market="US", stock_codes=["AAPL"])
|
||||||
|
store.save(kr)
|
||||||
|
store.save(us)
|
||||||
|
|
||||||
|
kr_loaded = store.load(date(2026, 2, 8), "KR")
|
||||||
|
us_loaded = store.load(date(2026, 2, 8), "US")
|
||||||
|
assert kr_loaded is not None
|
||||||
|
assert us_loaded is not None
|
||||||
|
assert kr_loaded.market == "KR"
|
||||||
|
assert us_loaded.market == "US"
|
||||||
|
assert kr_loaded.stock_playbooks[0].stock_code == "005930"
|
||||||
|
assert us_loaded.stock_playbooks[0].stock_code == "AAPL"
|
||||||
|
|
||||||
|
def test_save_different_dates(self, store: PlaybookStore) -> None:
|
||||||
|
d1 = _make_playbook(target_date=date(2026, 2, 7))
|
||||||
|
d2 = _make_playbook(target_date=date(2026, 2, 8))
|
||||||
|
store.save(d1)
|
||||||
|
store.save(d2)
|
||||||
|
|
||||||
|
assert store.load(date(2026, 2, 7), "KR") is not None
|
||||||
|
assert store.load(date(2026, 2, 8), "KR") is not None
|
||||||
|
|
||||||
|
def test_replace_updates_data(self, store: PlaybookStore) -> None:
|
||||||
|
pb1 = _make_playbook(outlook=MarketOutlook.BEARISH)
|
||||||
|
store.save(pb1)
|
||||||
|
|
||||||
|
pb2 = _make_playbook(outlook=MarketOutlook.BULLISH)
|
||||||
|
store.save(pb2)
|
||||||
|
|
||||||
|
loaded = store.load(date(2026, 2, 8), "KR")
|
||||||
|
assert loaded is not None
|
||||||
|
assert loaded.market_outlook == MarketOutlook.BULLISH
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatus:
|
||||||
|
def test_get_status(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook())
|
||||||
|
status = store.get_status(date(2026, 2, 8), "KR")
|
||||||
|
assert status == PlaybookStatus.READY
|
||||||
|
|
||||||
|
def test_get_status_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
assert store.get_status(date(2026, 1, 1), "KR") is None
|
||||||
|
|
||||||
|
def test_update_status(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook())
|
||||||
|
updated = store.update_status(date(2026, 2, 8), "KR", PlaybookStatus.EXPIRED)
|
||||||
|
assert updated is True
|
||||||
|
|
||||||
|
status = store.get_status(date(2026, 2, 8), "KR")
|
||||||
|
assert status == PlaybookStatus.EXPIRED
|
||||||
|
|
||||||
|
def test_update_status_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
updated = store.update_status(date(2026, 1, 1), "KR", PlaybookStatus.FAILED)
|
||||||
|
assert updated is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Match count
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchCount:
|
||||||
|
def test_increment_match_count(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook())
|
||||||
|
store.increment_match_count(date(2026, 2, 8), "KR")
|
||||||
|
store.increment_match_count(date(2026, 2, 8), "KR")
|
||||||
|
|
||||||
|
stats = store.get_stats(date(2026, 2, 8), "KR")
|
||||||
|
assert stats is not None
|
||||||
|
assert stats["match_count"] == 2
|
||||||
|
|
||||||
|
def test_increment_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
result = store.increment_match_count(date(2026, 1, 1), "KR")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Stats
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStats:
|
||||||
|
def test_get_stats(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook())
|
||||||
|
stats = store.get_stats(date(2026, 2, 8), "KR")
|
||||||
|
assert stats is not None
|
||||||
|
assert stats["status"] == "ready"
|
||||||
|
assert stats["token_count"] == 150
|
||||||
|
assert stats["scenario_count"] == 1
|
||||||
|
assert stats["match_count"] == 0
|
||||||
|
assert stats["generated_at"] != ""
|
||||||
|
|
||||||
|
def test_get_stats_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
assert store.get_stats(date(2026, 1, 1), "KR") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List recent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestListRecent:
|
||||||
|
def test_list_recent(self, store: PlaybookStore) -> None:
|
||||||
|
for day in range(5, 10):
|
||||||
|
store.save(_make_playbook(target_date=date(2026, 2, day)))
|
||||||
|
results = store.list_recent(market="KR", limit=3)
|
||||||
|
assert len(results) == 3
|
||||||
|
# Most recent first
|
||||||
|
assert results[0]["date"] == "2026-02-09"
|
||||||
|
assert results[2]["date"] == "2026-02-07"
|
||||||
|
|
||||||
|
def test_list_recent_all_markets(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook(market="KR"))
|
||||||
|
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
|
||||||
|
results = store.list_recent(market=None, limit=10)
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_list_recent_empty(self, store: PlaybookStore) -> None:
|
||||||
|
results = store.list_recent(market="KR")
|
||||||
|
assert results == []
|
||||||
|
|
||||||
|
def test_list_recent_filter_by_market(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook(market="KR"))
|
||||||
|
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
|
||||||
|
kr_only = store.list_recent(market="KR")
|
||||||
|
assert len(kr_only) == 1
|
||||||
|
assert kr_only[0]["market"] == "KR"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDelete:
|
||||||
|
def test_delete(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook())
|
||||||
|
deleted = store.delete(date(2026, 2, 8), "KR")
|
||||||
|
assert deleted is True
|
||||||
|
assert store.load(date(2026, 2, 8), "KR") is None
|
||||||
|
|
||||||
|
def test_delete_not_found(self, store: PlaybookStore) -> None:
|
||||||
|
deleted = store.delete(date(2026, 1, 1), "KR")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
def test_delete_one_market_keeps_other(self, store: PlaybookStore) -> None:
|
||||||
|
store.save(_make_playbook(market="KR"))
|
||||||
|
store.save(_make_playbook(market="US", stock_codes=["AAPL"]))
|
||||||
|
store.delete(date(2026, 2, 8), "KR")
|
||||||
|
assert store.load(date(2026, 2, 8), "KR") is None
|
||||||
|
assert store.load(date(2026, 2, 8), "US") is not None
|
||||||
552
tests/test_pre_market_planner.py
Normal file
552
tests/test_pre_market_planner.py
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
"""Tests for PreMarketPlanner — Gemini prompt builder + response parser."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.analysis.smart_scanner import ScanCandidate
|
||||||
|
from src.brain.gemini_client import TradeDecision
|
||||||
|
from src.config import Settings
|
||||||
|
from src.context.store import ContextLayer
|
||||||
|
from src.strategy.models import (
|
||||||
|
CrossMarketContext,
|
||||||
|
DayPlaybook,
|
||||||
|
MarketOutlook,
|
||||||
|
PlaybookStatus,
|
||||||
|
ScenarioAction,
|
||||||
|
)
|
||||||
|
from src.strategy.pre_market_planner import PreMarketPlanner
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate(
|
||||||
|
code: str = "005930",
|
||||||
|
name: str = "Samsung",
|
||||||
|
price: float = 71000,
|
||||||
|
rsi: float = 28.5,
|
||||||
|
volume_ratio: float = 3.2,
|
||||||
|
signal: str = "oversold",
|
||||||
|
score: float = 82.0,
|
||||||
|
) -> ScanCandidate:
|
||||||
|
return ScanCandidate(
|
||||||
|
stock_code=code,
|
||||||
|
name=name,
|
||||||
|
price=price,
|
||||||
|
volume=1_500_000,
|
||||||
|
volume_ratio=volume_ratio,
|
||||||
|
rsi=rsi,
|
||||||
|
signal=signal,
|
||||||
|
score=score,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_response_json(
|
||||||
|
outlook: str = "neutral_to_bullish",
|
||||||
|
stocks: list[dict] | None = None,
|
||||||
|
global_rules: list[dict] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Build a valid Gemini JSON response."""
|
||||||
|
if stocks is None:
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30, "volume_ratio_above": 2.5},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"allocation_pct": 15.0,
|
||||||
|
"stop_loss_pct": -2.0,
|
||||||
|
"take_profit_pct": 4.0,
|
||||||
|
"rationale": "Oversold bounce with high volume",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
if global_rules is None:
|
||||||
|
global_rules = [
|
||||||
|
{
|
||||||
|
"condition": "portfolio_pnl_pct < -2.0",
|
||||||
|
"action": "REDUCE_ALL",
|
||||||
|
"rationale": "Near circuit breaker",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
return json.dumps(
|
||||||
|
{"market_outlook": outlook, "global_rules": global_rules, "stocks": stocks}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_planner(
|
||||||
|
gemini_response: str = "",
|
||||||
|
token_count: int = 200,
|
||||||
|
context_data: dict | None = None,
|
||||||
|
scorecard_data: dict | None = None,
|
||||||
|
) -> PreMarketPlanner:
|
||||||
|
"""Create a PreMarketPlanner with mocked dependencies."""
|
||||||
|
if not gemini_response:
|
||||||
|
gemini_response = _gemini_response_json()
|
||||||
|
|
||||||
|
# Mock GeminiClient
|
||||||
|
gemini = AsyncMock()
|
||||||
|
gemini.decide = AsyncMock(
|
||||||
|
return_value=TradeDecision(
|
||||||
|
action="HOLD",
|
||||||
|
confidence=0,
|
||||||
|
rationale=gemini_response,
|
||||||
|
token_count=token_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock ContextStore
|
||||||
|
store = MagicMock()
|
||||||
|
store.get_context = MagicMock(return_value=scorecard_data)
|
||||||
|
|
||||||
|
# Mock ContextSelector
|
||||||
|
selector = MagicMock()
|
||||||
|
selector.select_layers = MagicMock(return_value=[ContextLayer.L7_REALTIME, ContextLayer.L6_DAILY])
|
||||||
|
selector.get_context_data = MagicMock(return_value=context_data or {})
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="test",
|
||||||
|
KIS_APP_SECRET="test",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PreMarketPlanner(gemini, store, selector, settings)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# generate_playbook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneratePlaybook:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_basic_generation(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert isinstance(pb, DayPlaybook)
|
||||||
|
assert pb.market == "KR"
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
assert pb.scenario_count == 1
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BULLISH
|
||||||
|
assert pb.token_count == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_candidates_returns_empty_playbook(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", [], today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.stock_count == 0
|
||||||
|
assert pb.scenario_count == 0
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_failure_returns_defensive(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("API timeout"))
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
# Defensive playbook has stop-loss scenarios
|
||||||
|
assert pb.stock_playbooks[0].scenarios[0].action == ScenarioAction.SELL
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_gemini_failure_empty_when_defensive_disabled(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
planner._settings.DEFENSIVE_PLAYBOOK_ON_FAILURE = False
|
||||||
|
planner._gemini.decide = AsyncMock(side_effect=RuntimeError("fail"))
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.stock_count == 0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multiple_candidates(self) -> None:
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "Oversold",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_above": 75},
|
||||||
|
"action": "SELL",
|
||||||
|
"confidence": 80,
|
||||||
|
"rationale": "Overbought",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||||
|
candidates = [_candidate(), _candidate(code="AAPL", name="Apple")]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("US", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.stock_count == 2
|
||||||
|
codes = [sp.stock_code for sp in pb.stock_playbooks]
|
||||||
|
assert "005930" in codes
|
||||||
|
assert "AAPL" in codes
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_stock_in_response_skipped(self) -> None:
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"scenarios": [{"condition": {"rsi_below": 30}, "action": "BUY", "confidence": 85, "rationale": "ok"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"stock_code": "UNKNOWN",
|
||||||
|
"scenarios": [{"condition": {"rsi_below": 20}, "action": "BUY", "confidence": 90, "rationale": "bad"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
planner = _make_planner(gemini_response=_gemini_response_json(stocks=stocks))
|
||||||
|
candidates = [_candidate()] # Only 005930
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
assert pb.stock_playbooks[0].stock_code == "005930"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_global_rules_parsed(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert len(pb.global_rules) == 1
|
||||||
|
assert pb.global_rules[0].action == ScenarioAction.REDUCE_ALL
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_count_from_decision(self) -> None:
|
||||||
|
planner = _make_planner(token_count=450)
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = await planner.generate_playbook("KR", candidates, today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert pb.token_count == 450
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _parse_response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseResponse:
|
||||||
|
def test_parse_full_response(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
response = _gemini_response_json(outlook="bearish")
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
assert pb.market_outlook == MarketOutlook.BEARISH
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
assert pb.stock_playbooks[0].scenarios[0].confidence == 85
|
||||||
|
|
||||||
|
def test_parse_with_markdown_fences(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
response = f"```json\n{_gemini_response_json()}\n```"
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
|
||||||
|
def test_parse_unknown_outlook_defaults_neutral(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
response = _gemini_response_json(outlook="super_bullish")
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
|
|
||||||
|
def test_parse_scenario_with_all_condition_fields(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {
|
||||||
|
"rsi_below": 25,
|
||||||
|
"volume_ratio_above": 3.0,
|
||||||
|
"price_change_pct_below": -2.0,
|
||||||
|
},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 92,
|
||||||
|
"allocation_pct": 20.0,
|
||||||
|
"stop_loss_pct": -3.0,
|
||||||
|
"take_profit_pct": 5.0,
|
||||||
|
"rationale": "Multi-condition entry",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
response = _gemini_response_json(stocks=stocks)
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
sc = pb.stock_playbooks[0].scenarios[0]
|
||||||
|
assert sc.condition.rsi_below == 25
|
||||||
|
assert sc.condition.volume_ratio_above == 3.0
|
||||||
|
assert sc.condition.price_change_pct_below == -2.0
|
||||||
|
assert sc.allocation_pct == 20.0
|
||||||
|
assert sc.stop_loss_pct == -3.0
|
||||||
|
assert sc.take_profit_pct == 5.0
|
||||||
|
|
||||||
|
def test_parse_empty_condition_scenario_skipped(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
stocks = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"condition": {},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "No conditions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 30},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 80,
|
||||||
|
"rationale": "Valid",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
response = _gemini_response_json(stocks=stocks)
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
# Empty condition scenario skipped, valid one kept
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
assert pb.stock_playbooks[0].scenarios[0].confidence == 80
|
||||||
|
|
||||||
|
def test_parse_max_scenarios_enforced(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
# Settings default MAX_SCENARIOS_PER_STOCK = 5
|
||||||
|
scenarios = [
|
||||||
|
{
|
||||||
|
"condition": {"rsi_below": 20 + i},
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 80 + i,
|
||||||
|
"rationale": f"Scenario {i}",
|
||||||
|
}
|
||||||
|
for i in range(8) # 8 scenarios, should be capped to 5
|
||||||
|
]
|
||||||
|
stocks = [{"stock_code": "005930", "scenarios": scenarios}]
|
||||||
|
response = _gemini_response_json(stocks=stocks)
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
assert len(pb.stock_playbooks[0].scenarios) == 5
|
||||||
|
|
||||||
|
def test_parse_invalid_json_raises(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
planner._parse_response("not json at all", date(2026, 2, 8), "KR", candidates, None)
|
||||||
|
|
||||||
|
def test_parse_cross_market_preserved(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
response = _gemini_response_json()
|
||||||
|
candidates = [_candidate()]
|
||||||
|
cross = CrossMarketContext(market="US", date="2026-02-07", total_pnl=1.5, win_rate=60)
|
||||||
|
|
||||||
|
pb = planner._parse_response(response, date(2026, 2, 8), "KR", candidates, cross)
|
||||||
|
|
||||||
|
assert pb.cross_market is not None
|
||||||
|
assert pb.cross_market.market == "US"
|
||||||
|
assert pb.cross_market.total_pnl == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_cross_market_context
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildCrossMarketContext:
|
||||||
|
def test_kr_reads_us_scorecard(self) -> None:
|
||||||
|
scorecard = {"total_pnl": 2.5, "win_rate": 65, "index_change_pct": 0.8, "lessons": ["Stay patient"]}
|
||||||
|
planner = _make_planner(scorecard_data=scorecard)
|
||||||
|
|
||||||
|
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert ctx is not None
|
||||||
|
assert ctx.market == "US"
|
||||||
|
assert ctx.total_pnl == 2.5
|
||||||
|
assert ctx.win_rate == 65
|
||||||
|
assert "Stay patient" in ctx.lessons
|
||||||
|
|
||||||
|
# Verify it queried scorecard_US
|
||||||
|
planner._context_store.get_context.assert_called_once_with(
|
||||||
|
ContextLayer.L6_DAILY, "2026-02-08", "scorecard_US"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_us_reads_kr_scorecard(self) -> None:
|
||||||
|
scorecard = {"total_pnl": -1.0, "win_rate": 40, "index_change_pct": -0.5}
|
||||||
|
planner = _make_planner(scorecard_data=scorecard)
|
||||||
|
|
||||||
|
ctx = planner.build_cross_market_context("US", today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert ctx is not None
|
||||||
|
assert ctx.market == "KR"
|
||||||
|
assert ctx.total_pnl == -1.0
|
||||||
|
|
||||||
|
planner._context_store.get_context.assert_called_once_with(
|
||||||
|
ContextLayer.L6_DAILY, "2026-02-08", "scorecard_KR"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_scorecard_returns_none(self) -> None:
|
||||||
|
planner = _make_planner(scorecard_data=None)
|
||||||
|
|
||||||
|
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert ctx is None
|
||||||
|
|
||||||
|
def test_invalid_scorecard_returns_none(self) -> None:
|
||||||
|
planner = _make_planner(scorecard_data="not a dict and not json")
|
||||||
|
|
||||||
|
ctx = planner.build_cross_market_context("KR", today=date(2026, 2, 8))
|
||||||
|
|
||||||
|
assert ctx is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_prompt
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildPrompt:
|
||||||
|
def test_prompt_contains_candidates(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
candidates = [_candidate(code="005930", name="Samsung")]
|
||||||
|
|
||||||
|
prompt = planner._build_prompt("KR", candidates, {}, None)
|
||||||
|
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "Samsung" in prompt
|
||||||
|
assert "RSI=" in prompt
|
||||||
|
assert "volume_ratio=" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_cross_market(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
cross = CrossMarketContext(
|
||||||
|
market="US", date="2026-02-07", total_pnl=1.5,
|
||||||
|
win_rate=60, index_change_pct=0.8, lessons=["Cut losses early"],
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = planner._build_prompt("KR", [_candidate()], {}, cross)
|
||||||
|
|
||||||
|
assert "Other Market (US)" in prompt
|
||||||
|
assert "+1.50%" in prompt
|
||||||
|
assert "Cut losses early" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_context_data(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
context = {"L6_DAILY": {"win_rate": 0.65, "total_pnl": 2.5}}
|
||||||
|
|
||||||
|
prompt = planner._build_prompt("KR", [_candidate()], context, None)
|
||||||
|
|
||||||
|
assert "Strategic Context" in prompt
|
||||||
|
assert "L6_DAILY" in prompt
|
||||||
|
assert "win_rate" in prompt
|
||||||
|
|
||||||
|
def test_prompt_contains_max_scenarios(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
prompt = planner._build_prompt("KR", [_candidate()], {}, None)
|
||||||
|
|
||||||
|
assert f"Max {planner._settings.MAX_SCENARIOS_PER_STOCK} scenarios" in prompt
|
||||||
|
|
||||||
|
def test_prompt_market_name(self) -> None:
|
||||||
|
planner = _make_planner()
|
||||||
|
prompt = planner._build_prompt("US", [_candidate()], {}, None)
|
||||||
|
assert "US market" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _extract_json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractJson:
|
||||||
|
def test_plain_json(self) -> None:
|
||||||
|
assert PreMarketPlanner._extract_json('{"a": 1}') == '{"a": 1}'
|
||||||
|
|
||||||
|
def test_with_json_fence(self) -> None:
|
||||||
|
text = '```json\n{"a": 1}\n```'
|
||||||
|
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
|
||||||
|
|
||||||
|
def test_with_plain_fence(self) -> None:
|
||||||
|
text = '```\n{"a": 1}\n```'
|
||||||
|
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
|
||||||
|
|
||||||
|
def test_with_whitespace(self) -> None:
|
||||||
|
text = ' \n {"a": 1} \n '
|
||||||
|
assert PreMarketPlanner._extract_json(text) == '{"a": 1}'
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Defensive playbook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefensivePlaybook:
|
||||||
|
def test_defensive_has_stop_loss(self) -> None:
|
||||||
|
candidates = [_candidate(code="005930"), _candidate(code="AAPL")]
|
||||||
|
pb = PreMarketPlanner._defensive_playbook(date(2026, 2, 8), "KR", candidates)
|
||||||
|
|
||||||
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL_TO_BEARISH
|
||||||
|
assert pb.stock_count == 2
|
||||||
|
for sp in pb.stock_playbooks:
|
||||||
|
assert sp.scenarios[0].action == ScenarioAction.SELL
|
||||||
|
assert sp.scenarios[0].stop_loss_pct == -3.0
|
||||||
|
|
||||||
|
def test_defensive_has_global_rule(self) -> None:
|
||||||
|
pb = PreMarketPlanner._defensive_playbook(date(2026, 2, 8), "KR", [_candidate()])
|
||||||
|
|
||||||
|
assert len(pb.global_rules) == 1
|
||||||
|
assert pb.global_rules[0].action == ScenarioAction.REDUCE_ALL
|
||||||
|
|
||||||
|
def test_empty_playbook(self) -> None:
|
||||||
|
pb = PreMarketPlanner._empty_playbook(date(2026, 2, 8), "US")
|
||||||
|
|
||||||
|
assert pb.stock_count == 0
|
||||||
|
assert pb.market == "US"
|
||||||
|
assert pb.market_outlook == MarketOutlook.NEUTRAL
|
||||||
442
tests/test_scenario_engine.py
Normal file
442
tests/test_scenario_engine.py
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
"""Tests for the local scenario engine."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.strategy.models import (
|
||||||
|
DayPlaybook,
|
||||||
|
GlobalRule,
|
||||||
|
ScenarioAction,
|
||||||
|
StockCondition,
|
||||||
|
StockPlaybook,
|
||||||
|
StockScenario,
|
||||||
|
)
|
||||||
|
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine() -> ScenarioEngine:
|
||||||
|
return ScenarioEngine()
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario(
|
||||||
|
rsi_below: float | None = None,
|
||||||
|
rsi_above: float | None = None,
|
||||||
|
volume_ratio_above: float | None = None,
|
||||||
|
action: ScenarioAction = ScenarioAction.BUY,
|
||||||
|
confidence: int = 85,
|
||||||
|
**kwargs,
|
||||||
|
) -> StockScenario:
|
||||||
|
return StockScenario(
|
||||||
|
condition=StockCondition(
|
||||||
|
rsi_below=rsi_below,
|
||||||
|
rsi_above=rsi_above,
|
||||||
|
volume_ratio_above=volume_ratio_above,
|
||||||
|
**kwargs,
|
||||||
|
),
|
||||||
|
action=action,
|
||||||
|
confidence=confidence,
|
||||||
|
rationale=f"Test scenario: {action.value}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _playbook(
|
||||||
|
stock_code: str = "005930",
|
||||||
|
scenarios: list[StockScenario] | None = None,
|
||||||
|
global_rules: list[GlobalRule] | None = None,
|
||||||
|
default_action: ScenarioAction = ScenarioAction.HOLD,
|
||||||
|
) -> DayPlaybook:
|
||||||
|
if scenarios is None:
|
||||||
|
scenarios = [_scenario(rsi_below=30.0)]
|
||||||
|
return DayPlaybook(
|
||||||
|
date=date(2026, 2, 7),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[StockPlaybook(stock_code=stock_code, scenarios=scenarios)],
|
||||||
|
global_rules=global_rules or [],
|
||||||
|
default_action=default_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# evaluate_condition
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluateCondition:
|
||||||
|
def test_rsi_below_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"rsi": 25.0})
|
||||||
|
|
||||||
|
def test_rsi_below_no_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 35.0})
|
||||||
|
|
||||||
|
def test_rsi_above_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_above=70.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"rsi": 75.0})
|
||||||
|
|
||||||
|
def test_rsi_above_no_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_above=70.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 65.0})
|
||||||
|
|
||||||
|
def test_volume_ratio_above_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(volume_ratio_above=3.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"volume_ratio": 4.5})
|
||||||
|
|
||||||
|
def test_volume_ratio_below_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(volume_ratio_below=1.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"volume_ratio": 0.5})
|
||||||
|
|
||||||
|
def test_price_above_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(price_above=50000)
|
||||||
|
assert engine.evaluate_condition(cond, {"current_price": 55000})
|
||||||
|
|
||||||
|
def test_price_below_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(price_below=50000)
|
||||||
|
assert engine.evaluate_condition(cond, {"current_price": 45000})
|
||||||
|
|
||||||
|
def test_price_change_pct_above_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(price_change_pct_above=2.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"price_change_pct": 3.5})
|
||||||
|
|
||||||
|
def test_price_change_pct_below_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(price_change_pct_below=-3.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"price_change_pct": -4.0})
|
||||||
|
|
||||||
|
def test_multiple_conditions_and_logic(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0, volume_ratio_above=3.0)
|
||||||
|
# Both met
|
||||||
|
assert engine.evaluate_condition(cond, {"rsi": 25.0, "volume_ratio": 4.0})
|
||||||
|
# Only RSI met
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 25.0, "volume_ratio": 2.0})
|
||||||
|
# Only volume met
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 35.0, "volume_ratio": 4.0})
|
||||||
|
# Neither met
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 35.0, "volume_ratio": 2.0})
|
||||||
|
|
||||||
|
def test_empty_condition_returns_false(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition()
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 25.0})
|
||||||
|
|
||||||
|
def test_missing_data_returns_false(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {})
|
||||||
|
|
||||||
|
def test_none_data_returns_false(self, engine: ScenarioEngine) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": None})
|
||||||
|
|
||||||
|
def test_boundary_value_not_matched(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""rsi_below=30 should NOT match rsi=30 (strict less than)."""
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 30.0})
|
||||||
|
|
||||||
|
def test_boundary_value_above_not_matched(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""rsi_above=70 should NOT match rsi=70 (strict greater than)."""
|
||||||
|
cond = StockCondition(rsi_above=70.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": 70.0})
|
||||||
|
|
||||||
|
def test_string_value_no_exception(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""String numeric value should not raise TypeError."""
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
# "25" can be cast to float → should match
|
||||||
|
assert engine.evaluate_condition(cond, {"rsi": "25"})
|
||||||
|
# "35" → should not match
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": "35"})
|
||||||
|
|
||||||
|
def test_percent_string_returns_false(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Percent string like '30%' cannot be cast to float → False, no exception."""
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert not engine.evaluate_condition(cond, {"rsi": "30%"})
|
||||||
|
|
||||||
|
def test_decimal_value_no_exception(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Decimal values should be safely handled."""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert engine.evaluate_condition(cond, {"rsi": Decimal("25.0")})
|
||||||
|
|
||||||
|
def test_mixed_invalid_types_no_exception(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""Various invalid types should not raise exceptions."""
|
||||||
|
cond = StockCondition(
|
||||||
|
rsi_below=30.0, volume_ratio_above=2.0,
|
||||||
|
price_above=100, price_change_pct_below=-1.0,
|
||||||
|
)
|
||||||
|
data = {
|
||||||
|
"rsi": [25], # list
|
||||||
|
"volume_ratio": "bad", # non-numeric string
|
||||||
|
"current_price": {}, # dict
|
||||||
|
"price_change_pct": object(), # arbitrary object
|
||||||
|
}
|
||||||
|
# Should return False (invalid types → None → False), never raise
|
||||||
|
assert not engine.evaluate_condition(cond, data)
|
||||||
|
|
||||||
|
def test_missing_key_logs_warning_once(self, caplog) -> None:
|
||||||
|
"""Missing key warning should fire only once per key per engine instance."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
eng = ScenarioEngine()
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
eng.evaluate_condition(cond, {})
|
||||||
|
eng.evaluate_condition(cond, {})
|
||||||
|
eng.evaluate_condition(cond, {})
|
||||||
|
# Warning should appear exactly once despite 3 calls
|
||||||
|
assert caplog.text.count("'rsi' but key missing") == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check_global_rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckGlobalRules:
|
||||||
|
def test_no_rules(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(global_rules=[])
|
||||||
|
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.0})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_rule_triggered(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
rationale="Near circuit breaker",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.5})
|
||||||
|
assert result is not None
|
||||||
|
assert result.action == ScenarioAction.REDUCE_ALL
|
||||||
|
|
||||||
|
def test_rule_not_triggered(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.0})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_first_rule_wins(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="portfolio_pnl_pct < -2.0", action=ScenarioAction.REDUCE_ALL),
|
||||||
|
GlobalRule(condition="portfolio_pnl_pct < -1.0", action=ScenarioAction.HOLD),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.5})
|
||||||
|
assert result is not None
|
||||||
|
assert result.action == ScenarioAction.REDUCE_ALL
|
||||||
|
|
||||||
|
def test_greater_than_operator(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="volatility_index > 30", action=ScenarioAction.HOLD),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {"volatility_index": 35})
|
||||||
|
assert result is not None
|
||||||
|
|
||||||
|
def test_missing_field_not_triggered(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="unknown_field < -2.0", action=ScenarioAction.REDUCE_ALL),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {"portfolio_pnl_pct": -5.0})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_invalid_condition_format(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="bad format", action=ScenarioAction.HOLD),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.check_global_rules(pb, {})
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_le_operator(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="portfolio_pnl_pct <= -2.0", action=ScenarioAction.REDUCE_ALL),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert engine.check_global_rules(pb, {"portfolio_pnl_pct": -2.0}) is not None
|
||||||
|
assert engine.check_global_rules(pb, {"portfolio_pnl_pct": -1.9}) is None
|
||||||
|
|
||||||
|
def test_ge_operator(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(condition="volatility >= 80.0", action=ScenarioAction.HOLD),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert engine.check_global_rules(pb, {"volatility": 80.0}) is not None
|
||||||
|
assert engine.check_global_rules(pb, {"volatility": 79.9}) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# evaluate (full pipeline)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvaluate:
|
||||||
|
def test_scenario_match(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
|
||||||
|
assert result.action == ScenarioAction.BUY
|
||||||
|
assert result.confidence == 85
|
||||||
|
assert result.matched_scenario is not None
|
||||||
|
|
||||||
|
def test_no_scenario_match_returns_default(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 50.0}, {})
|
||||||
|
assert result.action == ScenarioAction.HOLD
|
||||||
|
assert result.confidence == 0
|
||||||
|
assert result.matched_scenario is None
|
||||||
|
|
||||||
|
def test_stock_not_in_playbook(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(stock_code="005930")
|
||||||
|
result = engine.evaluate(pb, "AAPL", {"rsi": 25.0}, {})
|
||||||
|
assert result.action == ScenarioAction.HOLD
|
||||||
|
assert result.confidence == 0
|
||||||
|
|
||||||
|
def test_global_rule_takes_priority(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[_scenario(rsi_below=30.0)],
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
rationale="Loss limit",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"rsi": 25.0}, # Would match scenario
|
||||||
|
{"portfolio_pnl_pct": -2.5}, # But global rule triggers first
|
||||||
|
)
|
||||||
|
assert result.action == ScenarioAction.REDUCE_ALL
|
||||||
|
assert result.global_rule_triggered is not None
|
||||||
|
assert result.matched_scenario is None
|
||||||
|
|
||||||
|
def test_first_scenario_wins(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
_scenario(rsi_below=30.0, action=ScenarioAction.BUY, confidence=90),
|
||||||
|
_scenario(rsi_below=25.0, action=ScenarioAction.BUY, confidence=95),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 20.0}, {})
|
||||||
|
# Both match, but first wins
|
||||||
|
assert result.confidence == 90
|
||||||
|
|
||||||
|
def test_sell_scenario(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 80.0}, {})
|
||||||
|
assert result.action == ScenarioAction.SELL
|
||||||
|
|
||||||
|
def test_empty_playbook(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = DayPlaybook(date=date(2026, 2, 7), market="KR", stock_playbooks=[])
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
|
||||||
|
assert result.action == ScenarioAction.HOLD
|
||||||
|
|
||||||
|
def test_match_details_populated(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(scenarios=[_scenario(rsi_below=30.0, volume_ratio_above=2.0)])
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb, "005930", {"rsi": 25.0, "volume_ratio": 3.0}, {}
|
||||||
|
)
|
||||||
|
assert result.match_details.get("rsi") == 25.0
|
||||||
|
assert result.match_details.get("volume_ratio") == 3.0
|
||||||
|
|
||||||
|
def test_custom_default_action(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[_scenario(rsi_below=10.0)], # Very unlikely to match
|
||||||
|
default_action=ScenarioAction.SELL,
|
||||||
|
)
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 50.0}, {})
|
||||||
|
assert result.action == ScenarioAction.SELL
|
||||||
|
|
||||||
|
def test_multiple_stocks_in_playbook(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = DayPlaybook(
|
||||||
|
date=date(2026, 2, 7),
|
||||||
|
market="US",
|
||||||
|
stock_playbooks=[
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code="AAPL",
|
||||||
|
scenarios=[_scenario(rsi_below=25.0, confidence=90)],
|
||||||
|
),
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code="MSFT",
|
||||||
|
scenarios=[_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
aapl = engine.evaluate(pb, "AAPL", {"rsi": 20.0}, {})
|
||||||
|
assert aapl.action == ScenarioAction.BUY
|
||||||
|
assert aapl.confidence == 90
|
||||||
|
|
||||||
|
msft = engine.evaluate(pb, "MSFT", {"rsi": 80.0}, {})
|
||||||
|
assert msft.action == ScenarioAction.SELL
|
||||||
|
|
||||||
|
def test_complex_multi_condition(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(
|
||||||
|
scenarios=[
|
||||||
|
_scenario(
|
||||||
|
rsi_below=30.0,
|
||||||
|
volume_ratio_above=3.0,
|
||||||
|
price_change_pct_below=-2.0,
|
||||||
|
confidence=95,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# All conditions met
|
||||||
|
result = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"rsi": 22.0, "volume_ratio": 4.0, "price_change_pct": -3.0},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result.action == ScenarioAction.BUY
|
||||||
|
assert result.confidence == 95
|
||||||
|
|
||||||
|
# One condition not met
|
||||||
|
result2 = engine.evaluate(
|
||||||
|
pb,
|
||||||
|
"005930",
|
||||||
|
{"rsi": 22.0, "volume_ratio": 4.0, "price_change_pct": -1.0},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
assert result2.action == ScenarioAction.HOLD
|
||||||
|
|
||||||
|
def test_scenario_match_returns_rationale(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
|
||||||
|
assert result.rationale != ""
|
||||||
|
|
||||||
|
def test_result_stock_code(self, engine: ScenarioEngine) -> None:
|
||||||
|
pb = _playbook()
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": 25.0}, {})
|
||||||
|
assert result.stock_code == "005930"
|
||||||
|
|
||||||
|
def test_match_details_normalized(self, engine: ScenarioEngine) -> None:
|
||||||
|
"""match_details should contain _safe_float normalized values, not raw."""
|
||||||
|
pb = _playbook(scenarios=[_scenario(rsi_below=30.0)])
|
||||||
|
# Pass string value — should be normalized to float in match_details
|
||||||
|
result = engine.evaluate(pb, "005930", {"rsi": "25.0"}, {})
|
||||||
|
assert result.action == ScenarioAction.BUY
|
||||||
|
assert result.match_details["rsi"] == 25.0
|
||||||
|
assert isinstance(result.match_details["rsi"], float)
|
||||||
377
tests/test_smart_scanner.py
Normal file
377
tests/test_smart_scanner.py
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
"""Tests for SmartVolatilityScanner."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
|
||||||
|
from src.analysis.volatility import VolatilityAnalyzer
|
||||||
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings() -> Settings:
|
||||||
|
"""Create test settings."""
|
||||||
|
return Settings(
|
||||||
|
KIS_APP_KEY="test",
|
||||||
|
KIS_APP_SECRET="test",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="test",
|
||||||
|
RSI_OVERSOLD_THRESHOLD=30,
|
||||||
|
RSI_MOMENTUM_THRESHOLD=70,
|
||||||
|
VOL_MULTIPLIER=2.0,
|
||||||
|
SCANNER_TOP_N=3,
|
||||||
|
DB_PATH=":memory:",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_broker(mock_settings: Settings) -> MagicMock:
|
||||||
|
"""Create mock broker."""
|
||||||
|
broker = MagicMock(spec=KISBroker)
|
||||||
|
broker._settings = mock_settings
|
||||||
|
broker.fetch_market_rankings = AsyncMock()
|
||||||
|
broker.get_daily_prices = AsyncMock()
|
||||||
|
return broker
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scanner(mock_broker: MagicMock, mock_settings: Settings) -> SmartVolatilityScanner:
|
||||||
|
"""Create smart scanner instance."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
return SmartVolatilityScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
volatility_analyzer=analyzer,
|
||||||
|
settings=mock_settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmartVolatilityScanner:
|
||||||
|
"""Test suite for SmartVolatilityScanner."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_finds_oversold_candidates(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test that scanner identifies oversold stocks with high volume."""
|
||||||
|
# Mock rankings
|
||||||
|
mock_broker.fetch_market_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"stock_code": "005930",
|
||||||
|
"name": "Samsung",
|
||||||
|
"price": 70000,
|
||||||
|
"volume": 5000000,
|
||||||
|
"change_rate": -3.5,
|
||||||
|
"volume_increase_rate": 250,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Should find at least one candidate (depending on exact RSI calculation)
|
||||||
|
mock_broker.fetch_market_rankings.assert_called_once()
|
||||||
|
mock_broker.get_daily_prices.assert_called_once_with("005930", days=20)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
async def test_scan_finds_momentum_candidates(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test that scanner identifies momentum stocks with high volume."""
|
||||||
|
mock_broker.fetch_market_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"stock_code": "035420",
|
||||||
|
"name": "NAVER",
|
||||||
|
"price": 250000,
|
||||||
|
"volume": 3000000,
|
||||||
|
"change_rate": 5.0,
|
||||||
|
"volume_increase_rate": 300,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock daily prices - trending up (momentum)
|
||||||
|
prices = []
|
||||||
|
for i in range(20):
|
||||||
|
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()
|
||||||
|
|
||||||
|
mock_broker.fetch_market_rankings.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_filters_low_volume(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test that stocks with low volume ratio are filtered out."""
|
||||||
|
mock_broker.fetch_market_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"stock_code": "000660",
|
||||||
|
"name": "SK Hynix",
|
||||||
|
"price": 150000,
|
||||||
|
"volume": 500000,
|
||||||
|
"change_rate": -5.0,
|
||||||
|
"volume_increase_rate": 50, # Only 50% increase (< 200%)
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Low volume
|
||||||
|
prices = []
|
||||||
|
for i in range(20):
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_uses_fallback_on_api_error(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test fallback to static list when ranking API fails."""
|
||||||
|
mock_broker.fetch_market_rankings.side_effect = ConnectionError("API unavailable")
|
||||||
|
|
||||||
|
# Fallback stocks should still be analyzed
|
||||||
|
prices = []
|
||||||
|
for i in range(20):
|
||||||
|
prices.append({
|
||||||
|
"date": f"2026020{i:02d}",
|
||||||
|
"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"])
|
||||||
|
|
||||||
|
# Should not crash
|
||||||
|
assert isinstance(candidates, list)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_returns_top_n_only(
|
||||||
|
self, scanner: SmartVolatilityScanner, mock_broker: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test that scan returns at most top_n candidates."""
|
||||||
|
# Return many stocks
|
||||||
|
mock_broker.fetch_market_rankings.return_value = [
|
||||||
|
{
|
||||||
|
"stock_code": f"00{i}000",
|
||||||
|
"name": f"Stock{i}",
|
||||||
|
"price": 10000 * i,
|
||||||
|
"volume": 5000000,
|
||||||
|
"change_rate": -10,
|
||||||
|
"volume_increase_rate": 500,
|
||||||
|
}
|
||||||
|
for i in range(1, 10)
|
||||||
|
]
|
||||||
|
|
||||||
|
# All oversold with high volume
|
||||||
|
def make_prices(code: str) -> list[dict]:
|
||||||
|
prices = []
|
||||||
|
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()
|
||||||
|
|
||||||
|
# Should respect top_n limit (3)
|
||||||
|
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
|
||||||
|
async def test_get_stock_codes(
|
||||||
|
self, scanner: SmartVolatilityScanner
|
||||||
|
) -> None:
|
||||||
|
"""Test extraction of stock codes from candidates."""
|
||||||
|
candidates = [
|
||||||
|
ScanCandidate(
|
||||||
|
stock_code="005930",
|
||||||
|
name="Samsung",
|
||||||
|
price=70000,
|
||||||
|
volume=5000000,
|
||||||
|
volume_ratio=2.5,
|
||||||
|
rsi=28,
|
||||||
|
signal="oversold",
|
||||||
|
score=85.0,
|
||||||
|
),
|
||||||
|
ScanCandidate(
|
||||||
|
stock_code="035420",
|
||||||
|
name="NAVER",
|
||||||
|
price=250000,
|
||||||
|
volume=3000000,
|
||||||
|
volume_ratio=3.0,
|
||||||
|
rsi=75,
|
||||||
|
signal="momentum",
|
||||||
|
score=88.0,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
codes = scanner.get_stock_codes(candidates)
|
||||||
|
|
||||||
|
assert codes == ["005930", "035420"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRSICalculation:
|
||||||
|
"""Test RSI calculation in VolatilityAnalyzer."""
|
||||||
|
|
||||||
|
def test_rsi_oversold(self) -> None:
|
||||||
|
"""Test RSI calculation for downtrending prices."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
|
||||||
|
# Steadily declining prices
|
||||||
|
prices = [100 - i * 0.5 for i in range(20)]
|
||||||
|
rsi = analyzer.calculate_rsi(prices, period=14)
|
||||||
|
|
||||||
|
assert rsi < 50 # Should be oversold territory
|
||||||
|
|
||||||
|
def test_rsi_overbought(self) -> None:
|
||||||
|
"""Test RSI calculation for uptrending prices."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
|
||||||
|
# Steadily rising prices
|
||||||
|
prices = [100 + i * 0.5 for i in range(20)]
|
||||||
|
rsi = analyzer.calculate_rsi(prices, period=14)
|
||||||
|
|
||||||
|
assert rsi > 50 # Should be overbought territory
|
||||||
|
|
||||||
|
def test_rsi_neutral(self) -> None:
|
||||||
|
"""Test RSI calculation for flat prices."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
|
||||||
|
# Flat prices with small oscillation
|
||||||
|
prices = [100 + (i % 2) * 0.1 for i in range(20)]
|
||||||
|
rsi = analyzer.calculate_rsi(prices, period=14)
|
||||||
|
|
||||||
|
assert 40 < rsi < 60 # Should be near neutral
|
||||||
|
|
||||||
|
def test_rsi_insufficient_data(self) -> None:
|
||||||
|
"""Test RSI returns neutral when insufficient data."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
|
||||||
|
prices = [100, 101, 102] # Only 3 prices, need 15+
|
||||||
|
rsi = analyzer.calculate_rsi(prices, period=14)
|
||||||
|
|
||||||
|
assert rsi == 50.0 # Default neutral
|
||||||
|
|
||||||
|
def test_rsi_all_gains(self) -> None:
|
||||||
|
"""Test RSI returns 100 when all gains (no losses)."""
|
||||||
|
analyzer = VolatilityAnalyzer()
|
||||||
|
|
||||||
|
# Monotonic increase
|
||||||
|
prices = [100 + i for i in range(20)]
|
||||||
|
rsi = analyzer.calculate_rsi(prices, period=14)
|
||||||
|
|
||||||
|
assert rsi == 100.0 # Maximum RSI
|
||||||
366
tests/test_strategy_models.py
Normal file
366
tests/test_strategy_models.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""Tests for strategy/playbook Pydantic models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from src.strategy.models import (
|
||||||
|
CrossMarketContext,
|
||||||
|
DayPlaybook,
|
||||||
|
GlobalRule,
|
||||||
|
MarketOutlook,
|
||||||
|
PlaybookStatus,
|
||||||
|
ScenarioAction,
|
||||||
|
StockCondition,
|
||||||
|
StockPlaybook,
|
||||||
|
StockScenario,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# StockCondition
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockCondition:
|
||||||
|
def test_empty_condition(self) -> None:
|
||||||
|
cond = StockCondition()
|
||||||
|
assert not cond.has_any_condition()
|
||||||
|
|
||||||
|
def test_single_field(self) -> None:
|
||||||
|
cond = StockCondition(rsi_below=30.0)
|
||||||
|
assert cond.has_any_condition()
|
||||||
|
|
||||||
|
def test_multiple_fields(self) -> None:
|
||||||
|
cond = StockCondition(rsi_below=25.0, volume_ratio_above=3.0)
|
||||||
|
assert cond.has_any_condition()
|
||||||
|
|
||||||
|
def test_all_fields(self) -> None:
|
||||||
|
cond = StockCondition(
|
||||||
|
rsi_below=30,
|
||||||
|
rsi_above=10,
|
||||||
|
volume_ratio_above=2.0,
|
||||||
|
volume_ratio_below=10.0,
|
||||||
|
price_above=1000,
|
||||||
|
price_below=50000,
|
||||||
|
price_change_pct_above=-5.0,
|
||||||
|
price_change_pct_below=5.0,
|
||||||
|
)
|
||||||
|
assert cond.has_any_condition()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# StockScenario
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockScenario:
|
||||||
|
def test_valid_scenario(self) -> None:
|
||||||
|
s = StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=25.0),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
allocation_pct=15.0,
|
||||||
|
stop_loss_pct=-2.0,
|
||||||
|
take_profit_pct=3.0,
|
||||||
|
rationale="Oversold bounce expected",
|
||||||
|
)
|
||||||
|
assert s.action == ScenarioAction.BUY
|
||||||
|
assert s.confidence == 85
|
||||||
|
|
||||||
|
def test_confidence_too_high(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=101,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_confidence_too_low(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=-1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_allocation_too_high(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=80,
|
||||||
|
allocation_pct=101.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_stop_loss_must_be_negative(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=80,
|
||||||
|
stop_loss_pct=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_take_profit_must_be_positive(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=80,
|
||||||
|
take_profit_pct=-1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
s = StockScenario(
|
||||||
|
condition=StockCondition(),
|
||||||
|
action=ScenarioAction.HOLD,
|
||||||
|
confidence=50,
|
||||||
|
)
|
||||||
|
assert s.allocation_pct == 10.0
|
||||||
|
assert s.stop_loss_pct == -2.0
|
||||||
|
assert s.take_profit_pct == 3.0
|
||||||
|
assert s.rationale == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# StockPlaybook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStockPlaybook:
|
||||||
|
def test_valid_playbook(self) -> None:
|
||||||
|
pb = StockPlaybook(
|
||||||
|
stock_code="005930",
|
||||||
|
stock_name="Samsung Electronics",
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=25.0),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert pb.stock_code == "005930"
|
||||||
|
assert len(pb.scenarios) == 1
|
||||||
|
|
||||||
|
def test_empty_scenarios_rejected(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code="005930",
|
||||||
|
scenarios=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_scenarios(self) -> None:
|
||||||
|
pb = StockPlaybook(
|
||||||
|
stock_code="AAPL",
|
||||||
|
scenarios=[
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=25.0),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
),
|
||||||
|
StockScenario(
|
||||||
|
condition=StockCondition(rsi_above=75.0),
|
||||||
|
action=ScenarioAction.SELL,
|
||||||
|
confidence=80,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert len(pb.scenarios) == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GlobalRule
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGlobalRule:
|
||||||
|
def test_valid_rule(self) -> None:
|
||||||
|
rule = GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
rationale="Risk limit approaching",
|
||||||
|
)
|
||||||
|
assert rule.action == ScenarioAction.REDUCE_ALL
|
||||||
|
|
||||||
|
def test_hold_rule(self) -> None:
|
||||||
|
rule = GlobalRule(
|
||||||
|
condition="volatility_index > 30",
|
||||||
|
action=ScenarioAction.HOLD,
|
||||||
|
)
|
||||||
|
assert rule.rationale == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CrossMarketContext
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCrossMarketContext:
|
||||||
|
def test_valid_context(self) -> None:
|
||||||
|
ctx = CrossMarketContext(
|
||||||
|
market="US",
|
||||||
|
date="2026-02-07",
|
||||||
|
total_pnl=-1.5,
|
||||||
|
win_rate=40.0,
|
||||||
|
index_change_pct=-2.3,
|
||||||
|
key_events=["Fed rate decision"],
|
||||||
|
lessons=["Avoid tech sector on rate hike days"],
|
||||||
|
)
|
||||||
|
assert ctx.market == "US"
|
||||||
|
assert len(ctx.key_events) == 1
|
||||||
|
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
ctx = CrossMarketContext(market="KR", date="2026-02-07")
|
||||||
|
assert ctx.total_pnl == 0.0
|
||||||
|
assert ctx.key_events == []
|
||||||
|
assert ctx.lessons == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DayPlaybook
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scenario(rsi_below: float = 25.0) -> StockScenario:
|
||||||
|
return StockScenario(
|
||||||
|
condition=StockCondition(rsi_below=rsi_below),
|
||||||
|
action=ScenarioAction.BUY,
|
||||||
|
confidence=85,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_playbook(**kwargs) -> DayPlaybook:
|
||||||
|
defaults = {
|
||||||
|
"date": date(2026, 2, 7),
|
||||||
|
"market": "KR",
|
||||||
|
"stock_playbooks": [
|
||||||
|
StockPlaybook(stock_code="005930", scenarios=[_make_scenario()]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return DayPlaybook(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDayPlaybook:
|
||||||
|
def test_valid_playbook(self) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
assert pb.market == "KR"
|
||||||
|
assert pb.date == date(2026, 2, 7)
|
||||||
|
assert pb.default_action == ScenarioAction.HOLD
|
||||||
|
assert pb.scenario_count == 1
|
||||||
|
assert pb.stock_count == 1
|
||||||
|
|
||||||
|
def test_generated_at_auto_set(self) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
assert pb.generated_at != ""
|
||||||
|
|
||||||
|
def test_explicit_generated_at(self) -> None:
|
||||||
|
pb = _make_playbook(generated_at="2026-02-07T08:30:00")
|
||||||
|
assert pb.generated_at == "2026-02-07T08:30:00"
|
||||||
|
|
||||||
|
def test_duplicate_stocks_rejected(self) -> None:
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
DayPlaybook(
|
||||||
|
date=date(2026, 2, 7),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[
|
||||||
|
StockPlaybook(stock_code="005930", scenarios=[_make_scenario()]),
|
||||||
|
StockPlaybook(stock_code="005930", scenarios=[_make_scenario(30)]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_empty_stock_playbooks_allowed(self) -> None:
|
||||||
|
pb = DayPlaybook(
|
||||||
|
date=date(2026, 2, 7),
|
||||||
|
market="KR",
|
||||||
|
stock_playbooks=[],
|
||||||
|
)
|
||||||
|
assert pb.stock_count == 0
|
||||||
|
assert pb.scenario_count == 0
|
||||||
|
|
||||||
|
def test_get_stock_playbook_found(self) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
result = pb.get_stock_playbook("005930")
|
||||||
|
assert result is not None
|
||||||
|
assert result.stock_code == "005930"
|
||||||
|
|
||||||
|
def test_get_stock_playbook_not_found(self) -> None:
|
||||||
|
pb = _make_playbook()
|
||||||
|
result = pb.get_stock_playbook("AAPL")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_with_global_rules(self) -> None:
|
||||||
|
pb = _make_playbook(
|
||||||
|
global_rules=[
|
||||||
|
GlobalRule(
|
||||||
|
condition="portfolio_pnl_pct < -2.0",
|
||||||
|
action=ScenarioAction.REDUCE_ALL,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert len(pb.global_rules) == 1
|
||||||
|
|
||||||
|
def test_with_cross_market_context(self) -> None:
|
||||||
|
ctx = CrossMarketContext(market="US", date="2026-02-07", total_pnl=-1.5)
|
||||||
|
pb = _make_playbook(cross_market=ctx)
|
||||||
|
assert pb.cross_market is not None
|
||||||
|
assert pb.cross_market.market == "US"
|
||||||
|
|
||||||
|
def test_market_outlook(self) -> None:
|
||||||
|
pb = _make_playbook(market_outlook=MarketOutlook.BEARISH)
|
||||||
|
assert pb.market_outlook == MarketOutlook.BEARISH
|
||||||
|
|
||||||
|
def test_multiple_stocks_multiple_scenarios(self) -> None:
|
||||||
|
pb = DayPlaybook(
|
||||||
|
date=date(2026, 2, 7),
|
||||||
|
market="US",
|
||||||
|
stock_playbooks=[
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code="AAPL",
|
||||||
|
scenarios=[_make_scenario(), _make_scenario(30)],
|
||||||
|
),
|
||||||
|
StockPlaybook(
|
||||||
|
stock_code="MSFT",
|
||||||
|
scenarios=[_make_scenario()],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert pb.stock_count == 2
|
||||||
|
assert pb.scenario_count == 3
|
||||||
|
|
||||||
|
def test_serialization_roundtrip(self) -> None:
|
||||||
|
pb = _make_playbook(
|
||||||
|
market_outlook=MarketOutlook.BULLISH,
|
||||||
|
cross_market=CrossMarketContext(market="US", date="2026-02-07"),
|
||||||
|
)
|
||||||
|
json_str = pb.model_dump_json()
|
||||||
|
restored = DayPlaybook.model_validate_json(json_str)
|
||||||
|
assert restored.market == pb.market
|
||||||
|
assert restored.date == pb.date
|
||||||
|
assert restored.scenario_count == pb.scenario_count
|
||||||
|
assert restored.cross_market is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnums:
|
||||||
|
def test_scenario_action_values(self) -> None:
|
||||||
|
assert ScenarioAction.BUY.value == "BUY"
|
||||||
|
assert ScenarioAction.SELL.value == "SELL"
|
||||||
|
assert ScenarioAction.HOLD.value == "HOLD"
|
||||||
|
assert ScenarioAction.REDUCE_ALL.value == "REDUCE_ALL"
|
||||||
|
|
||||||
|
def test_market_outlook_values(self) -> None:
|
||||||
|
assert len(MarketOutlook) == 5
|
||||||
|
|
||||||
|
def test_playbook_status_values(self) -> None:
|
||||||
|
assert PlaybookStatus.READY.value == "ready"
|
||||||
|
assert PlaybookStatus.EXPIRED.value == "expired"
|
||||||
483
tests/test_telegram.py
Normal file
483
tests/test_telegram.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""Tests for Telegram notification client."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.notifications.telegram_client import NotificationPriority, TelegramClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestTelegramClientInit:
|
||||||
|
"""Test client initialization scenarios."""
|
||||||
|
|
||||||
|
def test_disabled_via_flag(self) -> None:
|
||||||
|
"""Client disabled via enabled=False flag."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=False
|
||||||
|
)
|
||||||
|
assert client._enabled is False
|
||||||
|
|
||||||
|
def test_disabled_missing_token(self) -> None:
|
||||||
|
"""Client disabled when bot_token is None."""
|
||||||
|
client = TelegramClient(bot_token=None, chat_id="456", enabled=True)
|
||||||
|
assert client._enabled is False
|
||||||
|
|
||||||
|
def test_disabled_missing_chat_id(self) -> None:
|
||||||
|
"""Client disabled when chat_id is None."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id=None, enabled=True)
|
||||||
|
assert client._enabled is False
|
||||||
|
|
||||||
|
def test_enabled_with_credentials(self) -> None:
|
||||||
|
"""Client enabled when credentials provided."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
assert client._enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestNotificationSending:
|
||||||
|
"""Test notification sending behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_success(self) -> None:
|
||||||
|
"""send_message returns True on successful send."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
result = await client.send_message("Test message")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert payload["chat_id"] == "456"
|
||||||
|
assert payload["text"] == "Test message"
|
||||||
|
assert payload["parse_mode"] == "HTML"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_disabled_client(self) -> None:
|
||||||
|
"""send_message returns False when client disabled."""
|
||||||
|
client = TelegramClient(enabled=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
result = await client.send_message("Test message")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_api_error(self) -> None:
|
||||||
|
"""send_message returns False on API error."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
result = await client.send_message("Test message")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_message_with_markdown(self) -> None:
|
||||||
|
"""send_message supports different parse modes."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
result = await client.send_message("*bold*", parse_mode="Markdown")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert payload["parse_mode"] == "Markdown"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_send_when_disabled(self) -> None:
|
||||||
|
"""Notifications not sent when client disabled."""
|
||||||
|
client = TelegramClient(enabled=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post") as mock_post:
|
||||||
|
await client.notify_trade_execution(
|
||||||
|
stock_code="AAPL",
|
||||||
|
market="United States",
|
||||||
|
action="BUY",
|
||||||
|
quantity=10,
|
||||||
|
price=150.0,
|
||||||
|
confidence=85.0,
|
||||||
|
)
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_trade_execution_format(self) -> None:
|
||||||
|
"""Trade notification has correct format."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_trade_execution(
|
||||||
|
stock_code="TSLA",
|
||||||
|
market="United States",
|
||||||
|
action="SELL",
|
||||||
|
quantity=5,
|
||||||
|
price=250.50,
|
||||||
|
confidence=92.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify API call was made
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
|
||||||
|
# Check payload structure
|
||||||
|
payload = call_args.kwargs["json"]
|
||||||
|
assert payload["chat_id"] == "456"
|
||||||
|
assert "TSLA" in payload["text"]
|
||||||
|
assert "SELL" in payload["text"]
|
||||||
|
assert "5" in payload["text"]
|
||||||
|
assert "250.50" in payload["text"]
|
||||||
|
assert "92%" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playbook_generated_format(self) -> None:
|
||||||
|
"""Playbook generated notification has expected fields."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_playbook_generated(
|
||||||
|
market="KR",
|
||||||
|
stock_count=4,
|
||||||
|
scenario_count=12,
|
||||||
|
token_count=980,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Playbook Generated" in payload["text"]
|
||||||
|
assert "Market: KR" in payload["text"]
|
||||||
|
assert "Stocks: 4" in payload["text"]
|
||||||
|
assert "Scenarios: 12" in payload["text"]
|
||||||
|
assert "Tokens: 980" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenario_matched_format(self) -> None:
|
||||||
|
"""Scenario matched notification has expected fields."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_scenario_matched(
|
||||||
|
stock_code="AAPL",
|
||||||
|
action="BUY",
|
||||||
|
condition_summary="RSI < 30, volume_ratio > 2.0",
|
||||||
|
confidence=88.2,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Scenario Matched" in payload["text"]
|
||||||
|
assert "AAPL" in payload["text"]
|
||||||
|
assert "Action: BUY" in payload["text"]
|
||||||
|
assert "RSI < 30" in payload["text"]
|
||||||
|
assert "88%" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playbook_failed_format(self) -> None:
|
||||||
|
"""Playbook failed notification has expected fields."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_playbook_failed(
|
||||||
|
market="US",
|
||||||
|
reason="Gemini timeout",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Playbook Failed" in payload["text"]
|
||||||
|
assert "Market: US" in payload["text"]
|
||||||
|
assert "Gemini timeout" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_circuit_breaker_priority(self) -> None:
|
||||||
|
"""Circuit breaker uses CRITICAL priority."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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.15, threshold=-3.0)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
# CRITICAL priority has 🚨 emoji
|
||||||
|
assert NotificationPriority.CRITICAL.emoji in payload["text"]
|
||||||
|
assert "-3.15%" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_error_handling(self) -> None:
|
||||||
|
"""API errors logged but don't crash."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
|
||||||
|
mock_resp.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
|
||||||
|
# Should not raise exception
|
||||||
|
await client.notify_system_start(mode="paper", enabled_markets=["KR"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_handling(self) -> None:
|
||||||
|
"""Timeouts logged but don't crash."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"aiohttp.ClientSession.post",
|
||||||
|
side_effect=aiohttp.ClientError("Connection timeout"),
|
||||||
|
):
|
||||||
|
# Should not raise exception
|
||||||
|
await client.notify_error(
|
||||||
|
error_type="Test Error", error_msg="Test", context="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_management(self) -> None:
|
||||||
|
"""Session created and reused correctly."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Session should be None initially
|
||||||
|
assert client._session is None
|
||||||
|
|
||||||
|
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):
|
||||||
|
await client.notify_market_open("Korea")
|
||||||
|
# Session should be created
|
||||||
|
assert client._session is not None
|
||||||
|
|
||||||
|
session1 = client._session
|
||||||
|
await client.notify_market_close("Korea", 1.5)
|
||||||
|
# Same session should be reused
|
||||||
|
assert client._session is session1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiting:
|
||||||
|
"""Test rate limiter behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limiter_enforced(self) -> None:
|
||||||
|
"""Rate limiter delays rapid requests."""
|
||||||
|
import time
|
||||||
|
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True, rate_limit=2.0
|
||||||
|
)
|
||||||
|
|
||||||
|
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):
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
# Send 3 messages (rate: 2/sec = 0.5s per message)
|
||||||
|
await client.notify_market_open("Korea")
|
||||||
|
await client.notify_market_open("United States")
|
||||||
|
await client.notify_market_open("Japan")
|
||||||
|
|
||||||
|
elapsed = time.monotonic() - start
|
||||||
|
|
||||||
|
# Should take at least 0.4 seconds (3 msgs at 2/sec with some tolerance)
|
||||||
|
assert elapsed >= 0.4
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessagePriorities:
|
||||||
|
"""Test priority-based messaging."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_low_priority_uses_info_emoji(self) -> None:
|
||||||
|
"""LOW priority uses ℹ️ emoji."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_market_open("Korea")
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert NotificationPriority.LOW.emoji in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_critical_priority_uses_alarm_emoji(self) -> None:
|
||||||
|
"""CRITICAL priority uses 🚨 emoji."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_system_shutdown("Circuit breaker tripped")
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert NotificationPriority.CRITICAL.emoji in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playbook_generated_priority(self) -> None:
|
||||||
|
"""Playbook generated uses MEDIUM priority emoji."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_playbook_generated(
|
||||||
|
market="KR",
|
||||||
|
stock_count=2,
|
||||||
|
scenario_count=4,
|
||||||
|
token_count=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert NotificationPriority.MEDIUM.emoji in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_playbook_failed_priority(self) -> None:
|
||||||
|
"""Playbook failed uses HIGH priority emoji."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_playbook_failed(
|
||||||
|
market="KR",
|
||||||
|
reason="Invalid JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert NotificationPriority.HIGH.emoji in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scenario_matched_priority(self) -> None:
|
||||||
|
"""Scenario matched uses HIGH priority emoji."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
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_scenario_matched(
|
||||||
|
stock_code="AAPL",
|
||||||
|
action="BUY",
|
||||||
|
condition_summary="RSI < 30",
|
||||||
|
confidence=80.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert NotificationPriority.HIGH.emoji in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestClientCleanup:
|
||||||
|
"""Test client cleanup behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_closes_session(self) -> None:
|
||||||
|
"""close() closes the HTTP session."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.closed = False
|
||||||
|
mock_session.close = AsyncMock()
|
||||||
|
client._session = mock_session
|
||||||
|
|
||||||
|
await client.close()
|
||||||
|
mock_session.close.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_close_handles_no_session(self) -> None:
|
||||||
|
"""close() handles None session gracefully."""
|
||||||
|
client = TelegramClient(
|
||||||
|
bot_token="123:abc", chat_id="456", enabled=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not raise exception
|
||||||
|
await client.close()
|
||||||
777
tests/test_telegram_commands.py
Normal file
777
tests/test_telegram_commands.py
Normal file
@@ -0,0 +1,777 @@
|
|||||||
|
"""Tests for Telegram command handler."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandHandlerInit:
|
||||||
|
"""Test command handler initialization."""
|
||||||
|
|
||||||
|
def test_init_with_client(self) -> None:
|
||||||
|
"""Handler initializes with TelegramClient."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
assert handler._client is client
|
||||||
|
assert handler._polling_interval == 1.0
|
||||||
|
assert handler._commands == {}
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
def test_custom_polling_interval(self) -> None:
|
||||||
|
"""Handler accepts custom polling interval."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client, polling_interval=2.5)
|
||||||
|
|
||||||
|
assert handler._polling_interval == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
class TestCommandRegistration:
|
||||||
|
"""Test command registration."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_command(self) -> None:
|
||||||
|
"""Commands can be registered."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def test_handler() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler.register_command("test", test_handler)
|
||||||
|
|
||||||
|
assert "test" in handler._commands
|
||||||
|
assert handler._commands["test"] is test_handler
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_register_multiple_commands(self) -> None:
|
||||||
|
"""Multiple commands can be registered."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def handler1() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handler2() -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
handler.register_command("start", handler1)
|
||||||
|
handler.register_command("help", handler2)
|
||||||
|
|
||||||
|
assert len(handler._commands) == 2
|
||||||
|
assert handler._commands["start"] is handler1
|
||||||
|
assert handler._commands["help"] is handler2
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollingLifecycle:
|
||||||
|
"""Test polling start/stop."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_polling(self) -> None:
|
||||||
|
"""Polling can be started."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
|
||||||
|
assert handler._running is True
|
||||||
|
assert handler._polling_task is not None
|
||||||
|
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_start_polling_disabled_client(self) -> None:
|
||||||
|
"""Polling not started when client disabled."""
|
||||||
|
client = TelegramClient(enabled=False)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
await handler.start_polling()
|
||||||
|
|
||||||
|
assert handler._running is False
|
||||||
|
assert handler._polling_task is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_polling(self) -> None:
|
||||||
|
"""Polling can be stopped."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
assert handler._running is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_double_start_ignored(self) -> None:
|
||||||
|
"""Starting already running handler is ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
|
||||||
|
await handler.start_polling()
|
||||||
|
task1 = handler._polling_task
|
||||||
|
|
||||||
|
await handler.start_polling() # Second start
|
||||||
|
task2 = handler._polling_task
|
||||||
|
|
||||||
|
# Should be the same task
|
||||||
|
assert task1 is task2
|
||||||
|
|
||||||
|
await handler.stop_polling()
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateHandling:
|
||||||
|
"""Test update parsing and handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_valid_command(self) -> None:
|
||||||
|
"""Valid commands are executed."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_unknown_command(self) -> None:
|
||||||
|
"""Unknown commands send help message."""
|
||||||
|
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)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/unknown",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Should send error message
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Unknown command" in payload["text"]
|
||||||
|
assert "/unknown" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignore_unauthorized_chat(self) -> None:
|
||||||
|
"""Commands from unauthorized chats are ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 999}, # Wrong chat_id
|
||||||
|
"text": "/test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ignore_non_command_text(self) -> None:
|
||||||
|
"""Non-command text is ignored."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("test", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "Hello, not a command",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_command_with_botname(self) -> None:
|
||||||
|
"""Commands with @botname suffix are handled correctly."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
executed = False
|
||||||
|
|
||||||
|
async def test_command() -> None:
|
||||||
|
nonlocal executed
|
||||||
|
executed = True
|
||||||
|
|
||||||
|
handler.register_command("start", test_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/start@mybot",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
assert executed is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_update_error_isolation(self) -> None:
|
||||||
|
"""Errors in handlers don't crash the system."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
async def failing_command() -> None:
|
||||||
|
raise ValueError("Test error")
|
||||||
|
|
||||||
|
handler.register_command("fail", failing_command)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/fail",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Should not raise exception
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTradingControlCommands:
|
||||||
|
"""Test trading control commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_command_pauses_trading(self) -> None:
|
||||||
|
"""Stop command clears pause event."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.set() # Initially active
|
||||||
|
|
||||||
|
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_stop() -> None:
|
||||||
|
"""Mock /stop handler."""
|
||||||
|
if not pause_event.is_set():
|
||||||
|
await client.send_message("⏸️ Trading is already paused")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.clear()
|
||||||
|
await client.send_message(
|
||||||
|
"<b>⏸️ Trading Paused</b>\n\n"
|
||||||
|
"All trading operations have been suspended.\n"
|
||||||
|
"Use /resume to restart trading."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("stop", mock_stop)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/stop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify pause event was cleared
|
||||||
|
assert not pause_event.is_set()
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Trading Paused" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_command_resumes_trading(self) -> None:
|
||||||
|
"""Resume command sets pause event."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (initially paused)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.clear() # Initially paused
|
||||||
|
|
||||||
|
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_resume() -> None:
|
||||||
|
"""Mock /resume handler."""
|
||||||
|
if pause_event.is_set():
|
||||||
|
await client.send_message("▶️ Trading is already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.set()
|
||||||
|
await client.send_message(
|
||||||
|
"<b>▶️ Trading Resumed</b>\n\n"
|
||||||
|
"Trading operations have been restarted."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("resume", mock_resume)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/resume",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify pause event was set
|
||||||
|
assert pause_event.is_set()
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Trading Resumed" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_when_already_paused(self) -> None:
|
||||||
|
"""Stop command when already paused sends appropriate message."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (already paused)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.clear()
|
||||||
|
|
||||||
|
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_stop() -> None:
|
||||||
|
"""Mock /stop handler."""
|
||||||
|
if not pause_event.is_set():
|
||||||
|
await client.send_message("⏸️ Trading is already paused")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.clear()
|
||||||
|
|
||||||
|
handler.register_command("stop", mock_stop)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/stop",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "already paused" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resume_when_already_active(self) -> None:
|
||||||
|
"""Resume command when already active sends appropriate message."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
# Create mock pause event (already active)
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
pause_event = asyncio.Event()
|
||||||
|
pause_event.set()
|
||||||
|
|
||||||
|
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_resume() -> None:
|
||||||
|
"""Mock /resume handler."""
|
||||||
|
if pause_event.is_set():
|
||||||
|
await client.send_message("▶️ Trading is already active")
|
||||||
|
return
|
||||||
|
|
||||||
|
pause_event.set()
|
||||||
|
|
||||||
|
handler.register_command("resume", mock_resume)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/resume",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "already active" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusCommands:
|
||||||
|
"""Test status query commands."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_command_shows_trading_info(self) -> None:
|
||||||
|
"""Status command displays mode, markets, and P&L."""
|
||||||
|
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_status() -> None:
|
||||||
|
"""Mock /status handler."""
|
||||||
|
message = (
|
||||||
|
"<b>📊 Trading Status</b>\n\n"
|
||||||
|
"<b>Mode:</b> PAPER\n"
|
||||||
|
"<b>Markets:</b> Korea, United States\n"
|
||||||
|
"<b>Trading:</b> Active\n\n"
|
||||||
|
"<b>Current P&L:</b> +2.50%\n"
|
||||||
|
"<b>Circuit Breaker:</b> -3.0%"
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("status", mock_status)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/status",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Trading Status" in payload["text"]
|
||||||
|
assert "PAPER" in payload["text"]
|
||||||
|
assert "P&L" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_command_error_handling(self) -> None:
|
||||||
|
"""Status command handles errors gracefully."""
|
||||||
|
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_status_error() -> None:
|
||||||
|
"""Mock /status handler with error."""
|
||||||
|
await client.send_message(
|
||||||
|
"<b>⚠️ Error</b>\n\nFailed to retrieve trading status."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("status", mock_status_error)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/status",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Should send error message
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Error" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_positions_command_shows_holdings(self) -> None:
|
||||||
|
"""Positions command displays account summary."""
|
||||||
|
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_positions() -> None:
|
||||||
|
"""Mock /positions handler."""
|
||||||
|
message = (
|
||||||
|
"<b>💼 Account Summary</b>\n\n"
|
||||||
|
"<b>Total Evaluation:</b> ₩10,500,000\n"
|
||||||
|
"<b>Available Cash:</b> ₩5,000,000\n"
|
||||||
|
"<b>Purchase Total:</b> ₩10,000,000\n"
|
||||||
|
"<b>P&L:</b> +5.00%\n\n"
|
||||||
|
"<i>Note: Individual position details require API enhancement</i>"
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("positions", mock_positions)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/positions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Account Summary" in payload["text"]
|
||||||
|
assert "Total Evaluation" in payload["text"]
|
||||||
|
assert "P&L" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_positions_command_empty_holdings(self) -> None:
|
||||||
|
"""Positions command handles empty portfolio."""
|
||||||
|
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_positions_empty() -> None:
|
||||||
|
"""Mock /positions handler with no positions."""
|
||||||
|
message = (
|
||||||
|
"<b>💼 Account Summary</b>\n\n"
|
||||||
|
"No balance information available."
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("positions", mock_positions_empty)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/positions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "No balance information available" in payload["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_positions_command_error_handling(self) -> None:
|
||||||
|
"""Positions command handles errors gracefully."""
|
||||||
|
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_positions_error() -> None:
|
||||||
|
"""Mock /positions handler with error."""
|
||||||
|
await client.send_message(
|
||||||
|
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
|
||||||
|
)
|
||||||
|
|
||||||
|
handler.register_command("positions", mock_positions_error)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/positions",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Should send error message
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Error" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestBasicCommands:
|
||||||
|
"""Test basic command implementations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_help_command_content(self) -> None:
|
||||||
|
"""Help command lists all available commands."""
|
||||||
|
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_help() -> None:
|
||||||
|
"""Mock /help handler."""
|
||||||
|
message = (
|
||||||
|
"<b>📖 Available Commands</b>\n\n"
|
||||||
|
"/help - Show available commands\n"
|
||||||
|
"/status - Trading status (mode, markets, P&L)\n"
|
||||||
|
"/positions - Current holdings\n"
|
||||||
|
"/stop - Pause trading\n"
|
||||||
|
"/resume - Resume trading"
|
||||||
|
)
|
||||||
|
await client.send_message(message)
|
||||||
|
|
||||||
|
handler.register_command("help", mock_help)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
|
||||||
|
update = {
|
||||||
|
"update_id": 1,
|
||||||
|
"message": {
|
||||||
|
"chat": {"id": 456},
|
||||||
|
"text": "/help",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await handler._handle_update(update)
|
||||||
|
|
||||||
|
# Verify message was sent
|
||||||
|
assert mock_post.call_count == 1
|
||||||
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
|
assert "Available Commands" in payload["text"]
|
||||||
|
assert "/help" in payload["text"]
|
||||||
|
assert "/status" in payload["text"]
|
||||||
|
assert "/positions" in payload["text"]
|
||||||
|
assert "/stop" in payload["text"]
|
||||||
|
assert "/resume" in payload["text"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetUpdates:
|
||||||
|
"""Test getUpdates API interaction."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_success(self) -> None:
|
||||||
|
"""getUpdates fetches and parses updates."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"ok": True,
|
||||||
|
"result": [
|
||||||
|
{"update_id": 1, "message": {"text": "/test"}},
|
||||||
|
{"update_id": 2, "message": {"text": "/help"}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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 len(updates) == 2
|
||||||
|
assert updates[0]["update_id"] == 1
|
||||||
|
assert updates[1]["update_id"] == 2
|
||||||
|
assert handler._last_update_id == 2
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_api_error(self) -> None:
|
||||||
|
"""getUpdates handles API errors gracefully."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 400
|
||||||
|
mock_resp.text = AsyncMock(return_value="Bad Request")
|
||||||
|
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 == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_updates_empty_result(self) -> None:
|
||||||
|
"""getUpdates handles empty results."""
|
||||||
|
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
|
||||||
|
handler = TelegramCommandHandler(client)
|
||||||
|
|
||||||
|
mock_resp = AsyncMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.json = AsyncMock(return_value={"ok": True, "result": []})
|
||||||
|
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 == []
|
||||||
663
tests/test_token_efficiency.py
Normal file
663
tests/test_token_efficiency.py
Normal file
@@ -0,0 +1,663 @@
|
|||||||
|
"""Tests for token efficiency optimization components.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Prompt compression and optimization
|
||||||
|
- Context selection logic
|
||||||
|
- Summarization
|
||||||
|
- Caching
|
||||||
|
- Token reduction metrics
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.brain.cache import DecisionCache
|
||||||
|
from src.brain.context_selector import ContextSelector, DecisionType
|
||||||
|
from src.brain.gemini_client import TradeDecision
|
||||||
|
from src.brain.prompt_optimizer import PromptOptimizer, TokenMetrics
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.context.summarizer import ContextSummarizer, SummaryStats
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Prompt Optimizer Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptOptimizer:
|
||||||
|
"""Tests for PromptOptimizer."""
|
||||||
|
|
||||||
|
def test_estimate_tokens(self):
|
||||||
|
"""Test token estimation."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
# Empty text
|
||||||
|
assert optimizer.estimate_tokens("") == 0
|
||||||
|
|
||||||
|
# Short text (4 chars = 1 token estimate)
|
||||||
|
assert optimizer.estimate_tokens("test") == 1
|
||||||
|
|
||||||
|
# Longer text
|
||||||
|
text = "This is a longer piece of text for testing token estimation."
|
||||||
|
tokens = optimizer.estimate_tokens(text)
|
||||||
|
assert tokens > 0
|
||||||
|
assert tokens == len(text) // 4
|
||||||
|
|
||||||
|
def test_count_tokens(self):
|
||||||
|
"""Test token counting metrics."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
text = "Hello world, this is a test."
|
||||||
|
metrics = optimizer.count_tokens(text)
|
||||||
|
|
||||||
|
assert isinstance(metrics, TokenMetrics)
|
||||||
|
assert metrics.char_count == len(text)
|
||||||
|
assert metrics.word_count == 6
|
||||||
|
assert metrics.estimated_tokens > 0
|
||||||
|
|
||||||
|
def test_compress_json(self):
|
||||||
|
"""Test JSON compression."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"action": "BUY",
|
||||||
|
"confidence": 85,
|
||||||
|
"rationale": "Strong uptrend",
|
||||||
|
}
|
||||||
|
|
||||||
|
compressed = optimizer.compress_json(data)
|
||||||
|
|
||||||
|
# Should have no newlines and minimal whitespace
|
||||||
|
assert "\n" not in compressed
|
||||||
|
# Note: JSON values may contain spaces (e.g., "Strong uptrend")
|
||||||
|
# but there should be no spaces around separators
|
||||||
|
assert ": " not in compressed
|
||||||
|
assert ", " not in compressed
|
||||||
|
|
||||||
|
# Should be valid JSON
|
||||||
|
import json
|
||||||
|
|
||||||
|
parsed = json.loads(compressed)
|
||||||
|
assert parsed == data
|
||||||
|
|
||||||
|
def test_abbreviate_text(self):
|
||||||
|
"""Test text abbreviation."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
text = "The current price is high and volume is increasing."
|
||||||
|
abbreviated = optimizer.abbreviate_text(text)
|
||||||
|
|
||||||
|
# Should contain abbreviations
|
||||||
|
assert "cur" in abbreviated or "P" in abbreviated
|
||||||
|
assert len(abbreviated) <= len(text)
|
||||||
|
|
||||||
|
def test_abbreviate_text_aggressive(self):
|
||||||
|
"""Test aggressive text abbreviation."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
text = "The price is increasing and the volume is high."
|
||||||
|
abbreviated = optimizer.abbreviate_text(text, aggressive=True)
|
||||||
|
|
||||||
|
# Should be shorter
|
||||||
|
assert len(abbreviated) < len(text)
|
||||||
|
|
||||||
|
# Should have removed articles
|
||||||
|
assert "the" not in abbreviated.lower()
|
||||||
|
|
||||||
|
def test_build_compressed_prompt(self):
|
||||||
|
"""Test compressed prompt building."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "005930",
|
||||||
|
"current_price": 75000,
|
||||||
|
"market_name": "Korean stock market",
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = optimizer.build_compressed_prompt(market_data)
|
||||||
|
|
||||||
|
# Should be much shorter than original
|
||||||
|
assert len(prompt) < 300
|
||||||
|
assert "005930" in prompt
|
||||||
|
assert "75000" in prompt
|
||||||
|
|
||||||
|
def test_build_compressed_prompt_no_instructions(self):
|
||||||
|
"""Test compressed prompt without instructions."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
market_data = {
|
||||||
|
"stock_code": "AAPL",
|
||||||
|
"current_price": 150.5,
|
||||||
|
"market_name": "United States",
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt = optimizer.build_compressed_prompt(market_data, include_instructions=False)
|
||||||
|
|
||||||
|
# Should be very short (data only)
|
||||||
|
assert len(prompt) < 100
|
||||||
|
assert "AAPL" in prompt
|
||||||
|
|
||||||
|
def test_truncate_context(self):
|
||||||
|
"""Test context truncation."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"price": 100.5,
|
||||||
|
"volume": 1000000,
|
||||||
|
"sentiment": 0.8,
|
||||||
|
"extra_data": "Some long text that should be truncated",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Truncate to small budget
|
||||||
|
truncated = optimizer.truncate_context(context, max_tokens=10)
|
||||||
|
|
||||||
|
# Should have fewer keys
|
||||||
|
assert len(truncated) <= len(context)
|
||||||
|
|
||||||
|
def test_truncate_context_with_priority(self):
|
||||||
|
"""Test context truncation with priority keys."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"price": 100.5,
|
||||||
|
"volume": 1000000,
|
||||||
|
"sentiment": 0.8,
|
||||||
|
"extra_data": "Some data",
|
||||||
|
}
|
||||||
|
|
||||||
|
priority_keys = ["price", "sentiment"]
|
||||||
|
truncated = optimizer.truncate_context(context, max_tokens=20, priority_keys=priority_keys)
|
||||||
|
|
||||||
|
# Priority keys should be included
|
||||||
|
assert "price" in truncated
|
||||||
|
assert "sentiment" in truncated
|
||||||
|
|
||||||
|
def test_calculate_compression_ratio(self):
|
||||||
|
"""Test compression ratio calculation."""
|
||||||
|
optimizer = PromptOptimizer()
|
||||||
|
|
||||||
|
original = "This is a very long piece of text that should be compressed significantly."
|
||||||
|
compressed = "Short text"
|
||||||
|
|
||||||
|
ratio = optimizer.calculate_compression_ratio(original, compressed)
|
||||||
|
|
||||||
|
# Ratio should be > 1 (original is longer)
|
||||||
|
assert ratio > 1.0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Context Selector Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextSelector:
|
||||||
|
"""Tests for ContextSelector."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(self):
|
||||||
|
"""Create in-memory ContextStore."""
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
# Create tables
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE context_metadata (
|
||||||
|
layer TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
retention_days INTEGER,
|
||||||
|
aggregation_source TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contexts (
|
||||||
|
layer TEXT,
|
||||||
|
timeframe TEXT,
|
||||||
|
key TEXT,
|
||||||
|
value TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
PRIMARY KEY (layer, timeframe, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return ContextStore(conn)
|
||||||
|
|
||||||
|
def test_select_layers_normal(self, store):
|
||||||
|
"""Test layer selection for normal decisions."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
layers = selector.select_layers(DecisionType.NORMAL)
|
||||||
|
|
||||||
|
# Should only select L7 (real-time)
|
||||||
|
assert layers == [ContextLayer.L7_REALTIME]
|
||||||
|
|
||||||
|
def test_select_layers_strategic(self, store):
|
||||||
|
"""Test layer selection for strategic decisions."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
layers = selector.select_layers(DecisionType.STRATEGIC)
|
||||||
|
|
||||||
|
# Should select L7 + L6 + L5
|
||||||
|
assert ContextLayer.L7_REALTIME in layers
|
||||||
|
assert ContextLayer.L6_DAILY in layers
|
||||||
|
assert ContextLayer.L5_WEEKLY in layers
|
||||||
|
assert len(layers) == 3
|
||||||
|
|
||||||
|
def test_select_layers_major_event(self, store):
|
||||||
|
"""Test layer selection for major events."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
layers = selector.select_layers(DecisionType.MAJOR_EVENT)
|
||||||
|
|
||||||
|
# Should select all layers
|
||||||
|
assert len(layers) == 7
|
||||||
|
assert ContextLayer.L1_LEGACY in layers
|
||||||
|
assert ContextLayer.L7_REALTIME in layers
|
||||||
|
|
||||||
|
def test_score_layer_relevance(self, store):
|
||||||
|
"""Test layer relevance scoring."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
# Add some data first so scores aren't penalized
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "price", 100.5)
|
||||||
|
store.set_context(ContextLayer.L1_LEGACY, "legacy", "lesson", "test")
|
||||||
|
|
||||||
|
# L7 should have high score for normal decisions
|
||||||
|
score = selector.score_layer_relevance(ContextLayer.L7_REALTIME, DecisionType.NORMAL)
|
||||||
|
assert score == 1.0
|
||||||
|
|
||||||
|
# L1 should have low score for normal decisions
|
||||||
|
score = selector.score_layer_relevance(ContextLayer.L1_LEGACY, DecisionType.NORMAL)
|
||||||
|
assert score == 0.0
|
||||||
|
|
||||||
|
# L1 should have high score for major events
|
||||||
|
score = selector.score_layer_relevance(ContextLayer.L1_LEGACY, DecisionType.MAJOR_EVENT)
|
||||||
|
assert score == 1.0
|
||||||
|
|
||||||
|
def test_select_with_scoring(self, store):
|
||||||
|
"""Test selection with relevance scoring."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
# Add data so layers aren't penalized
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "price", 100.5)
|
||||||
|
|
||||||
|
selection = selector.select_with_scoring(DecisionType.NORMAL, min_score=0.5)
|
||||||
|
|
||||||
|
# Should only select high-relevance layers
|
||||||
|
assert len(selection.layers) >= 1
|
||||||
|
assert ContextLayer.L7_REALTIME in selection.layers
|
||||||
|
assert all(selection.relevance_scores[layer] >= 0.5 for layer in selection.layers)
|
||||||
|
|
||||||
|
def test_get_context_data(self, store):
|
||||||
|
"""Test context data retrieval."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
# Add some test data
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "price", 100.5)
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "volume", 1000000)
|
||||||
|
|
||||||
|
context_data = selector.get_context_data([ContextLayer.L7_REALTIME])
|
||||||
|
|
||||||
|
# Should retrieve data
|
||||||
|
assert "L7_REALTIME" in context_data
|
||||||
|
assert "price" in context_data["L7_REALTIME"]
|
||||||
|
assert context_data["L7_REALTIME"]["price"] == 100.5
|
||||||
|
|
||||||
|
def test_estimate_context_tokens(self, store):
|
||||||
|
"""Test context token estimation."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
context_data = {
|
||||||
|
"L7_REALTIME": {"price": 100.5, "volume": 1000000},
|
||||||
|
"L6_DAILY": {"avg_price": 99.8, "avg_volume": 950000},
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens = selector.estimate_context_tokens(context_data)
|
||||||
|
|
||||||
|
# Should estimate tokens
|
||||||
|
assert tokens > 0
|
||||||
|
|
||||||
|
def test_optimize_context_for_budget(self, store):
|
||||||
|
"""Test context optimization for token budget."""
|
||||||
|
selector = ContextSelector(store)
|
||||||
|
|
||||||
|
# Add test data
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "price", 100.5)
|
||||||
|
|
||||||
|
# Get optimized context within budget
|
||||||
|
context = selector.optimize_context_for_budget(DecisionType.NORMAL, max_tokens=50)
|
||||||
|
|
||||||
|
# Should return data within budget
|
||||||
|
tokens = selector.estimate_context_tokens(context)
|
||||||
|
assert tokens <= 50
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Context Summarizer Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextSummarizer:
|
||||||
|
"""Tests for ContextSummarizer."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(self):
|
||||||
|
"""Create in-memory ContextStore."""
|
||||||
|
conn = sqlite3.connect(":memory:")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE context_metadata (
|
||||||
|
layer TEXT PRIMARY KEY,
|
||||||
|
description TEXT,
|
||||||
|
retention_days INTEGER,
|
||||||
|
aggregation_source TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contexts (
|
||||||
|
layer TEXT,
|
||||||
|
timeframe TEXT,
|
||||||
|
key TEXT,
|
||||||
|
value TEXT,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT,
|
||||||
|
PRIMARY KEY (layer, timeframe, key)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return ContextStore(conn)
|
||||||
|
|
||||||
|
def test_summarize_numeric_values(self, store):
|
||||||
|
"""Test numeric value summarization."""
|
||||||
|
summarizer = ContextSummarizer(store)
|
||||||
|
|
||||||
|
values = [10.0, 20.0, 30.0, 40.0, 50.0]
|
||||||
|
stats = summarizer.summarize_numeric_values(values)
|
||||||
|
|
||||||
|
assert isinstance(stats, SummaryStats)
|
||||||
|
assert stats.count == 5
|
||||||
|
assert stats.mean == 30.0
|
||||||
|
assert stats.min == 10.0
|
||||||
|
assert stats.max == 50.0
|
||||||
|
assert stats.std is not None
|
||||||
|
|
||||||
|
def test_summarize_numeric_values_trend(self, store):
|
||||||
|
"""Test trend detection in numeric values."""
|
||||||
|
summarizer = ContextSummarizer(store)
|
||||||
|
|
||||||
|
# Uptrend
|
||||||
|
values_up = [10.0, 15.0, 20.0, 25.0, 30.0, 35.0]
|
||||||
|
stats_up = summarizer.summarize_numeric_values(values_up)
|
||||||
|
assert stats_up.trend == "up"
|
||||||
|
|
||||||
|
# Downtrend
|
||||||
|
values_down = [35.0, 30.0, 25.0, 20.0, 15.0, 10.0]
|
||||||
|
stats_down = summarizer.summarize_numeric_values(values_down)
|
||||||
|
assert stats_down.trend == "down"
|
||||||
|
|
||||||
|
# Flat
|
||||||
|
values_flat = [20.0, 20.1, 19.9, 20.0, 20.1, 19.9]
|
||||||
|
stats_flat = summarizer.summarize_numeric_values(values_flat)
|
||||||
|
assert stats_flat.trend == "flat"
|
||||||
|
|
||||||
|
def test_summarize_layer(self, store):
|
||||||
|
"""Test layer summarization."""
|
||||||
|
summarizer = ContextSummarizer(store)
|
||||||
|
|
||||||
|
# Add test data
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "price", 100.5)
|
||||||
|
store.set_context(ContextLayer.L6_DAILY, "2026-02-04", "volume", 1000000)
|
||||||
|
|
||||||
|
summary = summarizer.summarize_layer(ContextLayer.L6_DAILY)
|
||||||
|
|
||||||
|
# Should have summary
|
||||||
|
assert "total_entries" in summary
|
||||||
|
assert summary["total_entries"] > 0
|
||||||
|
|
||||||
|
def test_create_compact_summary(self, store):
|
||||||
|
"""Test compact summary creation."""
|
||||||
|
summarizer = ContextSummarizer(store)
|
||||||
|
|
||||||
|
# Add test data
|
||||||
|
store.set_context(ContextLayer.L7_REALTIME, "2026-02-04", "price", 100.5)
|
||||||
|
|
||||||
|
layers = [ContextLayer.L7_REALTIME, ContextLayer.L6_DAILY]
|
||||||
|
summary = summarizer.create_compact_summary(layers, top_n_metrics=3)
|
||||||
|
|
||||||
|
# Should have summaries for layers
|
||||||
|
assert "L7_REALTIME" in summary
|
||||||
|
|
||||||
|
def test_format_summary_for_prompt(self, store):
|
||||||
|
"""Test summary formatting for prompt."""
|
||||||
|
summarizer = ContextSummarizer(store)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
"L7_REALTIME": {
|
||||||
|
"price": {"avg": 100.5, "trend": "up"},
|
||||||
|
"volume": {"avg": 1000000, "trend": "flat"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatted = summarizer.format_summary_for_prompt(summary)
|
||||||
|
|
||||||
|
# Should be formatted string
|
||||||
|
assert isinstance(formatted, str)
|
||||||
|
assert "L7_REALTIME" in formatted
|
||||||
|
assert "100.5" in formatted or "100.50" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Decision Cache Tests
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestDecisionCache:
|
||||||
|
"""Tests for DecisionCache."""
|
||||||
|
|
||||||
|
def test_cache_init(self):
|
||||||
|
"""Test cache initialization."""
|
||||||
|
cache = DecisionCache(ttl_seconds=60, max_size=100)
|
||||||
|
|
||||||
|
assert cache.ttl_seconds == 60
|
||||||
|
assert cache.max_size == 100
|
||||||
|
|
||||||
|
def test_cache_miss(self):
|
||||||
|
"""Test cache miss."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
market_data = {"stock_code": "005930", "current_price": 75000}
|
||||||
|
|
||||||
|
decision = cache.get(market_data)
|
||||||
|
|
||||||
|
# Should be None (cache miss)
|
||||||
|
assert decision is None
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
assert metrics.cache_misses == 1
|
||||||
|
assert metrics.cache_hits == 0
|
||||||
|
|
||||||
|
def test_cache_hit(self):
|
||||||
|
"""Test cache hit."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
market_data = {"stock_code": "005930", "current_price": 75000}
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Set cache
|
||||||
|
cache.set(market_data, decision)
|
||||||
|
|
||||||
|
# Get from cache
|
||||||
|
cached = cache.get(market_data)
|
||||||
|
|
||||||
|
assert cached is not None
|
||||||
|
assert cached.action == "HOLD"
|
||||||
|
assert cached.confidence == 50
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
assert metrics.cache_hits == 1
|
||||||
|
|
||||||
|
def test_cache_ttl_expiration(self):
|
||||||
|
"""Test cache TTL expiration."""
|
||||||
|
cache = DecisionCache(ttl_seconds=1) # 1 second TTL
|
||||||
|
|
||||||
|
market_data = {"stock_code": "005930", "current_price": 75000}
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Set cache
|
||||||
|
cache.set(market_data, decision)
|
||||||
|
|
||||||
|
# Should hit immediately
|
||||||
|
cached = cache.get(market_data)
|
||||||
|
assert cached is not None
|
||||||
|
|
||||||
|
# Wait for expiration
|
||||||
|
time.sleep(1.1)
|
||||||
|
|
||||||
|
# Should miss after expiration
|
||||||
|
cached = cache.get(market_data)
|
||||||
|
assert cached is None
|
||||||
|
|
||||||
|
def test_cache_max_size(self):
|
||||||
|
"""Test cache max size eviction."""
|
||||||
|
cache = DecisionCache(max_size=2)
|
||||||
|
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Add 3 entries (exceeds max_size)
|
||||||
|
for i in range(3):
|
||||||
|
market_data = {"stock_code": f"00{i}", "current_price": 1000 * i}
|
||||||
|
cache.set(market_data, decision)
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
|
||||||
|
# Should have evicted 1 entry
|
||||||
|
assert metrics.total_entries == 2
|
||||||
|
assert metrics.evictions == 1
|
||||||
|
|
||||||
|
def test_invalidate_all(self):
|
||||||
|
"""Test invalidate all cache entries."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Add entries
|
||||||
|
for i in range(3):
|
||||||
|
market_data = {"stock_code": f"00{i}", "current_price": 1000}
|
||||||
|
cache.set(market_data, decision)
|
||||||
|
|
||||||
|
# Invalidate all
|
||||||
|
count = cache.invalidate()
|
||||||
|
|
||||||
|
assert count == 3
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
assert metrics.total_entries == 0
|
||||||
|
|
||||||
|
def test_invalidate_by_stock(self):
|
||||||
|
"""Test invalidate cache by stock code."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Add entries for different stocks
|
||||||
|
cache.set({"stock_code": "005930", "current_price": 75000}, decision)
|
||||||
|
cache.set({"stock_code": "000660", "current_price": 50000}, decision)
|
||||||
|
|
||||||
|
# Invalidate specific stock
|
||||||
|
count = cache.invalidate("005930")
|
||||||
|
|
||||||
|
assert count >= 1
|
||||||
|
|
||||||
|
# Other stock should still be cached
|
||||||
|
cached = cache.get({"stock_code": "000660", "current_price": 50000})
|
||||||
|
assert cached is not None
|
||||||
|
|
||||||
|
def test_cleanup_expired(self):
|
||||||
|
"""Test cleanup of expired entries."""
|
||||||
|
cache = DecisionCache(ttl_seconds=1)
|
||||||
|
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
|
||||||
|
# Add entry
|
||||||
|
cache.set({"stock_code": "005930", "current_price": 75000}, decision)
|
||||||
|
|
||||||
|
# Wait for expiration
|
||||||
|
time.sleep(1.1)
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
count = cache.cleanup_expired()
|
||||||
|
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
assert metrics.total_entries == 0
|
||||||
|
|
||||||
|
def test_should_cache_decision(self):
|
||||||
|
"""Test decision caching criteria."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
# HOLD decisions should be cached
|
||||||
|
hold_decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
assert cache.should_cache_decision(hold_decision) is True
|
||||||
|
|
||||||
|
# High confidence BUY should be cached
|
||||||
|
buy_decision = TradeDecision(action="BUY", confidence=95, rationale="Test")
|
||||||
|
assert cache.should_cache_decision(buy_decision) is True
|
||||||
|
|
||||||
|
# Low confidence BUY should not be cached
|
||||||
|
low_conf_buy = TradeDecision(action="BUY", confidence=60, rationale="Test")
|
||||||
|
assert cache.should_cache_decision(low_conf_buy) is False
|
||||||
|
|
||||||
|
def test_cache_hit_rate(self):
|
||||||
|
"""Test cache hit rate calculation."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
decision = TradeDecision(action="HOLD", confidence=50, rationale="Test")
|
||||||
|
market_data = {"stock_code": "005930", "current_price": 75000}
|
||||||
|
|
||||||
|
# First request (miss)
|
||||||
|
cache.get(market_data)
|
||||||
|
|
||||||
|
# Set cache
|
||||||
|
cache.set(market_data, decision)
|
||||||
|
|
||||||
|
# Second request (hit)
|
||||||
|
cache.get(market_data)
|
||||||
|
|
||||||
|
# Third request (hit)
|
||||||
|
cache.get(market_data)
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
|
||||||
|
# 1 miss, 2 hits out of 3 requests
|
||||||
|
assert metrics.total_requests == 3
|
||||||
|
assert metrics.cache_hits == 2
|
||||||
|
assert metrics.cache_misses == 1
|
||||||
|
assert metrics.hit_rate == pytest.approx(2 / 3)
|
||||||
|
|
||||||
|
def test_reset_metrics(self):
|
||||||
|
"""Test metrics reset."""
|
||||||
|
cache = DecisionCache()
|
||||||
|
|
||||||
|
market_data = {"stock_code": "005930", "current_price": 75000}
|
||||||
|
|
||||||
|
# Generate some activity
|
||||||
|
cache.get(market_data)
|
||||||
|
cache.get(market_data)
|
||||||
|
|
||||||
|
# Reset
|
||||||
|
cache.reset_metrics()
|
||||||
|
|
||||||
|
metrics = cache.get_metrics()
|
||||||
|
assert metrics.total_requests == 0
|
||||||
|
assert metrics.cache_hits == 0
|
||||||
|
assert metrics.cache_misses == 0
|
||||||
576
tests/test_volatility.py
Normal file
576
tests/test_volatility.py
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
"""Tests for volatility analysis and market scanning."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sqlite3
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.analysis.scanner import MarketScanner, ScanResult
|
||||||
|
from src.analysis.volatility import VolatilityAnalyzer, VolatilityMetrics
|
||||||
|
from src.broker.kis_api import KISBroker
|
||||||
|
from src.broker.overseas import OverseasBroker
|
||||||
|
from src.config import Settings
|
||||||
|
from src.context.layer import ContextLayer
|
||||||
|
from src.context.store import ContextStore
|
||||||
|
from src.db import init_db
|
||||||
|
from src.markets.schedule import MARKETS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_conn() -> sqlite3.Connection:
|
||||||
|
"""Provide an in-memory database connection."""
|
||||||
|
return init_db(":memory:")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def context_store(db_conn: sqlite3.Connection) -> ContextStore:
|
||||||
|
"""Provide a ContextStore instance."""
|
||||||
|
return ContextStore(db_conn)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def volatility_analyzer() -> VolatilityAnalyzer:
|
||||||
|
"""Provide a VolatilityAnalyzer instance."""
|
||||||
|
return VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings() -> Settings:
|
||||||
|
"""Provide mock settings for broker initialization."""
|
||||||
|
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 mock_overseas_broker(mock_broker: KISBroker) -> OverseasBroker:
|
||||||
|
"""Provide a mock overseas broker."""
|
||||||
|
overseas = OverseasBroker(mock_broker)
|
||||||
|
overseas.get_overseas_price = AsyncMock() # type: ignore[method-assign]
|
||||||
|
return overseas
|
||||||
|
|
||||||
|
|
||||||
|
class TestVolatilityAnalyzer:
|
||||||
|
"""Test suite for VolatilityAnalyzer."""
|
||||||
|
|
||||||
|
def test_calculate_atr(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test ATR calculation."""
|
||||||
|
high_prices = [110.0, 112.0, 115.0, 113.0, 116.0] + [120.0] * 10
|
||||||
|
low_prices = [105.0, 107.0, 110.0, 108.0, 111.0] + [115.0] * 10
|
||||||
|
close_prices = [108.0, 110.0, 112.0, 111.0, 114.0] + [118.0] * 10
|
||||||
|
|
||||||
|
atr = volatility_analyzer.calculate_atr(high_prices, low_prices, close_prices, period=14)
|
||||||
|
|
||||||
|
assert atr > 0.0
|
||||||
|
# ATR should be roughly the average true range
|
||||||
|
assert 3.0 <= atr <= 6.0
|
||||||
|
|
||||||
|
def test_calculate_atr_insufficient_data(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test ATR with insufficient data returns 0."""
|
||||||
|
high_prices = [110.0, 112.0]
|
||||||
|
low_prices = [105.0, 107.0]
|
||||||
|
close_prices = [108.0, 110.0]
|
||||||
|
|
||||||
|
atr = volatility_analyzer.calculate_atr(high_prices, low_prices, close_prices, period=14)
|
||||||
|
|
||||||
|
assert atr == 0.0
|
||||||
|
|
||||||
|
def test_calculate_price_change(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test price change percentage calculation."""
|
||||||
|
# 10% increase
|
||||||
|
change = volatility_analyzer.calculate_price_change(110.0, 100.0)
|
||||||
|
assert change == pytest.approx(10.0)
|
||||||
|
|
||||||
|
# 5% decrease
|
||||||
|
change = volatility_analyzer.calculate_price_change(95.0, 100.0)
|
||||||
|
assert change == pytest.approx(-5.0)
|
||||||
|
|
||||||
|
# Zero past price
|
||||||
|
change = volatility_analyzer.calculate_price_change(100.0, 0.0)
|
||||||
|
assert change == 0.0
|
||||||
|
|
||||||
|
def test_calculate_volume_surge(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test volume surge ratio calculation."""
|
||||||
|
# 2x surge
|
||||||
|
surge = volatility_analyzer.calculate_volume_surge(2000.0, 1000.0)
|
||||||
|
assert surge == pytest.approx(2.0)
|
||||||
|
|
||||||
|
# Below average
|
||||||
|
surge = volatility_analyzer.calculate_volume_surge(500.0, 1000.0)
|
||||||
|
assert surge == pytest.approx(0.5)
|
||||||
|
|
||||||
|
# Zero average
|
||||||
|
surge = volatility_analyzer.calculate_volume_surge(1000.0, 0.0)
|
||||||
|
assert surge == 1.0
|
||||||
|
|
||||||
|
def test_calculate_pv_divergence_bullish(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test bullish price-volume divergence."""
|
||||||
|
# Price up + Volume up = bullish
|
||||||
|
divergence = volatility_analyzer.calculate_pv_divergence(5.0, 2.0)
|
||||||
|
assert divergence > 0.0
|
||||||
|
|
||||||
|
def test_calculate_pv_divergence_bearish(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test bearish price-volume divergence."""
|
||||||
|
# Price up + Volume down = bearish divergence
|
||||||
|
divergence = volatility_analyzer.calculate_pv_divergence(5.0, 0.5)
|
||||||
|
assert divergence < 0.0
|
||||||
|
|
||||||
|
def test_calculate_pv_divergence_selling_pressure(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test selling pressure detection."""
|
||||||
|
# Price down + Volume up = selling pressure
|
||||||
|
divergence = volatility_analyzer.calculate_pv_divergence(-5.0, 2.0)
|
||||||
|
assert divergence < 0.0
|
||||||
|
|
||||||
|
def test_calculate_momentum_score(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test momentum score calculation."""
|
||||||
|
score = volatility_analyzer.calculate_momentum_score(
|
||||||
|
price_change_1m=5.0,
|
||||||
|
price_change_5m=3.0,
|
||||||
|
price_change_15m=2.0,
|
||||||
|
volume_surge=2.5,
|
||||||
|
atr=1.5,
|
||||||
|
current_price=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 0.0 <= score <= 100.0
|
||||||
|
assert score > 50.0 # Should be high for strong positive momentum
|
||||||
|
|
||||||
|
def test_calculate_momentum_score_negative(
|
||||||
|
self, volatility_analyzer: VolatilityAnalyzer
|
||||||
|
) -> None:
|
||||||
|
"""Test momentum score with negative price changes."""
|
||||||
|
score = volatility_analyzer.calculate_momentum_score(
|
||||||
|
price_change_1m=-5.0,
|
||||||
|
price_change_5m=-3.0,
|
||||||
|
price_change_15m=-2.0,
|
||||||
|
volume_surge=1.0,
|
||||||
|
atr=1.0,
|
||||||
|
current_price=100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 0.0 <= score <= 100.0
|
||||||
|
assert score < 50.0 # Should be low for negative momentum
|
||||||
|
|
||||||
|
def test_analyze(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test full analysis of a stock."""
|
||||||
|
orderbook_data = {
|
||||||
|
"output1": {
|
||||||
|
"stck_prpr": "50000",
|
||||||
|
"acml_vol": "1000000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
price_history = {
|
||||||
|
"high": [51000.0] * 20,
|
||||||
|
"low": [49000.0] * 20,
|
||||||
|
"close": [48000.0] + [50000.0] * 19,
|
||||||
|
"volume": [500000.0] * 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics = volatility_analyzer.analyze("005930", orderbook_data, price_history)
|
||||||
|
|
||||||
|
assert metrics.stock_code == "005930"
|
||||||
|
assert metrics.current_price == 50000.0
|
||||||
|
assert metrics.atr > 0.0
|
||||||
|
assert metrics.volume_surge == pytest.approx(2.0) # 1M / 500K
|
||||||
|
assert 0.0 <= metrics.momentum_score <= 100.0
|
||||||
|
|
||||||
|
def test_is_breakout(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test breakout detection."""
|
||||||
|
# Strong breakout
|
||||||
|
metrics = VolatilityMetrics(
|
||||||
|
stock_code="005930",
|
||||||
|
current_price=50000.0,
|
||||||
|
atr=500.0,
|
||||||
|
price_change_1m=2.5,
|
||||||
|
price_change_5m=3.0,
|
||||||
|
price_change_15m=4.0,
|
||||||
|
volume_surge=3.0,
|
||||||
|
pv_divergence=50.0,
|
||||||
|
momentum_score=85.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert volatility_analyzer.is_breakout(metrics) is True
|
||||||
|
|
||||||
|
def test_is_breakout_no_volume(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test that breakout requires volume confirmation."""
|
||||||
|
# Price up but no volume = not a real breakout
|
||||||
|
metrics = VolatilityMetrics(
|
||||||
|
stock_code="005930",
|
||||||
|
current_price=50000.0,
|
||||||
|
atr=500.0,
|
||||||
|
price_change_1m=2.5,
|
||||||
|
price_change_5m=3.0,
|
||||||
|
price_change_15m=4.0,
|
||||||
|
volume_surge=1.2, # Below threshold
|
||||||
|
pv_divergence=10.0,
|
||||||
|
momentum_score=70.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert volatility_analyzer.is_breakout(metrics) is False
|
||||||
|
|
||||||
|
def test_is_breakdown(self, volatility_analyzer: VolatilityAnalyzer) -> None:
|
||||||
|
"""Test breakdown detection."""
|
||||||
|
# Strong breakdown
|
||||||
|
metrics = VolatilityMetrics(
|
||||||
|
stock_code="005930",
|
||||||
|
current_price=50000.0,
|
||||||
|
atr=500.0,
|
||||||
|
price_change_1m=-2.5,
|
||||||
|
price_change_5m=-3.0,
|
||||||
|
price_change_15m=-4.0,
|
||||||
|
volume_surge=3.0,
|
||||||
|
pv_divergence=-50.0,
|
||||||
|
momentum_score=15.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert volatility_analyzer.is_breakdown(metrics) is True
|
||||||
|
|
||||||
|
def test_volatility_metrics_repr(self) -> None:
|
||||||
|
"""Test VolatilityMetrics string representation."""
|
||||||
|
metrics = VolatilityMetrics(
|
||||||
|
stock_code="005930",
|
||||||
|
current_price=50000.0,
|
||||||
|
atr=500.0,
|
||||||
|
price_change_1m=2.5,
|
||||||
|
price_change_5m=3.0,
|
||||||
|
price_change_15m=4.0,
|
||||||
|
volume_surge=3.0,
|
||||||
|
pv_divergence=50.0,
|
||||||
|
momentum_score=85.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
repr_str = repr(metrics)
|
||||||
|
assert "005930" in repr_str
|
||||||
|
assert "50000.00" in repr_str
|
||||||
|
assert "2.50%" in repr_str
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarketScanner:
|
||||||
|
"""Test suite for MarketScanner."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scanner(
|
||||||
|
self,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> MarketScanner:
|
||||||
|
"""Provide a MarketScanner instance."""
|
||||||
|
return MarketScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=volatility_analyzer,
|
||||||
|
context_store=context_store,
|
||||||
|
top_n=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_domestic(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning a domestic stock."""
|
||||||
|
mock_broker.get_orderbook.return_value = {
|
||||||
|
"output1": {
|
||||||
|
"stck_prpr": "50000",
|
||||||
|
"acml_vol": "1000000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
metrics = await scanner.scan_stock("005930", market)
|
||||||
|
|
||||||
|
assert metrics is not None
|
||||||
|
assert metrics.stock_code == "005930"
|
||||||
|
assert metrics.current_price == 50000.0
|
||||||
|
|
||||||
|
# Verify L7 context was stored
|
||||||
|
latest_timeframe = context_store.get_latest_timeframe(ContextLayer.L7_REALTIME)
|
||||||
|
assert latest_timeframe is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_overseas(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning an overseas stock."""
|
||||||
|
mock_overseas_broker.get_overseas_price.return_value = {
|
||||||
|
"output": {
|
||||||
|
"last": "150.50",
|
||||||
|
"tvol": "5000000",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["US_NASDAQ"]
|
||||||
|
metrics = await scanner.scan_stock("AAPL", market)
|
||||||
|
|
||||||
|
assert metrics is not None
|
||||||
|
assert metrics.stock_code == "AAPL"
|
||||||
|
assert metrics.current_price == 150.50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_overseas_empty_price(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning overseas stock with empty price string (issue #49)."""
|
||||||
|
mock_overseas_broker.get_overseas_price.return_value = {
|
||||||
|
"output": {
|
||||||
|
"last": "", # Empty string
|
||||||
|
"tvol": "", # Empty string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["US_NASDAQ"]
|
||||||
|
metrics = await scanner.scan_stock("AAPL", market)
|
||||||
|
|
||||||
|
assert metrics is not None
|
||||||
|
assert metrics.stock_code == "AAPL"
|
||||||
|
assert metrics.current_price == 0.0 # Should default to 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_stock_error_handling(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
) -> None:
|
||||||
|
"""Test that scan_stock handles errors gracefully."""
|
||||||
|
mock_broker.get_orderbook.side_effect = Exception("Network error")
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
metrics = await scanner.scan_stock("005930", market)
|
||||||
|
|
||||||
|
assert metrics is None # Should return None on error, not crash
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_market(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""Test scanning a full market."""
|
||||||
|
|
||||||
|
def mock_orderbook(stock_code: str) -> dict[str, Any]:
|
||||||
|
"""Generate mock orderbook with varying prices."""
|
||||||
|
base_price = int(stock_code) if stock_code.isdigit() else 50000
|
||||||
|
return {
|
||||||
|
"output1": {
|
||||||
|
"stck_prpr": str(base_price),
|
||||||
|
"acml_vol": str(base_price * 20), # Volume proportional to price
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_broker.get_orderbook.side_effect = mock_orderbook
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
stock_codes = ["005930", "000660", "035420"]
|
||||||
|
|
||||||
|
result = await scanner.scan_market(market, stock_codes)
|
||||||
|
|
||||||
|
assert result.market_code == "KR"
|
||||||
|
assert result.total_scanned == 3
|
||||||
|
assert len(result.top_movers) <= 5
|
||||||
|
assert all(isinstance(m, VolatilityMetrics) for m in result.top_movers)
|
||||||
|
|
||||||
|
# Verify scan result was stored in L7
|
||||||
|
latest_timeframe = context_store.get_latest_timeframe(ContextLayer.L7_REALTIME)
|
||||||
|
assert latest_timeframe is not None
|
||||||
|
scan_result = context_store.get_context(
|
||||||
|
ContextLayer.L7_REALTIME,
|
||||||
|
latest_timeframe,
|
||||||
|
"KR_scan_result",
|
||||||
|
)
|
||||||
|
assert scan_result is not None
|
||||||
|
assert scan_result["total_scanned"] == 3
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_market_with_breakouts(
|
||||||
|
self,
|
||||||
|
scanner: MarketScanner,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
) -> None:
|
||||||
|
"""Test that scan detects breakouts."""
|
||||||
|
# Mock strong price increase with volume
|
||||||
|
mock_broker.get_orderbook.return_value = {
|
||||||
|
"output1": {
|
||||||
|
"stck_prpr": "55000", # High price
|
||||||
|
"acml_vol": "5000000", # High volume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
stock_codes = ["005930"]
|
||||||
|
|
||||||
|
result = await scanner.scan_market(market, stock_codes)
|
||||||
|
|
||||||
|
# With high volume and price, might detect breakouts
|
||||||
|
# (depends on price history which is empty in this test)
|
||||||
|
assert isinstance(result.breakouts, list)
|
||||||
|
assert isinstance(result.breakdowns, list)
|
||||||
|
|
||||||
|
def test_get_updated_watchlist(self, scanner: MarketScanner) -> None:
|
||||||
|
"""Test watchlist update logic."""
|
||||||
|
current_watchlist = ["005930", "000660", "035420"]
|
||||||
|
|
||||||
|
# Create scan result with new leaders
|
||||||
|
top_movers = [
|
||||||
|
VolatilityMetrics("005930", 50000, 500, 2.0, 3.0, 4.0, 3.0, 50.0, 90.0),
|
||||||
|
VolatilityMetrics("005380", 48000, 480, 1.8, 2.5, 3.0, 2.8, 45.0, 85.0),
|
||||||
|
VolatilityMetrics("005490", 46000, 460, 1.5, 2.0, 2.5, 2.5, 40.0, 80.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
scan_result = ScanResult(
|
||||||
|
market_code="KR",
|
||||||
|
timestamp="2026-02-04T10:00:00",
|
||||||
|
total_scanned=10,
|
||||||
|
top_movers=top_movers,
|
||||||
|
breakouts=["005380"],
|
||||||
|
breakdowns=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = scanner.get_updated_watchlist(
|
||||||
|
current_watchlist,
|
||||||
|
scan_result,
|
||||||
|
max_replacements=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "005930" in updated # Should keep existing top mover
|
||||||
|
assert "005380" in updated # Should add new leader
|
||||||
|
assert len(updated) == len(current_watchlist) # Should maintain size
|
||||||
|
|
||||||
|
def test_get_updated_watchlist_all_keepers(self, scanner: MarketScanner) -> None:
|
||||||
|
"""Test watchlist when all current stocks are still top movers."""
|
||||||
|
current_watchlist = ["005930", "000660", "035420"]
|
||||||
|
|
||||||
|
top_movers = [
|
||||||
|
VolatilityMetrics("005930", 50000, 500, 2.0, 3.0, 4.0, 3.0, 50.0, 90.0),
|
||||||
|
VolatilityMetrics("000660", 48000, 480, 1.8, 2.5, 3.0, 2.8, 45.0, 85.0),
|
||||||
|
VolatilityMetrics("035420", 46000, 460, 1.5, 2.0, 2.5, 2.5, 40.0, 80.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
scan_result = ScanResult(
|
||||||
|
market_code="KR",
|
||||||
|
timestamp="2026-02-04T10:00:00",
|
||||||
|
total_scanned=10,
|
||||||
|
top_movers=top_movers,
|
||||||
|
breakouts=[],
|
||||||
|
breakdowns=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = scanner.get_updated_watchlist(
|
||||||
|
current_watchlist,
|
||||||
|
scan_result,
|
||||||
|
max_replacements=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should keep all current stocks since they're all in top movers
|
||||||
|
assert set(updated) == set(current_watchlist)
|
||||||
|
|
||||||
|
def test_get_updated_watchlist_max_replacements(
|
||||||
|
self, scanner: MarketScanner
|
||||||
|
) -> None:
|
||||||
|
"""Test that max_replacements limit is respected."""
|
||||||
|
current_watchlist = ["000660", "035420", "005490"]
|
||||||
|
|
||||||
|
# All new leaders (none in current watchlist)
|
||||||
|
top_movers = [
|
||||||
|
VolatilityMetrics("005930", 50000, 500, 2.0, 3.0, 4.0, 3.0, 50.0, 90.0),
|
||||||
|
VolatilityMetrics("005380", 48000, 480, 1.8, 2.5, 3.0, 2.8, 45.0, 85.0),
|
||||||
|
VolatilityMetrics("035720", 46000, 460, 1.5, 2.0, 2.5, 2.5, 40.0, 80.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
scan_result = ScanResult(
|
||||||
|
market_code="KR",
|
||||||
|
timestamp="2026-02-04T10:00:00",
|
||||||
|
total_scanned=10,
|
||||||
|
top_movers=top_movers,
|
||||||
|
breakouts=[],
|
||||||
|
breakdowns=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = scanner.get_updated_watchlist(
|
||||||
|
current_watchlist,
|
||||||
|
scan_result,
|
||||||
|
max_replacements=1, # Only allow 1 replacement
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should add at most 1 new leader
|
||||||
|
new_additions = [code for code in updated if code not in current_watchlist]
|
||||||
|
assert len(new_additions) <= 1
|
||||||
|
assert len(updated) == len(current_watchlist)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scan_market_respects_concurrency_limit(
|
||||||
|
self,
|
||||||
|
mock_broker: KISBroker,
|
||||||
|
mock_overseas_broker: OverseasBroker,
|
||||||
|
volatility_analyzer: VolatilityAnalyzer,
|
||||||
|
context_store: ContextStore,
|
||||||
|
) -> None:
|
||||||
|
"""scan_market should limit concurrent scans to max_concurrent_scans."""
|
||||||
|
max_concurrent = 2
|
||||||
|
scanner = MarketScanner(
|
||||||
|
broker=mock_broker,
|
||||||
|
overseas_broker=mock_overseas_broker,
|
||||||
|
volatility_analyzer=volatility_analyzer,
|
||||||
|
context_store=context_store,
|
||||||
|
top_n=5,
|
||||||
|
max_concurrent_scans=max_concurrent,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track peak concurrency
|
||||||
|
active_count = 0
|
||||||
|
peak_count = 0
|
||||||
|
|
||||||
|
original_scan = scanner.scan_stock
|
||||||
|
|
||||||
|
async def tracking_scan(code: str, market: Any) -> VolatilityMetrics:
|
||||||
|
nonlocal active_count, peak_count
|
||||||
|
active_count += 1
|
||||||
|
peak_count = max(peak_count, active_count)
|
||||||
|
await asyncio.sleep(0.05) # Simulate API call duration
|
||||||
|
active_count -= 1
|
||||||
|
return VolatilityMetrics(code, 50000, 500, 1.0, 1.0, 1.0, 1.0, 10.0, 50.0)
|
||||||
|
|
||||||
|
scanner.scan_stock = tracking_scan # type: ignore[method-assign]
|
||||||
|
|
||||||
|
market = MARKETS["KR"]
|
||||||
|
stock_codes = ["001", "002", "003", "004", "005", "006"]
|
||||||
|
|
||||||
|
await scanner.scan_market(market, stock_codes)
|
||||||
|
|
||||||
|
assert peak_count <= max_concurrent
|
||||||
Reference in New Issue
Block a user