37 Commits

Author SHA1 Message Date
agentson
a67a9aa41f style: sort imports for main tests
All checks were successful
Gitea CI / test (pull_request) Successful in 35s
2026-03-04 03:08:47 +09:00
agentson
47a87fa2f6 chore: retrigger CI for PR #406
Some checks failed
Gitea CI / test (pull_request) Failing after 6s
2026-03-04 03:07:43 +09:00
agentson
2e3aed5664 fix: handle US session transitions and suppress US_DAY trading (#400)
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
2026-03-04 03:07:14 +09:00
f245f55a32 Merge pull request 'fix: #398 KR rt_cd rejection handling [REQ-OPS-001] [TASK-OPS-001] [TEST-OPS-001]' (#405) from fix/398 into feature/398-400-401
All checks were successful
Gitea CI / test (push) Successful in 35s
2026-03-04 03:04:42 +09:00
agentson
90cd7c0504 chore: retrigger CI for PR #405
All checks were successful
Gitea CI / test (pull_request) Successful in 36s
2026-03-04 03:03:53 +09:00
agentson
b283880774 fix: handle KR order rejection via rt_cd check (#398)
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
2026-03-04 03:02:26 +09:00
c217e8cd72 Merge pull request 'fix: runtime anomaly handling for overnight startup and monitor (#396 #397)' (#404) from feature/issue-396-397-runtime-anomaly-fixes into main
All checks were successful
Gitea CI / test (push) Successful in 36s
Reviewed-on: #404
Reviewed-by: jihoson <kiparang7th@gmail.com>
2026-03-04 02:46:38 +09:00
agentson
bcbbf80d16 docs: clarify APP_CMD legacy and APP_CMD_ARGS contract
All checks were successful
Gitea CI / test (push) Successful in 36s
Gitea CI / test (pull_request) Successful in 36s
2026-03-04 02:43:32 +09:00
agentson
dc0775cbc6 fix: add safer custom command path for run_overnight
All checks were successful
Gitea CI / test (push) Successful in 36s
Gitea CI / test (pull_request) Successful in 35s
2026-03-04 02:36:24 +09:00
agentson
c412412f7b fix: address second-round review findings on PR #404
All checks were successful
Gitea CI / test (push) Successful in 36s
Gitea CI / test (pull_request) Successful in 36s
2026-03-04 02:29:54 +09:00
agentson
3cde8779fa fix: address PR #404 review feedback
All checks were successful
Gitea CI / test (push) Successful in 36s
Gitea CI / test (pull_request) Successful in 35s
2026-03-04 02:23:43 +09:00
agentson
370ee8cc85 fix: make overnight startup portable in CI environments
All checks were successful
Gitea CI / test (push) Successful in 34s
Gitea CI / test (pull_request) Successful in 34s
2026-03-04 02:07:52 +09:00
agentson
528e17a29c fix: stabilize overnight startup and monitor live fallback (#396 #397)
Some checks failed
Gitea CI / test (push) Failing after 37s
Gitea CI / test (pull_request) Failing after 38s
2026-03-04 02:04:13 +09:00
d2f3fe9108 Merge pull request 'docs: consolidate CLAUDE entrypoint into agents guide (#402)' (#403) from temp/agents-md-migration-20260303 into main
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #403
Reviewed-by: jihoson <kiparang7th@gmail.com>
2026-03-04 01:52:46 +09:00
agentson
12bcccab42 ci: rerun PR checks after traceability update
All checks were successful
Gitea CI / test (pull_request) Successful in 32s
2026-03-04 01:32:42 +09:00
agentson
ef16cf8800 docs: consolidate agent entrypoint into agents.md (#402)
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
2026-03-04 01:31:00 +09:00
3c58c5d110 Merge pull request 'merge: feature/v3-session-policy-stream into main' (#399) from feature/main-merge-v3-session-policy-stream-20260303 into main
Some checks failed
Gitea CI / test (push) Failing after 5s
Reviewed-on: #399
Reviewed-by: jihoson <kiparang7th@gmail.com>
2026-03-04 00:47:20 +09:00
agentson
8ecd3ac55f chore: retrigger CI after PR governance body update
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 32s
2026-03-04 00:36:57 +09:00
agentson
79ad108e2f Merge origin/feature/v3-session-policy-stream into main
Some checks failed
Gitea CI / test (pull_request) Failing after 5s
Gitea CI / test (push) Failing after 5s
2026-03-04 00:30:45 +09:00
d9cf056df8 Merge pull request 'process: add PR body post-check gate and tooling (#392)' (#393) from feature/issue-392-pr-body-postcheck into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #393
2026-03-02 18:34:59 +09:00
agentson
bd9286a39f fix: require executable tea fallback binary (#392)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 18:32:07 +09:00
agentson
f4f8827353 fix: harden PR body validator for mixed escaped-newline and tea path (#392)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 18:27:59 +09:00
agentson
7d24f19cc4 process: add mandatory PR body post-check step (#392)
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 18:20:17 +09:00
7cd818f1e2 Merge pull request 'process: enforce issue-status consistency for completion marks (#390)' (#391) from feature/issue-390-validate-completion-consistency into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 32s
Reviewed-on: #391
2026-03-02 10:38:21 +09:00
agentson
7c17535c3d test: narrow pending keyword and add pending-only guard (#390)
All checks were successful
Gitea CI / test (pull_request) Successful in 33s
Gitea CI / test (push) Successful in 32s
2026-03-02 10:33:58 +09:00
agentson
453d67b91c docs: sync requirements registry for governance gate (#390)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s
2026-03-02 10:03:38 +09:00
agentson
ade5971387 process: enforce issue-status consistency in audit doc validation (#390)
Some checks failed
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Failing after 5s
2026-03-02 09:59:40 +09:00
87683a88b4 Merge pull request 'risk: define and implement kill-switch refresh retry policy (#377)' (#389) from feature/issue-377-kill-switch-refresh-retry into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 32s
Reviewed-on: #389
2026-03-02 09:47:56 +09:00
agentson
b34937ea9d risk: polish retry coverage and refresh failure summary
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 31s
2026-03-02 09:44:24 +09:00
agentson
ba2370e40e risk: add kill-switch refresh retry policy and tests (#377)
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 34s
2026-03-02 09:38:39 +09:00
1c41379815 Merge pull request 'strategy: align model exit signal policy with v2 spec (#369)' (#388) from feature/issue-369-model-exit-signal-spec-sync into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 32s
Reviewed-on: #388
2026-03-02 09:35:23 +09:00
agentson
5e4c94bfeb strategy: implement model assist be-lock path and clarify audit note
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 09:31:38 +09:00
agentson
2332ba868f strategy: align model exit signal as assist-only trigger (#369)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s
2026-03-02 09:25:03 +09:00
f6e4cc7ea9 Merge pull request 'analysis: reflect cost/execution in v2 backtest pipeline (#368)' (#387) from feature/issue-368-backtest-cost-execution into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #387
2026-03-02 09:21:06 +09:00
agentson
2776a074b5 analysis: remove dead init and split execution seeds in fold
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 09:17:22 +09:00
agentson
0fb56a4a1a ci: retrigger after PR traceability update
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 34s
2026-03-02 09:08:00 +09:00
agentson
7e9738d5df docs: bump requirements registry version for policy change sync
Some checks failed
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Failing after 5s
2026-03-02 04:01:26 +09:00
27 changed files with 1302 additions and 256 deletions

190
CLAUDE.md
View File

@@ -1,187 +1,9 @@
# The Ouroboros
# Agent Entry Point
AI-powered trading agent for global stock markets with self-evolution capabilities.
This file moved to [agents.md](./agents.md).
## Quick Start
Follow `agents.md` as the single source of truth for Claude/Codex session behavior and project workflow gates.
```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
Core process references:
- [Workflow Guide](docs/workflow.md)
- [Command Reference](docs/commands.md)

199
agents.md Normal file
View File

@@ -0,0 +1,199 @@
# 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

View File

@@ -59,6 +59,18 @@ scripts/tea_comment.sh 374 /tmp/comment.md
- `scripts/tea_comment.sh` accepts stdin with `-` as body source.
- 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
```bash
~/bin/tea issues create --repo X --title "Y" --description "Z"

View File

@@ -1,6 +1,6 @@
<!--
Doc-ID: DOC-REQ-001
Version: 1.0.7
Version: 1.0.12
Status: active
Owner: strategy
Updated: 2026-03-02
@@ -19,7 +19,7 @@ Updated: 2026-03-02
- `REQ-V2-005`: 라벨링은 Triple Barrier(Upper/Lower/Time) 방식이어야 한다.
- `REQ-V2-006`: 검증은 Walk-forward + Purge/Embargo를 강제한다.
- `REQ-V2-007`: 백테스트는 비용/슬리피지/체결실패를 반영하지 않으면 채택 불가다.
- `REQ-V2-008`: Kill Switch는 신규주문차단 -> 미체결취소 -> 재조회 -> 리스크축소 -> 스냅샷 순서다.
- `REQ-V2-008`: Kill Switch는 신규주문차단 -> 미체결취소 -> 재조회(실패 시 최대 3회, 1s/2s backoff 재시도, 성공 시 즉시 중단) -> 리스크축소 -> 스냅샷 순서다.
## v3 핵심 요구사항
@@ -38,3 +38,7 @@ Updated: 2026-03-02
- `REQ-OPS-002`: 문서의 수치 정책은 원장에서만 변경한다.
- `REQ-OPS-003`: 구현 태스크는 반드시 테스트 태스크를 동반한다.
- `REQ-OPS-004`: 원본 계획 문서(`v2`, `v3`)는 `docs/ouroboros/source/` 경로를 단일 기준으로 사용한다.
## 변경 이력
- 2026-03-02: `v1.0.12` 문서 검증 게이트 강화(#390) 반영에 따라 정책 문서 동기화 체크를 수행했다. (`REQ-OPS-002`)

View File

@@ -9,7 +9,7 @@ Updated: 2026-03-02
# v2/v3 구현 감사 및 수익률 분석 보고서
작성일: 2026-02-28
최종 업데이트: 2026-03-02 (#373 상태표 정합화 반영)
최종 업데이트: 2026-03-02 (#377 kill-switch refresh 재시도 정책 반영)
대상 기간: 2026-02-25 ~ 2026-02-28 (실거래)
분석 브랜치: `feature/v3-session-policy-stream`
@@ -32,11 +32,11 @@ Updated: 2026-03-02
| 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-003 | EXITED 우선 평가 | `position_state_machine.py:38-48` | ✅ 완료 |
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ⚠️ 부분 (`#369`) |
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model assist-only, 직접 EXIT 미트리거) | `src/strategy/exit_rules.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-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ⚠️ 부분 (`#377`) |
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh(retry)→Reduce→Snapshot) | `src/core/kill_switch.py` | ✅ 완료 |
### 1.3 v3 구현 상태: 부분 완료 (2026-03-02 기준)
@@ -45,7 +45,7 @@ Updated: 2026-03-02
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ✅ 완료 | 세션 경계 E2E 회귀(override 적용/해제 + 재로딩 실패 폴백) 보강 (`#376`) |
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화 oldest-drop 정책으로 정합화 (`#371`), 재검증 강화는 `#328` 추적 |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ✅ 완료 | DB 기록(`#324`), 재검증 강화(`#328`), 큐 포화 oldest-drop(`#371`) 반영 |
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.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 |
@@ -89,13 +89,13 @@ Updated: 2026-03-02
- **해소**: 세션 경계 E2E 회귀 테스트를 추가해 override 적용/해제, 재로딩 실패 시 폴백 유지를 검증함 (`#376`)
- **요구사항**: REQ-V3-002
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ⚠️ 부분 해소 (#324, #328, #371)
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → 해소 (#324, #328, #371)
- **위치**: `src/core/blackout_manager.py`, `src/main.py`
- **현 상태**:
- #324 추적 범위(DB 기록)는 구현 경로가 존재
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
- #371: 큐 포화 정책을 oldest-drop으로 명시/구현해 최신 intent 유실 경로 제거
- #324: 복구 주문 DB 기록 구현 및 테스트 반영
- #328: 가격/세션 재검증 강화 구현 및 머지 완료
- #371: 큐 포화 정책을 oldest-drop으로 명시/구현해 최신 intent 유실 경로 제거
- **요구사항**: REQ-V3-004
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
@@ -328,7 +328,7 @@ Updated: 2026-03-02
| 블랙아웃 복구 주문 `log_trade()` 추가 (GAP-4) | #324 | ✅ 머지 |
| 세션 전환 리스크 파라미터 동적 재로딩 (GAP-3) | #327 | ✅ 머지 |
| session_id 거래/의사결정 로그 명시 전달 (GAP-1, GAP-2) | #326 | ✅ 머지 |
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4 잔여) | #328 | ✅ 머지 |
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4) | #328 | ✅ 머지 |
**잔여 개선 항목:**
@@ -337,7 +337,6 @@ Updated: 2026-03-02
| P1 | US 시장 ATR 공급 경로 완성 (ROOT-5 잔여) | 중간 |
| P1 | FX PnL 운영 활성화 (REQ-V3-007) | 낮음 |
| P2 | pred_down_prob ML 모델 대체 (ROOT-5 잔여) | 높음 |
| P2 | 세션 경계 E2E 통합 테스트 보강 (GAP-3 잔여) | 낮음 |
### 5.3 권장 실행 순서

View File

@@ -128,6 +128,16 @@ tea pr create \
--description "$PR_BODY"
```
PR 생성 직후 본문 무결성 검증(필수):
```bash
python3 scripts/validate_pr_body.py --pr <PR_NUMBER>
```
강제 규칙:
- 검증 실패(`\n` 리터럴, 코드펜스 불균형, 헤더/리스트 누락) 상태에서는 리뷰/머지 금지
- 본문 수정 후 같은 명령으로 재검증 통과 필요
금지 패턴:
- `-d "line1\nline2"` (웹 UI에 `\n` 문자 그대로 노출될 수 있음)

View File

@@ -8,8 +8,32 @@ CHECK_INTERVAL="${CHECK_INTERVAL:-30}"
TMUX_AUTO="${TMUX_AUTO:-true}"
TMUX_ATTACH="${TMUX_ATTACH:-true}"
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"
if [ -z "${APP_CMD:-}" ]; then
# Custom override contract:
# 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
PYTHON_BIN=".venv/bin/python"
elif command -v python3 >/dev/null 2>&1; then
@@ -21,9 +45,14 @@ if [ -z "${APP_CMD:-}" ]; then
exit 1
fi
dashboard_port="${DASHBOARD_PORT:-8080}"
APP_CMD="DASHBOARD_PORT=$dashboard_port $PYTHON_BIN -m src.main --mode=live --dashboard"
APP_CMD="$PYTHON_BIN -m src.main --mode=live --dashboard"
RUNS_DASHBOARD="true"
else
USE_DEFAULT_APP_CMD="false"
USE_SAFE_CUSTOM_APP_CMD="false"
if [[ "$APP_CMD" == *"--dashboard"* ]]; then
RUNS_DASHBOARD="true"
fi
fi
mkdir -p "$LOG_DIR"
@@ -34,6 +63,24 @@ WATCHDOG_LOG="$LOG_DIR/watchdog_${timestamp}.log"
PID_FILE="$LOG_DIR/app.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
old_pid="$(cat "$PID_FILE" || true)"
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
@@ -43,7 +90,29 @@ if [ -f "$PID_FILE" ]; then
fi
echo "[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] starting: $APP_CMD" | tee -a "$RUN_LOG"
nohup bash -lc "$APP_CMD" >>"$RUN_LOG" 2>&1 &
if [ "$RUNS_DASHBOARD" = "true" ] && is_port_in_use "$dashboard_port"; then
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=$!
echo "$app_pid" > "$PID_FILE"
@@ -54,6 +123,20 @@ nohup env PID_FILE="$PID_FILE" LOG_FILE="$WATCHDOG_LOG" CHECK_INTERVAL="$CHECK_I
watchdog_pid=$!
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
시작 완료
- app pid: $app_pid

View File

@@ -7,12 +7,15 @@ ROOT_DIR="${ROOT_DIR:-/home/agentson/repos/The-Ouroboros}"
LOG_DIR="${LOG_DIR:-$ROOT_DIR/data/overnight}"
INTERVAL_SEC="${INTERVAL_SEC:-60}"
MAX_HOURS="${MAX_HOURS:-24}"
MAX_LOOPS="${MAX_LOOPS:-0}"
POLICY_TZ="${POLICY_TZ:-Asia/Seoul}"
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
cd "$ROOT_DIR"
OUT_LOG="$LOG_DIR/runtime_verify_$(date +%Y%m%d_%H%M%S).log"
END_TS=$(( $(date +%s) + MAX_HOURS*3600 ))
loops=0
log() {
printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$OUT_LOG" >/dev/null
@@ -31,6 +34,11 @@ check_signal() {
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() {
local name="$1"
local pattern="$2"
@@ -44,42 +52,94 @@ check_forbidden() {
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}"
while true; do
loops=$((loops + 1))
now=$(date +%s)
if [ "$now" -ge "$END_TS" ]; then
log "[INFO] monitor completed (time window reached)"
exit 0
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)"
if [ -z "$latest_run" ]; then
log "[ANOMALY] no run log found"
sleep "$INTERVAL_SEC"
continue
fi
# Basic liveness hints.
app_pid="$(cat "$LOG_DIR/app.pid" 2>/dev/null || true)"
wd_pid="$(cat "$LOG_DIR/watchdog.pid" 2>/dev/null || true)"
live_pids="$(find_live_pids)"
app_alive=0
wd_alive=0
port_alive=0
[ -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
ss -ltnp 2>/dev/null | rg -q ':8080' && port_alive=1
log "[HEARTBEAT] run_log=$latest_run app_alive=$app_alive watchdog_alive=$wd_alive port8080=$port_alive"
if [ "$app_alive" -eq 0 ] && [ -n "$live_pids" ]; then
app_alive=1
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).
not_observed=0
check_signal "LIVE_MODE" "Mode: live" "$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 "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_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))
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))
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 "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_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 "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
log "[ANOMALY] coverage_not_observed=$not_observed (treat as FAIL)"
@@ -95,11 +155,17 @@ while true; do
is_weekend=1
fi
if [ "$is_weekend" -eq 1 ]; then
if [ "$defer_log_checks" -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.
check_forbidden "WEEKEND_KR_SESSION_ACTIVE" \
"Market session active: KR|session=KRX_REG|Processing market: Korea Exchange" \
"$latest_run" || forbidden_hits=$((forbidden_hits+1))
if [ -n "$latest_run" ]; then
check_forbidden "WEEKEND_KR_SESSION_ACTIVE" \
"Market session active: KR|session=KRX_REG|Processing market: Korea Exchange" \
"$latest_run" || forbidden_hits=$((forbidden_hits+1))
else
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=no_run_log"
fi
else
log "[FORBIDDEN] WEEKEND_KR_SESSION_ACTIVE=SKIP reason=weekday"
fi

View File

@@ -92,6 +92,25 @@ 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:
errors: list[str] = []
@@ -117,6 +136,7 @@ def main() -> int:
validate_summary_docs_reference_core_docs(errors)
validate_commands_endpoint_duplicates(errors)
validate_testing_doc_has_dynamic_count_guidance(errors)
validate_pr_body_postcheck_guidance(errors)
if errors:
print("[FAIL] docs sync validation failed")
@@ -128,6 +148,7 @@ def main() -> int:
print("[OK] summary docs link to core docs and links resolve")
print("[OK] commands endpoint rows have no duplicates")
print("[OK] testing doc includes dynamic count guidance")
print("[OK] PR body post-check guidance exists in commands/workflow docs")
return 0

View File

@@ -33,6 +33,9 @@ ALLOWED_PLAN_TARGETS = {
"2": (DOC_DIR / "source" / "ouroboros_plan_v2.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]:
@@ -119,6 +122,38 @@ def collect_req_traceability(
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:
if not DOC_DIR.exists():
print(f"ERROR: missing directory {DOC_DIR}")
@@ -140,6 +175,8 @@ def main() -> int:
text = path.read_text(encoding="utf-8")
validate_metadata(path, text, errors, doc_ids)
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_req_traceability(text, req_to_task, req_to_test)

117
scripts/validate_pr_body.py Normal file
View File

@@ -0,0 +1,117 @@
#!/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())

View File

@@ -94,14 +94,6 @@ def run_v2_backtest_pipeline(
else sorted({bar.session_id for bar in bars})
)
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]
lows = [float(bar.low) for bar in bars]
@@ -156,7 +148,7 @@ def run_v2_backtest_pipeline(
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,
fold_seed=fold_idx + 1000,
)
b0_pred = _baseline_b0_pred(train_labels)
m1_pred = _m1_pred(train_labels)

View File

@@ -3,13 +3,14 @@
Order is fixed:
1) block new orders
2) cancel pending orders
3) refresh order state
3) refresh order state (retry up to 3 attempts with exponential backoff)
4) reduce risk
5) snapshot and notify
"""
from __future__ import annotations
import asyncio
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
@@ -34,16 +35,55 @@ class KillSwitchOrchestrator:
report: KillSwitchReport,
name: str,
fn: StepCallable | None,
) -> None:
) -> bool:
report.steps.append(name)
if fn is None:
return
return True
try:
result = fn()
if inspect.isawaitable(result):
await result
if result is False:
raise RuntimeError("step returned False")
return True
except Exception as exc: # pragma: no cover - intentionally resilient
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(
self,
@@ -54,6 +94,8 @@ class KillSwitchOrchestrator:
reduce_risk: StepCallable | None = None,
snapshot_state: StepCallable | None = None,
notify: StepCallable | None = None,
refresh_retry_attempts: int = 3,
refresh_retry_base_delay_sec: float = 1.0,
) -> KillSwitchReport:
report = KillSwitchReport(reason=reason)
@@ -61,7 +103,12 @@ class KillSwitchOrchestrator:
report.steps.append("block_new_orders")
await self._run_step(report, "cancel_pending_orders", cancel_pending_orders)
await self._run_step(report, "refresh_order_state", refresh_order_state)
await self._run_refresh_with_retry(
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, "snapshot_state", snapshot_state)
await self._run_step(report, "notify", notify)

View File

@@ -1375,7 +1375,10 @@ async def _cancel_pending_orders_for_kill_switch(
)
if failures:
raise RuntimeError("; ".join(failures[:3]))
summary = "; ".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(
@@ -1384,6 +1387,7 @@ async def _refresh_order_state_for_kill_switch(
overseas_broker: OverseasBroker,
markets: list[MarketInfo],
) -> None:
failures: list[str] = []
seen_overseas: set[str] = set()
for market in markets:
try:
@@ -1399,6 +1403,12 @@ async def _refresh_order_state_for_kill_switch(
market.exchange_code,
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:
@@ -2074,6 +2084,15 @@ async def trading_cycle(
quantity=quantity,
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:
# For overseas orders, always use limit orders (지정가):
# - KIS market orders (ORD_DVSN=01) calculate quantity based on upper limit
@@ -3283,6 +3302,15 @@ async def run_daily_session(
quantity=quantity,
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:
# KIS VTS only accepts limit orders; use 0.5% premium for BUY
if decision.action == "BUY":
@@ -3522,6 +3550,20 @@ 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_evolution_loop(
evolution_optimizer: EvolutionOptimizer,
telegram: TelegramClient,
@@ -4035,7 +4077,7 @@ async def run(settings: Settings) -> None:
last_scan_time: dict[str, float] = {}
# Track market open/close state for notifications
_market_states: dict[str, bool] = {} # market_code -> is_open
_market_states: dict[str, str] = {} # market_code -> session_id
# Trading control events
shutdown = asyncio.Event()
@@ -4153,8 +4195,8 @@ async def run(settings: Settings) -> None:
if not open_markets:
# Notify market close for any markets that were open
for market_code, is_open in list(_market_states.items()):
if is_open:
for market_code, session_id in list(_market_states.items()):
if session_id:
try:
from src.markets.schedule import MARKETS
@@ -4171,7 +4213,7 @@ async def run(settings: Settings) -> None:
)
except Exception as exc:
logger.warning("Market close notification failed: %s", exc)
_market_states[market_code] = False
_market_states.pop(market_code, None)
# Clear playbook for closed market (new one generated next open)
playbooks.pop(market_code, None)
@@ -4217,13 +4259,16 @@ async def run(settings: Settings) -> None:
settings=settings,
)
# Notify market open if it just opened
if not _market_states.get(market.code, False):
# Notify on market/session transition (e.g., US_PRE -> US_REG)
session_changed = _has_market_session_transition(
_market_states, market.code, session_info.session_id
)
if session_changed:
try:
await telegram.notify_market_open(market.name)
except Exception as exc:
logger.warning("Market open notification failed: %s", exc)
_market_states[market.code] = True
_market_states[market.code] = session_info.session_id
# Check and handle domestic pending (unfilled) limit orders.
if market.is_domestic:
@@ -4255,7 +4300,12 @@ async def run(settings: Settings) -> None:
now_timestamp = asyncio.get_event_loop().time()
last_scan = last_scan_time.get(market.code, 0.0)
rescan_interval = settings.RESCAN_INTERVAL_SECONDS
if now_timestamp - last_scan >= rescan_interval:
if _should_rescan_market(
last_scan=last_scan,
now_timestamp=now_timestamp,
rescan_interval=rescan_interval,
session_changed=session_changed,
):
try:
logger.info("Smart Scanner: Scanning %s market", market.name)

View File

@@ -207,7 +207,7 @@ def get_open_markets(
from src.core.order_policy import classify_session_id
session_id = classify_session_id(market, now)
return session_id not in {"KR_OFF", "US_OFF"}
return session_id not in {"KR_OFF", "US_OFF", "US_DAY"}
return is_market_open(market, now)
open_markets = [
@@ -254,10 +254,10 @@ def get_next_market_open(
from src.core.order_policy import classify_session_id
ts = start_utc.astimezone(ZoneInfo("UTC")).replace(second=0, microsecond=0)
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
prev_active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
for _ in range(7 * 24 * 60):
ts = ts + timedelta(minutes=1)
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF"}
active = classify_session_id(market, ts) not in {"KR_OFF", "US_OFF", "US_DAY"}
if active and not prev_active:
return ts
prev_active = active

View File

@@ -85,8 +85,8 @@ def evaluate_exit(
reason = "atr_trailing_stop"
elif be_lock_threat:
reason = "be_lock_threat"
elif model_exit_signal:
reason = "model_liquidity_exit"
elif model_exit_signal and next_state == PositionState.BE_LOCK:
reason = "model_assist_be_lock"
elif take_profit_hit:
# Backward-compatible immediate profit-taking path.
reason = "arm_take_profit"

View File

@@ -40,7 +40,8 @@ def evaluate_exit_first(inp: StateTransitionInput) -> bool:
EXITED must be evaluated before any promotion.
"""
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.model_exit_signal or inp.be_lock_threat
# model_exit_signal is assist-only and must not trigger EXIT directly.
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.be_lock_threat
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:
@@ -61,5 +62,8 @@ def promote_state(current: PositionState, inp: StateTransitionInput) -> Position
target = PositionState.ARMED
elif inp.unrealized_pnl_pct >= inp.be_arm_pct:
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

View File

@@ -53,3 +53,52 @@ async def test_kill_switch_collects_step_errors() -> None:
report = await ks.trigger(reason="test", cancel_pending_orders=_boom)
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"]

View File

@@ -34,6 +34,7 @@ from src.main import (
_extract_held_codes_from_balance,
_extract_held_qty_from_balance,
_handle_market_close,
_has_market_session_transition,
_inject_staged_exit_features,
_maybe_queue_order_intent,
_resolve_market_setting,
@@ -43,6 +44,7 @@ from src.main import (
_run_evolution_loop,
_should_block_overseas_buy_for_fx_buffer,
_should_force_exit_for_overnight,
_should_rescan_market,
_split_trade_pnl_components,
_start_dashboard_server,
_stoploss_cooldown_minutes,
@@ -140,6 +142,38 @@ class TestExtractAvgPriceFromBalance:
result = _extract_avg_price_from_balance(balance, "AAPL", is_domestic=False)
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,
)
def test_returns_zero_when_field_absent(self) -> None:
"""Returns 0.0 when pchs_avg_pric key is missing entirely."""
balance = {"output1": [{"pdno": "005930", "ord_psbl_qty": "5"}]}
@@ -913,6 +947,46 @@ class TestTradingCycleTelegramIntegration:
# Verify notification was attempted
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
async def test_fat_finger_notification_sent(
self,
@@ -7154,3 +7228,27 @@ async def test_trigger_emergency_kill_switch_records_cancel_failure() -> None:
)
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)

View File

@@ -165,6 +165,17 @@ class TestGetOpenMarkets:
)
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:
"""Test get_next_market_open function."""
@@ -214,8 +225,8 @@ class TestGetNextMarketOpen:
def test_get_next_market_open_prefers_extended_session(self) -> None:
"""Extended lookup should return premarket open time before regular open."""
# Monday 2026-02-02 07:00 EST = 12:00 UTC
# By v3 KST session rules, US is OFF only in KST 07:00-10:00 (UTC 22:00-01:00).
# At 12:00 UTC market is active, so next OFF->ON transition is 01:00 UTC next day.
# US_DAY is treated as non-tradable in extended lookup, so after entering
# US_DAY the next tradable OFF->ON transition is US_PRE at 09:00 UTC next day.
test_time = datetime(2026, 2, 2, 12, 0, tzinfo=ZoneInfo("UTC"))
market, next_open = get_next_market_open(
enabled_markets=["US_NASDAQ"],
@@ -223,7 +234,7 @@ class TestGetNextMarketOpen:
include_extended_sessions=True,
)
assert market.code == "US_NASDAQ"
assert next_open == datetime(2026, 2, 3, 1, 0, tzinfo=ZoneInfo("UTC"))
assert next_open == datetime(2026, 2, 3, 9, 0, tzinfo=ZoneInfo("UTC"))
class TestExpandMarketCodes:

View File

@@ -0,0 +1,160 @@
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)

View File

@@ -22,17 +22,18 @@ def test_take_profit_exit_for_backward_compatibility() -> None:
assert out.reason == "arm_take_profit"
def test_model_assist_exit_signal() -> None:
def test_model_assist_signal_promotes_be_lock_without_direct_exit() -> None:
out = evaluate_exit(
current_state=PositionState.ARMED,
config=ExitRuleConfig(model_prob_threshold=0.62, arm_pct=10.0),
current_state=PositionState.HOLDING,
config=ExitRuleConfig(model_prob_threshold=0.62, be_arm_pct=1.2, arm_pct=10.0),
inp=ExitRuleInput(
current_price=101.0,
current_price=100.5,
entry_price=100.0,
peak_price=105.0,
pred_down_prob=0.8,
liquidity_weak=True,
),
)
assert out.should_exit is True
assert out.reason == "model_liquidity_exit"
assert out.should_exit is False
assert out.state == PositionState.BE_LOCK
assert out.reason == "model_assist_be_lock"

View File

@@ -28,3 +28,29 @@ def test_exited_has_priority_over_promotion() -> None:
),
)
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

View File

@@ -121,3 +121,44 @@ def test_validate_testing_doc_has_dynamic_count_guidance(monkeypatch) -> None:
monkeypatch.setattr(module, "_read", fake_read)
module.validate_testing_doc_has_dynamic_count_guidance(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)

View File

@@ -79,3 +79,42 @@ def test_validate_links_avoids_duplicate_error_for_invalid_plan_link(tmp_path) -
assert len(errors) == 1
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 == []

View File

@@ -0,0 +1,126 @@
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()

View File

@@ -105,3 +105,35 @@
- next_ticket: #368
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
- 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), 실패 시 지수 백오프로 재시도하고 성공 시 즉시 중단, 소진 시 오류를 기록한 뒤 다음 단계를 계속 수행한다.