2 Commits

Author SHA1 Message Date
agentson
8b5fcfb7c1 test: add stop-loss reentry cooldown behavioral coverage (#319)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 17:16:26 +09:00
agentson
a16a1e3e05 feat: enforce stop-loss reentry cooldown window (#319)
Some checks failed
Gitea CI / test (push) Has been cancelled
Gitea CI / test (pull_request) Has been cancelled
2026-02-28 14:41:14 +09:00
27 changed files with 212 additions and 2299 deletions

View File

@@ -13,8 +13,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
@@ -28,21 +26,7 @@ jobs:
run: python3 scripts/session_handover_check.py --strict
- name: Validate governance assets
env:
GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }}
GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
RANGE=""
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}...${{ github.sha }}"
fi
if [ -n "$RANGE" ]; then
python3 scripts/validate_governance_assets.py "$RANGE"
else
python3 scripts/validate_governance_assets.py
fi
run: python3 scripts/validate_governance_assets.py
- name: Validate Ouroboros docs
run: python3 scripts/validate_ouroboros_docs.py

View File

@@ -25,21 +25,7 @@ jobs:
run: python3 scripts/session_handover_check.py --strict
- name: Validate governance assets
env:
GOVERNANCE_PR_TITLE: ${{ github.event.pull_request.title }}
GOVERNANCE_PR_BODY: ${{ github.event.pull_request.body }}
run: |
RANGE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
RANGE="${{ github.event.pull_request.base.sha }}...${{ github.sha }}"
elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
RANGE="${{ github.event.before }}...${{ github.sha }}"
fi
if [ -n "$RANGE" ]; then
python3 scripts/validate_governance_assets.py "$RANGE"
else
python3 scripts/validate_governance_assets.py
fi
run: python3 scripts/validate_governance_assets.py
- name: Validate Ouroboros docs
run: python3 scripts/validate_ouroboros_docs.py

View File

@@ -81,9 +81,9 @@ SCANNER_TOP_N=3 # Max candidates per scan
- **Evolution-ready** — Selection context logged for strategy optimization
- **Fault-tolerant** — Falls back to static watchlist on API failure
### Trading Mode Integration
### Realtime Mode Only
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).
Smart Scanner runs in `TRADE_MODE=realtime` only. Daily mode uses static watchlists for batch efficiency.
## Documentation
@@ -122,7 +122,7 @@ src/
├── 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)
├── dashboard/ # FastAPI read-only monitoring (8 API endpoints)
├── data/ # External data integration (news, market data, calendar)
├── evolution/ # Self-improvement (optimizer, daily review, scorecard)
├── logging/ # Decision logger (audit trail)
@@ -133,7 +133,7 @@ src/
├── main.py # Trading loop orchestrator
└── config.py # Settings (from .env)
tests/ # 998 tests across 41 files
tests/ # 551 tests across 25 files
docs/ # Extended documentation
```

View File

@@ -39,7 +39,7 @@ KIS(한국투자증권) API로 매매하고, Google Gemini로 판단하며, 자
| 컨텍스트 | `src/context/` | L1-L7 계층형 메모리 시스템 |
| 분석 | `src/analysis/` | RSI, ATR, Smart Volatility Scanner |
| 알림 | `src/notifications/` | 텔레그램 양방향 (알림 + 9개 명령어) |
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (10개 API) |
| 대시보드 | `src/dashboard/` | FastAPI 읽기 전용 모니터링 (8개 API) |
| 진화 | `src/evolution/` | 전략 진화 + Daily Review + Scorecard |
| 의사결정 로그 | `src/logging/` | 전체 거래 결정 감사 추적 |
| 데이터 | `src/data/` | 뉴스, 시장 데이터, 경제 캘린더 연동 |
@@ -153,16 +153,19 @@ docker compose up -d ouroboros
## 테스트
998개 테스트가 41개 파일에 걸쳐 구현되어 있습니다. 최소 커버리지 80%.
551개 테스트가 25개 파일에 걸쳐 구현되어 있습니다. 최소 커버리지 80%.
```
tests/test_main.py — 거래 루프 통합
tests/test_scenario_engine.py 시나리오 매칭
tests/test_pre_market_planner.py — 플레이북 생성
tests/test_overseas_broker.py — 해외 브로커
tests/test_telegram_commands.py — 텔레그램 명령어
tests/test_telegram.py — 텔레그램 알림
... 외 35개 파일 ※ 파일별 수치는 CI 기준으로 변동 가능
tests/test_scenario_engine.py — 시나리오 매칭 (44개)
tests/test_data_integration.py — 외부 데이터 연동 (38개)
tests/test_pre_market_planner.py — 플레이북 생성 (37개)
tests/test_main.py — 거래 루프 통합 (37개)
tests/test_token_efficiency.py 토큰 최적화 (34개)
tests/test_strategy_models.py — 전략 모델 검증 (33개)
tests/test_telegram_commands.py — 텔레그램 명령어 (31개)
tests/test_latency_control.py — 지연시간 제어 (30개)
tests/test_telegram.py — 텔레그램 알림 (25개)
... 외 16개 파일
```
**상세**: [docs/testing.md](docs/testing.md)
@@ -174,8 +177,8 @@ tests/test_telegram.py — 텔레그램 알림
- **AI**: Google Gemini Pro
- **DB**: SQLite (5개 테이블: trades, contexts, decision_logs, playbooks, context_metadata)
- **대시보드**: FastAPI + uvicorn
- **검증**: pytest + coverage (998 tests)
- **CI/CD**: Gitea CI (`.gitea/workflows/ci.yml`)
- **검증**: pytest + coverage (551 tests)
- **CI/CD**: GitHub Actions
- **배포**: Docker + Docker Compose
## 프로젝트 구조
@@ -209,7 +212,7 @@ The-Ouroboros/
│ ├── config.py # Pydantic 설정
│ ├── db.py # SQLite 데이터베이스
│ └── main.py # 비동기 거래 루프
├── tests/ # 998개 테스트 (41개 파일)
├── tests/ # 551개 테스트 (25개 파일)
├── Dockerfile # 멀티스테이지 빌드
├── docker-compose.yml # 서비스 오케스트레이션
└── pyproject.toml # 의존성 및 도구 설정

View File

@@ -84,37 +84,6 @@ High-frequency trading with individual stock analysis:
- Momentum scoring (0-100 scale)
- Breakout/breakdown pattern detection
**TripleBarrierLabeler** (`triple_barrier.py`) — Financial time-series labeling (v2)
- Triple Barrier method: upper (take-profit), lower (stop-loss), time barrier
- First-touch labeling: labels confirmed by whichever barrier is breached first
- `max_holding_minutes` (calendar-minute) time barrier — session-aware, bar-period independent
- Tie-break mode: `"stop_first"` (conservative) or `"take_first"`
- Feature-label strict separation to prevent look-ahead bias
**BacktestPipeline** (`backtest_pipeline.py`) — End-to-end validation pipeline (v2)
- `run_v2_backtest_pipeline()`: cost guard → triple barrier labeling → walk-forward splits → fold scoring
- `BacktestPipelineResult`: artifact contract for reproducible output
- `fold_has_leakage()`: leakage detection utility
**WalkForwardSplit** (`walk_forward_split.py`) — Time-series validation (v2)
- Fold-based walk-forward splits (no random shuffling)
- Purge/Embargo: excludes N bars before/after fold boundaries to prevent data leakage
**BacktestExecutionModel** (`backtest_execution_model.py`) — Conservative fill simulation (v2/v3)
- Session-aware slippage: KRX_REG 5bps, NXT_AFTER 15bps, US_REG 3bps, US_PRE/DAY 30-50bps
- Order failure rate simulation per session
- Partial fill rate simulation with min/max ratio bounds
- Unfavorable-direction fill assumption (no simple close-price fill)
**BacktestCostGuard** (`backtest_cost_guard.py`) — Cost model validator (v2)
- `validate_backtest_cost_model()`: fail-fast check that session cost assumptions are present
- Enforces realistic cost assumptions before any backtest run proceeds
**SmartVolatilityScanner** (`smart_scanner.py`) — Python-first filtering pipeline
- **Domestic (KR)**:
@@ -129,7 +98,7 @@ High-frequency trading with individual stock analysis:
- **Step 4**: Return top N candidates (default 3)
- **Fallback (overseas only)**: If ranking API is unavailable, uses dynamic universe
from runtime active symbols + recent traded symbols + current holdings (no static watchlist)
- **Both modes**: Realtime 중심이지만 Daily 경로(`run_daily_session()`)에서도 후보 선별에 사용
- **Realtime mode only**: Daily mode uses batch processing for API efficiency
**Benefits:**
- Reduces Gemini API calls from 20-30 stocks to 1-3 qualified candidates
@@ -155,9 +124,9 @@ High-frequency trading with individual stock analysis:
- Selects appropriate context layers for current market conditions
### 4. Risk Manager & Session Policy (`src/core/`)
### 4. Risk Manager (`src/core/risk_manager.py`)
**RiskManager** (`risk_manager.py`) — Safety circuit breaker and order validation
**RiskManager** — Safety circuit breaker and order validation
> **READ-ONLY by policy** (see [`docs/agents.md`](./agents.md))
@@ -167,59 +136,8 @@ High-frequency trading with individual stock analysis:
- **Fat-Finger Protection**: Rejects orders exceeding 30% of available cash
- Must always be enforced, cannot be disabled
**OrderPolicy** (`order_policy.py`) — Session classification and order type enforcement (v3)
- `classify_session_id()`: Classifies current KR/US session from KST clock
- KR: `NXT_PRE` (08:00-08:50), `KRX_REG` (09:00-15:30), `NXT_AFTER` (15:30-20:00)
- US: `US_DAY` (10:00-18:00), `US_PRE` (18:00-23:30), `US_REG` (23:30-06:00), `US_AFTER` (06:00-07:00)
- Low-liquidity session detection: `NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`
- Market order forbidden in low-liquidity sessions (`OrderPolicyRejected` raised)
- Limit/IOC/FOK orders always allowed
**KillSwitch** (`kill_switch.py`) — Emergency trading halt orchestration (v2)
- Fixed 5-step atomic sequence:
1. Block new orders (`new_orders_blocked = True`)
2. Cancel all unfilled orders
3. Refresh order state (query final status)
4. Reduce risk (force-close or reduce positions)
5. Snapshot state + send Telegram alert
- Async, injectable step callables — each step individually testable
- Highest priority: overrides overnight exception and all other rules
**BlackoutManager** (`blackout_manager.py`) — KIS maintenance window handling (v3)
- Configurable blackout windows (e.g., `23:30-00:10 KST`)
- `queue_order()`: Queues order intent during blackout, enforces max queue size
- `pop_recovery_batch()`: Returns queued intents after recovery
- Recovery revalidation path (in `src/main.py`):
- Stale BUY drop (position already exists)
- Stale SELL drop (position absent)
- `validate_order_policy()` rechecked
- Price drift check (>5% → drop, configurable via `BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT`)
### 5. Strategy (`src/strategy/`)
**PositionStateMachine** (`position_state_machine.py`) — 4-state sell state machine (v2)
- States: `HOLDING` → `BE_LOCK` → `ARMED` → `EXITED`
- `HOLDING`: Normal holding
- `BE_LOCK`: Profit ≥ `be_arm_pct` — stop-loss elevated to break-even
- `ARMED`: Profit ≥ `arm_pct` — peak-tracking trailing stop active
- `EXITED`: Position closed
- `promote_state()`: Immediately elevates to highest admissible state (handles gaps/skips)
- `evaluate_exit_first()`: EXITED conditions checked before state promotion
- Monotonic: states only move up, never down
**ExitRules** (`exit_rules.py`) — 4-layer composite exit logic (v2)
- **Hard Stop**: `unrealized <= hard_stop_pct` (always enforced, ATR-adaptive for KR)
- **Break-Even Lock**: Once in BE_LOCK/ARMED, exit if price falls to entry price
- **ATR Trailing Stop**: `trailing_stop_price = peak_price - (atr_multiplier_k × ATR)`
- **Model Signal**: Exit if `pred_down_prob >= model_prob_threshold AND liquidity_weak`
- `evaluate_exit()`: Returns `ExitEvaluation` with next state, exit flag, reason, trailing price
- `ExitRuleConfig`: Frozen dataclass with all tunable parameters
**Pre-Market Planner** (`pre_market_planner.py`) — AI playbook generation
- Runs before market open (configurable `PRE_MARKET_MINUTES`, default 30)
@@ -277,7 +195,7 @@ High-frequency trading with individual stock analysis:
- Configurable host/port (`DASHBOARD_HOST`, `DASHBOARD_PORT`, default `127.0.0.1:8080`)
- Serves static HTML frontend
**10 API Endpoints:**
**8 API Endpoints:**
| Endpoint | Method | Description |
|----------|--------|-------------|
@@ -289,8 +207,6 @@ High-frequency trading with individual stock analysis:
| `/api/context/{layer}` | GET | Query context by layer (L1-L7) |
| `/api/decisions` | GET | Decision log entries with outcomes |
| `/api/scenarios/active` | GET | Today's matched scenarios |
| `/api/pnl/history` | GET | P&L history time series |
| `/api/positions` | GET | Current open positions |
### 8. Notifications (`src/notifications/telegram_client.py`)
@@ -532,12 +448,8 @@ CREATE TABLE trades (
pnl REAL DEFAULT 0.0,
market TEXT DEFAULT 'KR',
exchange_code TEXT DEFAULT 'KRX',
session_id TEXT DEFAULT 'UNKNOWN', -- v3: KRX_REG | NXT_AFTER | US_REG | US_PRE | ...
selection_context TEXT, -- JSON: {rsi, volume_ratio, signal, score}
decision_id TEXT, -- Links to decision_logs
strategy_pnl REAL, -- v3: Core strategy P&L (separated from FX)
fx_pnl REAL DEFAULT 0.0, -- v3: FX gain/loss for USD trades (schema ready, activation pending)
mode TEXT -- paper | live
decision_id TEXT -- Links to decision_logs
);
```
@@ -563,14 +475,13 @@ CREATE TABLE decision_logs (
stock_code TEXT,
market TEXT,
exchange_code TEXT,
session_id TEXT DEFAULT 'UNKNOWN', -- v3: session when decision was made
action TEXT,
confidence INTEGER,
rationale TEXT,
context_snapshot TEXT, -- JSON: full context at decision time
input_data TEXT, -- JSON: market data used
outcome_pnl REAL,
outcome_accuracy INTEGER,
outcome_accuracy REAL,
reviewed INTEGER DEFAULT 0,
review_notes TEXT
);
@@ -583,7 +494,7 @@ CREATE TABLE playbooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
market TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending', -- pending → generated → active → expired
status TEXT DEFAULT 'generated',
playbook_json TEXT NOT NULL, -- Full playbook with scenarios
generated_at TEXT NOT NULL,
token_count INTEGER,
@@ -641,29 +552,6 @@ PLANNER_TIMEOUT_SECONDS=60 # Timeout for playbook generation
DEFENSIVE_PLAYBOOK_ON_FAILURE=true # Fallback on AI failure
RESCAN_INTERVAL_SECONDS=300 # Scenario rescan interval during trading
# Optional — v2 Exit Rules (State Machine)
STAGED_EXIT_BE_ARM_PCT=1.2 # Break-even lock threshold (%)
STAGED_EXIT_ARM_PCT=3.0 # Armed state threshold (%)
KR_ATR_STOP_MULTIPLIER_K=2.0 # ATR multiplier for KR dynamic hard stop
KR_ATR_STOP_MIN_PCT=-2.0 # KR hard stop floor (must tighten, negative)
KR_ATR_STOP_MAX_PCT=-7.0 # KR hard stop ceiling (loosest, negative)
# Optional — v2 Trade Filters
STOP_LOSS_COOLDOWN_MINUTES=120 # Cooldown after stop-loss before re-entry (same ticker)
US_MIN_PRICE=5.0 # Minimum US stock price for BUY ($)
# Optional — v3 Session Risk Management
SESSION_RISK_RELOAD_ENABLED=true # Reload risk params at session boundaries
SESSION_RISK_PROFILES_JSON="{}" # Per-session overrides JSON: {"KRX_REG": {"be_arm_pct": 1.0}}
OVERNIGHT_EXCEPTION_ENABLED=true # Allow holding through session close (conditions apply)
# Optional — v3 Blackout (KIS maintenance windows)
ORDER_BLACKOUT_ENABLED=true
ORDER_BLACKOUT_WINDOWS_KST=23:30-00:10 # Comma-separated: "HH:MM-HH:MM"
ORDER_BLACKOUT_QUEUE_MAX=500 # Max queued orders during blackout
BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED=true
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT=5.0 # Drop recovery order if price drifted >5%
# Optional — Smart Scanner (realtime mode only)
RSI_OVERSOLD_THRESHOLD=30 # 0-50, oversold threshold
RSI_MOMENTUM_THRESHOLD=70 # 50-100, momentum threshold

View File

@@ -136,7 +136,7 @@ No decorator needed for async tests.
# Install all dependencies (production + dev)
pip install -e ".[dev]"
# Run full test suite with coverage (998 tests across 41 files)
# Run full test suite with coverage (551 tests across 25 files)
pytest -v --cov=src --cov-report=term-missing
# Run a single test file
@@ -202,8 +202,6 @@ Dashboard runs as a daemon thread on `DASHBOARD_HOST:DASHBOARD_PORT` (default: `
| `GET /api/context/{layer}` | Context data by layer L1-L7 (query: `timeframe`) |
| `GET /api/decisions` | Decision log entries (query: `limit`, `market`) |
| `GET /api/scenarios/active` | Today's matched scenarios |
| `GET /api/pnl/history` | P&L history over time |
| `GET /api/positions` | Current open positions |
## Telegram Commands

View File

@@ -24,17 +24,11 @@ Updated: 2026-02-27
## 2) 필수 상태 체크 (필수)
필수 CI 항목:
| 참조 기준 | 이름 | 설명 |
|-----------|------|------|
| **job 단위** (브랜치 보호 설정 시 사용) | `test` | 전체 CI job (문서 검증 + 테스트 포함) |
| **step 단위** (로그 확인 시 참조) | `validate_ouroboros_docs` | `python3 scripts/validate_ouroboros_docs.py` 실행 step |
| **step 단위** | `run_tests` | `pytest -q` 실행 step |
> **주의**: Gitea 브랜치 보호의 Required Status Checks는 **job 이름** 기준으로 설정한다 (`test`). step 이름은 UI 로그 탐색용이며 보호 규칙에 직접 입력하지 않는다.
- `validate_ouroboros_docs` (명령: `python3 scripts/validate_ouroboros_docs.py`)
- `test` (명령: `pytest -q`)
설정 기준:
- `test` job이 `success` 아니면 머지 금지
- 위 2개 체크가 `success` 아니면 머지 금지
- 체크 스킵/중립 상태 허용 금지
## 3) 필수 리뷰어 규칙 (권장 -> 필수)

View File

@@ -1,15 +1,14 @@
<!--
Doc-ID: DOC-AUDIT-001
Version: 1.1.0
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-03-01
Updated: 2026-02-28
-->
# v2/v3 구현 감사 및 수익률 분석 보고서
작성일: 2026-02-28
최종 업데이트: 2026-03-01 (Phase 2 완료 + Phase 3 부분 완료 반영)
대상 기간: 2026-02-25 ~ 2026-02-28 (실거래)
분석 브랜치: `feature/v3-session-policy-stream`
@@ -30,80 +29,69 @@ Updated: 2026-03-01
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py` | ✅ 완료 |
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ✅ 완료 |
### 1.2 v3 구현 상태: ~85% 완료 (2026-03-01 기준)
### 1.2 v3 구현 상태: ~75% 완료
| REQ-ID | 요구사항 | 상태 | 비고 |
|--------|----------|------|------|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ✅ 완료 | #326 머지 — `log_decision()` 파라미터 추가, `log_trade()` 명시적 전달 |
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | #327 머지 — 재로딩 메커니즘 구현, 세션 훅 테스트 미작성 |
| REQ-ID | 요구사항 | 상태 | 갭 설명 |
|--------|----------|------|---------|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 아래 GAP-1, GAP-2 참조 |
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 아래 GAP-3 참조 |
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ✅ 완료 | #324(DB 기록) + #328(가격/세션 재검증) 머지 |
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 아래 GAP-4 참조 (부분 해소) |
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 코드 완료 / 운영 미반영 | `src/db.py` 스키마·함수 완료, 운영 데이터 `fx_pnl` 전부 0 |
| 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:459-471` |
### 1.3 운영 거버넌스: ~60% 완료 (2026-03-01 재평가)
### 1.3 운영 거버넌스: ~20% 완료
| REQ-ID | 요구사항 | 상태 | 비고 |
|--------|----------|------|------|
| REQ-ID | 요구사항 | 상태 | 갭 설명 |
|--------|----------|------|---------|
| REQ-OPS-001 | 타임존 명시 (KST/UTC) | ⚠️ 부분 | DB 기록은 UTC, 세션은 KST. 일부 로그에서 타임존 미표기 |
| REQ-OPS-002 | 정책 변경 시 레지스트리 업데이트 강제 | ⚠️ 기본 구현 완료 | `scripts/validate_governance_assets.py` CI 완료; 규칙 고도화 잔여 |
| REQ-OPS-003 | TASK-REQ 매핑 강제 | ⚠️ 기본 구현 완료 | `scripts/validate_ouroboros_docs.py` CI 연동 완료; PR 강제 검증 강화 잔여 |
| REQ-OPS-002 | 정책 변경 시 레지스트리 업데이트 강제 | ❌ 미구현 | CI 검증 없음 |
| REQ-OPS-003 | TASK-REQ 매핑 강제 | ❌ 미구현 | PR 단위 자동 검증 없음 |
---
## 2. 구현 갭 상세
> **2026-03-01 업데이트**: GAP-1~5 모두 해소되었거나 이슈 머지로 부분 해소됨.
### GAP-1: DecisionLogger에 session_id 미포함 (CRITICAL)
### GAP-1: DecisionLogger에 session_id 미포함 → ✅ 해소 (#326)
- **위치**: `src/logging/decision_logger.py`
- ~~문제: `log_decision()` 함수에 `session_id` 파라미터가 없음~~
- **해소**: #326 머지 — `log_decision()` 파라미터에 `session_id` 추가, DB 기록 포함
- **위치**: `src/logging/decision_logger.py:40`
- **문제**: `log_decision()` 함수에 `session_id` 파라미터가 없음
- **영향**: 어떤 세션에서 전략적 의사결정이 내려졌는지 추적 불가
- **요구사항**: REQ-V3-001
### GAP-2: src/main.py 거래 로그에 session_id 미전달 → ✅ 해소 (#326)
### GAP-2: src/main.py 거래 로그에 session_id 미전달 (CRITICAL)
- **위치**: `src/main.py`
- ~~문제: `log_trade()` 호출 시 `session_id` 파라미터를 전달하지 않음~~
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
- **위치**: `src/main.py` line 1625, 1682, 2769
- **문제**: `log_trade()` 호출 시 `session_id` 파라미터를 전달하지 않음
- **현상**: 시장 코드 기반 자동 추론에 의존 → 실제 런타임 세션과 불일치 가능
- **요구사항**: REQ-V3-001
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ⚠️ 부분 해소 (#327)
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 (HIGH)
- **위치**: `src/main.py`, `src/config.py`
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- **잔여 갭**: 세션 경계 실시간 전환 E2E 통합 테스트 보강 필요 (`test_main.py`에 설정 오버라이드/폴백 단위 테스트는 존재)
- **위치**: `src/main.py` 전체
- **문제**: 리스크 파라미터가 시작 시 한 번만 로딩되고, 세션 경계 변경 시 재로딩 메커니즘 없음
- **영향**: NXT_AFTER(저유동) → KRX_REG(정규장) 전환 시에도 동일 파라미터 사용
- **요구사항**: REQ-V3-002
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ✅ 해소 (#324, #328)
### GAP-4: 블랙아웃 복구 시 재검증 부분 해소, DB 기록 미구현 (HIGH)
- **위치**: `src/core/blackout_manager.py`, `src/main.py`
- **해소 내용**:
- #324 머지 — 복구 주문 실행 후 `log_trade()` 호출, rationale에 `[blackout-recovery]` prefix
- #328 머지 — 가격 유효성 검증 (진입가 대비 급변 시 드롭), 세션 변경 시 새 파라미터로 재검증
- **위치**: `src/core/blackout_manager.py:89-96`, `src/main.py:694-791`
- **상태**: `pop_recovery_batch()` 자체는 단순 dequeue이나, 실행 경로에서 부분 재검증 수행:
- stale BUY 드롭 (포지션 이미 존재 시) — `src/main.py:713-720`
- stale SELL 드롭 (포지션 부재 시) — `src/main.py:721-727`
- `validate_order_policy()` 호출 — `src/main.py:729-734`
- **잔여 갭**: 가격 유효성(시세 변동), 세션 변경에 따른 파라미터 재적용은 미구현
- **신규 발견**: 블랙아웃 복구 주문이 `log_trade()` 없이 실행되어 거래 DB에 기록되지 않음 → 성과 리포트 불일치 유발
- **요구사항**: REQ-V3-004
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
### GAP-5: 시간장벽이 봉 개수 고정 (MEDIUM)
- **위치**: `src/analysis/triple_barrier.py`
- ~~문제: `max_holding_bars` (고정 봉 수) 사용~~
- **해소**: #329 머지 — `max_holding_minutes` (캘린더 분) 기반 시간장벽 전환
- 봉 주기 무관하게 일정 시간 경과 시 장벽 도달
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
- **위치**: `src/analysis/triple_barrier.py:19`
- **문제**: `max_holding_bars` (고정 봉 수) 사용, v3 계획의 `max_holding_minutes` (캘린더 시간) 미반영
- **요구사항**: REQ-V2-005 / v3 확장
### GAP-6 (신규): FX PnL 운영 미활성 (LOW — 코드 완료)
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
- **문제**: 스키마와 함수는 완료되었으나 운영 데이터에서 `fx_pnl` 전부 0
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
- **요구사항**: REQ-V3-007
---
## 3. 실거래 수익률 분석
@@ -256,25 +244,18 @@ Updated: 2026-03-01
- **문제**: 중첩 `def evaluate` 정의 (들여쓰기 오류)
- **영향**: 런타임 실패 → 기본 전략으로 폴백 → 진화 시스템 사실상 무효
### ROOT-5: v2 청산 로직이 부분 통합되었으나 실효성 부족 → ⚠️ 부분 해소 (#325)
### ROOT-5: v2 청산 로직이 부분 통합되었으나 실효성 부족 (HIGH)
**초기 진단 (2026-02-28 감사 기준):**
- `hard_stop_pct`에 고정 `-2.0`이 기본값으로 들어가 v2 계획의 ATR 적응형 의도와 괴리
- `be_arm_pct`/`arm_pct`가 playbook의 `take_profit_pct`에서 기계적 파생(`* 0.4`)되어 v2 계획의 독립 파라미터 튜닝 불
- `atr_value`, `pred_down_prob` 등 런타임 피처가 0.0으로 공급되어 사실상 hard stop만 발동
- **현재 상태**: `src/main.py:500-583`에서 `evaluate_exit()` 기반 staged exit override가 동작함
- 상태기계(HOLDING→BE_LOCK→ARMED→EXITED) 전이 구현
- 4중 청산(hard stop, BE lock threat, ATR trailing, model/liquidity exit) 평
- **실효성 문제**:
- `hard_stop_pct`에 고정 `-2.0`이 기본값으로 들어가 v2 계획의 ATR 적응형 의도와 괴리
- `be_arm_pct`/`arm_pct`가 playbook의 `take_profit_pct`에서 기계적 파생(`* 0.4`)되어 v2 계획의 독립 파라미터 튜닝 불가
- `atr_value`, `pred_down_prob` 등 런타임 피처가 대부분 0.0으로 들어와 사실상 hard stop만 발동
- **결론**: 코드 통합은 되었으나, 피처 공급과 파라미터 설정이 미비하여 v2 설계 가치가 실현되지 않는 상태
**현재 상태 (#325 머지 후):**
- `STAGED_EXIT_BE_ARM_PCT`, `STAGED_EXIT_ARM_PCT` 환경변수로 독립 파라미터 설정 가능
- `_inject_staged_exit_features()`: KR 시장 ATR 실시간 계산 주입, RSI 기반 `pred_down_prob` 공급
- KR ATR dynamic hard stop (#318)으로 `-2.0` 고정값 문제 해소
**잔여 리스크:**
- KR 외 시장(US 등)에서 `atr_value` 공급 경로 불완전 — hard stop 편향 잔존 가능
- `pred_down_prob`가 RSI 프록시 수준 — 추후 실제 ML 모델 대체 권장
### ROOT-6: SELL 손익 계산이 부분청산/수량 불일치에 취약 (CRITICAL) → ✅ 해소 (#322)
> **현재 상태**: #322 머지로 해소됨. 아래는 원인 발견 시점(2026-02-28) 진단 기록.
### ROOT-6: SELL 손익 계산이 부분청산/수량 불일치에 취약 (CRITICAL)
- **위치**: `src/main.py:1658-1663`, `src/main.py:2755-2760`
- **문제**: PnL 계산이 실제 매도 수량(`sell_qty`)이 아닌 직전 BUY의 `buy_qty`를 사용
@@ -282,9 +263,7 @@ Updated: 2026-03-01
- **영향**: 부분청산, 역분할/액분할, startup-sync 후 수량 드리프트 시 손익 과대/과소 계상
- **실증**: CRCA 이상치(BUY 146주 → SELL 15주에서 PnL +4,612 USD) 가 이 버그와 정합
### ROOT-7: BUY 매칭 키에 exchange_code 미포함 — 잠재 오매칭 리스크 (HIGH) → ✅ 해소 (#323)
> **현재 상태**: #323 머지로 해소됨. 아래는 원인 발견 시점(2026-02-28) 진단 기록.
### ROOT-7: BUY 매칭 키에 exchange_code 미포함 — 잠재 오매칭 리스크 (HIGH)
- **위치**: `src/db.py:292-313`
- **문제**: `get_latest_buy_trade()``(stock_code, market)`만으로 매칭, `exchange_code` 미사용
@@ -304,28 +283,17 @@ Updated: 2026-03-01
| P1 | US 최소 가격 필터: $5 이하 종목 진입 차단 | 페니스탁 대폭락 방지 | 낮음 |
| P1 | 진화 전략 코드 생성 시 syntax 검증 추가 | 진화 시스템 정상화 | 낮음 |
### 5.2 구조적 개선 현황 (2026-03-01 기준)
### 5.2 구조적 개선 (아키텍처 변경)
**완료 항목 (모니터링 단계):**
| 항목 | 이슈 | 상태 |
|------|------|------|
| SELL PnL 계산을 sell_qty 기준으로 수정 (ROOT-6) | #322 | ✅ 머지 |
| v2 staged exit 피처 공급 + 독립 파라미터 설정 (ROOT-5) | #325 | ✅ 머지 |
| BUY 매칭 키에 exchange_code 추가 (ROOT-7) | #323 | ✅ 머지 |
| 블랙아웃 복구 주문 `log_trade()` 추가 (GAP-4) | #324 | ✅ 머지 |
| 세션 전환 리스크 파라미터 동적 재로딩 (GAP-3) | #327 | ✅ 머지 |
| session_id 거래/의사결정 로그 명시 전달 (GAP-1, GAP-2) | #326 | ✅ 머지 |
| 블랙아웃 복구 가격/세션 재검증 강화 (GAP-4 잔여) | #328 | ✅ 머지 |
**잔여 개선 항목:**
| 우선순위 | 방안 | 난이도 |
|----------|------|--------|
| P1 | US 시장 ATR 공급 경로 완성 (ROOT-5 잔여) | 중간 |
| P1 | FX PnL 운영 활성화 (REQ-V3-007) | 낮음 |
| P2 | pred_down_prob ML 모델 대체 (ROOT-5 잔여) | 높음 |
| P2 | 세션 경계 E2E 통합 테스트 보강 (GAP-3 잔여) | 낮음 |
| 우선순위 | 방안 | 예상 효과 | 난이도 |
|----------|------|-----------|--------|
| **P0** | **SELL PnL 계산을 sell_qty 기준으로 수정 (ROOT-6)** | 손익 계상 정확도 확보, 이상치 제거 | 낮음 |
| **P0** | **v2 staged exit에 실제 피처 공급 (atr_value, pred_down_prob) + 독립 파라미터 설정 (ROOT-5)** | v2 설계 가치 실현, 수익 보호 | 중간 |
| P0 | BUY 매칭 키에 exchange_code 추가 (ROOT-7) | 오매칭 방지 | 낮음 |
| P0 | 블랙아웃 복구 주문에 `log_trade()` 추가 (GAP-4) | DB/성과 리포트 정합성 | 낮음 |
| P1 | 세션 전환 시 리스크 파라미터 동적 재로딩 (GAP-3 해소) | 세션별 최적 파라미터 적용 | 중간 |
| P1 | session_id를 거래 로그/의사결정 로그에 명시적 전달 (GAP-1,2 해소) | 세션별 성과 분석 가능 | 낮음 |
| P2 | 블랙아웃 복구 시 가격/세션 재검증 강화 (GAP-4 잔여) | 세션 변경 후 무효 주문 방지 | 중간 |
### 5.3 권장 실행 순서
@@ -366,26 +334,14 @@ Phase 3 (중기): v3 세션 최적화
- ✅ 블랙아웃 복구 후 유효 intent 실행 (`tests/test_main.py:5811`)
- ✅ 블랙아웃 복구 후 정책 거부 intent 드롭 (`tests/test_main.py:5851`)
### 테스트 추가됨 (Phase 1~3, 2026-03-01)
### 테스트 미존재
- ✅ KR ATR 기반 동적 hard stop (`test_main.py`#318)
- ✅ 재진입 쿨다운 (손절 후 동일 종목 매수 차단) (`test_main.py`#319)
- ✅ US 최소 가격 필터 ($5 이하 차단) (`test_main.py`#320)
- ✅ 진화 전략 syntax 검증 (`test_evolution.py`#321)
- ✅ SELL PnL sell_qty 기준 계산 (`test_main.py`#322)
- ✅ BUY 매칭 키 exchange_code 포함 (`test_db.py`#323)
- ✅ 블랙아웃 복구 주문 DB 기록 (`test_main.py`#324)
- ✅ staged exit에 실제 ATR/RSI 피처 공급 (`test_main.py`#325)
- ✅ session_id 거래/의사결정 로그 명시적 전달 (`test_main.py`, `test_decision_logger.py`#326)
- ✅ 블랙아웃 복구 후 유효 intent 실행 (`tests/test_main.py:5811`)
- ✅ 블랙아웃 복구 후 정책 거부 intent 드롭 (`tests/test_main.py:5851`)
### 테스트 미존재 (잔여)
- ❌ 세션 전환 훅 콜백 (GAP-3 잔여)
- ❌ 세션 경계 리스크 파라미터 재로딩 단위 테스트 (GAP-3 잔여)
- ❌ 세션 전환 훅 콜백
- ❌ 세션 경계 리스크 파라미터 재로딩
- ❌ DecisionLogger session_id 캡처
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
-FX PnL 운영 활성화 검증 (GAP-6)
-블랙아웃 복구 주문의 DB 기록 검증
- ❌ SELL PnL 계산 시 수량 불일치 케이스
---

View File

@@ -1,19 +1,16 @@
<!--
Doc-ID: DOC-ACTION-085
Version: 1.1.0
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-03-01
Updated: 2026-02-28
-->
# 손실 복구 실행 계획
작성일: 2026-02-28
최종 업데이트: 2026-03-01 (Phase 1~3 완료 상태 반영)
기반 문서: [80_implementation_audit.md](./80_implementation_audit.md) (ROOT 7개 + GAP 5개)
> **2026-03-01 현황**: Phase 1 ✅ 완료, Phase 2 ✅ 완료, Phase 3 ✅ 기본 완료 (ACT-13 고도화 잔여)
---
## 1. 요약
@@ -38,13 +35,13 @@ Updated: 2026-03-01
## 2. Phase별 작업 분해
### Phase 1: 즉시 — 손실 출혈 차단 ✅ 완료
### Phase 1: 즉시 — 손실 출혈 차단
가장 큰 손실 패턴(노이즈 손절, 반복 매매, 페니스탁)을 즉시 제거한다.
---
#### ACT-01: KR 손절선 ATR 기반 동적 확대 ✅ 머지
#### ACT-01: KR 손절선 ATR 기반 동적 확대
- **ROOT 참조**: ROOT-1 (hard_stop_pct -2%가 KR 소형주 변동성 대비 과소)
- **Gitea 이슈**: feat: KR 손절선 ATR 기반 동적 확대 (-2% → ATR 적응형)
@@ -63,7 +60,7 @@ Updated: 2026-03-01
---
#### ACT-02: 손절 후 동일 종목 재진입 쿨다운 ✅ 머지
#### ACT-02: 손절 후 동일 종목 재진입 쿨다운
- **ROOT 참조**: ROOT-2 (동일 종목 반복 매매)
- **Gitea 이슈**: feat: 손절 후 동일 종목 재진입 쿨다운 (1~2시간)
@@ -82,7 +79,7 @@ Updated: 2026-03-01
---
#### ACT-03: US $5 이하 종목 진입 차단 필터 ✅ 머지
#### ACT-03: US $5 이하 종목 진입 차단 필터
- **ROOT 참조**: ROOT-3 (미국 페니스탁 무분별 진입)
- **Gitea 이슈**: feat: US $5 이하 종목 진입 차단 필터
@@ -100,7 +97,7 @@ Updated: 2026-03-01
---
#### ACT-04: 진화 전략 코드 생성 시 syntax 검증 추가 ✅ 머지
#### ACT-04: 진화 전략 코드 생성 시 syntax 검증 추가
- **ROOT 참조**: ROOT-4 (진화 전략 문법 오류)
- **Gitea 이슈**: fix: 진화 전략 코드 생성 시 syntax 검증 추가
@@ -119,13 +116,13 @@ Updated: 2026-03-01
---
### Phase 2: 단기 — 데이터 정합성 + v2 실효화 ✅ 완료
### Phase 2: 단기 — 데이터 정합성 + v2 실효화
손익 계산 정확도를 확보하고, v2 청산 로직을 실효화한다.
---
#### ACT-05: SELL PnL 계산을 sell_qty 기준으로 수정 ✅ 머지
#### ACT-05: SELL PnL 계산을 sell_qty 기준으로 수정
- **ROOT 참조**: ROOT-6 (CRITICAL — PnL 계산이 buy_qty 사용)
- **Gitea 이슈**: fix(critical): SELL PnL 계산을 sell_qty 기준으로 수정
@@ -144,7 +141,7 @@ Updated: 2026-03-01
---
#### ACT-06: BUY 매칭 키에 exchange_code 추가 ✅ 머지
#### ACT-06: BUY 매칭 키에 exchange_code 추가
- **ROOT 참조**: ROOT-7 (BUY 매칭 키에 exchange_code 미포함)
- **Gitea 이슈**: fix: BUY 매칭 키에 exchange_code 추가
@@ -162,12 +159,12 @@ Updated: 2026-03-01
---
#### ACT-07: 블랙아웃 복구 주문에 log_trade() 추가 ✅ 머지
#### ACT-07: 블랙아웃 복구 주문에 log_trade() 추가
- **ROOT 참조**: GAP-4 (블랙아웃 복구 주문 DB 미기록)
- **Gitea 이슈**: fix: 블랙아웃 복구 주문에 log_trade() 추가
- **Gitea 이슈 번호**: #324
- **변경 대상 파일**: `src/main.py` `process_blackout_recovery_orders()` 함수 내 복구 주문 실행 경로
- **변경 대상 파일**: `src/main.py` (line 694-791, 블랙아웃 복구 실행 경로)
- **현재 동작**: 블랙아웃 복구 주문이 실행되나 `log_trade()` 호출 없음 → DB에 기록 안 됨
- **목표 동작**: 복구 주문 실행 후 `log_trade()` 호출하여 DB에 기록. rationale에 `[blackout-recovery]` prefix 추가
- **수용 기준**:
@@ -181,7 +178,7 @@ Updated: 2026-03-01
---
#### ACT-08: v2 staged exit에 실제 피처 공급 ✅ 머지
#### ACT-08: v2 staged exit에 실제 피처 공급
- **ROOT 참조**: ROOT-5 (v2 청산 로직 실효성 부족)
- **Gitea 이슈**: feat: v2 staged exit에 실제 피처(ATR, pred_down_prob) 공급
@@ -203,7 +200,7 @@ Updated: 2026-03-01
---
#### ACT-09: session_id를 거래/의사결정 로그에 명시적 전달 ✅ 머지
#### ACT-09: session_id를 거래/의사결정 로그에 명시적 전달
- **ROOT 참조**: GAP-1 (DecisionLogger session_id 미포함), GAP-2 (log_trade session_id 미전달)
- **Gitea 이슈**: feat: session_id를 거래/의사결정 로그에 명시적 전달
@@ -226,13 +223,13 @@ Updated: 2026-03-01
---
### Phase 3: 중기 — v3 세션 최적화 ✅ 기본 완료 (ACT-13 고도화 잔여)
### Phase 3: 중기 — v3 세션 최적화
세션 경계 처리와 운영 거버넌스를 강화한다.
---
#### ACT-10: 세션 전환 시 리스크 파라미터 동적 재로딩 ✅ 머지
#### ACT-10: 세션 전환 시 리스크 파라미터 동적 재로딩
- **ROOT 참조**: GAP-3 (세션 전환 시 리스크 파라미터 재로딩 없음)
- **Gitea 이슈**: feat: 세션 전환 시 리스크 파라미터 동적 재로딩
@@ -244,12 +241,14 @@ Updated: 2026-03-01
- NXT_AFTER → KRX_REG 전환 시 파라미터 재로딩 확인
- 재로딩 이벤트 로그 기록
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- **테스트**: `test_main.py`에 설정 오버라이드/리로드/폴백 단위 테스트 포함. **잔여**: 세션 경계 실시간 전환 E2E 보강
- **테스트 계획**:
- 단위: 세션 전환 훅 콜백 테스트
- 단위: 재로딩 실패 시 폴백 테스트
- **의존성**: ACT-09 (session_id 인프라)
---
#### ACT-11: 블랙아웃 복구 시 가격/세션 재검증 강화 ✅ 머지
#### ACT-11: 블랙아웃 복구 시 가격/세션 재검증 강화
- **ROOT 참조**: GAP-4 잔여 (가격 유효성, 세션 변경 재적용 미구현)
- **Gitea 이슈**: feat: 블랙아웃 복구 시 가격/세션 재검증 강화
@@ -269,7 +268,7 @@ Updated: 2026-03-01
---
#### ACT-12: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환 ✅ 머지
#### ACT-12: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환
- **ROOT 참조**: GAP-5 (시간장벽이 봉 개수 고정)
- **Gitea 이슈**: feat: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환
@@ -287,13 +286,21 @@ Updated: 2026-03-01
---
#### ACT-13: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑) ✅ 기본 구현 완료, 고도화 잔여
#### ACT-13: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑)
- **ROOT 참조**: REQ-OPS-002 (정책 변경 시 레지스트리 업데이트 강제), REQ-OPS-003 (TASK-REQ 매핑 강제)
- **Gitea 이슈**: infra: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑)
- **Gitea 이슈 번호**: #330
- **현재 동작**: `.gitea/workflows/ci.yml`에서 `scripts/validate_governance_assets.py` + `scripts/validate_ouroboros_docs.py` 자동 실행
- **잔여 고도화**: PR 본문 REQ/TASK/TEST 강제 레벨 상향, 정책 파일 미업데이트 시 CI 실패 기준 강화
- **변경 대상 파일**: `.gitea/workflows/`, `scripts/validate_governance_assets.py`
- **현재 동작**: CI 자동 검증 없음. 문서 검증은 수동 실행
- **목표 동작**:
- PR 시 정책 레지스트리(`01_requirements_registry.md`) 변경 여부 자동 검증
- TASK/이슈가 REQ-ID를 참조하는지 자동 검증
- **수용 기준**:
- 정책 파일 변경 시 레지스트리 미업데이트면 CI 실패
- 새 이슈/PR에 REQ-ID 미참조 시 경고
- **테스트 계획**:
- CI 파이프라인 자체 테스트 (정상/실패 케이스)
- **의존성**: 없음
---
@@ -304,7 +311,7 @@ Updated: 2026-03-01
- 모든 ACT 항목에 대해 개별 테스트 작성
- 커버리지 >= 80% 유지
- 현재 CI 기준 전체 테스트 통과 확인 (2026-03-01 기준 998 tests collected)
- 기존 551개 테스트 전체 통과 확인
### 3.2 통합 테스트
@@ -382,36 +389,4 @@ Phase 3
---
## 6. 미진 사항 (2026-03-01 기준)
Phase 1~3 구현 완료 후에도 다음 항목이 운영상 미완료 상태이다.
### 6.1 운영 검증 필요
| 항목 | 설명 | 우선순위 |
|------|------|----------|
| FX PnL 운영 활성화 | `fx_pnl`/`strategy_pnl` 컬럼 존재하나 모든 운영 데이터 값이 0 | P1 |
| 세션 경계 E2E 통합 테스트 보강 | `test_main.py`에 단위 테스트 존재; 세션 경계 실시간 전환 E2E 미작성 | P2 |
| v2 상태기계 통합 end-to-end | 실거래 경로에서 HOLDING→BE_LOCK→ARMED→EXITED 전체 시나리오 테스트 미작성 | P2 |
### 6.2 아키텍처 수준 잔여 갭
| 항목 | 설명 | 배경 문서 |
|------|------|-----------|
| CI 자동 검증 고도화 (#330) | 기본 구현 완료(`validate_governance_assets.py` CI 연동); 규칙/강제수준 고도화 필요 | REQ-OPS-002, REQ-OPS-003 |
| pred_down_prob ML 모델 대체 | 현재 RSI 프록시 사용 — 추후 실제 GBDT/ML 모델로 대체 권장 | ROOT-5, ouroboros_plan_v2.txt §3.D |
| KR/US 파라미터 민감도 분석 | v2 계획의 be_arm_pct/arm_pct/atr_k 최적값 탐색 미수행 | ouroboros_plan_v2.txt §8 |
### 6.3 v3 실험 매트릭스 미착수
ouroboros_plan_v3.txt §9에 정의된 3개 실험이 아직 시작되지 않았다.
| 실험 ID | 시장 | 포커스 | 상태 |
|---------|------|--------|------|
| EXP-KR-01 | KR | NXT 야간 특화 (p_thresh 0.65) | ❌ 미착수 |
| EXP-US-01 | US | 21h 준연속 운용 (atr_k 2.5) | ❌ 미착수 |
| EXP-HYB-01 | Global | KR 낮 + US 밤 연계 레짐 자산배분 | ❌ 미착수 |
---
*끝.*

View File

@@ -18,15 +18,13 @@ Updated: 2026-02-26
4. v3 실행 지시서: [20_phase_v3_execution.md](./20_phase_v3_execution.md)
5. 코드 레벨 작업 지시: [30_code_level_work_orders.md](./30_code_level_work_orders.md)
6. 수용 기준/테스트 계획: [40_acceptance_and_test_plan.md](./40_acceptance_and_test_plan.md)
7. PM 시나리오/이슈 분류 **(A)**: [50_scenario_matrix_and_issue_taxonomy.md](./50_scenario_matrix_and_issue_taxonomy.md)
8. TPM 제어 프로토콜/수용 매트릭스 **(B)**: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
7. PM 시나리오/이슈 분류: [50_scenario_matrix_and_issue_taxonomy.md](./50_scenario_matrix_and_issue_taxonomy.md)
8. TPM 제어 프로토콜/수용 매트릭스: [50_tpm_control_protocol.md](./50_tpm_control_protocol.md)
9. 저장소 강제 설정 체크리스트: [60_repo_enforcement_checklist.md](./60_repo_enforcement_checklist.md)
10. 메인 에이전트 아이디에이션 백로그: [70_main_agent_ideation.md](./70_main_agent_ideation.md)
11. v2/v3 구현 감사 및 수익률 분석: [80_implementation_audit.md](./80_implementation_audit.md)
12. 손실 복구 실행 계획: [85_loss_recovery_action_plan.md](./85_loss_recovery_action_plan.md)
> **참고**: 7번·8번은 `50_` 프리픽스를 공유합니다. (A) = 시나리오/이슈 분류, (B) = TPM 제어 프로토콜.
## 운영 규칙
- 계획 변경은 반드시 `01_requirements_registry.md`의 ID 정의부터 수정한다.

View File

@@ -87,7 +87,7 @@
- 선정 기준 추적 → Evolution 시스템 최적화 가능
- API 장애 시 정적 watchlist로 자동 전환
**참고 (당시 구현 기준):** Realtime 모드 전용으로 설계되었으나, 이후 Daily 경로에서도 스캐너를 사용하도록 변경됨. 해외 fallback도 정적 watchlist → 동적 유니버스(active/recent/holdings)로 전환 (2026-02-16 참조).
**참고:** Realtime 모드 전용. Daily 모드는 배치 효율성을 위해 정적 watchlist 사용.
**이슈/PR:** #76, #77
@@ -388,126 +388,3 @@ Order result: 모의투자 매수주문이 완료 되었습니다. ✓
- `ruff check src/analysis/backtest_pipeline.py tests/test_backtest_pipeline_integration.py`
**이슈/PR:** #305
---
## 2026-02-28 ~ 2026-03-01
### v2/v3 손실 복구 실행 계획 — Phase 1 완료 (#318~#321)
**배경:**
- `docs/ouroboros/80_implementation_audit.md` 감사 결과 식별된 7개 근본 원인(ROOT) 및 5개 구현 갭(GAP) 중
가장 큰 손실 패턴 4개를 Phase 1로 즉시 제거.
**구현 내용:**
1. **ACT-01: KR 손절선 ATR 기반 동적 확대** (#318)
- `src/main.py`, `src/config.py`
- KR 시장: ATR(14) 기반 동적 hard stop (`k=2.0`, 범위 -2%~-7%)
- ATR 미제공 시 기존 -2% 폴백
- ROOT-1 (hard_stop_pct 고정값 과소) 해소
2. **ACT-02: 손절 후 동일 종목 재진입 쿨다운** (#319)
- `src/main.py`, `src/config.py`
- 손절(pnl<0) 후 동일 종목 `COOLDOWN_MINUTES`(기본 120분) 동안 BUY 차단
- 익절에는 미적용
- ROOT-2 (동일 종목 반복 매매) 해소
3. **ACT-03: US $5 이하 종목 진입 차단 필터** (#320)
- `src/main.py`, `src/config.py`
- US 시장 BUY 시 현재가 `US_MIN_PRICE`(기본 $5) 이하 차단
- ROOT-3 (미국 페니스탁 무분별 진입) 해소
4. **ACT-04: 진화 전략 코드 syntax 검증** (#321)
- `src/evolution/optimizer.py`
- `ast.parse()` + `compile()` 선검증 후 통과한 코드만 저장
- ROOT-4 (진화 전략 문법 오류) 해소
**이슈/PR:** #318, #319, #320, #321
---
### v2/v3 손실 복구 실행 계획 — Phase 2 완료 (#322~#326)
**배경:**
- 손익 계산 정확도 확보 및 v2 청산 로직 실효화.
**구현 내용:**
1. **ACT-05: SELL PnL 계산을 sell_qty 기준으로 수정** (#322)
- `src/main.py` (line 1658-1663, 2755-2760)
- `trade_pnl = (trade_price - buy_price) * sell_qty`로 변경
- ROOT-6 (PnL 계산 buy_qty 사용 CRITICAL) 해소
2. **ACT-06: BUY 매칭 키에 exchange_code 추가** (#323)
- `src/db.py`
- `get_latest_buy_trade()``(stock_code, market, exchange_code)` 기준 매칭
- exchange_code NULL인 레거시 데이터 하위 호환 유지
- ROOT-7 (오매칭 리스크) 해소
3. **ACT-07: 블랙아웃 복구 주문에 log_trade() 추가** (#324)
- `src/main.py` (블랙아웃 복구 실행 경로)
- 복구 주문 실행 후 `log_trade()` 호출, rationale에 `[blackout-recovery]` prefix
- GAP-4 (블랙아웃 복구 주문 DB 미기록) 해소
4. **ACT-08: v2 staged exit에 실제 피처 공급** (#325)
- `src/main.py`, `src/strategy/exit_rules.py`
- `atr_value`: ATR(14) 실시간 계산 공급
- `pred_down_prob`: RSI 기반 하락 확률 추정값 공급 (ML 모델 대체 가능)
- `be_arm_pct`/`arm_pct` 독립 파라미터 설정 가능 (take_profit_pct * 0.4 파생 제거)
- ROOT-5 (v2 청산 로직 실효성 부족) 해소
5. **ACT-09: session_id를 거래/의사결정 로그에 명시적 전달** (#326)
- `src/logging/decision_logger.py`, `src/main.py`, `src/db.py`
- `log_decision()`: session_id 파라미터 추가
- `log_trade()`: 런타임 session_id 명시적 전달
- GAP-1, GAP-2 (session_id 미포함) 부분 해소
**이슈/PR:** #322, #323, #324, #325, #326
---
### v2/v3 손실 복구 실행 계획 — Phase 3 부분 완료 (#327~#329)
**배경:**
- 세션 경계 처리 및 시간장벽 캘린더 기반 전환.
**구현 내용:**
1. **ACT-10: 세션 전환 시 리스크 파라미터 동적 재로딩** (#327)
- `src/main.py`, `src/config.py`
- 세션 경계 변경 이벤트 시 `SESSION_RISK_PROFILES_JSON` 기반 재로딩
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
- GAP-3 (세션 전환 시 파라미터 재로딩 없음) 부분 해소
2. **ACT-11: 블랙아웃 복구 시 가격/세션 재검증 강화** (#328)
- `src/main.py`, `src/core/blackout_manager.py`
- 복구 시 현재 시세 조회하여 가격 유효성 검증 (진입가 대비 급등/급락 시 드롭)
- 세션 변경 시 새 세션의 파라미터로 재검증
- GAP-4 잔여 (가격/세션 재검증) 부분 해소
3. **ACT-12: Triple Barrier 시간장벽을 캘린더 시간(분) 기반으로 전환** (#329)
- `src/analysis/triple_barrier.py`
- `max_holding_minutes` (캘린더 분) 기반 전환, 봉 주기 무관 일관 동작
- 기존 `max_holding_bars` deprecated 경고 유지 (하위 호환)
- GAP-5 (시간장벽 봉 개수 고정) 해소
**미완료 (ACT-13):**
- **#330: CI 자동 검증 (정책 레지스트리 + TASK-REQ 매핑)** — 문서 구조화 작업으로 대체 진행 중
**이슈/PR:** #327, #328, #329
---
### v2/v3 문서 구조화 및 감사 문서 작성 (#331)
**배경:**
- Phase 1~3 구현 완료 후 감사 결과와 실행 계획을 문서화
- 기존 감사 문서가 산발적으로 관리되어 통합 정리 필요
**구현 내용:**
- `docs/ouroboros/80_implementation_audit.md` 신규 작성: v2/v3 구현 감사 + 실거래 수익률 분석
- `docs/ouroboros/85_loss_recovery_action_plan.md` 신규 작성: ROOT/GAP 해소 Phase별 실행 계획
- `scripts/audit_queries.sql` 신규 작성: 성과 재현용 표준 집계 SQL
**이슈/PR:** #331

View File

@@ -2,7 +2,7 @@
## Test Structure
**998 tests** across **41 files**. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
**551 tests** across **25 files**. `asyncio_mode = "auto"` in pyproject.toml — async tests need no special decorator.
The `settings` fixture in `conftest.py` provides safe defaults with test credentials and in-memory DB.
@@ -23,8 +23,6 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
- Network error handling
- SSL context configuration
> **Note**: 아래 파일별 테스트 수는 릴리즈 시점 스냅샷이며 실제 수치와 다를 수 있습니다. 현재 정확한 수치는 `pytest --collect-only -q`로 확인하세요.
##### `tests/test_brain.py` (24 tests)
- Valid JSON parsing and markdown-wrapped JSON handling
- Malformed JSON fallback
@@ -92,7 +90,7 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
- Python-first filtering pipeline
- RSI and volume ratio filter logic
- Candidate scoring and ranking
- Fallback to static watchlist (domestic) or dynamic universe (overseas)
- Fallback to static watchlist
#### Context & Memory
@@ -140,8 +138,8 @@ The `settings` fixture in `conftest.py` provides safe defaults with test credent
#### Dashboard
##### `tests/test_dashboard.py` (14 tests)
- FastAPI endpoint responses (10 API routes)
- Status, playbook, scorecard, performance, context, decisions, scenarios, pnl/history, positions
- FastAPI endpoint responses (8 API routes)
- Status, playbook, scorecard, performance, context, decisions, scenarios
- Query parameter handling (market, date, limit)
#### Performance & Quality

View File

@@ -3,19 +3,9 @@
from __future__ import annotations
import subprocess
import sys
import os
import re
from pathlib import Path
REQUIREMENTS_REGISTRY = "docs/ouroboros/01_requirements_registry.md"
TASK_WORK_ORDERS_DOC = "docs/ouroboros/30_code_level_work_orders.md"
TASK_DEF_LINE = re.compile(r"^-\s+`(?P<task_id>TASK-[A-Z0-9-]+-\d{3})`(?P<body>.*)$")
REQ_ID_IN_LINE = re.compile(r"\bREQ-[A-Z0-9-]+-\d{3}\b")
TASK_ID_IN_TEXT = re.compile(r"\bTASK-[A-Z0-9-]+-\d{3}\b")
TEST_ID_IN_TEXT = re.compile(r"\bTEST-[A-Z0-9-]+-\d{3}\b")
def must_contain(path: Path, required: list[str], errors: list[str]) -> None:
if not path.exists():
@@ -27,101 +17,8 @@ def must_contain(path: Path, required: list[str], errors: list[str]) -> None:
errors.append(f"{path}: missing required token -> {token}")
def normalize_changed_path(path: str) -> str:
normalized = path.strip().replace("\\", "/")
if normalized.startswith("./"):
normalized = normalized[2:]
return normalized
def is_policy_file(path: str) -> bool:
normalized = normalize_changed_path(path)
if not normalized.endswith(".md"):
return False
if not normalized.startswith("docs/ouroboros/"):
return False
return normalized != REQUIREMENTS_REGISTRY
def load_changed_files(args: list[str], errors: list[str]) -> list[str]:
if not args:
return []
# Single range input (e.g. BASE..HEAD or BASE...HEAD)
if len(args) == 1 and ".." in args[0]:
range_spec = args[0]
try:
completed = subprocess.run(
["git", "diff", "--name-only", range_spec],
check=True,
capture_output=True,
text=True,
)
except (subprocess.CalledProcessError, FileNotFoundError) as exc:
errors.append(f"failed to load changed files from range '{range_spec}': {exc}")
return []
return [
normalize_changed_path(line)
for line in completed.stdout.splitlines()
if line.strip()
]
return [normalize_changed_path(path) for path in args if path.strip()]
def validate_registry_sync(changed_files: list[str], errors: list[str]) -> None:
if not changed_files:
return
changed_set = set(changed_files)
policy_changed = any(is_policy_file(path) for path in changed_set)
registry_changed = REQUIREMENTS_REGISTRY in changed_set
if policy_changed and not registry_changed:
errors.append(
"policy file changed without updating docs/ouroboros/01_requirements_registry.md"
)
def validate_task_req_mapping(errors: list[str], *, task_doc: Path | None = None) -> None:
path = task_doc or Path(TASK_WORK_ORDERS_DOC)
if not path.exists():
errors.append(f"missing file: {path}")
return
text = path.read_text(encoding="utf-8")
found_task = False
for line in text.splitlines():
m = TASK_DEF_LINE.match(line.strip())
if not m:
continue
found_task = True
if not REQ_ID_IN_LINE.search(m.group("body")):
errors.append(
f"{path}: TASK without REQ mapping -> {m.group('task_id')}"
)
if not found_task:
errors.append(f"{path}: no TASK definitions found")
def validate_pr_traceability(warnings: list[str]) -> None:
title = os.getenv("GOVERNANCE_PR_TITLE", "").strip()
body = os.getenv("GOVERNANCE_PR_BODY", "").strip()
if not title and not body:
return
text = f"{title}\n{body}"
if not REQ_ID_IN_LINE.search(text):
warnings.append("PR text missing REQ-ID reference")
if not TASK_ID_IN_TEXT.search(text):
warnings.append("PR text missing TASK-ID reference")
if not TEST_ID_IN_TEXT.search(text):
warnings.append("PR text missing TEST-ID reference")
def main() -> int:
errors: list[str] = []
warnings: list[str] = []
changed_files = load_changed_files(sys.argv[1:], errors)
pr_template = Path(".gitea/PULL_REQUEST_TEMPLATE.md")
issue_template = Path(".gitea/ISSUE_TEMPLATE/runtime_verification.md")
@@ -184,10 +81,6 @@ def main() -> int:
if not handover_script.exists():
errors.append(f"missing file: {handover_script}")
validate_registry_sync(changed_files, errors)
validate_task_req_mapping(errors)
validate_pr_traceability(warnings)
if errors:
print("[FAIL] governance asset validation failed")
for err in errors:
@@ -195,10 +88,6 @@ def main() -> int:
return 1
print("[OK] governance assets validated")
if warnings:
print(f"[WARN] governance advisory: {len(warnings)}")
for warn in warnings:
print(f"- {warn}")
return 0

View File

@@ -8,10 +8,8 @@ from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from statistics import mean
from typing import Literal
from typing import cast
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
@@ -24,7 +22,6 @@ class BacktestBar:
low: float
close: float
session_id: str
timestamp: datetime | None = None
@dataclass(frozen=True)
@@ -89,27 +86,16 @@ def run_v2_backtest_pipeline(
highs = [float(bar.high) for bar in bars]
lows = [float(bar.low) for bar in bars]
closes = [float(bar.close) for bar in bars]
timestamps = [bar.timestamp for bar in bars]
normalized_entries = sorted(set(int(i) for i in entry_indices))
if normalized_entries[0] < 0 or normalized_entries[-1] >= len(bars):
raise IndexError("entry index out of range")
resolved_timestamps: list[datetime] | None = None
if triple_barrier_spec.max_holding_minutes is not None:
if any(ts is None for ts in timestamps):
raise ValueError(
"BacktestBar.timestamp is required for all bars when "
"triple_barrier_spec.max_holding_minutes is set"
)
resolved_timestamps = cast(list[datetime], timestamps)
labels_by_bar_index: dict[int, int] = {}
for idx in normalized_entries:
labels_by_bar_index[idx] = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=resolved_timestamps,
entry_index=idx,
side=side,
spec=triple_barrier_spec,

View File

@@ -5,9 +5,7 @@ Implements first-touch labeling with upper/lower/time barriers.
from __future__ import annotations
import warnings
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal, Sequence
@@ -18,18 +16,9 @@ TieBreakMode = Literal["stop_first", "take_first"]
class TripleBarrierSpec:
take_profit_pct: float
stop_loss_pct: float
max_holding_bars: int | None = None
max_holding_minutes: int | None = None
max_holding_bars: int
tie_break: TieBreakMode = "stop_first"
def __post_init__(self) -> None:
if self.max_holding_minutes is None and self.max_holding_bars is None:
raise ValueError("one of max_holding_minutes or max_holding_bars must be set")
if self.max_holding_minutes is not None and self.max_holding_minutes <= 0:
raise ValueError("max_holding_minutes must be positive")
if self.max_holding_bars is not None and self.max_holding_bars <= 0:
raise ValueError("max_holding_bars must be positive")
@dataclass(frozen=True)
class TripleBarrierLabel:
@@ -46,7 +35,6 @@ def label_with_triple_barrier(
highs: Sequence[float],
lows: Sequence[float],
closes: Sequence[float],
timestamps: Sequence[datetime] | None = None,
entry_index: int,
side: int,
spec: TripleBarrierSpec,
@@ -65,6 +53,8 @@ def label_with_triple_barrier(
raise ValueError("highs, lows, closes lengths must match")
if entry_index < 0 or entry_index >= len(closes):
raise IndexError("entry_index out of range")
if spec.max_holding_bars <= 0:
raise ValueError("max_holding_bars must be positive")
entry_price = float(closes[entry_index])
if entry_price <= 0:
@@ -78,31 +68,13 @@ def label_with_triple_barrier(
upper = entry_price * (1.0 + spec.stop_loss_pct)
lower = entry_price * (1.0 - spec.take_profit_pct)
if spec.max_holding_minutes is not None:
if timestamps is None:
raise ValueError("timestamps are required when max_holding_minutes is set")
if len(timestamps) != len(closes):
raise ValueError("timestamps length must match OHLC lengths")
expiry_timestamp = timestamps[entry_index] + timedelta(minutes=spec.max_holding_minutes)
last_index = entry_index
for idx in range(entry_index + 1, len(closes)):
if timestamps[idx] > expiry_timestamp:
break
last_index = idx
else:
assert spec.max_holding_bars is not None
warnings.warn(
"TripleBarrierSpec.max_holding_bars is deprecated; use max_holding_minutes with timestamps instead.",
DeprecationWarning,
stacklevel=2,
)
last_index = min(len(closes) - 1, entry_index + spec.max_holding_bars)
for idx in range(entry_index + 1, last_index + 1):
high_price = float(highs[idx])
low_price = float(lows[idx])
h = float(highs[idx])
l = float(lows[idx])
up_touch = high_price >= upper
down_touch = low_price <= lower
up_touch = h >= upper
down_touch = l <= lower
if not up_touch and not down_touch:
continue

View File

@@ -60,16 +60,8 @@ class Settings(BaseSettings):
# This value is used as a fallback when the balance API returns 0 in paper mode.
PAPER_OVERSEAS_CASH: float = Field(default=50000.0, ge=0.0)
USD_BUFFER_MIN: float = Field(default=1000.0, ge=0.0)
US_MIN_PRICE: float = Field(default=5.0, ge=0.0)
STAGED_EXIT_BE_ARM_PCT: float = Field(default=1.2, gt=0.0, le=30.0)
STAGED_EXIT_ARM_PCT: float = Field(default=3.0, gt=0.0, le=100.0)
STOPLOSS_REENTRY_COOLDOWN_MINUTES: int = Field(default=120, ge=1, le=1440)
KR_ATR_STOP_MULTIPLIER_K: float = Field(default=2.0, ge=0.1, le=10.0)
KR_ATR_STOP_MIN_PCT: float = Field(default=-2.0, le=0.0)
KR_ATR_STOP_MAX_PCT: float = Field(default=-7.0, le=0.0)
OVERNIGHT_EXCEPTION_ENABLED: bool = True
SESSION_RISK_RELOAD_ENABLED: bool = True
SESSION_RISK_PROFILES_JSON: str = "{}"
# Trading frequency mode (daily = batch API calls, realtime = per-stock calls)
TRADE_MODE: str = Field(default="daily", pattern="^(daily|realtime)$")
@@ -78,8 +70,6 @@ class Settings(BaseSettings):
ORDER_BLACKOUT_ENABLED: bool = True
ORDER_BLACKOUT_WINDOWS_KST: str = "23:30-00:10"
ORDER_BLACKOUT_QUEUE_MAX: int = Field(default=500, ge=10, le=5000)
BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED: bool = True
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT: float = Field(default=5.0, ge=0.0, le=100.0)
# Pre-Market Planner
PRE_MARKET_MINUTES: int = Field(default=30, ge=10, le=120)

View File

@@ -109,7 +109,6 @@ def init_db(db_path: str) -> sqlite3.Connection:
stock_code TEXT NOT NULL,
market TEXT NOT NULL,
exchange_code TEXT NOT NULL,
session_id TEXT DEFAULT 'UNKNOWN',
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT NOT NULL,
@@ -122,27 +121,6 @@ def init_db(db_path: str) -> sqlite3.Connection:
)
"""
)
decision_columns = {
row[1]
for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()
}
if "session_id" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN session_id TEXT DEFAULT 'UNKNOWN'")
conn.execute(
"""
UPDATE decision_logs
SET session_id = 'UNKNOWN'
WHERE session_id IS NULL OR session_id = ''
"""
)
if "outcome_pnl" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN outcome_pnl REAL")
if "outcome_accuracy" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN outcome_accuracy INTEGER")
if "reviewed" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN reviewed INTEGER DEFAULT 0")
if "review_notes" not in decision_columns:
conn.execute("ALTER TABLE decision_logs ADD COLUMN review_notes TEXT")
conn.execute(
"""
@@ -312,34 +290,9 @@ def _resolve_session_id(*, market: str, session_id: str | None) -> str:
def get_latest_buy_trade(
conn: sqlite3.Connection,
stock_code: str,
market: str,
exchange_code: str | None = None,
conn: sqlite3.Connection, stock_code: str, market: str
) -> dict[str, Any] | None:
"""Fetch the most recent BUY trade for a stock and market."""
if exchange_code:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity
FROM trades
WHERE stock_code = ?
AND market = ?
AND action = 'BUY'
AND decision_id IS NOT NULL
AND (
exchange_code = ?
OR exchange_code IS NULL
OR exchange_code = ''
)
ORDER BY
CASE WHEN exchange_code = ? THEN 0 ELSE 1 END,
timestamp DESC
LIMIT 1
""",
(stock_code, market, exchange_code, exchange_code),
)
else:
cursor = conn.execute(
"""
SELECT decision_id, price, quantity

View File

@@ -9,7 +9,6 @@ This module:
from __future__ import annotations
import ast
import json
import logging
import sqlite3
@@ -29,24 +28,24 @@ from src.logging.decision_logger import DecisionLogger
logger = logging.getLogger(__name__)
STRATEGIES_DIR = Path("src/strategies")
STRATEGY_TEMPLATE = """\
\"\"\"Auto-generated strategy: {name}
STRATEGY_TEMPLATE = textwrap.dedent("""\
\"\"\"Auto-generated strategy: {name}
Generated at: {timestamp}
Rationale: {rationale}
\"\"\"
Generated at: {timestamp}
Rationale: {rationale}
\"\"\"
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
from __future__ import annotations
from typing import Any
from src.strategies.base import BaseStrategy
class {class_name}(BaseStrategy):
class {class_name}(BaseStrategy):
\"\"\"Strategy: {name}\"\"\"
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
{body}
"""
{body}
""")
class EvolutionOptimizer:
@@ -236,8 +235,7 @@ class EvolutionOptimizer:
file_path = STRATEGIES_DIR / file_name
# Indent the body for the class method
normalized_body = textwrap.dedent(body).strip()
indented_body = textwrap.indent(normalized_body, " ")
indented_body = textwrap.indent(body, " ")
# Generate rationale from patterns
rationale = f"Auto-evolved from {len(failures)} failures. "
@@ -249,16 +247,9 @@ class EvolutionOptimizer:
timestamp=datetime.now(UTC).isoformat(),
rationale=rationale,
class_name=class_name,
body=indented_body.rstrip(),
body=indented_body.strip(),
)
try:
parsed = ast.parse(content, filename=str(file_path))
compile(parsed, filename=str(file_path), mode="exec")
except SyntaxError as exc:
logger.warning("Generated strategy failed syntax validation: %s", exc)
return None
file_path.write_text(content)
logger.info("Generated strategy file: %s", file_path)
return file_path

View File

@@ -19,7 +19,6 @@ class DecisionLog:
stock_code: str
market: str
exchange_code: str
session_id: str
action: str
confidence: int
rationale: str
@@ -48,7 +47,6 @@ class DecisionLogger:
rationale: str,
context_snapshot: dict[str, Any],
input_data: dict[str, Any],
session_id: str | None = None,
) -> str:
"""Log a trading decision with full context.
@@ -61,22 +59,20 @@ class DecisionLogger:
rationale: Reasoning for the decision
context_snapshot: L1-L7 context snapshot at decision time
input_data: Market data inputs (price, volume, orderbook, etc.)
session_id: Runtime session identifier
Returns:
decision_id: Unique identifier for this decision
"""
decision_id = str(uuid.uuid4())
timestamp = datetime.now(UTC).isoformat()
resolved_session = session_id or "UNKNOWN"
self.conn.execute(
"""
INSERT INTO decision_logs (
decision_id, timestamp, stock_code, market, exchange_code,
session_id, action, confidence, rationale, context_snapshot, input_data
action, confidence, rationale, context_snapshot, input_data
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
decision_id,
@@ -84,7 +80,6 @@ class DecisionLogger:
stock_code,
market,
exchange_code,
resolved_session,
action,
confidence,
rationale,
@@ -111,7 +106,7 @@ class DecisionLogger:
query = """
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
session_id, action, confidence, rationale, context_snapshot, input_data,
action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE reviewed = 0 AND confidence >= ?
@@ -173,7 +168,7 @@ class DecisionLogger:
"""
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
session_id, action, confidence, rationale, context_snapshot, input_data,
action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE decision_id = ?
@@ -201,7 +196,7 @@ class DecisionLogger:
"""
SELECT
decision_id, timestamp, stock_code, market, exchange_code,
session_id, action, confidence, rationale, context_snapshot, input_data,
action, confidence, rationale, context_snapshot, input_data,
outcome_pnl, outcome_accuracy, reviewed, review_notes
FROM decision_logs
WHERE confidence >= ?
@@ -228,14 +223,13 @@ class DecisionLogger:
stock_code=row[2],
market=row[3],
exchange_code=row[4],
session_id=row[5] or "UNKNOWN",
action=row[6],
confidence=row[7],
rationale=row[8],
context_snapshot=json.loads(row[9]),
input_data=json.loads(row[10]),
outcome_pnl=row[11],
outcome_accuracy=row[12],
reviewed=bool(row[13]),
review_notes=row[14],
action=row[5],
confidence=row[6],
rationale=row[7],
context_snapshot=json.loads(row[8]),
input_data=json.loads(row[9]),
outcome_pnl=row[10],
outcome_accuracy=row[11],
reviewed=bool(row[12]),
review_notes=row[13],
)

View File

@@ -71,11 +71,6 @@ _SESSION_CLOSE_WINDOWS = {"NXT_AFTER", "US_AFTER"}
_RUNTIME_EXIT_STATES: dict[str, PositionState] = {}
_RUNTIME_EXIT_PEAKS: dict[str, float] = {}
_STOPLOSS_REENTRY_COOLDOWN_UNTIL: dict[str, float] = {}
_VOLATILITY_ANALYZER = VolatilityAnalyzer()
_SESSION_RISK_PROFILES_RAW = "{}"
_SESSION_RISK_PROFILES_MAP: dict[str, dict[str, Any]] = {}
_SESSION_RISK_LAST_BY_MARKET: dict[str, str] = {}
_SESSION_RISK_OVERRIDES_BY_MARKET: dict[str, dict[str, Any]] = {}
def safe_float(value: str | float | None, default: float = 0.0) -> float:
@@ -116,256 +111,14 @@ DAILY_TRADE_SESSIONS = 4 # Number of trading sessions per day
TRADE_SESSION_INTERVAL_HOURS = 6 # Hours between sessions
def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> int:
"""Choose quantity basis for SELL outcome PnL with safe fallback."""
resolved_sell = int(sell_qty or 0)
if resolved_sell > 0:
return resolved_sell
return max(0, int(buy_qty or 0))
def _compute_kr_dynamic_stop_loss_pct(
*,
market: MarketInfo | None = None,
entry_price: float,
atr_value: float,
fallback_stop_loss_pct: float,
settings: Settings | None,
) -> float:
"""Compute KR dynamic hard-stop threshold in percent."""
if entry_price <= 0 or atr_value <= 0:
return fallback_stop_loss_pct
k = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MULTIPLIER_K",
default=2.0,
)
min_pct = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MIN_PCT",
default=-2.0,
)
max_pct = _resolve_market_setting(
market=market,
settings=settings,
key="KR_ATR_STOP_MAX_PCT",
default=-7.0,
)
if max_pct > min_pct:
min_pct, max_pct = max_pct, min_pct
dynamic_stop_pct = -((k * atr_value) / entry_price) * 100.0
return max(max_pct, min(min_pct, dynamic_stop_pct))
def _stoploss_cooldown_key(*, market: MarketInfo, stock_code: str) -> str:
return f"{market.code}:{stock_code}"
def _parse_session_risk_profiles(settings: Settings | None) -> dict[str, dict[str, Any]]:
def _stoploss_cooldown_minutes(settings: Settings | None) -> int:
if settings is None:
return {}
global _SESSION_RISK_PROFILES_RAW, _SESSION_RISK_PROFILES_MAP
raw = str(getattr(settings, "SESSION_RISK_PROFILES_JSON", "{}") or "{}")
if raw == _SESSION_RISK_PROFILES_RAW:
return _SESSION_RISK_PROFILES_MAP
parsed_map: dict[str, dict[str, Any]] = {}
try:
decoded = json.loads(raw)
if isinstance(decoded, dict):
for session_id, session_values in decoded.items():
if isinstance(session_id, str) and isinstance(session_values, dict):
parsed_map[session_id] = session_values
except (ValueError, TypeError) as exc:
logger.warning("Invalid SESSION_RISK_PROFILES_JSON; using defaults: %s", exc)
parsed_map = {}
_SESSION_RISK_PROFILES_RAW = raw
_SESSION_RISK_PROFILES_MAP = parsed_map
return _SESSION_RISK_PROFILES_MAP
def _coerce_setting_value(*, value: Any, default: Any) -> Any:
if isinstance(default, bool):
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
if isinstance(value, (int, float)):
return value != 0
return default
if isinstance(default, int) and not isinstance(default, bool):
try:
return int(value)
except (ValueError, TypeError):
return default
if isinstance(default, float):
return safe_float(value, float(default))
if isinstance(default, str):
return str(value)
return value
def _session_risk_overrides(
*,
market: MarketInfo | None,
settings: Settings | None,
) -> dict[str, Any]:
if market is None or settings is None:
return {}
if not bool(getattr(settings, "SESSION_RISK_RELOAD_ENABLED", True)):
return {}
session_id = get_session_info(market).session_id
previous_session = _SESSION_RISK_LAST_BY_MARKET.get(market.code)
if previous_session == session_id:
return _SESSION_RISK_OVERRIDES_BY_MARKET.get(market.code, {})
profile_map = _parse_session_risk_profiles(settings)
merged: dict[str, Any] = {}
default_profile = profile_map.get("default")
if isinstance(default_profile, dict):
merged.update(default_profile)
session_profile = profile_map.get(session_id)
if isinstance(session_profile, dict):
merged.update(session_profile)
_SESSION_RISK_LAST_BY_MARKET[market.code] = session_id
_SESSION_RISK_OVERRIDES_BY_MARKET[market.code] = merged
if previous_session is None:
logger.info(
"Session risk profile initialized for %s: %s (overrides=%s)",
market.code,
session_id,
",".join(sorted(merged.keys())) if merged else "none",
)
else:
logger.info(
"Session risk profile reloaded for %s: %s -> %s (overrides=%s)",
market.code,
previous_session,
session_id,
",".join(sorted(merged.keys())) if merged else "none",
)
return merged
def _resolve_market_setting(
*,
market: MarketInfo | None,
settings: Settings | None,
key: str,
default: Any,
) -> Any:
if settings is None:
return default
fallback = getattr(settings, key, default)
overrides = _session_risk_overrides(market=market, settings=settings)
if key not in overrides:
return fallback
return _coerce_setting_value(value=overrides[key], default=fallback)
def _stoploss_cooldown_minutes(
settings: Settings | None,
market: MarketInfo | None = None,
) -> int:
minutes = _resolve_market_setting(
market=market,
settings=settings,
key="STOPLOSS_REENTRY_COOLDOWN_MINUTES",
default=120,
)
return max(1, int(minutes))
def _estimate_pred_down_prob_from_rsi(rsi: float | str | None) -> float:
"""Estimate downside probability from RSI using a simple linear mapping."""
if rsi is None:
return 0.5
rsi_value = max(0.0, min(100.0, safe_float(rsi, 50.0)))
return rsi_value / 100.0
async def _compute_kr_atr_value(
*,
broker: KISBroker,
stock_code: str,
period: int = 14,
) -> float:
"""Compute ATR(period) for KR stocks using daily OHLC."""
days = max(period + 1, 30)
try:
daily_prices = await _retry_connection(
broker.get_daily_prices,
stock_code,
days=days,
label=f"daily_prices:{stock_code}",
)
except ConnectionError as exc:
logger.warning("ATR source unavailable for %s: %s", stock_code, exc)
return 0.0
except Exception as exc:
logger.warning("Unexpected ATR fetch failure for %s: %s", stock_code, exc)
return 0.0
if not isinstance(daily_prices, list):
return 0.0
highs: list[float] = []
lows: list[float] = []
closes: list[float] = []
for row in daily_prices:
if not isinstance(row, dict):
continue
high = safe_float(row.get("high"), 0.0)
low = safe_float(row.get("low"), 0.0)
close = safe_float(row.get("close"), 0.0)
if high <= 0 or low <= 0 or close <= 0:
continue
highs.append(high)
lows.append(low)
closes.append(close)
if len(highs) < period + 1 or len(lows) < period + 1 or len(closes) < period + 1:
return 0.0
return max(0.0, _VOLATILITY_ANALYZER.calculate_atr(highs, lows, closes, period=period))
async def _inject_staged_exit_features(
*,
market: MarketInfo,
stock_code: str,
open_position: dict[str, Any] | None,
market_data: dict[str, Any],
broker: KISBroker | None,
) -> None:
"""Inject ATR/pred_down_prob used by staged exit evaluation."""
if not open_position:
return
if "pred_down_prob" not in market_data:
market_data["pred_down_prob"] = _estimate_pred_down_prob_from_rsi(
market_data.get("rsi")
)
existing_atr = safe_float(market_data.get("atr_value"), 0.0)
if existing_atr > 0:
return
if market.is_domestic and broker is not None:
market_data["atr_value"] = await _compute_kr_atr_value(
broker=broker,
stock_code=stock_code,
)
return
market_data["atr_value"] = 0.0
return 120
return max(1, int(getattr(settings, "STOPLOSS_REENTRY_COOLDOWN_MINUTES", 120)))
async def _retry_connection(coro_factory: Any, *args: Any, label: str = "", **kwargs: Any) -> Any:
@@ -475,7 +228,6 @@ async def sync_positions_from_broker(
price=avg_price,
market=log_market,
exchange_code=market.exchange_code,
session_id=get_session_info(market).session_id,
mode=settings.MODE,
)
logger.info(
@@ -711,14 +463,7 @@ def _should_block_overseas_buy_for_fx_buffer(
):
return False, total_cash - order_amount, 0.0
remaining = total_cash - order_amount
required = float(
_resolve_market_setting(
market=market,
settings=settings,
key="USD_BUFFER_MIN",
default=1000.0,
)
)
required = settings.USD_BUFFER_MIN
return remaining < required, remaining, required
@@ -734,13 +479,7 @@ def _should_force_exit_for_overnight(
return True
if settings is None:
return False
overnight_enabled = _resolve_market_setting(
market=market,
settings=settings,
key="OVERNIGHT_EXCEPTION_ENABLED",
default=True,
)
return not bool(overnight_enabled)
return not settings.OVERNIGHT_EXCEPTION_ENABLED
def _build_runtime_position_key(
@@ -770,7 +509,6 @@ def _apply_staged_exit_override_for_hold(
open_position: dict[str, Any] | None,
market_data: dict[str, Any],
stock_playbook: Any | None,
settings: Settings | None = None,
) -> TradeDecision:
"""Apply v2 staged exit semantics for HOLD positions using runtime state."""
if decision.action != "HOLD" or not open_position:
@@ -786,41 +524,6 @@ def _apply_staged_exit_override_for_hold(
if stock_playbook and stock_playbook.scenarios:
stop_loss_threshold = stock_playbook.scenarios[0].stop_loss_pct
take_profit_threshold = stock_playbook.scenarios[0].take_profit_pct
atr_value = safe_float(market_data.get("atr_value"), 0.0)
if market.code == "KR":
stop_loss_threshold = _compute_kr_dynamic_stop_loss_pct(
market=market,
entry_price=entry_price,
atr_value=atr_value,
fallback_stop_loss_pct=stop_loss_threshold,
settings=settings,
)
if settings is None:
be_arm_pct = max(0.5, take_profit_threshold * 0.4)
arm_pct = take_profit_threshold
else:
be_arm_pct = max(
0.1,
float(
_resolve_market_setting(
market=market,
settings=settings,
key="STAGED_EXIT_BE_ARM_PCT",
default=1.2,
)
),
)
arm_pct = max(
be_arm_pct,
float(
_resolve_market_setting(
market=market,
settings=settings,
key="STAGED_EXIT_ARM_PCT",
default=3.0,
)
),
)
runtime_key = _build_runtime_position_key(
market_code=market.code,
@@ -839,14 +542,14 @@ def _apply_staged_exit_override_for_hold(
current_state=current_state,
config=ExitRuleConfig(
hard_stop_pct=stop_loss_threshold,
be_arm_pct=be_arm_pct,
arm_pct=arm_pct,
be_arm_pct=max(0.5, take_profit_threshold * 0.4),
arm_pct=take_profit_threshold,
),
inp=ExitRuleInput(
current_price=current_price,
entry_price=entry_price,
peak_price=peak_price,
atr_value=atr_value,
atr_value=safe_float(market_data.get("atr_value"), 0.0),
pred_down_prob=safe_float(market_data.get("pred_down_prob"), 0.0),
liquidity_weak=safe_float(market_data.get("volume_ratio"), 1.0) < 1.0,
),
@@ -866,7 +569,7 @@ def _apply_staged_exit_override_for_hold(
elif exit_eval.reason == "arm_take_profit":
rationale = (
f"Take-profit triggered ({pnl_pct:.2f}% >= "
f"{arm_pct:.2f}%)"
f"{take_profit_threshold:.2f}%)"
)
elif exit_eval.reason == "atr_trailing_stop":
rationale = "ATR trailing-stop triggered"
@@ -1004,7 +707,6 @@ async def process_blackout_recovery_orders(
broker: KISBroker,
overseas_broker: OverseasBroker,
db_conn: Any,
settings: Settings | None = None,
) -> None:
intents = BLACKOUT_ORDER_MANAGER.pop_recovery_batch()
if not intents:
@@ -1036,63 +738,6 @@ async def process_blackout_recovery_orders(
continue
try:
revalidation_enabled = bool(
_resolve_market_setting(
market=market,
settings=settings,
key="BLACKOUT_RECOVERY_PRICE_REVALIDATION_ENABLED",
default=True,
)
)
if revalidation_enabled:
if market.is_domestic:
current_price, _, _ = await _retry_connection(
broker.get_current_price,
intent.stock_code,
label=f"recovery_price:{market.code}:{intent.stock_code}",
)
else:
price_data = await _retry_connection(
overseas_broker.get_overseas_price,
market.exchange_code,
intent.stock_code,
label=f"recovery_price:{market.code}:{intent.stock_code}",
)
current_price = safe_float(price_data.get("output", {}).get("last"), 0.0)
queued_price = float(intent.price)
max_drift_pct = float(
_resolve_market_setting(
market=market,
settings=settings,
key="BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT",
default=5.0,
)
)
if queued_price <= 0 or current_price <= 0:
logger.info(
"Drop queued intent by price revalidation (invalid price): %s %s (%s) queued=%.4f current=%.4f",
intent.order_type,
intent.stock_code,
market.code,
queued_price,
current_price,
)
continue
drift_pct = abs(current_price - queued_price) / queued_price * 100.0
if drift_pct > max_drift_pct:
logger.info(
"Drop queued intent by price revalidation: %s %s (%s) queued=%.4f current=%.4f drift=%.2f%% max=%.2f%%",
intent.order_type,
intent.stock_code,
market.code,
queued_price,
current_price,
drift_pct,
max_drift_pct,
)
continue
validate_order_policy(
market=market,
order_type=intent.order_type,
@@ -1116,20 +761,6 @@ async def process_blackout_recovery_orders(
accepted = result.get("rt_cd", "0") == "0"
if accepted:
runtime_session_id = get_session_info(market).session_id
log_trade(
conn=db_conn,
stock_code=intent.stock_code,
action=intent.order_type,
confidence=0,
rationale=f"[blackout-recovery] {intent.source}",
quantity=intent.quantity,
price=float(intent.price),
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
)
logger.info(
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
intent.order_type,
@@ -1370,7 +1001,6 @@ async def trading_cycle(
) -> None:
"""Execute one trading cycle for a single stock."""
cycle_start_time = asyncio.get_event_loop().time()
_session_risk_overrides(market=market, settings=settings)
# 1. Fetch market data
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
@@ -1620,14 +1250,7 @@ async def trading_cycle(
# 2.1. Apply market_outlook-based BUY confidence threshold
if decision.action == "BUY":
base_threshold = int(
_resolve_market_setting(
market=market,
settings=settings,
key="CONFIDENCE_THRESHOLD",
default=80,
)
)
base_threshold = (settings.CONFIDENCE_THRESHOLD if settings else 80)
outlook = playbook.market_outlook
if outlook == MarketOutlook.BEARISH:
min_confidence = 90
@@ -1679,31 +1302,6 @@ async def trading_cycle(
stock_code,
market.name,
)
elif market.code.startswith("US"):
min_price = float(
_resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
)
if current_price <= min_price:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"US minimum price filter blocked BUY "
f"(price={current_price:.4f} <= {min_price:.4f})"
),
)
logger.info(
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
stock_code,
market.name,
current_price,
min_price,
)
if decision.action == "BUY":
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
now_epoch = datetime.now(UTC).timestamp()
@@ -1729,13 +1327,6 @@ async def trading_cycle(
market_code=market.code,
stock_code=stock_code,
)
await _inject_staged_exit_features(
market=market,
stock_code=stock_code,
open_position=open_position,
market_data=market_data,
broker=broker,
)
decision = _apply_staged_exit_override_for_hold(
decision=decision,
market=market,
@@ -1743,7 +1334,6 @@ async def trading_cycle(
open_position=open_position,
market_data=market_data,
stock_playbook=stock_playbook,
settings=settings,
)
if open_position and decision.action == "HOLD" and _should_force_exit_for_overnight(
market=market,
@@ -1806,12 +1396,10 @@ async def trading_cycle(
"pnl_pct": pnl_pct,
}
runtime_session_id = get_session_info(market).session_id
decision_id = decision_logger.log_decision(
stock_code=stock_code,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
action=decision.action,
confidence=decision.confidence,
rationale=decision.rationale,
@@ -2076,7 +1664,6 @@ async def trading_cycle(
pnl=0.0,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
mode=settings.MODE if settings else "paper",
)
logger.info("Order result: %s", result.get("msg1", "OK"))
@@ -2096,17 +1683,11 @@ async def trading_cycle(
logger.warning("Telegram notification failed: %s", exc)
if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(
db_conn,
stock_code,
market.code,
exchange_code=market.exchange_code,
)
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 0)
sell_qty = _resolve_sell_qty_for_pnl(sell_qty=quantity, buy_qty=buy_qty)
trade_pnl = (trade_price - buy_price) * sell_qty
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
@@ -2114,7 +1695,7 @@ async def trading_cycle(
)
if trade_pnl < 0:
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
cooldown_minutes = _stoploss_cooldown_minutes(settings, market=market)
cooldown_minutes = _stoploss_cooldown_minutes(settings)
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
datetime.now(UTC).timestamp() + cooldown_minutes * 60
)
@@ -2149,7 +1730,6 @@ async def trading_cycle(
pnl=trade_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
selection_context=selection_context,
decision_id=decision_id,
mode=settings.MODE if settings else "paper",
@@ -2566,12 +2146,10 @@ async def run_daily_session(
# Process each open market
for market in open_markets:
_session_risk_overrides(market=market, settings=settings)
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=settings,
)
# Use market-local date for playbook keying
market_today = datetime.now(market.timezone).date()
@@ -2904,31 +2482,6 @@ async def run_daily_session(
stock_code,
market.name,
)
elif market.code.startswith("US"):
min_price = float(
_resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
)
if stock_data["current_price"] <= min_price:
decision = TradeDecision(
action="HOLD",
confidence=decision.confidence,
rationale=(
f"US minimum price filter blocked BUY "
f"(price={stock_data['current_price']:.4f} <= {min_price:.4f})"
),
)
logger.info(
"BUY suppressed for %s (%s): US min price filter %.4f <= %.4f",
stock_code,
market.name,
stock_data["current_price"],
min_price,
)
if decision.action == "BUY":
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
now_epoch = datetime.now(UTC).timestamp()
@@ -2953,13 +2506,6 @@ async def run_daily_session(
market_code=market.code,
stock_code=stock_code,
)
await _inject_staged_exit_features(
market=market,
stock_code=stock_code,
open_position=daily_open,
market_data=stock_data,
broker=broker,
)
decision = _apply_staged_exit_override_for_hold(
decision=decision,
market=market,
@@ -2967,7 +2513,6 @@ async def run_daily_session(
open_position=daily_open,
market_data=stock_data,
stock_playbook=stock_playbook,
settings=settings,
)
if daily_open and decision.action == "HOLD" and _should_force_exit_for_overnight(
market=market,
@@ -3009,12 +2554,10 @@ async def run_daily_session(
"pnl_pct": pnl_pct,
}
runtime_session_id = get_session_info(market).session_id
decision_id = decision_logger.log_decision(
stock_code=stock_code,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
action=decision.action,
confidence=decision.confidence,
rationale=decision.rationale,
@@ -3266,20 +2809,11 @@ async def run_daily_session(
continue
if decision.action == "SELL" and order_succeeded:
buy_trade = get_latest_buy_trade(
db_conn,
stock_code,
market.code,
exchange_code=market.exchange_code,
)
buy_trade = get_latest_buy_trade(db_conn, stock_code, market.code)
if buy_trade and buy_trade.get("price") is not None:
buy_price = float(buy_trade["price"])
buy_qty = int(buy_trade.get("quantity") or 0)
sell_qty = _resolve_sell_qty_for_pnl(
sell_qty=quantity,
buy_qty=buy_qty,
)
trade_pnl = (trade_price - buy_price) * sell_qty
buy_qty = int(buy_trade.get("quantity") or 1)
trade_pnl = (trade_price - buy_price) * buy_qty
decision_logger.update_outcome(
decision_id=buy_trade["decision_id"],
pnl=trade_pnl,
@@ -3287,10 +2821,7 @@ async def run_daily_session(
)
if trade_pnl < 0:
cooldown_key = _stoploss_cooldown_key(market=market, stock_code=stock_code)
cooldown_minutes = _stoploss_cooldown_minutes(
settings,
market=market,
)
cooldown_minutes = _stoploss_cooldown_minutes(settings)
_STOPLOSS_REENTRY_COOLDOWN_UNTIL[cooldown_key] = (
datetime.now(UTC).timestamp() + cooldown_minutes * 60
)
@@ -3315,7 +2846,6 @@ async def run_daily_session(
pnl=trade_pnl,
market=market.code,
exchange_code=market.exchange_code,
session_id=runtime_session_id,
decision_id=decision_id,
mode=settings.MODE,
)
@@ -4098,7 +3628,6 @@ async def run(settings: Settings) -> None:
break
session_info = get_session_info(market)
_session_risk_overrides(market=market, settings=settings)
logger.info(
"Market session active: %s (%s) session=%s",
market.code,
@@ -4110,7 +3639,6 @@ async def run(settings: Settings) -> None:
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=settings,
)
# Notify market open if it just opened

View File

@@ -1,7 +1,5 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from src.analysis.backtest_cost_guard import BacktestCostModel
from src.analysis.backtest_pipeline import (
BacktestBar,
@@ -14,7 +12,6 @@ from src.analysis.walk_forward_split import generate_walk_forward_splits
def _bars() -> list[BacktestBar]:
base_ts = datetime(2026, 2, 28, 0, 0, tzinfo=UTC)
closes = [100.0, 101.0, 102.0, 101.5, 103.0, 102.5, 104.0, 103.5, 105.0, 104.5, 106.0, 105.5]
bars: list[BacktestBar] = []
for i, close in enumerate(closes):
@@ -24,7 +21,6 @@ def _bars() -> list[BacktestBar]:
low=close - 1.0,
close=close,
session_id="KRX_REG" if i % 2 == 0 else "US_PRE",
timestamp=base_ts + timedelta(minutes=i),
)
)
return bars
@@ -47,7 +43,7 @@ def test_pipeline_happy_path_returns_fold_and_artifact_contract() -> None:
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
max_holding_bars=3,
),
walk_forward=WalkForwardConfig(
train_size=4,
@@ -88,7 +84,7 @@ def test_pipeline_cost_guard_fail_fast() -> None:
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
max_holding_bars=3,
),
walk_forward=WalkForwardConfig(train_size=2, test_size=1),
cost_model=bad,
@@ -123,7 +119,7 @@ def test_pipeline_deterministic_seed_free_deterministic_result() -> None:
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
max_holding_bars=3,
),
walk_forward=WalkForwardConfig(
train_size=4,
@@ -138,31 +134,3 @@ def test_pipeline_deterministic_seed_free_deterministic_result() -> None:
out1 = run_v2_backtest_pipeline(**cfg)
out2 = run_v2_backtest_pipeline(**cfg)
assert out1 == out2
def test_pipeline_rejects_minutes_spec_when_timestamp_missing() -> None:
bars = _bars()
bars[2] = BacktestBar(
high=bars[2].high,
low=bars[2].low,
close=bars[2].close,
session_id=bars[2].session_id,
timestamp=None,
)
try:
run_v2_backtest_pipeline(
bars=bars,
entry_indices=[0, 1, 2, 3],
side=1,
triple_barrier_spec=TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.01,
max_holding_minutes=3,
),
walk_forward=WalkForwardConfig(train_size=2, test_size=1),
cost_model=_cost_model(),
)
except ValueError as exc:
assert "BacktestBar.timestamp is required" in str(exc)
else:
raise AssertionError("expected timestamp validation error")

View File

@@ -3,7 +3,7 @@
import tempfile
import os
from src.db import get_latest_buy_trade, get_open_position, init_db, log_trade
from src.db import get_open_position, init_db, log_trade
def test_get_open_position_returns_latest_buy() -> None:
@@ -329,89 +329,3 @@ def test_log_trade_unknown_market_falls_back_to_unknown_session() -> None:
row = conn.execute("SELECT session_id FROM trades ORDER BY id DESC LIMIT 1").fetchone()
assert row is not None
assert row[0] == "UNKNOWN"
def test_get_latest_buy_trade_prefers_exchange_code_match() -> None:
conn = init_db(":memory:")
log_trade(
conn=conn,
stock_code="AAPL",
action="BUY",
confidence=80,
rationale="legacy",
quantity=10,
price=120.0,
market="US_NASDAQ",
exchange_code="",
decision_id="legacy-buy",
)
log_trade(
conn=conn,
stock_code="AAPL",
action="BUY",
confidence=85,
rationale="matched",
quantity=5,
price=125.0,
market="US_NASDAQ",
exchange_code="NASD",
decision_id="matched-buy",
)
matched = get_latest_buy_trade(
conn,
stock_code="AAPL",
market="US_NASDAQ",
exchange_code="NASD",
)
assert matched is not None
assert matched["decision_id"] == "matched-buy"
def test_decision_logs_session_id_migration_backfills_unknown() -> None:
import sqlite3
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
old_conn = sqlite3.connect(db_path)
old_conn.execute(
"""
CREATE TABLE decision_logs (
decision_id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
stock_code TEXT NOT NULL,
market TEXT NOT NULL,
exchange_code TEXT NOT NULL,
action TEXT NOT NULL,
confidence INTEGER NOT NULL,
rationale TEXT NOT NULL,
context_snapshot TEXT NOT NULL,
input_data TEXT NOT NULL
)
"""
)
old_conn.execute(
"""
INSERT INTO decision_logs (
decision_id, timestamp, stock_code, market, exchange_code,
action, confidence, rationale, context_snapshot, input_data
) VALUES (
'd1', '2026-01-01T00:00:00+00:00', 'AAPL', 'US_NASDAQ', 'NASD',
'BUY', 80, 'legacy row', '{}', '{}'
)
"""
)
old_conn.commit()
old_conn.close()
conn = init_db(db_path)
columns = {row[1] for row in conn.execute("PRAGMA table_info(decision_logs)").fetchall()}
assert "session_id" in columns
row = conn.execute(
"SELECT session_id FROM decision_logs WHERE decision_id='d1'"
).fetchone()
assert row is not None
assert row[0] == "UNKNOWN"
conn.close()
finally:
os.unlink(db_path)

View File

@@ -49,7 +49,7 @@ def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Co
# Verify record exists in database
cursor = db_conn.execute(
"SELECT decision_id, action, confidence, session_id FROM decision_logs WHERE decision_id = ?",
"SELECT decision_id, action, confidence FROM decision_logs WHERE decision_id = ?",
(decision_id,),
)
row = cursor.fetchone()
@@ -57,7 +57,6 @@ def test_log_decision_creates_record(logger: DecisionLogger, db_conn: sqlite3.Co
assert row[0] == decision_id
assert row[1] == "BUY"
assert row[2] == 85
assert row[3] == "UNKNOWN"
def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
@@ -85,24 +84,6 @@ def test_log_decision_stores_context_snapshot(logger: DecisionLogger) -> None:
assert decision is not None
assert decision.context_snapshot == context_snapshot
assert decision.input_data == input_data
assert decision.session_id == "UNKNOWN"
def test_log_decision_stores_explicit_session_id(logger: DecisionLogger) -> None:
decision_id = logger.log_decision(
stock_code="AAPL",
market="US_NASDAQ",
exchange_code="NASD",
action="BUY",
confidence=88,
rationale="session check",
context_snapshot={},
input_data={},
session_id="US_PRE",
)
decision = logger.get_decision_by_id(decision_id)
assert decision is not None
assert decision.session_id == "US_PRE"
def test_get_unreviewed_decisions(logger: DecisionLogger) -> None:
@@ -297,7 +278,6 @@ def test_decision_log_dataclass() -> None:
stock_code="005930",
market="KR",
exchange_code="KRX",
session_id="KRX_REG",
action="BUY",
confidence=85,
rationale="Test",
@@ -306,7 +286,6 @@ def test_decision_log_dataclass() -> None:
)
assert log.decision_id == "test-uuid"
assert log.session_id == "KRX_REG"
assert log.action == "BUY"
assert log.confidence == 85
assert log.reviewed is False

View File

@@ -245,52 +245,6 @@ async def test_generate_strategy_creates_file(optimizer: EvolutionOptimizer, tmp
assert "def evaluate" in strategy_path.read_text()
@pytest.mark.asyncio
async def test_generate_strategy_saves_valid_python_code(
optimizer: EvolutionOptimizer, tmp_path: Path,
) -> None:
"""Test that syntactically valid generated code is saved."""
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
mock_response = Mock()
mock_response.text = (
'price = market_data.get("current_price", 0)\n'
'if price > 0:\n'
' return {"action": "BUY", "confidence": 80, "rationale": "Positive price"}\n'
'return {"action": "HOLD", "confidence": 50, "rationale": "No signal"}\n'
)
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is not None
assert strategy_path.exists()
@pytest.mark.asyncio
async def test_generate_strategy_blocks_invalid_python_code(
optimizer: EvolutionOptimizer, tmp_path: Path, caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that syntactically invalid generated code is not saved."""
failures = [{"decision_id": "1", "timestamp": "2024-01-15T09:30:00+00:00"}]
mock_response = Mock()
mock_response.text = (
'if market_data.get("current_price", 0) > 0\n'
' return {"action": "BUY", "confidence": 80, "rationale": "broken"}\n'
)
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
with caplog.at_level("WARNING"):
strategy_path = await optimizer.generate_strategy(failures)
assert strategy_path is None
assert list(tmp_path.glob("*.py")) == []
assert "failed syntax validation" in caplog.text
@pytest.mark.asyncio
async def test_generate_strategy_handles_api_error(optimizer: EvolutionOptimizer) -> None:
"""Test that generate_strategy handles Gemini API errors gracefully."""

View File

@@ -4,7 +4,6 @@ from datetime import UTC, date, datetime
from unittest.mock import ANY, AsyncMock, MagicMock, patch
import pytest
import src.main as main_module
from src.config import Settings
from src.context.layer import ContextLayer
@@ -16,14 +15,7 @@ from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger
from src.main import (
KILL_SWITCH,
_SESSION_RISK_LAST_BY_MARKET,
_SESSION_RISK_OVERRIDES_BY_MARKET,
_SESSION_RISK_PROFILES_MAP,
_STOPLOSS_REENTRY_COOLDOWN_UNTIL,
_apply_staged_exit_override_for_hold,
_compute_kr_atr_value,
_estimate_pred_down_prob_from_rsi,
_inject_staged_exit_features,
_RUNTIME_EXIT_PEAKS,
_RUNTIME_EXIT_STATES,
_should_force_exit_for_overnight,
@@ -36,13 +28,9 @@ from src.main import (
_extract_held_qty_from_balance,
_handle_market_close,
_retry_connection,
_resolve_market_setting,
_resolve_sell_qty_for_pnl,
_run_context_scheduler,
_run_evolution_loop,
_start_dashboard_server,
_stoploss_cooldown_minutes,
_compute_kr_dynamic_stop_loss_pct,
handle_domestic_pending_orders,
handle_overseas_pending_orders,
process_blackout_recovery_orders,
@@ -105,19 +93,11 @@ def _reset_kill_switch_state() -> None:
KILL_SWITCH.clear_block()
_RUNTIME_EXIT_STATES.clear()
_RUNTIME_EXIT_PEAKS.clear()
_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()
yield
KILL_SWITCH.clear_block()
_RUNTIME_EXIT_STATES.clear()
_RUNTIME_EXIT_PEAKS.clear()
_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()
@@ -142,266 +122,6 @@ class TestExtractAvgPriceFromBalance:
result = _extract_avg_price_from_balance(balance, "005930", is_domestic=True)
assert result == 0.0
def test_resolve_sell_qty_for_pnl_prefers_sell_qty() -> None:
assert _resolve_sell_qty_for_pnl(sell_qty=30, buy_qty=100) == 30
def test_resolve_sell_qty_for_pnl_uses_buy_qty_fallback_when_sell_qty_missing() -> None:
assert _resolve_sell_qty_for_pnl(sell_qty=None, buy_qty=12) == 12
def test_resolve_sell_qty_for_pnl_returns_zero_when_both_missing() -> None:
assert _resolve_sell_qty_for_pnl(sell_qty=None, buy_qty=None) == 0
def test_compute_kr_dynamic_stop_loss_pct_falls_back_without_atr() -> None:
out = _compute_kr_dynamic_stop_loss_pct(
entry_price=100.0,
atr_value=0.0,
fallback_stop_loss_pct=-2.0,
settings=None,
)
assert out == -2.0
def test_compute_kr_dynamic_stop_loss_pct_clamps_to_min_and_max() -> None:
# Small ATR -> clamp to min (-2%)
out_small = _compute_kr_dynamic_stop_loss_pct(
entry_price=100.0,
atr_value=0.2,
fallback_stop_loss_pct=-2.0,
settings=None,
)
assert out_small == -2.0
# Large ATR -> clamp to max (-7%)
out_large = _compute_kr_dynamic_stop_loss_pct(
entry_price=100.0,
atr_value=10.0,
fallback_stop_loss_pct=-2.0,
settings=None,
)
assert out_large == -7.0
def test_compute_kr_dynamic_stop_loss_pct_uses_settings_values() -> None:
settings = MagicMock(
KR_ATR_STOP_MULTIPLIER_K=3.0,
KR_ATR_STOP_MIN_PCT=-1.5,
KR_ATR_STOP_MAX_PCT=-6.0,
)
out = _compute_kr_dynamic_stop_loss_pct(
entry_price=100.0,
atr_value=1.0,
fallback_stop_loss_pct=-2.0,
settings=settings,
)
assert out == -3.0
def test_resolve_market_setting_uses_session_profile_override() -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
SESSION_RISK_PROFILES_JSON='{"US_PRE": {"US_MIN_PRICE": 7.5}}',
)
market = MagicMock()
market.code = "US_NASDAQ"
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_PRE")):
value = _resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
assert value == pytest.approx(7.5)
def test_stoploss_cooldown_minutes_uses_session_override() -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
STOPLOSS_REENTRY_COOLDOWN_MINUTES=120,
SESSION_RISK_PROFILES_JSON='{"NXT_AFTER": {"STOPLOSS_REENTRY_COOLDOWN_MINUTES": 45}}',
)
market = MagicMock()
market.code = "KR"
with patch("src.main.get_session_info", return_value=MagicMock(session_id="NXT_AFTER")):
value = _stoploss_cooldown_minutes(settings, market=market)
assert value == 45
def test_resolve_market_setting_ignores_profile_when_reload_disabled() -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
US_MIN_PRICE=5.0,
SESSION_RISK_RELOAD_ENABLED=False,
SESSION_RISK_PROFILES_JSON='{"US_PRE": {"US_MIN_PRICE": 9.5}}',
)
market = MagicMock()
market.code = "US_NASDAQ"
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_PRE")):
value = _resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
assert value == pytest.approx(5.0)
def test_resolve_market_setting_falls_back_on_invalid_profile_json() -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
US_MIN_PRICE=5.0,
SESSION_RISK_PROFILES_JSON="{invalid-json",
)
market = MagicMock()
market.code = "US_NASDAQ"
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_PRE")):
value = _resolve_market_setting(
market=market,
settings=settings,
key="US_MIN_PRICE",
default=5.0,
)
assert value == pytest.approx(5.0)
def test_resolve_market_setting_coerces_bool_string_override() -> None:
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
OVERNIGHT_EXCEPTION_ENABLED=True,
SESSION_RISK_PROFILES_JSON='{"US_AFTER": {"OVERNIGHT_EXCEPTION_ENABLED": "false"}}',
)
market = MagicMock()
market.code = "US_NASDAQ"
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
value = _resolve_market_setting(
market=market,
settings=settings,
key="OVERNIGHT_EXCEPTION_ENABLED",
default=True,
)
assert value is False
def test_estimate_pred_down_prob_from_rsi_uses_linear_mapping() -> None:
assert _estimate_pred_down_prob_from_rsi(None) == 0.5
assert _estimate_pred_down_prob_from_rsi(0.0) == 0.0
assert _estimate_pred_down_prob_from_rsi(50.0) == 0.5
assert _estimate_pred_down_prob_from_rsi(100.0) == 1.0
@pytest.mark.asyncio
async def test_compute_kr_atr_value_returns_zero_on_short_series() -> None:
broker = MagicMock()
broker.get_daily_prices = AsyncMock(
return_value=[{"high": 101.0, "low": 99.0, "close": 100.0}] * 10
)
atr = await _compute_kr_atr_value(broker=broker, stock_code="005930")
assert atr == 0.0
@pytest.mark.asyncio
async def test_inject_staged_exit_features_sets_pred_down_prob_and_atr_for_kr() -> None:
market = MagicMock()
market.is_domestic = True
stock_data: dict[str, float] = {"rsi": 65.0}
broker = MagicMock()
broker.get_daily_prices = AsyncMock(
return_value=[
{"high": 102.0 + i, "low": 98.0 + i, "close": 100.0 + i}
for i in range(40)
]
)
await _inject_staged_exit_features(
market=market,
stock_code="005930",
open_position={"price": 100.0, "quantity": 1},
market_data=stock_data,
broker=broker,
)
assert stock_data["pred_down_prob"] == pytest.approx(0.65)
assert stock_data["atr_value"] > 0.0
def test_apply_staged_exit_uses_independent_arm_threshold_settings() -> None:
market = MagicMock()
market.code = "KR"
market.name = "Korea"
decision = MagicMock()
decision.action = "HOLD"
decision.confidence = 70
decision.rationale = "hold"
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
STAGED_EXIT_BE_ARM_PCT=2.2,
STAGED_EXIT_ARM_PCT=5.4,
)
captured: dict[str, float] = {}
def _fake_eval(**kwargs): # type: ignore[no-untyped-def]
cfg = kwargs["config"]
captured["be_arm_pct"] = cfg.be_arm_pct
captured["arm_pct"] = cfg.arm_pct
class _Out:
should_exit = False
reason = "none"
state = PositionState.HOLDING
return _Out()
with patch("src.main.evaluate_exit", side_effect=_fake_eval):
out = _apply_staged_exit_override_for_hold(
decision=decision,
market=market,
stock_code="005930",
open_position={"price": 100.0, "quantity": 1, "decision_id": "d1", "timestamp": "t1"},
market_data={"current_price": 101.0, "rsi": 60.0, "pred_down_prob": 0.6},
stock_playbook=None,
settings=settings,
)
assert out is decision
assert captured["be_arm_pct"] == pytest.approx(2.2)
assert captured["arm_pct"] == pytest.approx(5.4)
def test_returns_zero_when_field_empty_string(self) -> None:
"""Returns 0.0 when pchs_avg_pric is an empty string."""
balance = {"output1": [{"pdno": "005930", "pchs_avg_pric": ""}]}
@@ -1836,10 +1556,7 @@ class TestScenarioEngineIntegration:
signal="oversold", score=85.0,
)
with (
patch("src.main.log_trade"),
patch("src.main.get_session_info", return_value=MagicMock(session_id="KRX_REG")),
):
with patch("src.main.log_trade"):
await trading_cycle(
broker=mock_broker,
overseas_broker=MagicMock(),
@@ -2193,7 +1910,6 @@ class TestScenarioEngineIntegration:
decision_logger.log_decision.assert_called_once()
call_kwargs = decision_logger.log_decision.call_args.kwargs
assert call_kwargs["session_id"] == "KRX_REG"
assert "scenario_match" in call_kwargs["context_snapshot"]
assert call_kwargs["context_snapshot"]["scenario_match"]["rsi"] == 45.0
@@ -2281,7 +1997,7 @@ async def test_sell_updates_original_buy_decision_outcome() -> None:
],
}
)
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
overseas_broker = MagicMock()
engine = MagicMock(spec=ScenarioEngine)
@@ -3136,9 +2852,6 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
assert call_kwargs["order_type"] == "SELL"
# Must use broker-confirmed qty (5), NOT DB-recorded ordered qty (10)
assert call_kwargs["quantity"] == 5
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
assert updated_buy is not None
assert updated_buy.outcome_pnl == -25.0
@pytest.mark.asyncio
@@ -4867,110 +4580,6 @@ async def test_run_daily_session_applies_staged_exit_override_on_hold() -> None:
assert broker.send_order.call_args.kwargs["order_type"] == "SELL"
@pytest.mark.asyncio
async def test_run_daily_session_passes_runtime_session_id_to_decision_and_trade_logs() -> None:
"""Daily session must explicitly forward runtime session_id to decision/trade logs."""
from src.analysis.smart_scanner import ScanCandidate
db_conn = init_db(":memory:")
settings = Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
MODE="paper",
)
broker = MagicMock()
broker.get_balance = AsyncMock(
return_value={
"output1": [],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "50000",
"pchs_amt_smtl_amt": "50000",
}
],
}
)
broker.get_current_price = AsyncMock(return_value=(100.0, 1.0, 0.0))
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
market.timezone = __import__("zoneinfo").ZoneInfo("Asia/Seoul")
smart_scanner = MagicMock()
smart_scanner.scan = AsyncMock(
return_value=[
ScanCandidate(
stock_code="005930",
name="Samsung",
price=100.0,
volume=1_000_000.0,
volume_ratio=2.0,
rsi=45.0,
signal="momentum",
score=80.0,
)
]
)
playbook_store = MagicMock()
playbook_store.load = MagicMock(return_value=_make_playbook("KR"))
scenario_engine = MagicMock(spec=ScenarioEngine)
scenario_engine.evaluate = MagicMock(return_value=_make_buy_match("005930"))
risk = MagicMock()
risk.check_circuit_breaker = MagicMock()
risk.validate_order = MagicMock()
decision_logger = MagicMock()
decision_logger.log_decision = MagicMock(return_value="d1")
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
async def _passthrough(fn, *a, label: str = "", **kw): # type: ignore[override]
return await fn(*a, **kw)
with (
patch("src.main.get_open_position", return_value=None),
patch("src.main.get_open_markets", return_value=[market]),
patch("src.main.get_session_info", return_value=MagicMock(session_id="KRX_REG")),
patch("src.main._retry_connection", new=_passthrough),
patch("src.main.log_trade") as mock_log_trade,
):
await run_daily_session(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=scenario_engine,
playbook_store=playbook_store,
pre_market_planner=MagicMock(),
risk=risk,
db_conn=db_conn,
decision_logger=decision_logger,
context_store=MagicMock(),
criticality_assessor=MagicMock(),
telegram=telegram,
settings=settings,
smart_scanner=smart_scanner,
daily_start_eval=0.0,
)
decision_logger.log_decision.assert_called_once()
assert decision_logger.log_decision.call_args.kwargs["session_id"] == "KRX_REG"
assert mock_log_trade.call_count >= 1
for call in mock_log_trade.call_args_list:
assert call.kwargs.get("session_id") == "KRX_REG"
# ---------------------------------------------------------------------------
# sync_positions_from_broker — startup DB sync tests (issue #206)
# ---------------------------------------------------------------------------
@@ -6147,149 +5756,6 @@ async def test_order_policy_rejection_skips_order_execution() -> None:
broker.send_order.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("price", "should_block"),
[
(4.99, True),
(5.00, True),
(5.01, False),
],
)
async def test_us_min_price_filter_boundary(price: float, should_block: bool) -> 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": str(price), "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 = MagicMock()
settings.POSITION_SIZING_ENABLED = False
settings.CONFIDENCE_THRESHOLD = 80
settings.MODE = "paper"
settings.PAPER_OVERSEAS_CASH = 50000
settings.US_MIN_PRICE = 5.0
settings.USD_BUFFER_MIN = 1000.0
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,
)
if should_block:
overseas_broker.send_overseas_order.assert_not_called()
else:
overseas_broker.send_overseas_order.assert_called_once()
@pytest.mark.asyncio
async def test_us_min_price_filter_not_applied_to_kr_market() -> None:
db_conn = init_db(":memory:")
decision_logger = DecisionLogger(db_conn)
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(4.0, 0.0, 0.0))
broker.get_balance = AsyncMock(
return_value={
"output1": [],
"output2": [
{
"tot_evlu_amt": "100000",
"dnca_tot_amt": "50000",
"pchs_amt_smtl_amt": "50000",
}
],
}
)
broker.send_order = AsyncMock(return_value={"msg1": "OK"})
market = MagicMock()
market.name = "Korea"
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
telegram = MagicMock()
telegram.notify_trade_execution = AsyncMock()
telegram.notify_fat_finger = AsyncMock()
telegram.notify_circuit_breaker = AsyncMock()
telegram.notify_scenario_matched = AsyncMock()
settings = MagicMock()
settings.POSITION_SIZING_ENABLED = False
settings.CONFIDENCE_THRESHOLD = 80
settings.MODE = "paper"
settings.US_MIN_PRICE = 5.0
settings.USD_BUFFER_MIN = 1000.0
await trading_cycle(
broker=broker,
overseas_broker=MagicMock(),
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("005930"))),
playbook=_make_playbook(),
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="005930",
scan_candidates={},
settings=settings,
)
broker.send_order.assert_called_once()
def test_overnight_policy_prioritizes_killswitch_over_exception() -> None:
market = MagicMock()
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
@@ -6448,7 +5914,6 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
"""Recovery must execute queued intents that pass revalidation."""
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
overseas_broker = MagicMock()
@@ -6474,7 +5939,6 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
patch("src.main.MARKETS", {"KR": market}),
patch("src.main.get_open_position", return_value=None),
patch("src.main.validate_order_policy"),
patch("src.main.get_session_info", return_value=MagicMock(session_id="KRX_REG")),
):
await process_blackout_recovery_orders(
broker=broker,
@@ -6483,19 +5947,6 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
)
broker.send_order.assert_called_once()
row = db_conn.execute(
"""
SELECT action, quantity, session_id, rationale
FROM trades
WHERE stock_code = '005930'
ORDER BY id DESC LIMIT 1
"""
).fetchone()
assert row is not None
assert row[0] == "BUY"
assert row[1] == 1
assert row[2] == "KRX_REG"
assert row[3].startswith("[blackout-recovery]")
@pytest.mark.asyncio
@@ -6503,7 +5954,6 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
"""Policy-rejected queued intents must not be requeued."""
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(100.0, 0.0, 0.0))
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
overseas_broker = MagicMock()
@@ -6547,149 +5997,6 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
blackout_manager.requeue.assert_not_called()
@pytest.mark.asyncio
async def test_process_blackout_recovery_drops_intent_on_excessive_price_drift() -> None:
"""Queued intent is dropped when current market price drift exceeds threshold."""
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_current_price = AsyncMock(return_value=(106.0, 0.0, 0.0))
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
overseas_broker = MagicMock()
market = MagicMock()
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
intent = MagicMock()
intent.market_code = "KR"
intent.stock_code = "005930"
intent.order_type = "BUY"
intent.quantity = 1
intent.price = 100.0
intent.source = "test"
intent.attempts = 0
blackout_manager = MagicMock()
blackout_manager.pop_recovery_batch.return_value = [intent]
with (
patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager),
patch("src.main.MARKETS", {"KR": market}),
patch("src.main.get_open_position", return_value=None),
patch("src.main.validate_order_policy") as validate_policy,
):
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT=5.0,
),
)
broker.send_order.assert_not_called()
validate_policy.assert_not_called()
@pytest.mark.asyncio
async def test_process_blackout_recovery_drops_overseas_intent_on_excessive_price_drift() -> None:
"""Overseas queued intent is dropped when price drift exceeds threshold."""
db_conn = init_db(":memory:")
broker = MagicMock()
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
overseas_broker = MagicMock()
overseas_broker.get_overseas_price = AsyncMock(return_value={"output": {"last": "106.0"}})
overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
market = MagicMock()
market.code = "US_NASDAQ"
market.exchange_code = "NASD"
market.is_domestic = False
intent = MagicMock()
intent.market_code = "US_NASDAQ"
intent.stock_code = "AAPL"
intent.order_type = "BUY"
intent.quantity = 1
intent.price = 100.0
intent.source = "test"
intent.attempts = 0
blackout_manager = MagicMock()
blackout_manager.pop_recovery_batch.return_value = [intent]
with (
patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager),
patch("src.main.MARKETS", {"US_NASDAQ": market}),
patch("src.main.get_open_position", return_value=None),
patch("src.main.validate_order_policy") as validate_policy,
):
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
settings=Settings(
KIS_APP_KEY="k",
KIS_APP_SECRET="s",
KIS_ACCOUNT_NO="12345678-01",
GEMINI_API_KEY="g",
BLACKOUT_RECOVERY_MAX_PRICE_DRIFT_PCT=5.0,
),
)
overseas_broker.send_overseas_order.assert_not_called()
validate_policy.assert_not_called()
@pytest.mark.asyncio
async def test_process_blackout_recovery_requeues_intent_when_price_lookup_fails() -> None:
"""Price lookup failure must requeue intent for a later retry."""
db_conn = init_db(":memory:")
broker = MagicMock()
broker.get_current_price = AsyncMock(side_effect=ConnectionError("price API down"))
broker.send_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
overseas_broker = MagicMock()
market = MagicMock()
market.code = "KR"
market.exchange_code = "KRX"
market.is_domestic = True
intent = MagicMock()
intent.market_code = "KR"
intent.stock_code = "005930"
intent.order_type = "BUY"
intent.quantity = 1
intent.price = 100.0
intent.source = "test"
intent.attempts = 0
blackout_manager = MagicMock()
blackout_manager.pop_recovery_batch.return_value = [intent]
with (
patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager),
patch("src.main.MARKETS", {"KR": market}),
patch("src.main.get_open_position", return_value=None),
patch("src.main.validate_order_policy") as validate_policy,
):
await process_blackout_recovery_orders(
broker=broker,
overseas_broker=overseas_broker,
db_conn=db_conn,
)
broker.send_order.assert_not_called()
validate_policy.assert_not_called()
blackout_manager.requeue.assert_called_once_with(intent)
assert intent.attempts == 1
@pytest.mark.asyncio
async def test_trigger_emergency_kill_switch_executes_operational_steps() -> None:
"""Emergency kill switch should execute cancel/refresh/reduce/notify callbacks."""

View File

@@ -1,9 +1,5 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
@@ -133,52 +129,3 @@ def test_short_tie_break_modes() -> None:
)
assert out_take.label == 1
assert out_take.touched == "take_profit"
def test_minutes_time_barrier_consistent_across_sampling() -> None:
base = datetime(2026, 2, 28, 9, 0, tzinfo=UTC)
highs = [100.0, 100.5, 100.6, 100.4]
lows = [100.0, 99.6, 99.4, 99.5]
closes = [100.0, 100.1, 100.0, 100.0]
spec = TripleBarrierSpec(
take_profit_pct=0.02,
stop_loss_pct=0.02,
max_holding_minutes=5,
)
out_1m = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=[base + timedelta(minutes=i) for i in range(4)],
entry_index=0,
side=1,
spec=spec,
)
out_5m = label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
timestamps=[base + timedelta(minutes=5 * i) for i in range(4)],
entry_index=0,
side=1,
spec=spec,
)
assert out_1m.touch_bar == 3
assert out_5m.touch_bar == 1
def test_bars_mode_emits_deprecation_warning() -> None:
highs = [100, 101, 103]
lows = [100, 99.6, 100]
closes = [100, 100, 102]
spec = TripleBarrierSpec(take_profit_pct=0.02, stop_loss_pct=0.01, max_holding_bars=3)
with pytest.deprecated_call(match="max_holding_bars is deprecated"):
label_with_triple_barrier(
highs=highs,
lows=lows,
closes=closes,
entry_index=0,
side=1,
spec=spec,
)

View File

@@ -1,116 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from types import SimpleNamespace
def _load_module():
script_path = Path(__file__).resolve().parents[1] / "scripts" / "validate_governance_assets.py"
spec = importlib.util.spec_from_file_location("validate_governance_assets", 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_is_policy_file_detects_ouroboros_policy_docs() -> None:
module = _load_module()
assert module.is_policy_file("docs/ouroboros/85_loss_recovery_action_plan.md")
assert not module.is_policy_file("docs/ouroboros/01_requirements_registry.md")
assert not module.is_policy_file("docs/workflow.md")
assert not module.is_policy_file("docs/ouroboros/notes.txt")
def test_validate_registry_sync_requires_registry_update_when_policy_changes() -> None:
module = _load_module()
errors: list[str] = []
module.validate_registry_sync(
["docs/ouroboros/85_loss_recovery_action_plan.md"],
errors,
)
assert errors
assert "policy file changed without updating" in errors[0]
def test_validate_registry_sync_passes_when_registry_included() -> None:
module = _load_module()
errors: list[str] = []
module.validate_registry_sync(
[
"docs/ouroboros/85_loss_recovery_action_plan.md",
"docs/ouroboros/01_requirements_registry.md",
],
errors,
)
assert errors == []
def test_load_changed_files_supports_explicit_paths() -> None:
module = _load_module()
errors: list[str] = []
changed = module.load_changed_files(
["./docs/ouroboros/85_loss_recovery_action_plan.md", " src/main.py "],
errors,
)
assert errors == []
assert changed == [
"docs/ouroboros/85_loss_recovery_action_plan.md",
"src/main.py",
]
def test_load_changed_files_with_range_uses_git_diff(monkeypatch) -> None:
module = _load_module()
errors: list[str] = []
def fake_run(cmd, check, capture_output, text): # noqa: ANN001
assert cmd[:3] == ["git", "diff", "--name-only"]
assert check is True
assert capture_output is True
assert text is True
return SimpleNamespace(stdout="docs/ouroboros/85_loss_recovery_action_plan.md\nsrc/main.py\n")
monkeypatch.setattr(module.subprocess, "run", fake_run)
changed = module.load_changed_files(["abc...def"], errors)
assert errors == []
assert changed == [
"docs/ouroboros/85_loss_recovery_action_plan.md",
"src/main.py",
]
def test_validate_task_req_mapping_reports_missing_req_reference(tmp_path) -> None:
module = _load_module()
doc = tmp_path / "work_orders.md"
doc.write_text(
"- `TASK-OPS-999` no req mapping line\n",
encoding="utf-8",
)
errors: list[str] = []
module.validate_task_req_mapping(errors, task_doc=doc)
assert errors
assert "TASK without REQ mapping" in errors[0]
def test_validate_task_req_mapping_passes_when_req_present(tmp_path) -> None:
module = _load_module()
doc = tmp_path / "work_orders.md"
doc.write_text(
"- `TASK-OPS-999` (`REQ-OPS-001`): enforce timezone labels\n",
encoding="utf-8",
)
errors: list[str] = []
module.validate_task_req_mapping(errors, task_doc=doc)
assert errors == []
def test_validate_pr_traceability_warns_when_req_missing(monkeypatch) -> None:
module = _load_module()
monkeypatch.setenv("GOVERNANCE_PR_TITLE", "feat: update policy checker")
monkeypatch.setenv("GOVERNANCE_PR_BODY", "Refs: TASK-OPS-001 TEST-ACC-007")
warnings: list[str] = []
module.validate_pr_traceability(warnings)
assert warnings
assert "PR text missing REQ-ID reference" in warnings