Compare commits
2 Commits
fix/401
...
200bc82a27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
200bc82a27 | ||
|
|
c967ae6715 |
190
CLAUDE.md
190
CLAUDE.md
@@ -1,9 +1,187 @@
|
|||||||
# Agent Entry Point
|
# The Ouroboros
|
||||||
|
|
||||||
This file moved to [agents.md](./agents.md).
|
AI-powered trading agent for global stock markets with self-evolution capabilities.
|
||||||
|
|
||||||
Follow `agents.md` as the single source of truth for Claude/Codex session behavior and project workflow gates.
|
## Quick Start
|
||||||
|
|
||||||
Core process references:
|
```bash
|
||||||
- [Workflow Guide](docs/workflow.md)
|
# Setup
|
||||||
- [Command Reference](docs/commands.md)
|
pip install -e ".[dev]"
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your KIS and Gemini API credentials
|
||||||
|
|
||||||
|
# Test
|
||||||
|
pytest -v --cov=src
|
||||||
|
|
||||||
|
# Run (paper trading)
|
||||||
|
python -m src.main --mode=paper
|
||||||
|
|
||||||
|
# Run with dashboard
|
||||||
|
python -m src.main --mode=paper --dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram Notifications (Optional)
|
||||||
|
|
||||||
|
Get real-time alerts for trades, circuit breakers, and system events via Telegram.
|
||||||
|
|
||||||
|
### Quick Setup
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Full documentation**: [src/notifications/README.md](src/notifications/README.md)
|
||||||
|
|
||||||
|
### What You'll Get
|
||||||
|
|
||||||
|
- 🟢 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
|
||||||
|
|
||||||
|
### Interactive Commands
|
||||||
|
|
||||||
|
With `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot supports 9 bidirectional commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`.
|
||||||
|
|
||||||
|
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
|
||||||
|
|
||||||
|
## Smart Volatility Scanner (Optional)
|
||||||
|
|
||||||
|
Python-first filtering pipeline that reduces Gemini API calls by pre-filtering stocks using technical indicators.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
Add to `.env` (optional, has sensible defaults):
|
||||||
|
```bash
|
||||||
|
RSI_OVERSOLD_THRESHOLD=30 # 0-50, default 30
|
||||||
|
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
|
||||||
|
|
||||||
|
### Trading Mode Integration
|
||||||
|
|
||||||
|
Smart Scanner runs in both `TRADE_MODE=realtime` and `daily` paths. On API failure, domestic stocks fall back to a static watchlist; overseas stocks fall back to a dynamic universe (active positions, recent holdings).
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **[Documentation Hub](docs/README.md)** — Top-level doc routing and reading order
|
||||||
|
- **[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
|
||||||
|
- **[Live Trading Checklist](docs/live-trading-checklist.md)** — 모의→실전 전환 체크리스트
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
├── backup/ # Disaster recovery (scheduler, cloud storage, health)
|
||||||
|
├── brain/ # Gemini AI decision engine (prompt optimizer, context selector)
|
||||||
|
├── broker/ # KIS API client (domestic + overseas)
|
||||||
|
├── context/ # L1-L7 hierarchical memory system
|
||||||
|
├── core/ # Risk manager (READ-ONLY)
|
||||||
|
├── dashboard/ # FastAPI read-only monitoring (10 API endpoints)
|
||||||
|
├── data/ # External data integration (news, market data, calendar)
|
||||||
|
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
|
||||||
|
├── logging/ # Decision logger (audit trail)
|
||||||
|
├── markets/ # Market schedules and timezone handling
|
||||||
|
├── notifications/ # Telegram alerts + bidirectional commands (9 commands)
|
||||||
|
├── strategy/ # Pre-market planner, scenario engine, playbook store
|
||||||
|
├── db.py # SQLite trade logging
|
||||||
|
├── main.py # Trading loop orchestrator
|
||||||
|
└── config.py # Settings (from .env)
|
||||||
|
|
||||||
|
tests/ # 998 tests across 41 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=paper --dashboard # With dashboard
|
||||||
|
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 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
|
||||||
|
- 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
|
||||||
|
|||||||
199
agents.md
199
agents.md
@@ -1,199 +0,0 @@
|
|||||||
# The Ouroboros
|
|
||||||
|
|
||||||
AI-powered trading agent for global stock markets with self-evolution capabilities.
|
|
||||||
|
|
||||||
## Agent Workflow Gate (Claude/Codex)
|
|
||||||
|
|
||||||
Before any implementation, both Claude and Codex must align on the same project process:
|
|
||||||
|
|
||||||
1. Read `docs/workflow.md` first (branch policy, issue/PR flow, merge rules).
|
|
||||||
2. Read `docs/commands.md` for required verification commands and failure handling.
|
|
||||||
3. Read `docs/agent-constraints.md` and `docs/agents.md` for safety constraints.
|
|
||||||
4. Check `workflow/session-handover.md` and append a session entry when starting or handing off work.
|
|
||||||
5. Confirm current branch is based on `main` or an explicitly designated temporary/base branch before editing.
|
|
||||||
|
|
||||||
If any instruction conflicts, default to the safer path and document the reason in the handover log.
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Setup
|
|
||||||
pip install -e ".[dev]"
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your KIS and Gemini API credentials
|
|
||||||
|
|
||||||
# Test
|
|
||||||
pytest -v --cov=src
|
|
||||||
|
|
||||||
# Run (paper trading)
|
|
||||||
python -m src.main --mode=paper
|
|
||||||
|
|
||||||
# Run with dashboard
|
|
||||||
python -m src.main --mode=paper --dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
## Telegram Notifications (Optional)
|
|
||||||
|
|
||||||
Get real-time alerts for trades, circuit breakers, and system events via Telegram.
|
|
||||||
|
|
||||||
### Quick Setup
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
**Full documentation**: [src/notifications/README.md](src/notifications/README.md)
|
|
||||||
|
|
||||||
### What You'll Get
|
|
||||||
|
|
||||||
- 🟢 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
|
|
||||||
|
|
||||||
### Interactive Commands
|
|
||||||
|
|
||||||
With `TELEGRAM_COMMANDS_ENABLED=true` (default), the bot supports 9 bidirectional commands: `/help`, `/status`, `/positions`, `/report`, `/scenarios`, `/review`, `/dashboard`, `/stop`, `/resume`.
|
|
||||||
|
|
||||||
**Fail-safe**: Notifications never crash the trading system. Missing credentials or API errors are logged but trading continues normally.
|
|
||||||
|
|
||||||
## Smart Volatility Scanner (Optional)
|
|
||||||
|
|
||||||
Python-first filtering pipeline that reduces Gemini API calls by pre-filtering stocks using technical indicators.
|
|
||||||
|
|
||||||
### How It Works
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Add to `.env` (optional, has sensible defaults):
|
|
||||||
```bash
|
|
||||||
RSI_OVERSOLD_THRESHOLD=30 # 0-50, default 30
|
|
||||||
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
|
|
||||||
|
|
||||||
### Trading Mode Integration
|
|
||||||
|
|
||||||
Smart Scanner runs in both `TRADE_MODE=realtime` and `daily` paths. On API failure, domestic stocks fall back to a static watchlist; overseas stocks fall back to a dynamic universe (active positions, recent holdings).
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
- **[Documentation Hub](docs/README.md)** — Top-level doc routing and reading order
|
|
||||||
- **[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
|
|
||||||
- **[Live Trading Checklist](docs/live-trading-checklist.md)** — 모의→실전 전환 체크리스트
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
├── backup/ # Disaster recovery (scheduler, cloud storage, health)
|
|
||||||
├── brain/ # Gemini AI decision engine (prompt optimizer, context selector)
|
|
||||||
├── broker/ # KIS API client (domestic + overseas)
|
|
||||||
├── context/ # L1-L7 hierarchical memory system
|
|
||||||
├── core/ # Risk manager (READ-ONLY)
|
|
||||||
├── dashboard/ # FastAPI read-only monitoring (10 API endpoints)
|
|
||||||
├── data/ # External data integration (news, market data, calendar)
|
|
||||||
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
|
|
||||||
├── logging/ # Decision logger (audit trail)
|
|
||||||
├── markets/ # Market schedules and timezone handling
|
|
||||||
├── notifications/ # Telegram alerts + bidirectional commands (9 commands)
|
|
||||||
├── strategy/ # Pre-market planner, scenario engine, playbook store
|
|
||||||
├── db.py # SQLite trade logging
|
|
||||||
├── main.py # Trading loop orchestrator
|
|
||||||
└── config.py # Settings (from .env)
|
|
||||||
|
|
||||||
tests/ # 998 tests across 41 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=paper --dashboard # With dashboard
|
|
||||||
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 임계값 (market_outlook별, 낮출 수 없음): BEARISH ≥ 90, NEUTRAL/기본 ≥ 80, BULLISH ≥ 75
|
|
||||||
- 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
|
|
||||||
@@ -59,18 +59,6 @@ scripts/tea_comment.sh 374 /tmp/comment.md
|
|||||||
- `scripts/tea_comment.sh` accepts stdin with `-` as body source.
|
- `scripts/tea_comment.sh` accepts stdin with `-` as body source.
|
||||||
- The helper fails fast when body looks like escaped-newline text only.
|
- The helper fails fast when body looks like escaped-newline text only.
|
||||||
|
|
||||||
#### PR Body Post-Check (Mandatory)
|
|
||||||
|
|
||||||
PR 생성 직후 본문이 `\n` 문자열로 깨지지 않았는지 반드시 확인한다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/validate_pr_body.py --pr <PR_NUMBER>
|
|
||||||
```
|
|
||||||
|
|
||||||
검증 실패 시:
|
|
||||||
- PR 본문을 API patch 또는 파일 기반 본문으로 즉시 수정
|
|
||||||
- 같은 명령으로 재검증 통과 후에만 리뷰/머지 진행
|
|
||||||
|
|
||||||
#### ❌ TTY Error - Interactive Confirmation Fails
|
#### ❌ TTY Error - Interactive Confirmation Fails
|
||||||
```bash
|
```bash
|
||||||
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
~/bin/tea issues create --repo X --title "Y" --description "Z"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Doc-ID: DOC-REQ-001
|
Doc-ID: DOC-REQ-001
|
||||||
Version: 1.0.12
|
Version: 1.0.3
|
||||||
Status: active
|
Status: active
|
||||||
Owner: strategy
|
Owner: strategy
|
||||||
Updated: 2026-03-02
|
Updated: 2026-03-02
|
||||||
@@ -19,14 +19,14 @@ Updated: 2026-03-02
|
|||||||
- `REQ-V2-005`: 라벨링은 Triple Barrier(Upper/Lower/Time) 방식이어야 한다.
|
- `REQ-V2-005`: 라벨링은 Triple Barrier(Upper/Lower/Time) 방식이어야 한다.
|
||||||
- `REQ-V2-006`: 검증은 Walk-forward + Purge/Embargo를 강제한다.
|
- `REQ-V2-006`: 검증은 Walk-forward + Purge/Embargo를 강제한다.
|
||||||
- `REQ-V2-007`: 백테스트는 비용/슬리피지/체결실패를 반영하지 않으면 채택 불가다.
|
- `REQ-V2-007`: 백테스트는 비용/슬리피지/체결실패를 반영하지 않으면 채택 불가다.
|
||||||
- `REQ-V2-008`: Kill Switch는 신규주문차단 -> 미체결취소 -> 재조회(실패 시 최대 3회, 1s/2s backoff 재시도, 성공 시 즉시 중단) -> 리스크축소 -> 스냅샷 순서다.
|
- `REQ-V2-008`: Kill Switch는 신규주문차단 -> 미체결취소 -> 재조회 -> 리스크축소 -> 스냅샷 순서다.
|
||||||
|
|
||||||
## v3 핵심 요구사항
|
## v3 핵심 요구사항
|
||||||
|
|
||||||
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
||||||
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
||||||
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
||||||
- `REQ-V3-004`: 블랙아웃 중 신호는 bounded Queue에 적재되며, 포화 시 oldest-drop 정책으로 최신 intent를 보존하고 복구 후 유효성 재검증을 거친다.
|
- `REQ-V3-004`: 블랙아웃 중 신호는 Queue에 적재되고, 복구 후 유효성 재검증을 거친다.
|
||||||
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
||||||
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
||||||
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
||||||
@@ -38,7 +38,3 @@ Updated: 2026-03-02
|
|||||||
- `REQ-OPS-002`: 문서의 수치 정책은 원장에서만 변경한다.
|
- `REQ-OPS-002`: 문서의 수치 정책은 원장에서만 변경한다.
|
||||||
- `REQ-OPS-003`: 구현 태스크는 반드시 테스트 태스크를 동반한다.
|
- `REQ-OPS-003`: 구현 태스크는 반드시 테스트 태스크를 동반한다.
|
||||||
- `REQ-OPS-004`: 원본 계획 문서(`v2`, `v3`)는 `docs/ouroboros/source/` 경로를 단일 기준으로 사용한다.
|
- `REQ-OPS-004`: 원본 계획 문서(`v2`, `v3`)는 `docs/ouroboros/source/` 경로를 단일 기준으로 사용한다.
|
||||||
|
|
||||||
## 변경 이력
|
|
||||||
|
|
||||||
- 2026-03-02: `v1.0.12` 문서 검증 게이트 강화(#390) 반영에 따라 정책 문서 동기화 체크를 수행했다. (`REQ-OPS-002`)
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Updated: 2026-03-02
|
|||||||
# v2/v3 구현 감사 및 수익률 분석 보고서
|
# v2/v3 구현 감사 및 수익률 분석 보고서
|
||||||
|
|
||||||
작성일: 2026-02-28
|
작성일: 2026-02-28
|
||||||
최종 업데이트: 2026-03-02 (#377 kill-switch refresh 재시도 정책 반영)
|
최종 업데이트: 2026-03-02 (#373 상태표 정합화 반영)
|
||||||
대상 기간: 2026-02-25 ~ 2026-02-28 (실거래)
|
대상 기간: 2026-02-25 ~ 2026-02-28 (실거래)
|
||||||
분석 브랜치: `feature/v3-session-policy-stream`
|
분석 브랜치: `feature/v3-session-policy-stream`
|
||||||
|
|
||||||
@@ -32,23 +32,23 @@ Updated: 2026-03-02
|
|||||||
| REQ-V2-001 | 4-상태 매도 상태기계 (HOLDING→BE_LOCK→ARMED→EXITED) | `src/strategy/position_state_machine.py` | ✅ 완료 |
|
| REQ-V2-001 | 4-상태 매도 상태기계 (HOLDING→BE_LOCK→ARMED→EXITED) | `src/strategy/position_state_machine.py` | ✅ 완료 |
|
||||||
| REQ-V2-002 | 즉시 최상위 상태 승격 (갭 대응) | `position_state_machine.py:51-70` | ✅ 완료 |
|
| REQ-V2-002 | 즉시 최상위 상태 승격 (갭 대응) | `position_state_machine.py:51-70` | ✅ 완료 |
|
||||||
| REQ-V2-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
|
| REQ-V2-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
|
||||||
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model assist-only, 직접 EXIT 미트리거) | `src/strategy/exit_rules.py` | ✅ 완료 |
|
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ⚠️ 부분 (`#369`) |
|
||||||
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
||||||
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
||||||
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
|
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
|
||||||
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh(retry)→Reduce→Snapshot) | `src/core/kill_switch.py` | ✅ 완료 |
|
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ⚠️ 부분 (`#377`) |
|
||||||
|
|
||||||
### 1.3 v3 구현 상태: 부분 완료 (2026-03-02 기준)
|
### 1.3 v3 구현 상태: 부분 완료 (2026-03-02 기준)
|
||||||
|
|
||||||
| REQ-ID | 요구사항 | 상태 | 비고 |
|
| REQ-ID | 요구사항 | 상태 | 비고 |
|
||||||
|--------|----------|------|------|
|
|--------|----------|------|------|
|
||||||
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
||||||
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ✅ 완료 | 세션 경계 E2E 회귀(override 적용/해제 + 재로딩 실패 폴백) 보강 (`#376`) |
|
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 구현 존재, 세션 경계 E2E 회귀 보강 필요 (`#376`) |
|
||||||
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
||||||
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ✅ 완료 | DB 기록(`#324`), 재검증 강화(`#328`), 큐 포화 oldest-drop(`#371`) 반영 |
|
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화 시 intent 유실 경로 존재 (`#371`), 재검증 강화를 `#328`에서 추적 |
|
||||||
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
||||||
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
||||||
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 런타임 분리 계산/전달 적용 (`#370`), buy-side `fx_rate` 미관측 시 `fx_pnl=0` fallback |
|
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 스키마 존재, 런타임 분리 계산/전달 미적용 (`#370`) |
|
||||||
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py` — `_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
|
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py` — `_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
|
||||||
|
|
||||||
### 1.4 운영 거버넌스: 부분 완료 (2026-03-02 재평가)
|
### 1.4 운영 거버넌스: 부분 완료 (2026-03-02 재평가)
|
||||||
@@ -80,22 +80,22 @@ Updated: 2026-03-02
|
|||||||
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
|
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
|
||||||
- **요구사항**: REQ-V3-001
|
- **요구사항**: REQ-V3-001
|
||||||
|
|
||||||
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ✅ 해소 (#327, #376)
|
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ⚠️ 부분 해소 (#327)
|
||||||
|
|
||||||
- **위치**: `src/main.py`, `src/config.py`
|
- **위치**: `src/main.py`, `src/config.py`
|
||||||
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
|
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
|
||||||
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
|
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
|
||||||
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
|
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
|
||||||
- **해소**: 세션 경계 E2E 회귀 테스트를 추가해 override 적용/해제, 재로딩 실패 시 폴백 유지를 검증함 (`#376`)
|
- **잔여 갭**: 세션 경계 실시간 전환 E2E 통합 테스트 보강 필요 (`test_main.py`에 설정 오버라이드/폴백 단위 테스트는 존재)
|
||||||
- **요구사항**: REQ-V3-002
|
- **요구사항**: REQ-V3-002
|
||||||
|
|
||||||
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ✅ 해소 (#324, #328, #371)
|
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ⚠️ 부분 해소 (#324, #328, #371)
|
||||||
|
|
||||||
- **위치**: `src/core/blackout_manager.py`, `src/main.py`
|
- **위치**: `src/core/blackout_manager.py`, `src/main.py`
|
||||||
- **현 상태**:
|
- **현 상태**:
|
||||||
- #324: 복구 주문 DB 기록 구현 및 테스트 반영
|
- #324 추적 범위(DB 기록)는 구현 경로가 존재
|
||||||
- #328: 가격/세션 재검증 강화 구현 및 머지 완료
|
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
|
||||||
- #371: 큐 포화 정책을 oldest-drop으로 명시/구현해 최신 intent 유실 경로 제거
|
- #371: 큐 포화 시 intent 유실 경로가 남아 있어 `REQ-V3-004`를 완료로 보기 어려움
|
||||||
- **요구사항**: REQ-V3-004
|
- **요구사항**: REQ-V3-004
|
||||||
|
|
||||||
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
||||||
@@ -107,12 +107,10 @@ Updated: 2026-03-02
|
|||||||
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
|
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
|
||||||
- **요구사항**: REQ-V2-005 / v3 확장
|
- **요구사항**: REQ-V2-005 / v3 확장
|
||||||
|
|
||||||
### GAP-6 (신규): FX PnL 분리 부분 해소 (MEDIUM)
|
### GAP-6 (신규): FX PnL 분리 미완료 (MEDIUM — 부분 구현)
|
||||||
|
|
||||||
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
|
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
|
||||||
- **현 상태**: 런타임 SELL 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 및 전달을 적용함 (`#370`).
|
- **문제**: 스키마와 함수는 존재하지만 런타임 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 전달이 누락됨 (`#370`)
|
||||||
- **운영 메모**: `trading_cycle`은 scanner 기반 `selection_context`에 `fx_rate`를 추가하고, `run_daily_session`은 scanner 컨텍스트 없이 `fx_rate` 스냅샷만 기록한다.
|
|
||||||
- **잔여**: 과거 BUY 레코드에 `fx_rate`가 없으면 해외 구간도 `fx_pnl=0` fallback으로 기록됨.
|
|
||||||
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
|
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
|
||||||
- **요구사항**: REQ-V3-007
|
- **요구사항**: REQ-V3-007
|
||||||
|
|
||||||
@@ -328,7 +326,7 @@ Updated: 2026-03-02
|
|||||||
| 블랙아웃 복구 주문 `log_trade()` 추가 (GAP-4) | #324 | ✅ 머지 |
|
| 블랙아웃 복구 주문 `log_trade()` 추가 (GAP-4) | #324 | ✅ 머지 |
|
||||||
| 세션 전환 리스크 파라미터 동적 재로딩 (GAP-3) | #327 | ✅ 머지 |
|
| 세션 전환 리스크 파라미터 동적 재로딩 (GAP-3) | #327 | ✅ 머지 |
|
||||||
| session_id 거래/의사결정 로그 명시 전달 (GAP-1, GAP-2) | #326 | ✅ 머지 |
|
| session_id 거래/의사결정 로그 명시 전달 (GAP-1, GAP-2) | #326 | ✅ 머지 |
|
||||||
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4) | #328 | ✅ 머지 |
|
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4 잔여) | #328 | ✅ 머지 |
|
||||||
|
|
||||||
**잔여 개선 항목:**
|
**잔여 개선 항목:**
|
||||||
|
|
||||||
@@ -337,6 +335,7 @@ Updated: 2026-03-02
|
|||||||
| P1 | US 시장 ATR 공급 경로 완성 (ROOT-5 잔여) | 중간 |
|
| P1 | US 시장 ATR 공급 경로 완성 (ROOT-5 잔여) | 중간 |
|
||||||
| P1 | FX PnL 운영 활성화 (REQ-V3-007) | 낮음 |
|
| P1 | FX PnL 운영 활성화 (REQ-V3-007) | 낮음 |
|
||||||
| P2 | pred_down_prob ML 모델 대체 (ROOT-5 잔여) | 높음 |
|
| P2 | pred_down_prob ML 모델 대체 (ROOT-5 잔여) | 높음 |
|
||||||
|
| P2 | 세션 경계 E2E 통합 테스트 보강 (GAP-3 잔여) | 낮음 |
|
||||||
|
|
||||||
### 5.3 권장 실행 순서
|
### 5.3 권장 실행 순서
|
||||||
|
|
||||||
@@ -393,7 +392,8 @@ Phase 3 (중기): v3 세션 최적화
|
|||||||
|
|
||||||
### 테스트 미존재 (잔여)
|
### 테스트 미존재 (잔여)
|
||||||
|
|
||||||
- ✅ 세션 전환 훅 콜백/세션 경계 리스크 재로딩 E2E 회귀 (`#376`)
|
- ❌ 세션 전환 훅 콜백 (GAP-3 잔여)
|
||||||
|
- ❌ 세션 경계 리스크 파라미터 재로딩 단위 테스트 (GAP-3 잔여)
|
||||||
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
|
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
|
||||||
- ❌ FX PnL 운영 활성화 검증 (GAP-6)
|
- ❌ FX PnL 운영 활성화 검증 (GAP-6)
|
||||||
|
|
||||||
|
|||||||
@@ -128,16 +128,6 @@ tea pr create \
|
|||||||
--description "$PR_BODY"
|
--description "$PR_BODY"
|
||||||
```
|
```
|
||||||
|
|
||||||
PR 생성 직후 본문 무결성 검증(필수):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 scripts/validate_pr_body.py --pr <PR_NUMBER>
|
|
||||||
```
|
|
||||||
|
|
||||||
강제 규칙:
|
|
||||||
- 검증 실패(`\n` 리터럴, 코드펜스 불균형, 헤더/리스트 누락) 상태에서는 리뷰/머지 금지
|
|
||||||
- 본문 수정 후 같은 명령으로 재검증 통과 필요
|
|
||||||
|
|
||||||
금지 패턴:
|
금지 패턴:
|
||||||
|
|
||||||
- `-d "line1\nline2"` (웹 UI에 `\n` 문자 그대로 노출될 수 있음)
|
- `-d "line1\nline2"` (웹 UI에 `\n` 문자 그대로 노출될 수 있음)
|
||||||
|
|||||||
@@ -8,32 +8,8 @@ CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
|
|||||||
TMUX_AUTO="${TMUX_AUTO:-true}"
|
TMUX_AUTO="${TMUX_AUTO:-true}"
|
||||||
TMUX_ATTACH="${TMUX_ATTACH:-true}"
|
TMUX_ATTACH="${TMUX_ATTACH:-true}"
|
||||||
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
TMUX_SESSION_PREFIX="${TMUX_SESSION_PREFIX:-ouroboros_overnight}"
|
||||||
STARTUP_GRACE_SEC="${STARTUP_GRACE_SEC:-3}"
|
|
||||||
dashboard_port="${DASHBOARD_PORT:-8080}"
|
|
||||||
APP_CMD_BIN="${APP_CMD_BIN:-}"
|
|
||||||
APP_CMD_ARGS="${APP_CMD_ARGS:-}"
|
|
||||||
RUNS_DASHBOARD="false"
|
|
||||||
|
|
||||||
# Custom override contract:
|
if [ -z "${APP_CMD:-}" ]; then
|
||||||
# 1) Preferred: APP_CMD_BIN + APP_CMD_ARGS
|
|
||||||
# - APP_CMD_BIN is treated as a single executable token.
|
|
||||||
# - APP_CMD_ARGS uses shell-style word splitting; quote/escape inside this
|
|
||||||
# variable is NOT preserved as a nested shell parse.
|
|
||||||
# 2) Legacy fallback: APP_CMD (raw shell command string)
|
|
||||||
# - This path remains for backward compatibility.
|
|
||||||
# - When APP_CMD includes --dashboard, caller should include explicit
|
|
||||||
# DASHBOARD_PORT assignment in APP_CMD if non-default port is required.
|
|
||||||
|
|
||||||
if [ -n "$APP_CMD_BIN" ]; then
|
|
||||||
USE_DEFAULT_APP_CMD="false"
|
|
||||||
USE_SAFE_CUSTOM_APP_CMD="true"
|
|
||||||
APP_CMD="${APP_CMD_BIN} ${APP_CMD_ARGS}"
|
|
||||||
if [[ " $APP_CMD_ARGS " == *" --dashboard "* ]]; then
|
|
||||||
RUNS_DASHBOARD="true"
|
|
||||||
fi
|
|
||||||
elif [ -z "${APP_CMD:-}" ]; then
|
|
||||||
USE_DEFAULT_APP_CMD="true"
|
|
||||||
USE_SAFE_CUSTOM_APP_CMD="false"
|
|
||||||
if [ -x ".venv/bin/python" ]; then
|
if [ -x ".venv/bin/python" ]; then
|
||||||
PYTHON_BIN=".venv/bin/python"
|
PYTHON_BIN=".venv/bin/python"
|
||||||
elif command -v python3 >/dev/null 2>&1; then
|
elif command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -45,14 +21,9 @@ elif [ -z "${APP_CMD:-}" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
APP_CMD="$PYTHON_BIN -m src.main --mode=live --dashboard"
|
dashboard_port="${DASHBOARD_PORT:-8080}"
|
||||||
RUNS_DASHBOARD="true"
|
|
||||||
else
|
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=live --dashboard"
|
||||||
USE_DEFAULT_APP_CMD="false"
|
|
||||||
USE_SAFE_CUSTOM_APP_CMD="false"
|
|
||||||
if [[ "$APP_CMD" == *"--dashboard"* ]]; then
|
|
||||||
RUNS_DASHBOARD="true"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "$LOG_DIR"
|
mkdir -p "$LOG_DIR"
|
||||||
@@ -63,24 +34,6 @@ WATCHDOG_LOG="$LOG_DIR/watchdog_${timestamp}.log"
|
|||||||
PID_FILE="$LOG_DIR/app.pid"
|
PID_FILE="$LOG_DIR/app.pid"
|
||||||
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
WATCHDOG_PID_FILE="$LOG_DIR/watchdog.pid"
|
||||||
|
|
||||||
is_port_in_use() {
|
|
||||||
local port="$1"
|
|
||||||
if command -v ss >/dev/null 2>&1; then
|
|
||||||
ss -ltn 2>/dev/null | grep -Eq ":${port}[[:space:]]"
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
if command -v netstat >/dev/null 2>&1; then
|
|
||||||
netstat -ltn 2>/dev/null | grep -Eq "[:.]${port}[[:space:]]"
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
# No supported socket inspection command found.
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ -f "$PID_FILE" ]; then
|
if [ -f "$PID_FILE" ]; then
|
||||||
old_pid="$(cat "$PID_FILE" || true)"
|
old_pid="$(cat "$PID_FILE" || true)"
|
||||||
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
|
||||||
@@ -90,29 +43,7 @@ if [ -f "$PID_FILE" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
|
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
|
||||||
if [ "$RUNS_DASHBOARD" = "true" ] && is_port_in_use "$dashboard_port"; then
|
nohup bash -lc "$APP_CMD" >>"$RUN_LOG" 2>&1 &
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] startup failed: dashboard port ${dashboard_port} already in use" | tee -a "$RUN_LOG"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$USE_DEFAULT_APP_CMD" = "true" ]; then
|
|
||||||
# Default path avoids shell word-splitting on executable paths.
|
|
||||||
nohup env DASHBOARD_PORT="$dashboard_port" "$PYTHON_BIN" -m src.main --mode=live --dashboard >>"$RUN_LOG" 2>&1 &
|
|
||||||
elif [ "$USE_SAFE_CUSTOM_APP_CMD" = "true" ]; then
|
|
||||||
# Safer custom path: executable path is handled as a single token.
|
|
||||||
if [ -n "$APP_CMD_ARGS" ]; then
|
|
||||||
# shellcheck disable=SC2206
|
|
||||||
app_args=( $APP_CMD_ARGS )
|
|
||||||
nohup env DASHBOARD_PORT="$dashboard_port" "$APP_CMD_BIN" "${app_args[@]}" >>"$RUN_LOG" 2>&1 &
|
|
||||||
else
|
|
||||||
nohup env DASHBOARD_PORT="$dashboard_port" "$APP_CMD_BIN" >>"$RUN_LOG" 2>&1 &
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Custom APP_CMD is treated as a shell command string.
|
|
||||||
# If executable paths include spaces, they must be quoted inside APP_CMD.
|
|
||||||
# Legacy compatibility path: caller owns quoting and env var injection.
|
|
||||||
nohup bash -lc "exec env $APP_CMD" >>"$RUN_LOG" 2>&1 &
|
|
||||||
fi
|
|
||||||
app_pid=$!
|
app_pid=$!
|
||||||
echo "$app_pid" > "$PID_FILE"
|
echo "$app_pid" > "$PID_FILE"
|
||||||
|
|
||||||
@@ -123,20 +54,6 @@ nohup env PID_FILE="$PID_FILE" LOG_FILE="$WATCHDOG_LOG" CHECK_INTERVAL="$CHECK_I
|
|||||||
watchdog_pid=$!
|
watchdog_pid=$!
|
||||||
echo "$watchdog_pid" > "$WATCHDOG_PID_FILE"
|
echo "$watchdog_pid" > "$WATCHDOG_PID_FILE"
|
||||||
|
|
||||||
sleep "$STARTUP_GRACE_SEC"
|
|
||||||
if ! kill -0 "$app_pid" 2>/dev/null; then
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] startup failed: app process exited early (pid=$app_pid)" | tee -a "$RUN_LOG"
|
|
||||||
[ -n "${watchdog_pid:-}" ] && kill "$watchdog_pid" 2>/dev/null || true
|
|
||||||
tail -n 20 "$RUN_LOG" || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if ! kill -0 "$watchdog_pid" 2>/dev/null; then
|
|
||||||
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] startup failed: watchdog exited early (pid=$watchdog_pid)" | tee -a "$WATCHDOG_LOG"
|
|
||||||
kill "$app_pid" 2>/dev/null || true
|
|
||||||
tail -n 20 "$WATCHDOG_LOG" || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
시작 완료
|
시작 완료
|
||||||
- app pid: $app_pid
|
- app pid: $app_pid
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ ROOT_DIR="${ROOT_DIR:-/home/agentson/repos/The-Ouroboros}"
|
|||||||
LOG_DIR="${LOG_DIR:-$ROOT_DIR/data/overnight}"
|
LOG_DIR="${LOG_DIR:-$ROOT_DIR/data/overnight}"
|
||||||
INTERVAL_SEC="${INTERVAL_SEC:-60}"
|
INTERVAL_SEC="${INTERVAL_SEC:-60}"
|
||||||
MAX_HOURS="${MAX_HOURS:-24}"
|
MAX_HOURS="${MAX_HOURS:-24}"
|
||||||
MAX_LOOPS="${MAX_LOOPS:-0}"
|
|
||||||
POLICY_TZ="${POLICY_TZ:-Asia/Seoul}"
|
POLICY_TZ="${POLICY_TZ:-Asia/Seoul}"
|
||||||
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
|
||||||
|
|
||||||
cd "$ROOT_DIR"
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
OUT_LOG="$LOG_DIR/runtime_verify_$(date +%Y%m%d_%H%M%S).log"
|
OUT_LOG="$LOG_DIR/runtime_verify_$(date +%Y%m%d_%H%M%S).log"
|
||||||
END_TS=$(( $(date +%s) + MAX_HOURS*3600 ))
|
END_TS=$(( $(date +%s) + MAX_HOURS*3600 ))
|
||||||
loops=0
|
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$OUT_LOG" >/dev/null
|
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$OUT_LOG" >/dev/null
|
||||||
@@ -34,11 +31,6 @@ check_signal() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
find_live_pids() {
|
|
||||||
# Detect live-mode process even when run_overnight pid files are absent.
|
|
||||||
pgrep -af "[s]rc.main --mode=live" 2>/dev/null | awk '{print $1}' | tr '\n' ',' | sed 's/,$//'
|
|
||||||
}
|
|
||||||
|
|
||||||
check_forbidden() {
|
check_forbidden() {
|
||||||
local name="$1"
|
local name="$1"
|
||||||
local pattern="$2"
|
local pattern="$2"
|
||||||
@@ -52,94 +44,42 @@ check_forbidden() {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
is_port_listening() {
|
|
||||||
local port="$1"
|
|
||||||
|
|
||||||
if command -v ss >/dev/null 2>&1; then
|
|
||||||
ss -ltn 2>/dev/null | grep -Eq ":${port}[[:space:]]"
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
|
||||||
lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
if command -v netstat >/dev/null 2>&1; then
|
|
||||||
netstat -ltn 2>/dev/null | grep -Eq "[:.]${port}[[:space:]]"
|
|
||||||
return $?
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
log "[INFO] runtime verify monitor started interval=${INTERVAL_SEC}s max_hours=${MAX_HOURS} policy_tz=${POLICY_TZ}"
|
log "[INFO] runtime verify monitor started interval=${INTERVAL_SEC}s max_hours=${MAX_HOURS} policy_tz=${POLICY_TZ}"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
loops=$((loops + 1))
|
|
||||||
now=$(date +%s)
|
now=$(date +%s)
|
||||||
if [ "$now" -ge "$END_TS" ]; then
|
if [ "$now" -ge "$END_TS" ]; then
|
||||||
log "[INFO] monitor completed (time window reached)"
|
log "[INFO] monitor completed (time window reached)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
if [ "$MAX_LOOPS" -gt 0 ] && [ "$loops" -gt "$MAX_LOOPS" ]; then
|
|
||||||
log "[INFO] monitor completed (max loops reached)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
latest_run="$(ls -t "$LOG_DIR"/run_*.log 2>/dev/null | head -n1 || true)"
|
latest_run="$(ls -t "$LOG_DIR"/run_*.log 2>/dev/null | head -n1 || true)"
|
||||||
|
if [ -z "$latest_run" ]; then
|
||||||
|
log "[ANOMALY] no run log found"
|
||||||
|
sleep "$INTERVAL_SEC"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
# Basic liveness hints.
|
# Basic liveness hints.
|
||||||
app_pid="$(cat "$LOG_DIR/app.pid" 2>/dev/null || true)"
|
app_pid="$(cat "$LOG_DIR/app.pid" 2>/dev/null || true)"
|
||||||
wd_pid="$(cat "$LOG_DIR/watchdog.pid" 2>/dev/null || true)"
|
wd_pid="$(cat "$LOG_DIR/watchdog.pid" 2>/dev/null || true)"
|
||||||
live_pids="$(find_live_pids)"
|
|
||||||
app_alive=0
|
app_alive=0
|
||||||
wd_alive=0
|
wd_alive=0
|
||||||
port_alive=0
|
port_alive=0
|
||||||
[ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null && app_alive=1
|
[ -n "$app_pid" ] && kill -0 "$app_pid" 2>/dev/null && app_alive=1
|
||||||
[ -n "$wd_pid" ] && kill -0 "$wd_pid" 2>/dev/null && wd_alive=1
|
[ -n "$wd_pid" ] && kill -0 "$wd_pid" 2>/dev/null && wd_alive=1
|
||||||
if [ "$app_alive" -eq 0 ] && [ -n "$live_pids" ]; then
|
ss -ltnp 2>/dev/null | rg -q ':8080' && port_alive=1
|
||||||
app_alive=1
|
log "[HEARTBEAT] run_log=$latest_run app_alive=$app_alive watchdog_alive=$wd_alive port8080=$port_alive"
|
||||||
fi
|
|
||||||
is_port_listening "$DASHBOARD_PORT" && port_alive=1
|
|
||||||
log "[HEARTBEAT] run_log=${latest_run:-none} app_alive=$app_alive watchdog_alive=$wd_alive port=${DASHBOARD_PORT} alive=$port_alive live_pids=${live_pids:-none}"
|
|
||||||
|
|
||||||
defer_log_checks=0
|
|
||||||
if [ -z "$latest_run" ] && [ "$app_alive" -eq 1 ]; then
|
|
||||||
defer_log_checks=1
|
|
||||||
log "[INFO] run log not yet available; defer log-based coverage checks"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$latest_run" ] && [ "$defer_log_checks" -eq 0 ]; then
|
|
||||||
log "[ANOMALY] no run log found"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Coverage matrix rows (session paths and policy gate evidence).
|
# Coverage matrix rows (session paths and policy gate evidence).
|
||||||
not_observed=0
|
not_observed=0
|
||||||
if [ "$app_alive" -eq 1 ]; then
|
|
||||||
log "[COVERAGE] LIVE_MODE=PASS source=process_liveness"
|
|
||||||
else
|
|
||||||
if [ -n "$latest_run" ]; then
|
|
||||||
check_signal "LIVE_MODE" "Mode: live" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "LIVE_MODE" "Mode: live" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
else
|
|
||||||
log "[COVERAGE] LIVE_MODE=NOT_OBSERVED reason=no_run_log_no_live_pid"
|
|
||||||
not_observed=$((not_observed+1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
if [ "$defer_log_checks" -eq 1 ]; then
|
|
||||||
for deferred in KR_LOOP NXT_PATH US_PRE_PATH US_DAY_PATH US_AFTER_PATH ORDER_POLICY_SESSION; do
|
|
||||||
log "[COVERAGE] ${deferred}=DEFERRED reason=no_run_log_process_alive"
|
|
||||||
done
|
|
||||||
elif [ -n "$latest_run" ]; then
|
|
||||||
check_signal "KR_LOOP" "Processing market: Korea Exchange" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "KR_LOOP" "Processing market: Korea Exchange" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
check_signal "NXT_PATH" "NXT_PRE|NXT_AFTER|session=NXT_" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "NXT_PATH" "NXT_PRE|NXT_AFTER|session=NXT_" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
check_signal "US_PRE_PATH" "US_PRE|session=US_PRE" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "US_PRE_PATH" "US_PRE|session=US_PRE" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
check_signal "US_DAY_PATH" "US_DAY|session=US_DAY|Processing market: .*NASDAQ|Processing market: .*NYSE|Processing market: .*AMEX" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "US_DAY_PATH" "US_DAY|session=US_DAY|Processing market: .*NASDAQ|Processing market: .*NYSE|Processing market: .*AMEX" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
check_signal "US_AFTER_PATH" "US_AFTER|session=US_AFTER" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "US_AFTER_PATH" "US_AFTER|session=US_AFTER" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
check_signal "ORDER_POLICY_SESSION" "Order policy rejected .*\\[session=" "$latest_run" || not_observed=$((not_observed+1))
|
check_signal "ORDER_POLICY_SESSION" "Order policy rejected .*\\[session=" "$latest_run" || not_observed=$((not_observed+1))
|
||||||
else
|
|
||||||
for missing in KR_LOOP NXT_PATH US_PRE_PATH US_DAY_PATH US_AFTER_PATH ORDER_POLICY_SESSION; do
|
|
||||||
log "[COVERAGE] ${missing}=NOT_OBSERVED reason=no_run_log"
|
|
||||||
not_observed=$((not_observed+1))
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$not_observed" -gt 0 ]; then
|
if [ "$not_observed" -gt 0 ]; then
|
||||||
log "[ANOMALY] coverage_not_observed=$not_observed (treat as FAIL)"
|
log "[ANOMALY] coverage_not_observed=$not_observed (treat as FAIL)"
|
||||||
@@ -155,17 +95,11 @@ while true; do
|
|||||||
is_weekend=1
|
is_weekend=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$defer_log_checks" -eq 1 ]; then
|
if [ "$is_weekend" -eq 1 ]; then
|
||||||
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=no_run_log_process_alive"
|
|
||||||
elif [ "$is_weekend" -eq 1 ]; then
|
|
||||||
# Weekend policy: KR regular session loop must never appear.
|
# Weekend policy: KR regular session loop must never appear.
|
||||||
if [ -n "$latest_run" ]; then
|
|
||||||
check_forbidden "WEEKEND_KR_SESSION_ACTIVE" \
|
check_forbidden "WEEKEND_KR_SESSION_ACTIVE" \
|
||||||
"Market session active: KR|session=KRX_REG|Processing market: Korea Exchange" \
|
"Market session active: KR|session=KRX_REG|Processing market: Korea Exchange" \
|
||||||
"$latest_run" || forbidden_hits=$((forbidden_hits+1))
|
"$latest_run" || forbidden_hits=$((forbidden_hits+1))
|
||||||
else
|
|
||||||
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=no_run_log"
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=weekday"
|
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=weekday"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -92,25 +92,6 @@ def validate_testing_doc_has_dynamic_count_guidance(errors: list[str]) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_pr_body_postcheck_guidance(errors: list[str]) -> None:
|
|
||||||
required_tokens = {
|
|
||||||
"commands": (
|
|
||||||
"PR Body Post-Check (Mandatory)",
|
|
||||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>",
|
|
||||||
),
|
|
||||||
"workflow": (
|
|
||||||
"PR 생성 직후 본문 무결성 검증(필수)",
|
|
||||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>",
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for key, tokens in required_tokens.items():
|
|
||||||
path = REQUIRED_FILES[key]
|
|
||||||
text = _read(path)
|
|
||||||
for token in tokens:
|
|
||||||
if token not in text:
|
|
||||||
errors.append(f"{path}: missing PR body post-check guidance token -> {token}")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
|
|
||||||
@@ -136,7 +117,6 @@ def main() -> int:
|
|||||||
validate_summary_docs_reference_core_docs(errors)
|
validate_summary_docs_reference_core_docs(errors)
|
||||||
validate_commands_endpoint_duplicates(errors)
|
validate_commands_endpoint_duplicates(errors)
|
||||||
validate_testing_doc_has_dynamic_count_guidance(errors)
|
validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||||
validate_pr_body_postcheck_guidance(errors)
|
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
print("[FAIL] docs sync validation failed")
|
print("[FAIL] docs sync validation failed")
|
||||||
@@ -148,7 +128,6 @@ def main() -> int:
|
|||||||
print("[OK] summary docs link to core docs and links resolve")
|
print("[OK] summary docs link to core docs and links resolve")
|
||||||
print("[OK] commands endpoint rows have no duplicates")
|
print("[OK] commands endpoint rows have no duplicates")
|
||||||
print("[OK] testing doc includes dynamic count guidance")
|
print("[OK] testing doc includes dynamic count guidance")
|
||||||
print("[OK] PR body post-check guidance exists in commands/workflow docs")
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ ALLOWED_PLAN_TARGETS = {
|
|||||||
"2": (DOC_DIR / "source" / "ouroboros_plan_v2.txt").resolve(),
|
"2": (DOC_DIR / "source" / "ouroboros_plan_v2.txt").resolve(),
|
||||||
"3": (DOC_DIR / "source" / "ouroboros_plan_v3.txt").resolve(),
|
"3": (DOC_DIR / "source" / "ouroboros_plan_v3.txt").resolve(),
|
||||||
}
|
}
|
||||||
ISSUE_REF_PATTERN = re.compile(r"#(?P<issue>\d+)")
|
|
||||||
ISSUE_DONE_PATTERN = re.compile(r"(?:✅|머지|해소|완료)")
|
|
||||||
ISSUE_PENDING_PATTERN = re.compile(r"(?:잔여|오픈 상태|추적 이슈)")
|
|
||||||
|
|
||||||
|
|
||||||
def iter_docs() -> list[Path]:
|
def iter_docs() -> list[Path]:
|
||||||
@@ -122,38 +119,6 @@ def collect_req_traceability(
|
|||||||
req_to_test.setdefault(req_id, set()).add(item_id)
|
req_to_test.setdefault(req_id, set()).add(item_id)
|
||||||
|
|
||||||
|
|
||||||
def validate_issue_status_consistency(path: Path, text: str, errors: list[str]) -> None:
|
|
||||||
issue_done_lines: dict[str, list[int]] = {}
|
|
||||||
issue_pending_lines: dict[str, list[int]] = {}
|
|
||||||
|
|
||||||
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
|
||||||
line = raw_line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
issue_ids = [m.group("issue") for m in ISSUE_REF_PATTERN.finditer(line)]
|
|
||||||
if not issue_ids:
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_pending = bool(ISSUE_PENDING_PATTERN.search(line))
|
|
||||||
is_done = bool(ISSUE_DONE_PATTERN.search(line)) and not is_pending
|
|
||||||
if not is_pending and not is_done:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for issue_id in issue_ids:
|
|
||||||
if is_done:
|
|
||||||
issue_done_lines.setdefault(issue_id, []).append(line_no)
|
|
||||||
if is_pending:
|
|
||||||
issue_pending_lines.setdefault(issue_id, []).append(line_no)
|
|
||||||
|
|
||||||
conflicted_issues = sorted(set(issue_done_lines) & set(issue_pending_lines))
|
|
||||||
for issue_id in conflicted_issues:
|
|
||||||
errors.append(
|
|
||||||
f"{path}: conflicting status for issue #{issue_id} "
|
|
||||||
f"(done at lines {issue_done_lines[issue_id]}, "
|
|
||||||
f"pending at lines {issue_pending_lines[issue_id]})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
if not DOC_DIR.exists():
|
if not DOC_DIR.exists():
|
||||||
print(f"ERROR: missing directory {DOC_DIR}")
|
print(f"ERROR: missing directory {DOC_DIR}")
|
||||||
@@ -175,8 +140,6 @@ def main() -> int:
|
|||||||
text = path.read_text(encoding="utf-8")
|
text = path.read_text(encoding="utf-8")
|
||||||
validate_metadata(path, text, errors, doc_ids)
|
validate_metadata(path, text, errors, doc_ids)
|
||||||
validate_links(path, text, errors)
|
validate_links(path, text, errors)
|
||||||
if path.name == "80_implementation_audit.md":
|
|
||||||
validate_issue_status_consistency(path, text, errors)
|
|
||||||
collect_ids(path, text, defs, refs)
|
collect_ids(path, text, defs, refs)
|
||||||
collect_req_traceability(text, req_to_task, req_to_test)
|
collect_req_traceability(text, req_to_task, req_to_test)
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate PR body formatting to prevent escaped-newline artifacts."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
HEADER_PATTERN = re.compile(r"^##\s+\S+", re.MULTILINE)
|
|
||||||
LIST_ITEM_PATTERN = re.compile(r"^\s*(?:-|\*|\d+\.)\s+\S+", re.MULTILINE)
|
|
||||||
FENCED_CODE_PATTERN = re.compile(r"```.*?```", re.DOTALL)
|
|
||||||
INLINE_CODE_PATTERN = re.compile(r"`[^`]*`")
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_code_segments(text: str) -> str:
|
|
||||||
without_fences = FENCED_CODE_PATTERN.sub("", text)
|
|
||||||
return INLINE_CODE_PATTERN.sub("", without_fences)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_tea_binary() -> str:
|
|
||||||
tea_from_path = shutil.which("tea")
|
|
||||||
if tea_from_path:
|
|
||||||
return tea_from_path
|
|
||||||
|
|
||||||
tea_home = Path.home() / "bin" / "tea"
|
|
||||||
if tea_home.exists() and tea_home.is_file() and os.access(tea_home, os.X_OK):
|
|
||||||
return str(tea_home)
|
|
||||||
|
|
||||||
raise RuntimeError("tea binary not found (checked PATH and ~/bin/tea)")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pr_body_text(text: str) -> list[str]:
|
|
||||||
errors: list[str] = []
|
|
||||||
searchable = _strip_code_segments(text)
|
|
||||||
if "\\n" in searchable:
|
|
||||||
errors.append("body contains escaped newline sequence (\\n)")
|
|
||||||
if text.count("```") % 2 != 0:
|
|
||||||
errors.append("body has unbalanced fenced code blocks (``` count is odd)")
|
|
||||||
if not HEADER_PATTERN.search(text):
|
|
||||||
errors.append("body is missing markdown section headers (e.g. '## Summary')")
|
|
||||||
if not LIST_ITEM_PATTERN.search(text):
|
|
||||||
errors.append("body is missing markdown list items")
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_pr_body(pr_number: int) -> str:
|
|
||||||
tea_binary = resolve_tea_binary()
|
|
||||||
try:
|
|
||||||
completed = subprocess.run(
|
|
||||||
[
|
|
||||||
tea_binary,
|
|
||||||
"api",
|
|
||||||
"-R",
|
|
||||||
"origin",
|
|
||||||
f"repos/{{owner}}/{{repo}}/pulls/{pr_number}",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
except (subprocess.CalledProcessError, FileNotFoundError, PermissionError) as exc:
|
|
||||||
raise RuntimeError(f"failed to fetch PR #{pr_number}: {exc}") from exc
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = json.loads(completed.stdout)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise RuntimeError(f"failed to parse PR payload for #{pr_number}: {exc}") from exc
|
|
||||||
|
|
||||||
body = payload.get("body", "")
|
|
||||||
if not isinstance(body, str):
|
|
||||||
raise RuntimeError(f"unexpected PR body type for #{pr_number}: {type(body).__name__}")
|
|
||||||
return body
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Validate PR body markdown formatting and escaped-newline artifacts."
|
|
||||||
)
|
|
||||||
group = parser.add_mutually_exclusive_group(required=True)
|
|
||||||
group.add_argument("--pr", type=int, help="PR number to fetch via `tea api`")
|
|
||||||
group.add_argument("--body-file", type=Path, help="Path to markdown body file")
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = parse_args()
|
|
||||||
if args.body_file is not None:
|
|
||||||
if not args.body_file.exists():
|
|
||||||
print(f"[FAIL] body file not found: {args.body_file}")
|
|
||||||
return 1
|
|
||||||
body = args.body_file.read_text(encoding="utf-8")
|
|
||||||
source = f"file:{args.body_file}"
|
|
||||||
else:
|
|
||||||
body = fetch_pr_body(args.pr)
|
|
||||||
source = f"pr:{args.pr}"
|
|
||||||
|
|
||||||
errors = validate_pr_body_text(body)
|
|
||||||
if errors:
|
|
||||||
print("[FAIL] PR body validation failed")
|
|
||||||
print(f"- source: {source}")
|
|
||||||
for err in errors:
|
|
||||||
print(f"- {err}")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
print("[OK] PR body validation passed")
|
|
||||||
print(f"- source: {source}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -32,7 +32,7 @@ def validate_backtest_cost_model(
|
|||||||
|
|
||||||
slippage = model.slippage_bps_by_session or {}
|
slippage = model.slippage_bps_by_session or {}
|
||||||
failure = model.failure_rate_by_session or {}
|
failure = model.failure_rate_by_session or {}
|
||||||
partial_fill = model.partial_fill_rate_by_session or {}
|
partial = model.partial_fill_rate_by_session or {}
|
||||||
|
|
||||||
missing_slippage = [s for s in required_sessions if s not in slippage]
|
missing_slippage = [s for s in required_sessions if s not in slippage]
|
||||||
if missing_slippage:
|
if missing_slippage:
|
||||||
@@ -45,12 +45,11 @@ def validate_backtest_cost_model(
|
|||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"missing failure_rate_by_session for sessions: {', '.join(missing_failure)}"
|
f"missing failure_rate_by_session for sessions: {', '.join(missing_failure)}"
|
||||||
)
|
)
|
||||||
|
missing_partial = [s for s in required_sessions if s not in partial]
|
||||||
missing_partial_fill = [s for s in required_sessions if s not in partial_fill]
|
if missing_partial:
|
||||||
if missing_partial_fill:
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"missing partial_fill_rate_by_session for sessions: "
|
"missing partial_fill_rate_by_session for sessions: "
|
||||||
f"{', '.join(missing_partial_fill)}"
|
f"{', '.join(missing_partial)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
for sess, bps in slippage.items():
|
for sess, bps in slippage.items():
|
||||||
@@ -59,6 +58,6 @@ def validate_backtest_cost_model(
|
|||||||
for sess, rate in failure.items():
|
for sess, rate in failure.items():
|
||||||
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
||||||
raise ValueError(f"failure rate must be within [0,1] for session={sess}")
|
raise ValueError(f"failure rate must be within [0,1] for session={sess}")
|
||||||
for sess, rate in partial_fill.items():
|
for sess, rate in partial.items():
|
||||||
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
||||||
raise ValueError(f"partial fill rate must be within [0,1] for session={sess}")
|
raise ValueError(f"partial fill rate must be within [0,1] for session={sess}")
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ class WalkForwardConfig:
|
|||||||
class BaselineScore:
|
class BaselineScore:
|
||||||
name: Literal["B0", "B1", "M1"]
|
name: Literal["B0", "B1", "M1"]
|
||||||
accuracy: float
|
accuracy: float
|
||||||
cost_adjusted_accuracy: float
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -94,6 +93,14 @@ def run_v2_backtest_pipeline(
|
|||||||
else sorted({bar.session_id for bar in bars})
|
else sorted({bar.session_id for bar in bars})
|
||||||
)
|
)
|
||||||
validate_backtest_cost_model(model=cost_model, required_sessions=resolved_sessions)
|
validate_backtest_cost_model(model=cost_model, required_sessions=resolved_sessions)
|
||||||
|
execution_model = BacktestExecutionModel(
|
||||||
|
ExecutionAssumptions(
|
||||||
|
slippage_bps_by_session=cost_model.slippage_bps_by_session or {},
|
||||||
|
failure_rate_by_session=cost_model.failure_rate_by_session or {},
|
||||||
|
partial_fill_rate_by_session=cost_model.partial_fill_rate_by_session or {},
|
||||||
|
seed=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
highs = [float(bar.high) for bar in bars]
|
highs = [float(bar.high) for bar in bars]
|
||||||
lows = [float(bar.low) for bar in bars]
|
lows = [float(bar.low) for bar in bars]
|
||||||
@@ -125,8 +132,6 @@ def run_v2_backtest_pipeline(
|
|||||||
).label
|
).label
|
||||||
|
|
||||||
ordered_labels = [labels_by_bar_index[idx] for idx in normalized_entries]
|
ordered_labels = [labels_by_bar_index[idx] for idx in normalized_entries]
|
||||||
ordered_sessions = [bars[idx].session_id for idx in normalized_entries]
|
|
||||||
ordered_prices = [bars[idx].close for idx in normalized_entries]
|
|
||||||
folds = generate_walk_forward_splits(
|
folds = generate_walk_forward_splits(
|
||||||
n_samples=len(normalized_entries),
|
n_samples=len(normalized_entries),
|
||||||
train_size=walk_forward.train_size,
|
train_size=walk_forward.train_size,
|
||||||
@@ -141,17 +146,8 @@ def run_v2_backtest_pipeline(
|
|||||||
for fold_idx, fold in enumerate(folds):
|
for fold_idx, fold in enumerate(folds):
|
||||||
train_labels = [ordered_labels[i] for i in fold.train_indices]
|
train_labels = [ordered_labels[i] for i in fold.train_indices]
|
||||||
test_labels = [ordered_labels[i] for i in fold.test_indices]
|
test_labels = [ordered_labels[i] for i in fold.test_indices]
|
||||||
test_sessions = [ordered_sessions[i] for i in fold.test_indices]
|
|
||||||
test_prices = [ordered_prices[i] for i in fold.test_indices]
|
|
||||||
if not test_labels:
|
if not test_labels:
|
||||||
continue
|
continue
|
||||||
execution_model = _build_execution_model(cost_model=cost_model, fold_seed=fold_idx)
|
|
||||||
execution_return_model = _build_execution_model(
|
|
||||||
cost_model=cost_model,
|
|
||||||
fold_seed=fold_idx + 1000,
|
|
||||||
)
|
|
||||||
b0_pred = _baseline_b0_pred(train_labels)
|
|
||||||
m1_pred = _m1_pred(train_labels)
|
|
||||||
execution_returns_bps: list[float] = []
|
execution_returns_bps: list[float] = []
|
||||||
execution_rejected = 0
|
execution_rejected = 0
|
||||||
execution_partial = 0
|
execution_partial = 0
|
||||||
@@ -159,7 +155,7 @@ def run_v2_backtest_pipeline(
|
|||||||
entry_bar_index = normalized_entries[rel_idx]
|
entry_bar_index = normalized_entries[rel_idx]
|
||||||
bar = bars[entry_bar_index]
|
bar = bars[entry_bar_index]
|
||||||
trade = _simulate_execution_adjusted_return_bps(
|
trade = _simulate_execution_adjusted_return_bps(
|
||||||
execution_model=execution_return_model,
|
execution_model=execution_model,
|
||||||
bar=bar,
|
bar=bar,
|
||||||
label=ordered_labels[rel_idx],
|
label=ordered_labels[rel_idx],
|
||||||
side=side,
|
side=side,
|
||||||
@@ -180,41 +176,11 @@ def run_v2_backtest_pipeline(
|
|||||||
train_label_distribution=_label_dist(train_labels),
|
train_label_distribution=_label_dist(train_labels),
|
||||||
test_label_distribution=_label_dist(test_labels),
|
test_label_distribution=_label_dist(test_labels),
|
||||||
baseline_scores=[
|
baseline_scores=[
|
||||||
BaselineScore(
|
BaselineScore(name="B0", accuracy=_baseline_b0(train_labels, test_labels)),
|
||||||
name="B0",
|
BaselineScore(name="B1", accuracy=_score_constant(1, test_labels)),
|
||||||
accuracy=_score_constant(b0_pred, test_labels),
|
|
||||||
cost_adjusted_accuracy=_score_with_execution(
|
|
||||||
prediction=b0_pred,
|
|
||||||
actual=test_labels,
|
|
||||||
sessions=test_sessions,
|
|
||||||
reference_prices=test_prices,
|
|
||||||
execution_model=execution_model,
|
|
||||||
commission_bps=float(cost_model.commission_bps or 0.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BaselineScore(
|
|
||||||
name="B1",
|
|
||||||
accuracy=_score_constant(1, test_labels),
|
|
||||||
cost_adjusted_accuracy=_score_with_execution(
|
|
||||||
prediction=1,
|
|
||||||
actual=test_labels,
|
|
||||||
sessions=test_sessions,
|
|
||||||
reference_prices=test_prices,
|
|
||||||
execution_model=execution_model,
|
|
||||||
commission_bps=float(cost_model.commission_bps or 0.0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BaselineScore(
|
BaselineScore(
|
||||||
name="M1",
|
name="M1",
|
||||||
accuracy=_score_constant(m1_pred, test_labels),
|
accuracy=_score_constant(_m1_pred(train_labels), test_labels),
|
||||||
cost_adjusted_accuracy=_score_with_execution(
|
|
||||||
prediction=m1_pred,
|
|
||||||
actual=test_labels,
|
|
||||||
sessions=test_sessions,
|
|
||||||
reference_prices=test_prices,
|
|
||||||
execution_model=execution_model,
|
|
||||||
commission_bps=float(cost_model.commission_bps or 0.0),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
execution_adjusted_avg_return_bps=(
|
execution_adjusted_avg_return_bps=(
|
||||||
@@ -253,15 +219,12 @@ def _score_constant(pred: int, actual: Sequence[int]) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _baseline_b0(train_labels: Sequence[int], test_labels: Sequence[int]) -> float:
|
def _baseline_b0(train_labels: Sequence[int], test_labels: Sequence[int]) -> float:
|
||||||
return _score_constant(_baseline_b0_pred(train_labels), test_labels)
|
|
||||||
|
|
||||||
|
|
||||||
def _baseline_b0_pred(train_labels: Sequence[int]) -> int:
|
|
||||||
if not train_labels:
|
if not train_labels:
|
||||||
return 0
|
return _score_constant(0, test_labels)
|
||||||
# Majority-class baseline from training fold.
|
# Majority-class baseline from training fold.
|
||||||
choices = (-1, 0, 1)
|
choices = (-1, 0, 1)
|
||||||
return max(choices, key=lambda c: train_labels.count(c))
|
pred = max(choices, key=lambda c: train_labels.count(c))
|
||||||
|
return _score_constant(pred, test_labels)
|
||||||
|
|
||||||
|
|
||||||
def _m1_pred(train_labels: Sequence[int]) -> int:
|
def _m1_pred(train_labels: Sequence[int]) -> int:
|
||||||
@@ -270,56 +233,6 @@ def _m1_pred(train_labels: Sequence[int]) -> int:
|
|||||||
return train_labels[-1]
|
return train_labels[-1]
|
||||||
|
|
||||||
|
|
||||||
def _build_execution_model(
|
|
||||||
*,
|
|
||||||
cost_model: BacktestCostModel,
|
|
||||||
fold_seed: int,
|
|
||||||
) -> BacktestExecutionModel:
|
|
||||||
return BacktestExecutionModel(
|
|
||||||
ExecutionAssumptions(
|
|
||||||
slippage_bps_by_session=dict(cost_model.slippage_bps_by_session or {}),
|
|
||||||
failure_rate_by_session=dict(cost_model.failure_rate_by_session or {}),
|
|
||||||
partial_fill_rate_by_session=dict(cost_model.partial_fill_rate_by_session or {}),
|
|
||||||
seed=fold_seed,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _score_with_execution(
|
|
||||||
*,
|
|
||||||
prediction: int,
|
|
||||||
actual: Sequence[int],
|
|
||||||
sessions: Sequence[str],
|
|
||||||
reference_prices: Sequence[float],
|
|
||||||
execution_model: BacktestExecutionModel,
|
|
||||||
commission_bps: float,
|
|
||||||
) -> float:
|
|
||||||
if not actual:
|
|
||||||
return 0.0
|
|
||||||
contributions: list[float] = []
|
|
||||||
for label, session_id, reference_price in zip(actual, sessions, reference_prices, strict=True):
|
|
||||||
if prediction == 0:
|
|
||||||
contributions.append(1.0 if label == 0 else 0.0)
|
|
||||||
continue
|
|
||||||
side = "BUY" if prediction > 0 else "SELL"
|
|
||||||
execution = execution_model.simulate(
|
|
||||||
ExecutionRequest(
|
|
||||||
side=side,
|
|
||||||
session_id=session_id,
|
|
||||||
qty=100,
|
|
||||||
reference_price=reference_price,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if execution.status == "REJECTED":
|
|
||||||
contributions.append(0.0)
|
|
||||||
continue
|
|
||||||
fill_ratio = execution.filled_qty / 100.0
|
|
||||||
cost_penalty = min(0.99, (commission_bps + execution.slippage_bps) / 10000.0)
|
|
||||||
correctness = 1.0 if prediction == label else 0.0
|
|
||||||
contributions.append(correctness * fill_ratio * (1.0 - cost_penalty))
|
|
||||||
return mean(contributions)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> str:
|
def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> str:
|
||||||
sess_key = "_".join(sessions)
|
sess_key = "_".join(sessions)
|
||||||
return f"v2p-e{n_entries}-f{n_folds}-s{sess_key}"
|
return f"v2p-e{n_entries}-f{n_folds}-s{sess_key}"
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ class BlackoutWindow:
|
|||||||
class QueuedOrderIntent:
|
class QueuedOrderIntent:
|
||||||
market_code: str
|
market_code: str
|
||||||
exchange_code: str
|
exchange_code: str
|
||||||
session_id: str
|
|
||||||
stock_code: str
|
stock_code: str
|
||||||
order_type: str
|
order_type: str
|
||||||
quantity: int
|
quantity: int
|
||||||
@@ -69,16 +68,11 @@ class BlackoutOrderManager:
|
|||||||
self._queue: deque[QueuedOrderIntent] = deque()
|
self._queue: deque[QueuedOrderIntent] = deque()
|
||||||
self._was_blackout = False
|
self._was_blackout = False
|
||||||
self._max_queue_size = max_queue_size
|
self._max_queue_size = max_queue_size
|
||||||
self._overflow_drop_count = 0
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
return len(self._queue)
|
return len(self._queue)
|
||||||
|
|
||||||
@property
|
|
||||||
def overflow_drop_count(self) -> int:
|
|
||||||
return self._overflow_drop_count
|
|
||||||
|
|
||||||
def in_blackout(self, now: datetime | None = None) -> bool:
|
def in_blackout(self, now: datetime | None = None) -> bool:
|
||||||
if not self.enabled or not self._windows:
|
if not self.enabled or not self._windows:
|
||||||
return False
|
return False
|
||||||
@@ -87,11 +81,8 @@ class BlackoutOrderManager:
|
|||||||
return any(window.contains(kst_now) for window in self._windows)
|
return any(window.contains(kst_now) for window in self._windows)
|
||||||
|
|
||||||
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
||||||
if self._max_queue_size <= 0:
|
|
||||||
return False
|
|
||||||
if len(self._queue) >= self._max_queue_size:
|
if len(self._queue) >= self._max_queue_size:
|
||||||
self._queue.popleft()
|
return False
|
||||||
self._overflow_drop_count += 1
|
|
||||||
self._queue.append(intent)
|
self._queue.append(intent)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@
|
|||||||
Order is fixed:
|
Order is fixed:
|
||||||
1) block new orders
|
1) block new orders
|
||||||
2) cancel pending orders
|
2) cancel pending orders
|
||||||
3) refresh order state (retry up to 3 attempts with exponential backoff)
|
3) refresh order state
|
||||||
4) reduce risk
|
4) reduce risk
|
||||||
5) snapshot and notify
|
5) snapshot and notify
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import inspect
|
import inspect
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -35,55 +34,16 @@ class KillSwitchOrchestrator:
|
|||||||
report: KillSwitchReport,
|
report: KillSwitchReport,
|
||||||
name: str,
|
name: str,
|
||||||
fn: StepCallable | None,
|
fn: StepCallable | None,
|
||||||
) -> bool:
|
) -> None:
|
||||||
report.steps.append(name)
|
report.steps.append(name)
|
||||||
if fn is None:
|
if fn is None:
|
||||||
return True
|
return
|
||||||
try:
|
try:
|
||||||
result = fn()
|
result = fn()
|
||||||
if inspect.isawaitable(result):
|
if inspect.isawaitable(result):
|
||||||
await result
|
await result
|
||||||
if result is False:
|
|
||||||
raise RuntimeError("step returned False")
|
|
||||||
return True
|
|
||||||
except Exception as exc: # pragma: no cover - intentionally resilient
|
except Exception as exc: # pragma: no cover - intentionally resilient
|
||||||
report.errors.append(f"{name}: {exc}")
|
report.errors.append(f"{name}: {exc}")
|
||||||
return False
|
|
||||||
|
|
||||||
async def _run_refresh_with_retry(
|
|
||||||
self,
|
|
||||||
report: KillSwitchReport,
|
|
||||||
fn: StepCallable | None,
|
|
||||||
*,
|
|
||||||
max_attempts: int,
|
|
||||||
base_delay_sec: float,
|
|
||||||
) -> None:
|
|
||||||
report.steps.append("refresh_order_state")
|
|
||||||
if fn is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
attempts = max(1, max_attempts)
|
|
||||||
delay = max(0.0, base_delay_sec)
|
|
||||||
last_exc: Exception | None = None
|
|
||||||
for attempt in range(1, attempts + 1):
|
|
||||||
try:
|
|
||||||
result = fn()
|
|
||||||
if inspect.isawaitable(result):
|
|
||||||
await result
|
|
||||||
if result is False:
|
|
||||||
raise RuntimeError("step returned False")
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
|
||||||
last_exc = exc
|
|
||||||
if attempt >= attempts:
|
|
||||||
break
|
|
||||||
if delay > 0:
|
|
||||||
await asyncio.sleep(delay * (2 ** (attempt - 1)))
|
|
||||||
if last_exc is not None:
|
|
||||||
report.errors.append(
|
|
||||||
"refresh_order_state: failed after "
|
|
||||||
f"{attempts} attempts ({last_exc})"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def trigger(
|
async def trigger(
|
||||||
self,
|
self,
|
||||||
@@ -94,8 +54,6 @@ class KillSwitchOrchestrator:
|
|||||||
reduce_risk: StepCallable | None = None,
|
reduce_risk: StepCallable | None = None,
|
||||||
snapshot_state: StepCallable | None = None,
|
snapshot_state: StepCallable | None = None,
|
||||||
notify: StepCallable | None = None,
|
notify: StepCallable | None = None,
|
||||||
refresh_retry_attempts: int = 3,
|
|
||||||
refresh_retry_base_delay_sec: float = 1.0,
|
|
||||||
) -> KillSwitchReport:
|
) -> KillSwitchReport:
|
||||||
report = KillSwitchReport(reason=reason)
|
report = KillSwitchReport(reason=reason)
|
||||||
|
|
||||||
@@ -103,12 +61,7 @@ class KillSwitchOrchestrator:
|
|||||||
report.steps.append("block_new_orders")
|
report.steps.append("block_new_orders")
|
||||||
|
|
||||||
await self._run_step(report, "cancel_pending_orders", cancel_pending_orders)
|
await self._run_step(report, "cancel_pending_orders", cancel_pending_orders)
|
||||||
await self._run_refresh_with_retry(
|
await self._run_step(report, "refresh_order_state", refresh_order_state)
|
||||||
report,
|
|
||||||
refresh_order_state,
|
|
||||||
max_attempts=refresh_retry_attempts,
|
|
||||||
base_delay_sec=refresh_retry_base_delay_sec,
|
|
||||||
)
|
|
||||||
await self._run_step(report, "reduce_risk", reduce_risk)
|
await self._run_step(report, "reduce_risk", reduce_risk)
|
||||||
await self._run_step(report, "snapshot_state", snapshot_state)
|
await self._run_step(report, "snapshot_state", snapshot_state)
|
||||||
await self._run_step(report, "notify", notify)
|
await self._run_step(report, "notify", notify)
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ def get_latest_buy_trade(
|
|||||||
if exchange_code:
|
if exchange_code:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT decision_id, price, quantity, selection_context
|
SELECT decision_id, price, quantity
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE stock_code = ?
|
WHERE stock_code = ?
|
||||||
AND market = ?
|
AND market = ?
|
||||||
@@ -339,7 +339,7 @@ def get_latest_buy_trade(
|
|||||||
else:
|
else:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT decision_id, price, quantity, selection_context
|
SELECT decision_id, price, quantity
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE stock_code = ?
|
WHERE stock_code = ?
|
||||||
AND market = ?
|
AND market = ?
|
||||||
|
|||||||
291
src/main.py
291
src/main.py
@@ -12,7 +12,6 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import threading
|
import threading
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -129,84 +128,6 @@ def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> i
|
|||||||
return max(0, int(buy_qty or 0))
|
return max(0, int(buy_qty or 0))
|
||||||
|
|
||||||
|
|
||||||
def _extract_fx_rate_from_sources(*sources: dict[str, Any] | None) -> float | None:
|
|
||||||
"""Best-effort FX rate extraction from broker payloads."""
|
|
||||||
# KIS overseas payloads expose exchange-rate fields with varying key names
|
|
||||||
# across endpoints/responses (price, balance, buying power). Keep this list
|
|
||||||
# centralised so schema drifts can be patched in one place.
|
|
||||||
rate_keys = (
|
|
||||||
"frst_bltn_exrt",
|
|
||||||
"bass_exrt",
|
|
||||||
"ovrs_exrt",
|
|
||||||
"aply_xchg_rt",
|
|
||||||
"xchg_rt",
|
|
||||||
"exchange_rate",
|
|
||||||
"fx_rate",
|
|
||||||
)
|
|
||||||
for source in sources:
|
|
||||||
if not isinstance(source, dict):
|
|
||||||
continue
|
|
||||||
for key in rate_keys:
|
|
||||||
rate = safe_float(source.get(key), 0.0)
|
|
||||||
if rate > 0:
|
|
||||||
return rate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _split_trade_pnl_components(
|
|
||||||
*,
|
|
||||||
market: MarketInfo,
|
|
||||||
trade_pnl: float,
|
|
||||||
buy_price: float,
|
|
||||||
sell_price: float,
|
|
||||||
quantity: int,
|
|
||||||
buy_fx_rate: float | None = None,
|
|
||||||
sell_fx_rate: float | None = None,
|
|
||||||
) -> tuple[float, float]:
|
|
||||||
"""Split total trade pnl into strategy/fx components.
|
|
||||||
|
|
||||||
For overseas symbols, use buy/sell FX rates when both are available.
|
|
||||||
Otherwise preserve backward-compatible behaviour (all strategy pnl).
|
|
||||||
"""
|
|
||||||
if trade_pnl == 0.0:
|
|
||||||
return 0.0, 0.0
|
|
||||||
if market.is_domestic:
|
|
||||||
return trade_pnl, 0.0
|
|
||||||
|
|
||||||
if (
|
|
||||||
buy_fx_rate is not None
|
|
||||||
and sell_fx_rate is not None
|
|
||||||
and buy_fx_rate > 0
|
|
||||||
and sell_fx_rate > 0
|
|
||||||
and quantity > 0
|
|
||||||
and buy_price > 0
|
|
||||||
and sell_price > 0
|
|
||||||
):
|
|
||||||
buy_notional = buy_price * quantity
|
|
||||||
fx_return = (sell_fx_rate - buy_fx_rate) / buy_fx_rate
|
|
||||||
fx_pnl = buy_notional * fx_return
|
|
||||||
strategy_pnl = trade_pnl - fx_pnl
|
|
||||||
return strategy_pnl, fx_pnl
|
|
||||||
|
|
||||||
return trade_pnl, 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_buy_fx_rate(buy_trade: dict[str, Any] | None) -> float | None:
|
|
||||||
if not buy_trade:
|
|
||||||
return None
|
|
||||||
raw_ctx = buy_trade.get("selection_context")
|
|
||||||
if not isinstance(raw_ctx, str) or not raw_ctx.strip():
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
decoded = json.loads(raw_ctx)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
if not isinstance(decoded, dict):
|
|
||||||
return None
|
|
||||||
rate = safe_float(decoded.get("fx_rate"), 0.0)
|
|
||||||
return rate if rate > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _compute_kr_dynamic_stop_loss_pct(
|
def _compute_kr_dynamic_stop_loss_pct(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo | None = None,
|
market: MarketInfo | None = None,
|
||||||
@@ -1005,7 +926,6 @@ async def build_overseas_symbol_universe(
|
|||||||
def _build_queued_order_intent(
|
def _build_queued_order_intent(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo,
|
market: MarketInfo,
|
||||||
session_id: str,
|
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
order_type: str,
|
order_type: str,
|
||||||
quantity: int,
|
quantity: int,
|
||||||
@@ -1015,7 +935,6 @@ def _build_queued_order_intent(
|
|||||||
return QueuedOrderIntent(
|
return QueuedOrderIntent(
|
||||||
market_code=market.code,
|
market_code=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -1028,7 +947,6 @@ def _build_queued_order_intent(
|
|||||||
def _maybe_queue_order_intent(
|
def _maybe_queue_order_intent(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo,
|
market: MarketInfo,
|
||||||
session_id: str,
|
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
order_type: str,
|
order_type: str,
|
||||||
quantity: int,
|
quantity: int,
|
||||||
@@ -1038,11 +956,9 @@ def _maybe_queue_order_intent(
|
|||||||
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
before_overflow_drops = BLACKOUT_ORDER_MANAGER.overflow_drop_count
|
|
||||||
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
||||||
_build_queued_order_intent(
|
_build_queued_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
session_id=session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -1051,7 +967,6 @@ def _maybe_queue_order_intent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if queued:
|
if queued:
|
||||||
after_overflow_drops = BLACKOUT_ORDER_MANAGER.overflow_drop_count
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
(
|
(
|
||||||
"Blackout active: queued order intent %s %s (%s) "
|
"Blackout active: queued order intent %s %s (%s) "
|
||||||
@@ -1065,22 +980,9 @@ def _maybe_queue_order_intent(
|
|||||||
source,
|
source,
|
||||||
BLACKOUT_ORDER_MANAGER.pending_count,
|
BLACKOUT_ORDER_MANAGER.pending_count,
|
||||||
)
|
)
|
||||||
if after_overflow_drops > before_overflow_drops:
|
|
||||||
logger.error(
|
|
||||||
(
|
|
||||||
"Blackout queue overflow policy applied: evicted oldest intent "
|
|
||||||
"to keep latest %s %s (%s) source=%s pending=%d total_evicted=%d"
|
|
||||||
),
|
|
||||||
order_type,
|
|
||||||
stock_code,
|
|
||||||
market.code,
|
|
||||||
source,
|
|
||||||
BLACKOUT_ORDER_MANAGER.pending_count,
|
|
||||||
after_overflow_drops,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Blackout queue unavailable: could not queue order intent %s %s (%s) qty=%d source=%s",
|
"Blackout queue full: dropped order intent %s %s (%s) qty=%d source=%s",
|
||||||
order_type,
|
order_type,
|
||||||
stock_code,
|
stock_code,
|
||||||
market.code,
|
market.code,
|
||||||
@@ -1376,10 +1278,7 @@ async def _cancel_pending_orders_for_kill_switch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
summary = "; ".join(failures[:3])
|
raise RuntimeError("; ".join(failures[:3]))
|
||||||
if len(failures) > 3:
|
|
||||||
summary = f"{summary} (+{len(failures) - 3} more)"
|
|
||||||
raise RuntimeError(summary)
|
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_order_state_for_kill_switch(
|
async def _refresh_order_state_for_kill_switch(
|
||||||
@@ -1388,7 +1287,6 @@ async def _refresh_order_state_for_kill_switch(
|
|||||||
overseas_broker: OverseasBroker,
|
overseas_broker: OverseasBroker,
|
||||||
markets: list[MarketInfo],
|
markets: list[MarketInfo],
|
||||||
) -> None:
|
) -> None:
|
||||||
failures: list[str] = []
|
|
||||||
seen_overseas: set[str] = set()
|
seen_overseas: set[str] = set()
|
||||||
for market in markets:
|
for market in markets:
|
||||||
try:
|
try:
|
||||||
@@ -1404,12 +1302,6 @@ async def _refresh_order_state_for_kill_switch(
|
|||||||
market.exchange_code,
|
market.exchange_code,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
failures.append(f"{market.code}/{market.exchange_code}: {exc}")
|
|
||||||
if failures:
|
|
||||||
summary = "; ".join(failures[:3])
|
|
||||||
if len(failures) > 3:
|
|
||||||
summary = f"{summary} (+{len(failures) - 3} more)"
|
|
||||||
raise RuntimeError(summary)
|
|
||||||
|
|
||||||
|
|
||||||
def _reduce_risk_for_kill_switch() -> None:
|
def _reduce_risk_for_kill_switch() -> None:
|
||||||
@@ -1479,7 +1371,6 @@ async def trading_cycle(
|
|||||||
_session_risk_overrides(market=market, settings=settings)
|
_session_risk_overrides(market=market, settings=settings)
|
||||||
|
|
||||||
# 1. Fetch market data
|
# 1. Fetch market data
|
||||||
balance_info: dict[str, Any] = {}
|
|
||||||
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
|
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
|
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
|
||||||
@@ -1502,6 +1393,8 @@ async def trading_cycle(
|
|||||||
balance_info = output2[0]
|
balance_info = output2[0]
|
||||||
elif isinstance(output2, dict):
|
elif isinstance(output2, dict):
|
||||||
balance_info = output2
|
balance_info = output2
|
||||||
|
else:
|
||||||
|
balance_info = {}
|
||||||
|
|
||||||
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
||||||
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
||||||
@@ -1921,9 +1814,6 @@ async def trading_cycle(
|
|||||||
quantity = 0
|
quantity = 0
|
||||||
trade_price = current_price
|
trade_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
buy_trade: dict[str, Any] | None = None
|
|
||||||
buy_price = 0.0
|
|
||||||
sell_qty = 0
|
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
logger.critical(
|
logger.critical(
|
||||||
@@ -2071,7 +1961,6 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
session_id=runtime_session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -2085,15 +1974,6 @@ async def trading_cycle(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=order_price,
|
price=order_price,
|
||||||
)
|
)
|
||||||
if result.get("rt_cd", "0") != "0":
|
|
||||||
order_succeeded = False
|
|
||||||
msg1 = result.get("msg1") or ""
|
|
||||||
logger.warning(
|
|
||||||
"KR order not accepted for %s: rt_cd=%s msg=%s",
|
|
||||||
stock_code,
|
|
||||||
result.get("rt_cd"),
|
|
||||||
msg1,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# For overseas orders, always use limit orders (지정가):
|
# For overseas orders, always use limit orders (지정가):
|
||||||
# - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit
|
# - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit
|
||||||
@@ -2128,7 +2008,6 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
session_id=runtime_session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -2249,26 +2128,6 @@ async def trading_cycle(
|
|||||||
"signal": candidate.signal,
|
"signal": candidate.signal,
|
||||||
"score": candidate.score,
|
"score": candidate.score,
|
||||||
}
|
}
|
||||||
sell_fx_rate = _extract_fx_rate_from_sources(price_output, balance_info)
|
|
||||||
if sell_fx_rate is not None and not market.is_domestic:
|
|
||||||
if selection_context is None:
|
|
||||||
selection_context = {"fx_rate": sell_fx_rate}
|
|
||||||
else:
|
|
||||||
selection_context["fx_rate"] = sell_fx_rate
|
|
||||||
|
|
||||||
strategy_pnl: float | None = None
|
|
||||||
fx_pnl: float | None = None
|
|
||||||
if decision.action == "SELL" and order_succeeded:
|
|
||||||
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
|
|
||||||
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
|
||||||
market=market,
|
|
||||||
trade_pnl=trade_pnl,
|
|
||||||
buy_price=buy_price,
|
|
||||||
sell_price=trade_price,
|
|
||||||
quantity=sell_qty or quantity,
|
|
||||||
buy_fx_rate=buy_fx_rate,
|
|
||||||
sell_fx_rate=sell_fx_rate,
|
|
||||||
)
|
|
||||||
|
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
@@ -2279,8 +2138,6 @@ async def trading_cycle(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=trade_price,
|
price=trade_price,
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
strategy_pnl=strategy_pnl,
|
|
||||||
fx_pnl=fx_pnl,
|
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=runtime_session_id,
|
session_id=runtime_session_id,
|
||||||
@@ -2879,7 +2736,6 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
balance_info: dict[str, Any] = {}
|
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
output2 = balance_data.get("output2", [{}])
|
output2 = balance_data.get("output2", [{}])
|
||||||
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
||||||
@@ -3134,9 +2990,6 @@ async def run_daily_session(
|
|||||||
quantity = 0
|
quantity = 0
|
||||||
trade_price = stock_data["current_price"]
|
trade_price = stock_data["current_price"]
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
buy_trade: dict[str, Any] | None = None
|
|
||||||
buy_price = 0.0
|
|
||||||
sell_qty = 0
|
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
@@ -3289,7 +3142,6 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
session_id=runtime_session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -3303,15 +3155,6 @@ async def run_daily_session(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=order_price,
|
price=order_price,
|
||||||
)
|
)
|
||||||
if result.get("rt_cd", "0") != "0":
|
|
||||||
order_succeeded = False
|
|
||||||
daily_msg1 = result.get("msg1") or ""
|
|
||||||
logger.warning(
|
|
||||||
"KR order not accepted for %s: rt_cd=%s msg=%s",
|
|
||||||
stock_code,
|
|
||||||
result.get("rt_cd"),
|
|
||||||
daily_msg1,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
|
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
|
||||||
if decision.action == "BUY":
|
if decision.action == "BUY":
|
||||||
@@ -3336,7 +3179,6 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
session_id=runtime_session_id,
|
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -3430,30 +3272,6 @@ async def run_daily_session(
|
|||||||
# Log trade (skip if order was rejected by API)
|
# Log trade (skip if order was rejected by API)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
continue
|
continue
|
||||||
strategy_pnl: float | None = None
|
|
||||||
fx_pnl: float | None = None
|
|
||||||
selection_context: dict[str, Any] | None = None
|
|
||||||
if decision.action == "SELL" and order_succeeded:
|
|
||||||
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
|
|
||||||
sell_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
|
|
||||||
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
|
||||||
market=market,
|
|
||||||
trade_pnl=trade_pnl,
|
|
||||||
buy_price=buy_price,
|
|
||||||
sell_price=trade_price,
|
|
||||||
quantity=sell_qty or quantity,
|
|
||||||
buy_fx_rate=buy_fx_rate,
|
|
||||||
sell_fx_rate=sell_fx_rate,
|
|
||||||
)
|
|
||||||
if sell_fx_rate is not None and not market.is_domestic:
|
|
||||||
# Daily path does not carry scanner candidate metrics, so this
|
|
||||||
# context intentionally stores FX snapshot only.
|
|
||||||
selection_context = {"fx_rate": sell_fx_rate}
|
|
||||||
elif not market.is_domestic:
|
|
||||||
snapshot_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
|
|
||||||
if snapshot_fx_rate is not None:
|
|
||||||
# BUY/HOLD in daily path: persist FX snapshot for later SELL split.
|
|
||||||
selection_context = {"fx_rate": snapshot_fx_rate}
|
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -3463,12 +3281,9 @@ async def run_daily_session(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=trade_price,
|
price=trade_price,
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
strategy_pnl=strategy_pnl,
|
|
||||||
fx_pnl=fx_pnl,
|
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=runtime_session_id,
|
session_id=runtime_session_id,
|
||||||
selection_context=selection_context,
|
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
mode=settings.MODE,
|
mode=settings.MODE,
|
||||||
)
|
)
|
||||||
@@ -3551,47 +3366,6 @@ def _run_context_scheduler(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _has_market_session_transition(
|
|
||||||
market_states: dict[str, str], market_code: str, session_id: str
|
|
||||||
) -> bool:
|
|
||||||
"""Return True when market session changed (or market has no prior state)."""
|
|
||||||
return market_states.get(market_code) != session_id
|
|
||||||
|
|
||||||
|
|
||||||
def _should_rescan_market(
|
|
||||||
*, last_scan: float, now_timestamp: float, rescan_interval: float, session_changed: bool
|
|
||||||
) -> bool:
|
|
||||||
"""Force rescan on session transition; otherwise follow interval cadence."""
|
|
||||||
return session_changed or (now_timestamp - last_scan >= rescan_interval)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_markets_in_parallel(
|
|
||||||
markets: list[Any], processor: Callable[[Any], Awaitable[None]]
|
|
||||||
) -> None:
|
|
||||||
"""Run market processors in parallel and fail fast on the first exception."""
|
|
||||||
if not markets:
|
|
||||||
return
|
|
||||||
|
|
||||||
tasks = [asyncio.create_task(processor(market)) for market in markets]
|
|
||||||
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_EXCEPTION)
|
|
||||||
|
|
||||||
first_exc: BaseException | None = None
|
|
||||||
for task in done:
|
|
||||||
exc = task.exception()
|
|
||||||
if exc is not None and first_exc is None:
|
|
||||||
first_exc = exc
|
|
||||||
|
|
||||||
if first_exc is not None:
|
|
||||||
for task in pending:
|
|
||||||
task.cancel()
|
|
||||||
if pending:
|
|
||||||
await asyncio.gather(*pending, return_exceptions=True)
|
|
||||||
raise first_exc
|
|
||||||
|
|
||||||
if pending:
|
|
||||||
await asyncio.gather(*pending)
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_evolution_loop(
|
async def _run_evolution_loop(
|
||||||
evolution_optimizer: EvolutionOptimizer,
|
evolution_optimizer: EvolutionOptimizer,
|
||||||
telegram: TelegramClient,
|
telegram: TelegramClient,
|
||||||
@@ -4105,7 +3879,7 @@ async def run(settings: Settings) -> None:
|
|||||||
last_scan_time: dict[str, float] = {}
|
last_scan_time: dict[str, float] = {}
|
||||||
|
|
||||||
# Track market open/close state for notifications
|
# Track market open/close state for notifications
|
||||||
_market_states: dict[str, str] = {} # market_code -> session_id
|
_market_states: dict[str, bool] = {} # market_code -> is_open
|
||||||
|
|
||||||
# Trading control events
|
# Trading control events
|
||||||
shutdown = asyncio.Event()
|
shutdown = asyncio.Event()
|
||||||
@@ -4223,8 +3997,8 @@ async def run(settings: Settings) -> None:
|
|||||||
|
|
||||||
if not open_markets:
|
if not open_markets:
|
||||||
# Notify market close for any markets that were open
|
# Notify market close for any markets that were open
|
||||||
for market_code, session_id in list(_market_states.items()):
|
for market_code, is_open in list(_market_states.items()):
|
||||||
if session_id:
|
if is_open:
|
||||||
try:
|
try:
|
||||||
from src.markets.schedule import MARKETS
|
from src.markets.schedule import MARKETS
|
||||||
|
|
||||||
@@ -4241,7 +4015,7 @@ async def run(settings: Settings) -> None:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Market close notification failed: %s", exc)
|
logger.warning("Market close notification failed: %s", exc)
|
||||||
_market_states.pop(market_code, None)
|
_market_states[market_code] = False
|
||||||
# Clear playbook for closed market (new one generated next open)
|
# Clear playbook for closed market (new one generated next open)
|
||||||
playbooks.pop(market_code, None)
|
playbooks.pop(market_code, None)
|
||||||
|
|
||||||
@@ -4266,9 +4040,10 @@ async def run(settings: Settings) -> None:
|
|||||||
await asyncio.sleep(TRADE_INTERVAL_SECONDS)
|
await asyncio.sleep(TRADE_INTERVAL_SECONDS)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def _process_realtime_market(market: MarketInfo) -> None:
|
# Process each open market
|
||||||
|
for market in open_markets:
|
||||||
if shutdown.is_set():
|
if shutdown.is_set():
|
||||||
return
|
break
|
||||||
|
|
||||||
session_info = get_session_info(market)
|
session_info = get_session_info(market)
|
||||||
_session_risk_overrides(market=market, settings=settings)
|
_session_risk_overrides(market=market, settings=settings)
|
||||||
@@ -4286,16 +4061,13 @@ async def run(settings: Settings) -> None:
|
|||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notify on market/session transition (e.g., US_PRE -> US_REG)
|
# Notify market open if it just opened
|
||||||
session_changed = _has_market_session_transition(
|
if not _market_states.get(market.code, False):
|
||||||
_market_states, market.code, session_info.session_id
|
|
||||||
)
|
|
||||||
if session_changed:
|
|
||||||
try:
|
try:
|
||||||
await telegram.notify_market_open(market.name)
|
await telegram.notify_market_open(market.name)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Market open notification failed: %s", exc)
|
logger.warning("Market open notification failed: %s", exc)
|
||||||
_market_states[market.code] = session_info.session_id
|
_market_states[market.code] = True
|
||||||
|
|
||||||
# Check and handle domestic pending (unfilled) limit orders.
|
# Check and handle domestic pending (unfilled) limit orders.
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
@@ -4327,12 +4099,7 @@ async def run(settings: Settings) -> None:
|
|||||||
now_timestamp = asyncio.get_event_loop().time()
|
now_timestamp = asyncio.get_event_loop().time()
|
||||||
last_scan = last_scan_time.get(market.code, 0.0)
|
last_scan = last_scan_time.get(market.code, 0.0)
|
||||||
rescan_interval = settings.RESCAN_INTERVAL_SECONDS
|
rescan_interval = settings.RESCAN_INTERVAL_SECONDS
|
||||||
if _should_rescan_market(
|
if now_timestamp - last_scan >= rescan_interval:
|
||||||
last_scan=last_scan,
|
|
||||||
now_timestamp=now_timestamp,
|
|
||||||
rescan_interval=rescan_interval,
|
|
||||||
session_changed=session_changed,
|
|
||||||
):
|
|
||||||
try:
|
try:
|
||||||
logger.info("Smart Scanner: Scanning %s market", market.name)
|
logger.info("Smart Scanner: Scanning %s market", market.name)
|
||||||
|
|
||||||
@@ -4357,9 +4124,12 @@ async def run(settings: Settings) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if candidates:
|
if candidates:
|
||||||
|
# Use scanner results directly as trading candidates
|
||||||
active_stocks[market.code] = smart_scanner.get_stock_codes(
|
active_stocks[market.code] = smart_scanner.get_stock_codes(
|
||||||
candidates
|
candidates
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Store candidates per market for selection context logging
|
||||||
scan_candidates[market.code] = {c.stock_code: c for c in candidates}
|
scan_candidates[market.code] = {c.stock_code: c for c in candidates}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -4369,8 +4139,12 @@ async def run(settings: Settings) -> None:
|
|||||||
[f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates],
|
[f"{c.stock_code}(RSI={c.rsi:.0f})" for c in candidates],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get market-local date for playbook keying
|
||||||
market_today = datetime.now(market.timezone).date()
|
market_today = datetime.now(market.timezone).date()
|
||||||
|
|
||||||
|
# Load or generate playbook (1 Gemini call per market per day)
|
||||||
if market.code not in playbooks:
|
if market.code not in playbooks:
|
||||||
|
# Try DB first (survives process restart)
|
||||||
stored_pb = playbook_store.load(market_today, market.code)
|
stored_pb = playbook_store.load(market_today, market.code)
|
||||||
if stored_pb is not None:
|
if stored_pb is not None:
|
||||||
playbooks[market.code] = stored_pb
|
playbooks[market.code] = stored_pb
|
||||||
@@ -4430,6 +4204,12 @@ async def run(settings: Settings) -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
logger.error("Smart Scanner failed for %s: %s", market.name, exc)
|
||||||
|
|
||||||
|
# Get active stocks from scanner (dynamic, no static fallback).
|
||||||
|
# Also include currently-held positions so stop-loss /
|
||||||
|
# take-profit can fire even when a holding drops off the
|
||||||
|
# scanner. Broker balance is the source of truth here —
|
||||||
|
# unlike the local DB it reflects actual fills and any
|
||||||
|
# manual trades done outside the bot.
|
||||||
scanner_codes = active_stocks.get(market.code, [])
|
scanner_codes = active_stocks.get(market.code, [])
|
||||||
try:
|
try:
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
@@ -4460,14 +4240,16 @@ async def run(settings: Settings) -> None:
|
|||||||
|
|
||||||
if not stock_codes:
|
if not stock_codes:
|
||||||
logger.debug("No active stocks for market %s", market.code)
|
logger.debug("No active stocks for market %s", market.code)
|
||||||
return
|
continue
|
||||||
|
|
||||||
logger.info("Processing market: %s (%d stocks)", market.name, len(stock_codes))
|
logger.info("Processing market: %s (%d stocks)", market.name, len(stock_codes))
|
||||||
|
|
||||||
|
# Process each stock from scanner results
|
||||||
for stock_code in stock_codes:
|
for stock_code in stock_codes:
|
||||||
if shutdown.is_set():
|
if shutdown.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Get playbook for this market
|
||||||
market_playbook = playbooks.get(
|
market_playbook = playbooks.get(
|
||||||
market.code,
|
market.code,
|
||||||
PreMarketPlanner._empty_playbook(
|
PreMarketPlanner._empty_playbook(
|
||||||
@@ -4475,6 +4257,7 @@ async def run(settings: Settings) -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Retry logic for connection errors
|
||||||
for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
|
for attempt in range(1, MAX_CONNECTION_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
await trading_cycle(
|
await trading_cycle(
|
||||||
@@ -4494,7 +4277,7 @@ async def run(settings: Settings) -> None:
|
|||||||
settings,
|
settings,
|
||||||
buy_cooldown,
|
buy_cooldown,
|
||||||
)
|
)
|
||||||
break
|
break # Success — exit retry loop
|
||||||
except CircuitBreakerTripped as exc:
|
except CircuitBreakerTripped as exc:
|
||||||
logger.critical("Circuit breaker tripped — shutting down")
|
logger.critical("Circuit breaker tripped — shutting down")
|
||||||
try:
|
try:
|
||||||
@@ -4516,19 +4299,17 @@ async def run(settings: Settings) -> None:
|
|||||||
MAX_CONNECTION_RETRIES,
|
MAX_CONNECTION_RETRIES,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
await asyncio.sleep(2**attempt)
|
await asyncio.sleep(2**attempt) # Exponential backoff
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Connection error for %s (all retries exhausted): %s",
|
"Connection error for %s (all retries exhausted): %s",
|
||||||
stock_code,
|
stock_code,
|
||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
break
|
break # Give up on this stock
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Unexpected error for %s: %s", stock_code, exc)
|
logger.exception("Unexpected error for %s: %s", stock_code, exc)
|
||||||
break
|
break # Don't retry on unexpected errors
|
||||||
|
|
||||||
await _run_markets_in_parallel(open_markets, _process_realtime_market)
|
|
||||||
|
|
||||||
# Log priority queue metrics periodically
|
# Log priority queue metrics periodically
|
||||||
metrics = await priority_queue.get_metrics()
|
metrics = await priority_queue.get_metrics()
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ def get_open_markets(
|
|||||||
from src.core.order_policy import classify_session_id
|
from src.core.order_policy import classify_session_id
|
||||||
|
|
||||||
session_id = classify_session_id(market, now)
|
session_id = classify_session_id(market, now)
|
||||||
return session_id not in {"KR_OFF", "US_OFF", "US_DAY"}
|
return session_id not in {"KR_OFF", "US_OFF"}
|
||||||
return is_market_open(market, now)
|
return is_market_open(market, now)
|
||||||
|
|
||||||
open_markets = [
|
open_markets = [
|
||||||
@@ -254,10 +254,10 @@ def get_next_market_open(
|
|||||||
from src.core.order_policy import classify_session_id
|
from src.core.order_policy import classify_session_id
|
||||||
|
|
||||||
ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0)
|
ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0)
|
||||||
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
|
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
|
||||||
for _ in range(7 * 24 * 60):
|
for _ in range(7 * 24 * 60):
|
||||||
ts = ts + timedelta(minutes=1)
|
ts = ts + timedelta(minutes=1)
|
||||||
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
|
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
|
||||||
if active and not prev_active:
|
if active and not prev_active:
|
||||||
return ts
|
return ts
|
||||||
prev_active = active
|
prev_active = active
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ def evaluate_exit(
|
|||||||
reason = "atr_trailing_stop"
|
reason = "atr_trailing_stop"
|
||||||
elif be_lock_threat:
|
elif be_lock_threat:
|
||||||
reason = "be_lock_threat"
|
reason = "be_lock_threat"
|
||||||
elif model_exit_signal and next_state == PositionState.BE_LOCK:
|
elif model_exit_signal:
|
||||||
reason = "model_assist_be_lock"
|
reason = "model_liquidity_exit"
|
||||||
elif take_profit_hit:
|
elif take_profit_hit:
|
||||||
# Backward-compatible immediate profit-taking path.
|
# Backward-compatible immediate profit-taking path.
|
||||||
reason = "arm_take_profit"
|
reason = "arm_take_profit"
|
||||||
|
|||||||
@@ -40,8 +40,7 @@ def evaluate_exit_first(inp: StateTransitionInput) -> bool:
|
|||||||
|
|
||||||
EXITED must be evaluated before any promotion.
|
EXITED must be evaluated before any promotion.
|
||||||
"""
|
"""
|
||||||
# model_exit_signal is assist-only and must not trigger EXIT directly.
|
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.model_exit_signal or inp.be_lock_threat
|
||||||
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.be_lock_threat
|
|
||||||
|
|
||||||
|
|
||||||
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:
|
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:
|
||||||
@@ -62,8 +61,5 @@ def promote_state(current: PositionState, inp: StateTransitionInput) -> Position
|
|||||||
target = PositionState.ARMED
|
target = PositionState.ARMED
|
||||||
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
|
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
|
||||||
target = PositionState.BE_LOCK
|
target = PositionState.BE_LOCK
|
||||||
elif inp.model_exit_signal:
|
|
||||||
# Model signal assists risk posture by tightening to BE_LOCK.
|
|
||||||
target = PositionState.BE_LOCK
|
|
||||||
|
|
||||||
return target if _STATE_RANK[target] > _STATE_RANK[current] else current
|
return target if _STATE_RANK[target] > _STATE_RANK[current] else current
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ def test_invalid_failure_rate_range_raises() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 1.2},
|
failure_rate_by_session={"KRX_REG": 1.2},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="failure rate must be within"):
|
with pytest.raises(ValueError, match="failure rate must be within"):
|
||||||
@@ -57,7 +57,7 @@ def test_unfavorable_fill_requirement_cannot_be_disabled() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
unfavorable_fill_required=False,
|
unfavorable_fill_required=False,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="unfavorable_fill_required must be True"):
|
with pytest.raises(ValueError, match="unfavorable_fill_required must be True"):
|
||||||
@@ -70,7 +70,7 @@ def test_non_finite_commission_rejected(bad_commission: float) -> None:
|
|||||||
commission_bps=bad_commission,
|
commission_bps=bad_commission,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="commission_bps"):
|
with pytest.raises(ValueError, match="commission_bps"):
|
||||||
@@ -83,7 +83,7 @@ def test_non_finite_slippage_rejected(bad_slippage: float) -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": bad_slippage},
|
slippage_bps_by_session={"KRX_REG": bad_slippage},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="slippage bps"):
|
with pytest.raises(ValueError, match="slippage bps"):
|
||||||
@@ -102,13 +102,13 @@ def test_missing_required_partial_fill_session_raises() -> None:
|
|||||||
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
|
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("bad_partial_fill", [float("nan"), float("inf"), float("-inf"), -0.1, 1.1])
|
@pytest.mark.parametrize("bad_rate", [-0.1, 1.1, float("nan")])
|
||||||
def test_invalid_partial_fill_rate_rejected(bad_partial_fill: float) -> None:
|
def test_invalid_partial_fill_rate_range_raises(bad_rate: float) -> None:
|
||||||
model = BacktestCostModel(
|
model = BacktestCostModel(
|
||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
partial_fill_rate_by_session={"KRX_REG": bad_partial_fill},
|
partial_fill_rate_by_session={"KRX_REG": bad_rate},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="partial fill rate must be within"):
|
with pytest.raises(ValueError, match="partial fill rate must be within"):
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def _cost_model() -> BacktestCostModel:
|
|||||||
commission_bps=3.0,
|
commission_bps=3.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.05, "US_PRE": 0.2},
|
partial_fill_rate_by_session={"KRX_REG": 0.1, "US_PRE": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -72,7 +72,6 @@ def test_pipeline_happy_path_returns_fold_and_artifact_contract() -> None:
|
|||||||
assert names == {"B0", "B1", "M1"}
|
assert names == {"B0", "B1", "M1"}
|
||||||
for score in fold.baseline_scores:
|
for score in fold.baseline_scores:
|
||||||
assert 0.0 <= score.accuracy <= 1.0
|
assert 0.0 <= score.accuracy <= 1.0
|
||||||
assert 0.0 <= score.cost_adjusted_accuracy <= 1.0
|
|
||||||
assert fold.execution_adjusted_trade_count >= 0
|
assert fold.execution_adjusted_trade_count >= 0
|
||||||
assert fold.execution_rejected_count >= 0
|
assert fold.execution_rejected_count >= 0
|
||||||
assert fold.execution_partial_count >= 0
|
assert fold.execution_partial_count >= 0
|
||||||
@@ -83,7 +82,7 @@ def test_pipeline_cost_guard_fail_fast() -> None:
|
|||||||
commission_bps=3.0,
|
commission_bps=3.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01},
|
failure_rate_by_session={"KRX_REG": 0.01},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.05},
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -174,8 +173,8 @@ def test_pipeline_rejects_minutes_spec_when_timestamp_missing() -> None:
|
|||||||
raise AssertionError("expected timestamp validation error")
|
raise AssertionError("expected timestamp validation error")
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_fold_scores_reflect_cost_and_execution_effects() -> None:
|
def test_pipeline_execution_adjusted_returns_reflect_cost_and_fill_assumptions() -> None:
|
||||||
cfg = dict(
|
base_cfg = dict(
|
||||||
bars=_bars(),
|
bars=_bars(),
|
||||||
entry_indices=[0, 1, 2, 3, 4, 5, 6, 7],
|
entry_indices=[0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
side=1,
|
side=1,
|
||||||
@@ -193,6 +192,7 @@ def test_pipeline_fold_scores_reflect_cost_and_execution_effects() -> None:
|
|||||||
min_train_size=3,
|
min_train_size=3,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
optimistic = BacktestCostModel(
|
optimistic = BacktestCostModel(
|
||||||
commission_bps=0.0,
|
commission_bps=0.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 0.0, "US_PRE": 0.0},
|
slippage_bps_by_session={"KRX_REG": 0.0, "US_PRE": 0.0},
|
||||||
@@ -202,19 +202,25 @@ def test_pipeline_fold_scores_reflect_cost_and_execution_effects() -> None:
|
|||||||
)
|
)
|
||||||
conservative = BacktestCostModel(
|
conservative = BacktestCostModel(
|
||||||
commission_bps=10.0,
|
commission_bps=10.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 30.0, "US_PRE": 80.0},
|
slippage_bps_by_session={"KRX_REG": 20.0, "US_PRE": 60.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.2, "US_PRE": 0.4},
|
failure_rate_by_session={"KRX_REG": 0.2, "US_PRE": 0.4},
|
||||||
partial_fill_rate_by_session={"KRX_REG": 0.5, "US_PRE": 0.7},
|
partial_fill_rate_by_session={"KRX_REG": 0.5, "US_PRE": 0.7},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
optimistic_out = run_v2_backtest_pipeline(cost_model=optimistic, **cfg)
|
|
||||||
conservative_out = run_v2_backtest_pipeline(cost_model=conservative, **cfg)
|
|
||||||
|
|
||||||
assert optimistic_out.folds and conservative_out.folds
|
opt_out = run_v2_backtest_pipeline(cost_model=optimistic, **base_cfg)
|
||||||
optimistic_score = optimistic_out.folds[0].baseline_scores[1].cost_adjusted_accuracy
|
cons_out = run_v2_backtest_pipeline(cost_model=conservative, **base_cfg)
|
||||||
conservative_score = conservative_out.folds[0].baseline_scores[1].cost_adjusted_accuracy
|
|
||||||
assert conservative_score < optimistic_score
|
|
||||||
|
|
||||||
optimistic_avg_return = optimistic_out.folds[0].execution_adjusted_avg_return_bps
|
opt_avg = sum(
|
||||||
conservative_avg_return = conservative_out.folds[0].execution_adjusted_avg_return_bps
|
f.execution_adjusted_avg_return_bps for f in opt_out.folds
|
||||||
assert conservative_avg_return < optimistic_avg_return
|
) / len(opt_out.folds)
|
||||||
|
cons_avg = sum(
|
||||||
|
f.execution_adjusted_avg_return_bps for f in cons_out.folds
|
||||||
|
) / len(cons_out.folds)
|
||||||
|
assert cons_avg < opt_avg
|
||||||
|
|
||||||
|
opt_trades = sum(f.execution_adjusted_trade_count for f in opt_out.folds)
|
||||||
|
cons_trades = sum(f.execution_adjusted_trade_count for f in cons_out.folds)
|
||||||
|
cons_rejected = sum(f.execution_rejected_count for f in cons_out.folds)
|
||||||
|
assert cons_trades <= opt_trades
|
||||||
|
assert cons_rejected >= 0
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ def test_recovery_batch_only_after_blackout_exit() -> None:
|
|||||||
intent = QueuedOrderIntent(
|
intent = QueuedOrderIntent(
|
||||||
market_code="KR",
|
market_code="KR",
|
||||||
exchange_code="KRX",
|
exchange_code="KRX",
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="005930",
|
stock_code="005930",
|
||||||
order_type="BUY",
|
order_type="BUY",
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@@ -65,7 +64,6 @@ def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
|
|||||||
intent = QueuedOrderIntent(
|
intent = QueuedOrderIntent(
|
||||||
market_code="KR",
|
market_code="KR",
|
||||||
exchange_code="KRX",
|
exchange_code="KRX",
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="005930",
|
stock_code="005930",
|
||||||
order_type="BUY",
|
order_type="BUY",
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@@ -81,54 +79,3 @@ def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
|
|||||||
manager.requeue(first_batch[0])
|
manager.requeue(first_batch[0])
|
||||||
second_batch = manager.pop_recovery_batch(outside_blackout)
|
second_batch = manager.pop_recovery_batch(outside_blackout)
|
||||||
assert len(second_batch) == 1
|
assert len(second_batch) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_queue_overflow_drops_oldest_and_keeps_latest() -> None:
|
|
||||||
manager = BlackoutOrderManager(
|
|
||||||
enabled=True,
|
|
||||||
windows=parse_blackout_windows_kst("23:30-00:10"),
|
|
||||||
max_queue_size=2,
|
|
||||||
)
|
|
||||||
first = QueuedOrderIntent(
|
|
||||||
market_code="KR",
|
|
||||||
exchange_code="KRX",
|
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="000001",
|
|
||||||
order_type="BUY",
|
|
||||||
quantity=1,
|
|
||||||
price=100.0,
|
|
||||||
source="first",
|
|
||||||
queued_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
second = QueuedOrderIntent(
|
|
||||||
market_code="KR",
|
|
||||||
exchange_code="KRX",
|
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="000002",
|
|
||||||
order_type="BUY",
|
|
||||||
quantity=1,
|
|
||||||
price=101.0,
|
|
||||||
source="second",
|
|
||||||
queued_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
third = QueuedOrderIntent(
|
|
||||||
market_code="KR",
|
|
||||||
exchange_code="KRX",
|
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="000003",
|
|
||||||
order_type="SELL",
|
|
||||||
quantity=2,
|
|
||||||
price=102.0,
|
|
||||||
source="third",
|
|
||||||
queued_at=datetime.now(UTC),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert manager.enqueue(first)
|
|
||||||
assert manager.enqueue(second)
|
|
||||||
assert manager.enqueue(third)
|
|
||||||
assert manager.pending_count == 2
|
|
||||||
assert manager.overflow_drop_count == 1
|
|
||||||
|
|
||||||
outside_blackout = datetime(2026, 1, 1, 15, 20, tzinfo=UTC)
|
|
||||||
batch = manager.pop_recovery_batch(outside_blackout)
|
|
||||||
assert [intent.stock_code for intent in batch] == ["000002", "000003"]
|
|
||||||
|
|||||||
@@ -53,52 +53,3 @@ async def test_kill_switch_collects_step_errors() -> None:
|
|||||||
|
|
||||||
report = await ks.trigger(reason="test", cancel_pending_orders=_boom)
|
report = await ks.trigger(reason="test", cancel_pending_orders=_boom)
|
||||||
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)
|
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kill_switch_refresh_retries_then_succeeds() -> None:
|
|
||||||
ks = KillSwitchOrchestrator()
|
|
||||||
refresh_calls = {"count": 0}
|
|
||||||
|
|
||||||
def _flaky_refresh() -> None:
|
|
||||||
refresh_calls["count"] += 1
|
|
||||||
if refresh_calls["count"] < 3:
|
|
||||||
raise RuntimeError("temporary refresh failure")
|
|
||||||
|
|
||||||
report = await ks.trigger(
|
|
||||||
reason="test",
|
|
||||||
refresh_order_state=_flaky_refresh,
|
|
||||||
refresh_retry_attempts=3,
|
|
||||||
refresh_retry_base_delay_sec=0.0,
|
|
||||||
)
|
|
||||||
assert refresh_calls["count"] == 3
|
|
||||||
assert report.errors == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kill_switch_refresh_retry_exhausted_records_error_and_continues() -> None:
|
|
||||||
ks = KillSwitchOrchestrator()
|
|
||||||
calls: list[str] = []
|
|
||||||
|
|
||||||
def _refresh_fail() -> None:
|
|
||||||
raise RuntimeError("persistent refresh failure")
|
|
||||||
|
|
||||||
def _reduce() -> None:
|
|
||||||
calls.append("reduce")
|
|
||||||
|
|
||||||
def _snapshot() -> None:
|
|
||||||
calls.append("snapshot")
|
|
||||||
|
|
||||||
report = await ks.trigger(
|
|
||||||
reason="test",
|
|
||||||
refresh_order_state=_refresh_fail,
|
|
||||||
reduce_risk=_reduce,
|
|
||||||
snapshot_state=_snapshot,
|
|
||||||
refresh_retry_attempts=2,
|
|
||||||
refresh_retry_base_delay_sec=0.0,
|
|
||||||
)
|
|
||||||
assert any(
|
|
||||||
err.startswith("refresh_order_state: failed after 2 attempts")
|
|
||||||
for err in report.errors
|
|
||||||
)
|
|
||||||
assert calls == ["reduce", "snapshot"]
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"""Tests for main trading loop integration."""
|
"""Tests for main trading loop integration."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from typing import Any
|
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -11,7 +9,6 @@ import src.main as main_module
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.layer import ContextLayer
|
from src.context.layer import ContextLayer
|
||||||
from src.context.scheduler import ScheduleResult
|
from src.context.scheduler import ScheduleResult
|
||||||
from src.core.blackout_manager import BlackoutOrderManager
|
|
||||||
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.db import init_db, log_trade
|
from src.db import init_db, log_trade
|
||||||
@@ -35,19 +32,14 @@ from src.main import (
|
|||||||
_extract_held_codes_from_balance,
|
_extract_held_codes_from_balance,
|
||||||
_extract_held_qty_from_balance,
|
_extract_held_qty_from_balance,
|
||||||
_handle_market_close,
|
_handle_market_close,
|
||||||
_has_market_session_transition,
|
|
||||||
_inject_staged_exit_features,
|
_inject_staged_exit_features,
|
||||||
_maybe_queue_order_intent,
|
|
||||||
_resolve_market_setting,
|
_resolve_market_setting,
|
||||||
_resolve_sell_qty_for_pnl,
|
_resolve_sell_qty_for_pnl,
|
||||||
_retry_connection,
|
_retry_connection,
|
||||||
_run_context_scheduler,
|
_run_context_scheduler,
|
||||||
_run_evolution_loop,
|
_run_evolution_loop,
|
||||||
_run_markets_in_parallel,
|
|
||||||
_should_block_overseas_buy_for_fx_buffer,
|
_should_block_overseas_buy_for_fx_buffer,
|
||||||
_should_force_exit_for_overnight,
|
_should_force_exit_for_overnight,
|
||||||
_should_rescan_market,
|
|
||||||
_split_trade_pnl_components,
|
|
||||||
_start_dashboard_server,
|
_start_dashboard_server,
|
||||||
_stoploss_cooldown_minutes,
|
_stoploss_cooldown_minutes,
|
||||||
_trigger_emergency_kill_switch,
|
_trigger_emergency_kill_switch,
|
||||||
@@ -110,22 +102,22 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_kill_switch_state() -> None:
|
def _reset_kill_switch_state() -> None:
|
||||||
"""Prevent cross-test leakage from global kill-switch state."""
|
"""Prevent cross-test leakage from global kill-switch state."""
|
||||||
def _reset_session_risk_globals() -> None:
|
|
||||||
_SESSION_RISK_LAST_BY_MARKET.clear()
|
|
||||||
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
|
||||||
_SESSION_RISK_PROFILES_MAP.clear()
|
|
||||||
main_module._SESSION_RISK_PROFILES_RAW = "{}"
|
|
||||||
|
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
_reset_session_risk_globals()
|
_SESSION_RISK_LAST_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_PROFILES_MAP.clear()
|
||||||
|
main_module._SESSION_RISK_PROFILES_RAW = "__reset__"
|
||||||
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
yield
|
yield
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
_reset_session_risk_globals()
|
_SESSION_RISK_LAST_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_PROFILES_MAP.clear()
|
||||||
|
main_module._SESSION_RISK_PROFILES_RAW = "__reset__"
|
||||||
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -144,63 +136,6 @@ class TestExtractAvgPriceFromBalance:
|
|||||||
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
|
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
|
||||||
assert result == 170.5
|
assert result == 170.5
|
||||||
|
|
||||||
|
|
||||||
class TestRealtimeSessionStateHelpers:
|
|
||||||
"""Tests for realtime loop session-transition/rescan helper logic."""
|
|
||||||
|
|
||||||
def test_has_market_session_transition_when_state_missing(self) -> None:
|
|
||||||
states: dict[str, str] = {}
|
|
||||||
assert _has_market_session_transition(states, "US_NASDAQ", "US_REG")
|
|
||||||
|
|
||||||
def test_has_market_session_transition_when_session_changes(self) -> None:
|
|
||||||
states = {"US_NASDAQ": "US_PRE"}
|
|
||||||
assert _has_market_session_transition(states, "US_NASDAQ", "US_REG")
|
|
||||||
|
|
||||||
def test_has_market_session_transition_false_when_same_session(self) -> None:
|
|
||||||
states = {"US_NASDAQ": "US_REG"}
|
|
||||||
assert not _has_market_session_transition(states, "US_NASDAQ", "US_REG")
|
|
||||||
|
|
||||||
def test_should_rescan_market_forces_on_session_transition(self) -> None:
|
|
||||||
assert _should_rescan_market(
|
|
||||||
last_scan=1000.0,
|
|
||||||
now_timestamp=1050.0,
|
|
||||||
rescan_interval=300.0,
|
|
||||||
session_changed=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_should_rescan_market_uses_interval_without_transition(self) -> None:
|
|
||||||
assert not _should_rescan_market(
|
|
||||||
last_scan=1000.0,
|
|
||||||
now_timestamp=1050.0,
|
|
||||||
rescan_interval=300.0,
|
|
||||||
session_changed=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMarketParallelRunner:
|
|
||||||
"""Tests for market-level parallel processing helper."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_markets_in_parallel_runs_all_markets(self) -> None:
|
|
||||||
processed: list[str] = []
|
|
||||||
|
|
||||||
async def _processor(market: str) -> None:
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
processed.append(market)
|
|
||||||
|
|
||||||
await _run_markets_in_parallel(["KR", "US_NASDAQ", "US_NYSE"], _processor)
|
|
||||||
assert set(processed) == {"KR", "US_NASDAQ", "US_NYSE"}
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_run_markets_in_parallel_propagates_errors(self) -> None:
|
|
||||||
async def _processor(market: str) -> None:
|
|
||||||
if market == "US_NASDAQ":
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="boom"):
|
|
||||||
await _run_markets_in_parallel(["KR", "US_NASDAQ"], _processor)
|
|
||||||
|
|
||||||
def test_returns_zero_when_field_absent(self) -> None:
|
def test_returns_zero_when_field_absent(self) -> None:
|
||||||
"""Returns 0.0 when pchs_avg_pric key is missing entirely."""
|
"""Returns 0.0 when pchs_avg_pric key is missing entirely."""
|
||||||
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}
|
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}
|
||||||
@@ -974,46 +909,6 @@ class TestTradingCycleTelegramIntegration:
|
|||||||
# Verify notification was attempted
|
# Verify notification was attempted
|
||||||
mock_telegram.notify_trade_execution.assert_called_once()
|
mock_telegram.notify_trade_execution.assert_called_once()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_kr_rejected_order_does_not_notify_or_log_trade(
|
|
||||||
self,
|
|
||||||
mock_broker: MagicMock,
|
|
||||||
mock_overseas_broker: MagicMock,
|
|
||||||
mock_scenario_engine: MagicMock,
|
|
||||||
mock_playbook: DayPlaybook,
|
|
||||||
mock_risk: MagicMock,
|
|
||||||
mock_db: MagicMock,
|
|
||||||
mock_decision_logger: MagicMock,
|
|
||||||
mock_context_store: MagicMock,
|
|
||||||
mock_criticality_assessor: MagicMock,
|
|
||||||
mock_telegram: MagicMock,
|
|
||||||
mock_market: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""KR orders rejected by KIS should not trigger success side effects."""
|
|
||||||
mock_broker.send_order = AsyncMock(
|
|
||||||
return_value={"rt_cd": "1", "msg1": "장운영시간이 아닙니다."}
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch("src.main.log_trade") as mock_log_trade:
|
|
||||||
await trading_cycle(
|
|
||||||
broker=mock_broker,
|
|
||||||
overseas_broker=mock_overseas_broker,
|
|
||||||
scenario_engine=mock_scenario_engine,
|
|
||||||
playbook=mock_playbook,
|
|
||||||
risk=mock_risk,
|
|
||||||
db_conn=mock_db,
|
|
||||||
decision_logger=mock_decision_logger,
|
|
||||||
context_store=mock_context_store,
|
|
||||||
criticality_assessor=mock_criticality_assessor,
|
|
||||||
telegram=mock_telegram,
|
|
||||||
market=mock_market,
|
|
||||||
stock_code="005930",
|
|
||||||
scan_candidates={},
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_telegram.notify_trade_execution.assert_not_called()
|
|
||||||
mock_log_trade.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fat_finger_notification_sent(
|
async def test_fat_finger_notification_sent(
|
||||||
self,
|
self,
|
||||||
@@ -3286,13 +3181,6 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
|
|||||||
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
|
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
|
||||||
assert updated_buy is not None
|
assert updated_buy is not None
|
||||||
assert updated_buy.outcome_pnl == -25.0
|
assert updated_buy.outcome_pnl == -25.0
|
||||||
sell_row = db_conn.execute(
|
|
||||||
"SELECT pnl, strategy_pnl, fx_pnl FROM trades WHERE action='SELL' ORDER BY id DESC LIMIT 1"
|
|
||||||
).fetchone()
|
|
||||||
assert sell_row is not None
|
|
||||||
assert sell_row[0] == -25.0
|
|
||||||
assert sell_row[1] == -25.0
|
|
||||||
assert sell_row[2] == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -4710,23 +4598,6 @@ def test_fx_buffer_guard_applies_only_to_us_and_respects_boundary() -> None:
|
|||||||
assert required_jp == 0.0
|
assert required_jp == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_split_trade_pnl_components_overseas_fx_split_preserves_total() -> None:
|
|
||||||
market = MagicMock()
|
|
||||||
market.is_domestic = False
|
|
||||||
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
|
||||||
market=market,
|
|
||||||
trade_pnl=20.0,
|
|
||||||
buy_price=100.0,
|
|
||||||
sell_price=110.0,
|
|
||||||
quantity=2,
|
|
||||||
buy_fx_rate=1200.0,
|
|
||||||
sell_fx_rate=1260.0,
|
|
||||||
)
|
|
||||||
assert strategy_pnl == 10.0
|
|
||||||
assert fx_pnl == 10.0
|
|
||||||
assert strategy_pnl + fx_pnl == pytest.approx(20.0)
|
|
||||||
|
|
||||||
|
|
||||||
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
|
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -6480,225 +6351,6 @@ async def test_us_min_price_filter_not_applied_to_kr_market() -> None:
|
|||||||
broker.send_order.assert_called_once()
|
broker.send_order.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_boundary_reloads_us_min_price_override_in_trading_cycle() -> None:
|
|
||||||
db_conn = init_db(":memory:")
|
|
||||||
decision_logger = DecisionLogger(db_conn)
|
|
||||||
|
|
||||||
broker = MagicMock()
|
|
||||||
broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
|
|
||||||
|
|
||||||
overseas_broker = MagicMock()
|
|
||||||
overseas_broker.get_overseas_price = AsyncMock(
|
|
||||||
return_value={"output": {"last": "7.0", "rate": "0.0"}}
|
|
||||||
)
|
|
||||||
overseas_broker.get_overseas_balance = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"output1": [],
|
|
||||||
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
overseas_broker.get_overseas_buying_power = AsyncMock(
|
|
||||||
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
|
|
||||||
)
|
|
||||||
overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
|
||||||
|
|
||||||
market = MagicMock()
|
|
||||||
market.name = "NASDAQ"
|
|
||||||
market.code = "US_NASDAQ"
|
|
||||||
market.exchange_code = "NASD"
|
|
||||||
market.is_domestic = False
|
|
||||||
|
|
||||||
telegram = MagicMock()
|
|
||||||
telegram.notify_trade_execution = AsyncMock()
|
|
||||||
telegram.notify_fat_finger = AsyncMock()
|
|
||||||
telegram.notify_circuit_breaker = AsyncMock()
|
|
||||||
telegram.notify_scenario_matched = AsyncMock()
|
|
||||||
|
|
||||||
settings = Settings(
|
|
||||||
KIS_APP_KEY="k",
|
|
||||||
KIS_APP_SECRET="s",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="g",
|
|
||||||
MODE="paper",
|
|
||||||
PAPER_OVERSEAS_CASH=50000.0,
|
|
||||||
US_MIN_PRICE=5.0,
|
|
||||||
USD_BUFFER_MIN=1000.0,
|
|
||||||
SESSION_RISK_RELOAD_ENABLED=True,
|
|
||||||
SESSION_RISK_PROFILES_JSON=(
|
|
||||||
'{"US_PRE": {"US_MIN_PRICE": 8.0}, "US_DAY": {"US_MIN_PRICE": 5.0}}'
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
current_session = {"id": "US_PRE"}
|
|
||||||
|
|
||||||
def _session_info(_: Any) -> MagicMock:
|
|
||||||
return MagicMock(session_id=current_session["id"])
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("src.main.get_open_position", return_value=None),
|
|
||||||
patch("src.main.get_session_info", side_effect=_session_info),
|
|
||||||
):
|
|
||||||
await trading_cycle(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
|
||||||
playbook=_make_playbook("US_NASDAQ"),
|
|
||||||
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
|
||||||
db_conn=db_conn,
|
|
||||||
decision_logger=decision_logger,
|
|
||||||
context_store=MagicMock(
|
|
||||||
get_latest_timeframe=MagicMock(return_value=None),
|
|
||||||
set_context=MagicMock(),
|
|
||||||
),
|
|
||||||
criticality_assessor=MagicMock(
|
|
||||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
|
||||||
get_timeout=MagicMock(return_value=5.0),
|
|
||||||
),
|
|
||||||
telegram=telegram,
|
|
||||||
market=market,
|
|
||||||
stock_code="AAPL",
|
|
||||||
scan_candidates={},
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
assert overseas_broker.send_overseas_order.call_count == 0
|
|
||||||
|
|
||||||
current_session["id"] = "US_DAY"
|
|
||||||
await trading_cycle(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
|
||||||
playbook=_make_playbook("US_NASDAQ"),
|
|
||||||
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
|
||||||
db_conn=db_conn,
|
|
||||||
decision_logger=decision_logger,
|
|
||||||
context_store=MagicMock(
|
|
||||||
get_latest_timeframe=MagicMock(return_value=None),
|
|
||||||
set_context=MagicMock(),
|
|
||||||
),
|
|
||||||
criticality_assessor=MagicMock(
|
|
||||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
|
||||||
get_timeout=MagicMock(return_value=5.0),
|
|
||||||
),
|
|
||||||
telegram=telegram,
|
|
||||||
market=market,
|
|
||||||
stock_code="AAPL",
|
|
||||||
scan_candidates={},
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert overseas_broker.send_overseas_order.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_boundary_falls_back_when_profile_reload_fails() -> None:
|
|
||||||
db_conn = init_db(":memory:")
|
|
||||||
decision_logger = DecisionLogger(db_conn)
|
|
||||||
|
|
||||||
broker = MagicMock()
|
|
||||||
broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
|
|
||||||
|
|
||||||
overseas_broker = MagicMock()
|
|
||||||
overseas_broker.get_overseas_price = AsyncMock(
|
|
||||||
return_value={"output": {"last": "7.0", "rate": "0.0"}}
|
|
||||||
)
|
|
||||||
overseas_broker.get_overseas_balance = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
"output1": [],
|
|
||||||
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
overseas_broker.get_overseas_buying_power = AsyncMock(
|
|
||||||
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
|
|
||||||
)
|
|
||||||
overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
|
||||||
|
|
||||||
market = MagicMock()
|
|
||||||
market.name = "NASDAQ"
|
|
||||||
market.code = "US_NASDAQ"
|
|
||||||
market.exchange_code = "NASD"
|
|
||||||
market.is_domestic = False
|
|
||||||
|
|
||||||
telegram = MagicMock()
|
|
||||||
telegram.notify_trade_execution = AsyncMock()
|
|
||||||
telegram.notify_fat_finger = AsyncMock()
|
|
||||||
telegram.notify_circuit_breaker = AsyncMock()
|
|
||||||
telegram.notify_scenario_matched = AsyncMock()
|
|
||||||
|
|
||||||
settings = Settings(
|
|
||||||
KIS_APP_KEY="k",
|
|
||||||
KIS_APP_SECRET="s",
|
|
||||||
KIS_ACCOUNT_NO="12345678-01",
|
|
||||||
GEMINI_API_KEY="g",
|
|
||||||
MODE="paper",
|
|
||||||
PAPER_OVERSEAS_CASH=50000.0,
|
|
||||||
US_MIN_PRICE=5.0,
|
|
||||||
USD_BUFFER_MIN=1000.0,
|
|
||||||
SESSION_RISK_RELOAD_ENABLED=True,
|
|
||||||
SESSION_RISK_PROFILES_JSON='{"US_PRE": {"US_MIN_PRICE": 8.0}}',
|
|
||||||
)
|
|
||||||
|
|
||||||
current_session = {"id": "US_PRE"}
|
|
||||||
|
|
||||||
def _session_info(_: Any) -> MagicMock:
|
|
||||||
return MagicMock(session_id=current_session["id"])
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("src.main.get_open_position", return_value=None),
|
|
||||||
patch("src.main.get_session_info", side_effect=_session_info),
|
|
||||||
):
|
|
||||||
await trading_cycle(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
|
||||||
playbook=_make_playbook("US_NASDAQ"),
|
|
||||||
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
|
||||||
db_conn=db_conn,
|
|
||||||
decision_logger=decision_logger,
|
|
||||||
context_store=MagicMock(
|
|
||||||
get_latest_timeframe=MagicMock(return_value=None),
|
|
||||||
set_context=MagicMock(),
|
|
||||||
),
|
|
||||||
criticality_assessor=MagicMock(
|
|
||||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
|
||||||
get_timeout=MagicMock(return_value=5.0),
|
|
||||||
),
|
|
||||||
telegram=telegram,
|
|
||||||
market=market,
|
|
||||||
stock_code="AAPL",
|
|
||||||
scan_candidates={},
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
assert overseas_broker.send_overseas_order.call_count == 0
|
|
||||||
|
|
||||||
settings.SESSION_RISK_PROFILES_JSON = "{invalid-json"
|
|
||||||
current_session["id"] = "US_DAY"
|
|
||||||
await trading_cycle(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
|
||||||
playbook=_make_playbook("US_NASDAQ"),
|
|
||||||
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
|
||||||
db_conn=db_conn,
|
|
||||||
decision_logger=decision_logger,
|
|
||||||
context_store=MagicMock(
|
|
||||||
get_latest_timeframe=MagicMock(return_value=None),
|
|
||||||
set_context=MagicMock(),
|
|
||||||
),
|
|
||||||
criticality_assessor=MagicMock(
|
|
||||||
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
|
||||||
get_timeout=MagicMock(return_value=5.0),
|
|
||||||
),
|
|
||||||
telegram=telegram,
|
|
||||||
market=market,
|
|
||||||
stock_code="AAPL",
|
|
||||||
scan_candidates={},
|
|
||||||
settings=settings,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert overseas_broker.send_overseas_order.call_count == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_overnight_policy_prioritizes_killswitch_over_exception() -> None:
|
def test_overnight_policy_prioritizes_killswitch_over_exception() -> None:
|
||||||
market = MagicMock()
|
market = MagicMock()
|
||||||
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
|
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
|
||||||
@@ -6823,7 +6475,6 @@ async def test_blackout_queues_order_and_skips_submission() -> None:
|
|||||||
blackout_manager.in_blackout.return_value = True
|
blackout_manager.in_blackout.return_value = True
|
||||||
blackout_manager.enqueue.return_value = True
|
blackout_manager.enqueue.return_value = True
|
||||||
blackout_manager.pending_count = 1
|
blackout_manager.pending_count = 1
|
||||||
blackout_manager.overflow_drop_count = 0
|
|
||||||
|
|
||||||
with patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager):
|
with patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager):
|
||||||
await trading_cycle(
|
await trading_cycle(
|
||||||
@@ -6853,43 +6504,6 @@ async def test_blackout_queues_order_and_skips_submission() -> None:
|
|||||||
blackout_manager.enqueue.assert_called_once()
|
blackout_manager.enqueue.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_blackout_queue_overflow_keeps_latest_intent() -> None:
|
|
||||||
manager = BlackoutOrderManager(enabled=True, windows=[], max_queue_size=1)
|
|
||||||
manager.in_blackout = lambda now=None: True # type: ignore[method-assign]
|
|
||||||
|
|
||||||
market = MagicMock()
|
|
||||||
market.code = "KR"
|
|
||||||
market.exchange_code = "KRX"
|
|
||||||
|
|
||||||
with patch("src.main.BLACKOUT_ORDER_MANAGER", manager):
|
|
||||||
assert _maybe_queue_order_intent(
|
|
||||||
market=market,
|
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="005930",
|
|
||||||
order_type="BUY",
|
|
||||||
quantity=1,
|
|
||||||
price=100.0,
|
|
||||||
source="test-first",
|
|
||||||
)
|
|
||||||
assert _maybe_queue_order_intent(
|
|
||||||
market=market,
|
|
||||||
session_id="KRX_REG",
|
|
||||||
stock_code="000660",
|
|
||||||
order_type="BUY",
|
|
||||||
quantity=2,
|
|
||||||
price=200.0,
|
|
||||||
source="test-second",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert manager.pending_count == 1
|
|
||||||
assert manager.overflow_drop_count == 1
|
|
||||||
manager.in_blackout = lambda now=None: False # type: ignore[method-assign]
|
|
||||||
batch = manager.pop_recovery_batch()
|
|
||||||
assert len(batch) == 1
|
|
||||||
assert batch[0].stock_code == "000660"
|
|
||||||
assert batch[0].session_id == "KRX_REG"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
||||||
"""Recovery must execute queued intents that pass revalidation."""
|
"""Recovery must execute queued intents that pass revalidation."""
|
||||||
@@ -6967,7 +6581,6 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
intent.session_id = "KRX_REG"
|
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -7017,7 +6630,6 @@ async def test_process_blackout_recovery_drops_intent_on_excessive_price_drift()
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
intent.session_id = "US_PRE"
|
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -7068,7 +6680,6 @@ async def test_process_blackout_recovery_drops_overseas_intent_on_excessive_pric
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
intent.session_id = "KRX_REG"
|
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -7118,7 +6729,6 @@ async def test_process_blackout_recovery_requeues_intent_when_price_lookup_fails
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
intent.session_id = "KRX_REG"
|
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -7255,27 +6865,3 @@ async def test_trigger_emergency_kill_switch_records_cancel_failure() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)
|
assert any(err.startswith("cancel_pending_orders:") for err in report.errors)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_refresh_order_state_failure_summary_includes_more_count() -> None:
|
|
||||||
broker = MagicMock()
|
|
||||||
broker.get_balance = AsyncMock(side_effect=RuntimeError("domestic down"))
|
|
||||||
overseas_broker = MagicMock()
|
|
||||||
overseas_broker.get_overseas_balance = AsyncMock(side_effect=RuntimeError("overseas down"))
|
|
||||||
|
|
||||||
markets = []
|
|
||||||
for code, exchange in [("KR", "KRX"), ("US_PRE", "NASD"), ("US_DAY", "NYSE"), ("JP", "TKSE")]:
|
|
||||||
market = MagicMock()
|
|
||||||
market.code = code
|
|
||||||
market.exchange_code = exchange
|
|
||||||
market.is_domestic = code == "KR"
|
|
||||||
markets.append(market)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match=r"\(\+1 more\)$") as exc_info:
|
|
||||||
await main_module._refresh_order_state_for_kill_switch(
|
|
||||||
broker=broker,
|
|
||||||
overseas_broker=overseas_broker,
|
|
||||||
markets=markets,
|
|
||||||
)
|
|
||||||
assert "KR/KRX" in str(exc_info.value)
|
|
||||||
|
|||||||
@@ -165,17 +165,6 @@ class TestGetOpenMarkets:
|
|||||||
)
|
)
|
||||||
assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"}
|
assert {m.code for m in extended} == {"US_NASDAQ", "US_NYSE", "US_AMEX"}
|
||||||
|
|
||||||
def test_get_open_markets_excludes_us_day_when_extended_enabled(self) -> None:
|
|
||||||
"""US_DAY should be treated as non-tradable even in extended-session lookup."""
|
|
||||||
# Monday 2026-02-02 10:30 KST = 01:30 UTC (US_DAY by session classification)
|
|
||||||
test_time = datetime(2026, 2, 2, 1, 30, tzinfo=ZoneInfo("UTC"))
|
|
||||||
extended = get_open_markets(
|
|
||||||
enabled_markets=["US_NASDAQ", "US_NYSE", "US_AMEX"],
|
|
||||||
now=test_time,
|
|
||||||
include_extended_sessions=True,
|
|
||||||
)
|
|
||||||
assert extended == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetNextMarketOpen:
|
class TestGetNextMarketOpen:
|
||||||
"""Test get_next_market_open function."""
|
"""Test get_next_market_open function."""
|
||||||
@@ -225,8 +214,8 @@ class TestGetNextMarketOpen:
|
|||||||
def test_get_next_market_open_prefers_extended_session(self) -> None:
|
def test_get_next_market_open_prefers_extended_session(self) -> None:
|
||||||
"""Extended lookup should return premarket open time before regular open."""
|
"""Extended lookup should return premarket open time before regular open."""
|
||||||
# Monday 2026-02-02 07:00 EST = 12:00 UTC
|
# Monday 2026-02-02 07:00 EST = 12:00 UTC
|
||||||
# US_DAY is treated as non-tradable in extended lookup, so after entering
|
# By v3 KST session rules, US is OFF only in KST 07:00-10:00 (UTC 22:00-01:00).
|
||||||
# US_DAY the next tradable OFF->ON transition is US_PRE at 09:00 UTC next day.
|
# At 12:00 UTC market is active, so next OFF->ON transition is 01:00 UTC next day.
|
||||||
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC"))
|
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
market, next_open = get_next_market_open(
|
market, next_open = get_next_market_open(
|
||||||
enabled_markets=["US_NASDAQ"],
|
enabled_markets=["US_NASDAQ"],
|
||||||
@@ -234,7 +223,7 @@ class TestGetNextMarketOpen:
|
|||||||
include_extended_sessions=True,
|
include_extended_sessions=True,
|
||||||
)
|
)
|
||||||
assert market.code == "US_NASDAQ"
|
assert market.code == "US_NASDAQ"
|
||||||
assert next_open == datetime(2026, 2, 3, 9, 0, tzinfo=ZoneInfo("UTC"))
|
assert next_open == datetime(2026, 2, 3, 1, 0, tzinfo=ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
|
||||||
class TestExpandMarketCodes:
|
class TestExpandMarketCodes:
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
RUN_OVERNIGHT = REPO_ROOT / "scripts" / "run_overnight.sh"
|
|
||||||
RUNTIME_MONITOR = REPO_ROOT / "scripts" / "runtime_verify_monitor.sh"
|
|
||||||
|
|
||||||
|
|
||||||
def _latest_runtime_log(log_dir: Path) -> str:
|
|
||||||
logs = sorted(log_dir.glob("runtime_verify_*.log"))
|
|
||||||
assert logs, "runtime monitor did not produce log output"
|
|
||||||
return logs[-1].read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_verify_monitor_detects_live_process_without_pid_files(tmp_path: Path) -> None:
|
|
||||||
log_dir = tmp_path / "overnight"
|
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
fake_live = subprocess.Popen(
|
|
||||||
["bash", "-lc", 'exec -a "src.main --mode=live" sleep 10'],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update(
|
|
||||||
{
|
|
||||||
"ROOT_DIR": str(REPO_ROOT),
|
|
||||||
"LOG_DIR": str(log_dir),
|
|
||||||
"INTERVAL_SEC": "1",
|
|
||||||
"MAX_HOURS": "1",
|
|
||||||
"MAX_LOOPS": "1",
|
|
||||||
"POLICY_TZ": "UTC",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
completed = subprocess.run(
|
|
||||||
["bash", str(RUNTIME_MONITOR)],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert completed.returncode == 0, completed.stderr
|
|
||||||
|
|
||||||
log_text = _latest_runtime_log(log_dir)
|
|
||||||
assert "app_alive=1" in log_text
|
|
||||||
assert "[COVERAGE] LIVE_MODE=PASS source=process_liveness" in log_text
|
|
||||||
assert "[ANOMALY]" not in log_text
|
|
||||||
finally:
|
|
||||||
fake_live.terminate()
|
|
||||||
fake_live.wait(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_overnight_fails_fast_when_dashboard_port_in_use(tmp_path: Path) -> None:
|
|
||||||
log_dir = tmp_path / "overnight"
|
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
sock.bind(("127.0.0.1", 0))
|
|
||||||
sock.listen(1)
|
|
||||||
port = sock.getsockname()[1]
|
|
||||||
try:
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update(
|
|
||||||
{
|
|
||||||
"LOG_DIR": str(log_dir),
|
|
||||||
"TMUX_AUTO": "false",
|
|
||||||
"DASHBOARD_PORT": str(port),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
completed = subprocess.run(
|
|
||||||
["bash", str(RUN_OVERNIGHT)],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert completed.returncode != 0
|
|
||||||
output = f"{completed.stdout}\n{completed.stderr}"
|
|
||||||
assert "already in use" in output
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_overnight_writes_live_pid_and_watchdog_pid(tmp_path: Path) -> None:
|
|
||||||
log_dir = tmp_path / "overnight"
|
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update(
|
|
||||||
{
|
|
||||||
"LOG_DIR": str(log_dir),
|
|
||||||
"TMUX_AUTO": "false",
|
|
||||||
"STARTUP_GRACE_SEC": "1",
|
|
||||||
"CHECK_INTERVAL": "2",
|
|
||||||
"APP_CMD_BIN": "sleep",
|
|
||||||
"APP_CMD_ARGS": "10",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
completed = subprocess.run(
|
|
||||||
["bash", str(RUN_OVERNIGHT)],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert completed.returncode == 0, f"{completed.stdout}\n{completed.stderr}"
|
|
||||||
|
|
||||||
app_pid = int((log_dir / "app.pid").read_text(encoding="utf-8").strip())
|
|
||||||
watchdog_pid = int((log_dir / "watchdog.pid").read_text(encoding="utf-8").strip())
|
|
||||||
|
|
||||||
os.kill(app_pid, 0)
|
|
||||||
os.kill(watchdog_pid, 0)
|
|
||||||
|
|
||||||
for pid in (watchdog_pid, app_pid):
|
|
||||||
try:
|
|
||||||
os.kill(pid, signal.SIGTERM)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_run_overnight_fails_when_process_exits_before_grace_period(tmp_path: Path) -> None:
|
|
||||||
log_dir = tmp_path / "overnight"
|
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.update(
|
|
||||||
{
|
|
||||||
"LOG_DIR": str(log_dir),
|
|
||||||
"TMUX_AUTO": "false",
|
|
||||||
"STARTUP_GRACE_SEC": "1",
|
|
||||||
"APP_CMD_BIN": "false",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
completed = subprocess.run(
|
|
||||||
["bash", str(RUN_OVERNIGHT)],
|
|
||||||
cwd=REPO_ROOT,
|
|
||||||
env=env,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
assert completed.returncode != 0
|
|
||||||
output = f"{completed.stdout}\n{completed.stderr}"
|
|
||||||
assert "startup failed:" in output
|
|
||||||
|
|
||||||
watchdog_pid_file = log_dir / "watchdog.pid"
|
|
||||||
if watchdog_pid_file.exists():
|
|
||||||
watchdog_pid = int(watchdog_pid_file.read_text(encoding="utf-8").strip())
|
|
||||||
with pytest.raises(ProcessLookupError):
|
|
||||||
os.kill(watchdog_pid, 0)
|
|
||||||
@@ -22,18 +22,17 @@ def test_take_profit_exit_for_backward_compatibility() -> None:
|
|||||||
assert out.reason == "arm_take_profit"
|
assert out.reason == "arm_take_profit"
|
||||||
|
|
||||||
|
|
||||||
def test_model_assist_signal_promotes_be_lock_without_direct_exit() -> None:
|
def test_model_assist_exit_signal() -> None:
|
||||||
out = evaluate_exit(
|
out = evaluate_exit(
|
||||||
current_state=PositionState.HOLDING,
|
current_state=PositionState.ARMED,
|
||||||
config=ExitRuleConfig(model_prob_threshold=0.62, be_arm_pct=1.2, arm_pct=10.0),
|
config=ExitRuleConfig(model_prob_threshold=0.62, arm_pct=10.0),
|
||||||
inp=ExitRuleInput(
|
inp=ExitRuleInput(
|
||||||
current_price=100.5,
|
current_price=101.0,
|
||||||
entry_price=100.0,
|
entry_price=100.0,
|
||||||
peak_price=105.0,
|
peak_price=105.0,
|
||||||
pred_down_prob=0.8,
|
pred_down_prob=0.8,
|
||||||
liquidity_weak=True,
|
liquidity_weak=True,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert out.should_exit is False
|
assert out.should_exit is True
|
||||||
assert out.state == PositionState.BE_LOCK
|
assert out.reason == "model_liquidity_exit"
|
||||||
assert out.reason == "model_assist_be_lock"
|
|
||||||
|
|||||||
@@ -28,29 +28,3 @@ def test_exited_has_priority_over_promotion() -> None:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert state == PositionState.EXITED
|
assert state == PositionState.EXITED
|
||||||
|
|
||||||
|
|
||||||
def test_model_signal_promotes_be_lock_as_assist() -> None:
|
|
||||||
state = promote_state(
|
|
||||||
PositionState.HOLDING,
|
|
||||||
StateTransitionInput(
|
|
||||||
unrealized_pnl_pct=0.5,
|
|
||||||
be_arm_pct=1.2,
|
|
||||||
arm_pct=2.8,
|
|
||||||
model_exit_signal=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert state == PositionState.BE_LOCK
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_signal_does_not_force_exit_directly() -> None:
|
|
||||||
state = promote_state(
|
|
||||||
PositionState.ARMED,
|
|
||||||
StateTransitionInput(
|
|
||||||
unrealized_pnl_pct=1.0,
|
|
||||||
be_arm_pct=1.2,
|
|
||||||
arm_pct=2.8,
|
|
||||||
model_exit_signal=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assert state == PositionState.ARMED
|
|
||||||
|
|||||||
@@ -121,44 +121,3 @@ def test_validate_testing_doc_has_dynamic_count_guidance(monkeypatch) -> None:
|
|||||||
monkeypatch.setattr(module, "_read", fake_read)
|
monkeypatch.setattr(module, "_read", fake_read)
|
||||||
module.validate_testing_doc_has_dynamic_count_guidance(errors)
|
module.validate_testing_doc_has_dynamic_count_guidance(errors)
|
||||||
assert errors == []
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_postcheck_guidance_passes(monkeypatch) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors: list[str] = []
|
|
||||||
fake_docs = {
|
|
||||||
str(module.REQUIRED_FILES["commands"]): (
|
|
||||||
"PR Body Post-Check (Mandatory)\n"
|
|
||||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>\n"
|
|
||||||
),
|
|
||||||
str(module.REQUIRED_FILES["workflow"]): (
|
|
||||||
"PR 생성 직후 본문 무결성 검증(필수)\n"
|
|
||||||
"python3 scripts/validate_pr_body.py --pr <PR_NUMBER>\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
def fake_read(path: Path) -> str:
|
|
||||||
return fake_docs[str(path)]
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "_read", fake_read)
|
|
||||||
module.validate_pr_body_postcheck_guidance(errors)
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_postcheck_guidance_reports_missing_tokens(
|
|
||||||
monkeypatch,
|
|
||||||
) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors: list[str] = []
|
|
||||||
fake_docs = {
|
|
||||||
str(module.REQUIRED_FILES["commands"]): "PR Body Post-Check (Mandatory)\n",
|
|
||||||
str(module.REQUIRED_FILES["workflow"]): "PR Body Post-Check\n",
|
|
||||||
}
|
|
||||||
|
|
||||||
def fake_read(path: Path) -> str:
|
|
||||||
return fake_docs[str(path)]
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "_read", fake_read)
|
|
||||||
module.validate_pr_body_postcheck_guidance(errors)
|
|
||||||
assert any("commands.md" in err for err in errors)
|
|
||||||
assert any("workflow.md" in err for err in errors)
|
|
||||||
|
|||||||
@@ -79,42 +79,3 @@ def test_validate_links_avoids_duplicate_error_for_invalid_plan_link(tmp_path) -
|
|||||||
|
|
||||||
assert len(errors) == 1
|
assert len(errors) == 1
|
||||||
assert "invalid plan link path" in errors[0]
|
assert "invalid plan link path" in errors[0]
|
||||||
|
|
||||||
|
|
||||||
def test_validate_issue_status_consistency_reports_conflicts() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors: list[str] = []
|
|
||||||
path = Path("docs/ouroboros/80_implementation_audit.md").resolve()
|
|
||||||
text = "\n".join(
|
|
||||||
[
|
|
||||||
"| REQ-V3-004 | 상태 | 부분 | `#328` 잔여 |",
|
|
||||||
"| 항목 | 상태 | ✅ 완료 | `#328` 머지 |",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
module.validate_issue_status_consistency(path, text, errors)
|
|
||||||
|
|
||||||
assert len(errors) == 1
|
|
||||||
assert "conflicting status for issue #328" in errors[0]
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_issue_status_consistency_allows_done_only() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors: list[str] = []
|
|
||||||
path = Path("docs/ouroboros/80_implementation_audit.md").resolve()
|
|
||||||
text = "| 항목 | 상태 | ✅ 완료 | `#371` 머지 |"
|
|
||||||
|
|
||||||
module.validate_issue_status_consistency(path, text, errors)
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_issue_status_consistency_allows_pending_only() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors: list[str] = []
|
|
||||||
path = Path("docs/ouroboros/80_implementation_audit.md").resolve()
|
|
||||||
text = "| 항목 | 상태 | 부분 | `#390` 추적 이슈 |"
|
|
||||||
|
|
||||||
module.validate_issue_status_consistency(path, text, errors)
|
|
||||||
|
|
||||||
assert errors == []
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def _load_module():
|
|
||||||
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_pr_body.py"
|
|
||||||
spec = importlib.util.spec_from_file_location("validate_pr_body", script_path)
|
|
||||||
assert spec is not None
|
|
||||||
assert spec.loader is not None
|
|
||||||
module = importlib.util.module_from_spec(spec)
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
return module
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_detects_escaped_newline() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors = module.validate_pr_body_text("## Summary\\n- item")
|
|
||||||
assert any("escaped newline" in err for err in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_detects_escaped_newline_in_multiline_body() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
text = "## Summary\n- first line\n- broken line with \\n literal"
|
|
||||||
errors = module.validate_pr_body_text(text)
|
|
||||||
assert any("escaped newline" in err for err in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_allows_escaped_newline_in_code_blocks() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
text = "\n".join(
|
|
||||||
[
|
|
||||||
"## Summary",
|
|
||||||
"- example uses `\\n` for explanation",
|
|
||||||
"```bash",
|
|
||||||
"printf 'line1\\nline2\\n'",
|
|
||||||
"```",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert module.validate_pr_body_text(text) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_detects_unbalanced_code_fence() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors = module.validate_pr_body_text("## Summary\n- item\n```bash\necho hi\n")
|
|
||||||
assert any("unbalanced fenced code blocks" in err for err in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_detects_missing_structure() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
errors = module.validate_pr_body_text("plain text only")
|
|
||||||
assert any("missing markdown section headers" in err for err in errors)
|
|
||||||
assert any("missing markdown list items" in err for err in errors)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_pr_body_text_passes_with_valid_markdown() -> None:
|
|
||||||
module = _load_module()
|
|
||||||
text = "\n".join(
|
|
||||||
[
|
|
||||||
"## Summary",
|
|
||||||
"- item",
|
|
||||||
"",
|
|
||||||
"## Validation",
|
|
||||||
"```bash",
|
|
||||||
"pytest -q",
|
|
||||||
"```",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
assert module.validate_pr_body_text(text) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_pr_body_reads_body_from_tea_api(monkeypatch) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
|
|
||||||
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
|
||||||
assert cmd[0] == "/tmp/tea-bin"
|
|
||||||
assert check is True
|
|
||||||
assert capture_output is True
|
|
||||||
assert text is True
|
|
||||||
return SimpleNamespace(stdout=json.dumps({"body": "## Summary\n- item"}))
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin")
|
|
||||||
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
||||||
assert module.fetch_pr_body(391) == "## Summary\n- item"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_pr_body_rejects_non_string_body(monkeypatch) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
|
|
||||||
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
|
|
||||||
return SimpleNamespace(stdout=json.dumps({"body": 123}))
|
|
||||||
|
|
||||||
monkeypatch.setattr(module, "resolve_tea_binary", lambda: "/tmp/tea-bin")
|
|
||||||
monkeypatch.setattr(module.subprocess, "run", fake_run)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
module.fetch_pr_body(391)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_tea_binary_falls_back_to_home_bin(monkeypatch, tmp_path) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
tea_home = tmp_path / "bin" / "tea"
|
|
||||||
tea_home.parent.mkdir(parents=True)
|
|
||||||
tea_home.write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
|
||||||
tea_home.chmod(0o755)
|
|
||||||
|
|
||||||
monkeypatch.setattr(module.shutil, "which", lambda _: None)
|
|
||||||
monkeypatch.setattr(module.Path, "home", lambda: tmp_path)
|
|
||||||
assert module.resolve_tea_binary() == str(tea_home)
|
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_tea_binary_rejects_non_executable_home_bin(monkeypatch, tmp_path) -> None:
|
|
||||||
module = _load_module()
|
|
||||||
tea_home = tmp_path / "bin" / "tea"
|
|
||||||
tea_home.parent.mkdir(parents=True)
|
|
||||||
tea_home.write_text("not executable\n", encoding="utf-8")
|
|
||||||
tea_home.chmod(0o644)
|
|
||||||
|
|
||||||
monkeypatch.setattr(module.shutil, "which", lambda _: None)
|
|
||||||
monkeypatch.setattr(module.Path, "home", lambda: tmp_path)
|
|
||||||
with pytest.raises(RuntimeError):
|
|
||||||
module.resolve_tea_binary()
|
|
||||||
@@ -105,35 +105,3 @@
|
|||||||
- next_ticket: #368
|
- next_ticket: #368
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
- risks_or_notes: TASK-V2-012 구현 갭 보완을 위해 cost guard + execution-adjusted fold metric + 회귀 테스트를 함께 반영한다.
|
- risks_or_notes: TASK-V2-012 구현 갭 보완을 위해 cost guard + execution-adjusted fold metric + 회귀 테스트를 함께 반영한다.
|
||||||
|
|
||||||
### 2026-03-02 | session=codex-v3-stream-next-ticket-369
|
|
||||||
- branch: feature/v3-session-policy-stream
|
|
||||||
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
|
||||||
- open_issues_reviewed: #369, #370, #371, #374, #375, #376, #377, #381
|
|
||||||
- next_ticket: #369
|
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
|
||||||
- risks_or_notes: 구현 티켓은 코드/테스트/문서(요구사항 원장/구현감사/PR traceability) 동시 반영을 기본 원칙으로 진행한다.
|
|
||||||
|
|
||||||
### 2026-03-02 | session=codex-issue369-start
|
|
||||||
- branch: feature/issue-369-model-exit-signal-spec-sync
|
|
||||||
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
|
||||||
- open_issues_reviewed: #369
|
|
||||||
- next_ticket: #369
|
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
|
||||||
- risks_or_notes: v2 사양 기준으로 model_exit_signal을 직접 청산 트리거가 아닌 보조 트리거로 정합화하고 테스트/문서를 동기화한다.
|
|
||||||
|
|
||||||
### 2026-03-02 | session=codex-v3-stream-next-ticket-377
|
|
||||||
- branch: feature/v3-session-policy-stream
|
|
||||||
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
|
||||||
- open_issues_reviewed: #377, #370, #371, #375, #376, #381
|
|
||||||
- next_ticket: #377
|
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
|
||||||
- risks_or_notes: kill switch refresh 재시도 정책(횟수/간격/중단조건)을 코드/테스트/요구사항 원장/감사 문서에 동시 반영한다.
|
|
||||||
|
|
||||||
### 2026-03-02 | session=codex-issue377-start
|
|
||||||
- branch: feature/issue-377-kill-switch-refresh-retry
|
|
||||||
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
|
||||||
- open_issues_reviewed: #377
|
|
||||||
- next_ticket: #377
|
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
|
||||||
- risks_or_notes: refresh 단계를 최대 3회(초기+재시도2), 실패 시 지수 백오프로 재시도하고 성공 시 즉시 중단, 소진 시 오류를 기록한 뒤 다음 단계를 계속 수행한다.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user