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
86 changed files with 1571 additions and 3438 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
@@ -25,24 +23,10 @@ jobs:
run: pip install ".[dev]"
- name: Session handover gate
run: python3 scripts/session_handover_check.py --strict --ci
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

@@ -22,24 +22,10 @@ jobs:
run: pip install ".[dev]"
- name: Session handover gate
run: python3 scripts/session_handover_check.py --strict --ci
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

@@ -1,9 +1,9 @@
<!--
Doc-ID: DOC-REQ-001
Version: 1.0.1
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-03-01
Updated: 2026-02-26
-->
# 요구사항 원장 (Single Source of Truth)
@@ -37,4 +37,3 @@ Updated: 2026-03-01
- `REQ-OPS-001`: 타임존은 모든 시간 필드에 명시(KST/UTC)되어야 한다.
- `REQ-OPS-002`: 문서의 수치 정책은 원장에서만 변경한다.
- `REQ-OPS-003`: 구현 태스크는 반드시 테스트 태스크를 동반한다.
- `REQ-OPS-004`: 원본 계획 문서(`v2`, `v3`)는 `docs/ouroboros/source/` 경로를 단일 기준으로 사용한다.

View File

@@ -51,7 +51,6 @@ Updated: 2026-02-26
- `TASK-OPS-001` (`REQ-OPS-001`): 시간 필드/로그 스키마의 타임존 표기 강제 규칙 구현
- `TASK-OPS-002` (`REQ-OPS-002`): 정책 수치 변경 시 `01_requirements_registry.md` 선수정 CI 체크 추가
- `TASK-OPS-003` (`REQ-OPS-003`): `TASK-*` 없는 `REQ-*` 또는 `TEST-*` 없는 `REQ-*`를 차단하는 문서 검증 게이트 유지
- `TASK-OPS-004` (`REQ-OPS-004`): v2/v3 원본 계획 문서 위치를 `docs/ouroboros/source/`로 표준화하고 링크 일관성 검증
## 커밋 규칙

View File

@@ -29,7 +29,6 @@ Updated: 2026-02-26
- `TEST-ACC-007` (`REQ-OPS-001`): 시간 관련 필드는 타임존(KST/UTC)이 누락되면 검증 실패한다.
- `TEST-ACC-008` (`REQ-OPS-002`): 정책 수치 변경이 원장 미반영이면 검증 실패한다.
- `TEST-ACC-009` (`REQ-OPS-003`): `REQ-*``TASK-*`/`TEST-*` 매핑 없이 존재하면 검증 실패한다.
- `TEST-ACC-019` (`REQ-OPS-004`): v2/v3 원본 계획 문서 링크는 `docs/ouroboros/source/` 경로 기준으로만 통과한다.
## 테스트 계층

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 감사 기준):**
- **현재 상태**: `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만 발동
- `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

@@ -1,14 +1,14 @@
<!--
Doc-ID: DOC-ROOT-001
Version: 1.0.1
Version: 1.0.0
Status: active
Owner: strategy
Updated: 2026-03-01
Updated: 2026-02-26
-->
# The Ouroboros 실행 문서 허브
이 폴더는 `source/ouroboros_plan_v2.txt`, `source/ouroboros_plan_v3.txt`를 구현 가능한 작업 지시서 수준으로 분해한 문서 허브다.
이 폴더는 `ouroboros_plan_v2.txt`, `ouroboros_plan_v3.txt`를 구현 가능한 작업 지시서 수준으로 분해한 문서 허브다.
## 읽기 순서 (Routing)
@@ -18,15 +18,13 @@ Updated: 2026-03-01
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 정의부터 수정한다.
@@ -40,5 +38,5 @@ python3 scripts/validate_ouroboros_docs.py
## 원본 계획 문서
- [v2](./source/ouroboros_plan_v2.txt)
- [v3](./source/ouroboros_plan_v3.txt)
- [v2](/home/agentson/repos/The-Ouroboros/ouroboros_plan_v2.txt)
- [v3](/home/agentson/repos/The-Ouroboros/ouroboros_plan_v3.txt)

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

@@ -66,7 +66,6 @@ def _check_handover_entry(
*,
branch: str,
strict: bool,
ci_mode: bool,
errors: list[str],
) -> None:
if not HANDOVER_LOG.exists():
@@ -89,10 +88,6 @@ def _check_handover_entry(
errors.append(f"latest handover entry missing token: {token}")
if strict:
if "- next_ticket: #TBD" in latest:
errors.append("latest handover entry must not use placeholder next_ticket (#TBD)")
if strict and not ci_mode:
today_utc = datetime.now(UTC).date().isoformat()
if today_utc not in latest:
errors.append(
@@ -104,6 +99,8 @@ def _check_handover_entry(
"latest handover entry must target current branch "
f"({branch_token})"
)
if "- next_ticket: #TBD" in latest:
errors.append("latest handover entry must not use placeholder next_ticket (#TBD)")
if "merged_to_feature_branch=no" in latest:
errors.append(
"process gate indicates not merged; implementation must stay blocked "
@@ -120,14 +117,6 @@ def main() -> int:
action="store_true",
help="Enforce today-date and current-branch match on latest handover entry.",
)
parser.add_argument(
"--ci",
action="store_true",
help=(
"CI mode: keep structural/token checks and placeholder guard, "
"but skip strict today-date/current-branch/merge-gate checks."
),
)
args = parser.parse_args()
errors: list[str] = []
@@ -136,15 +125,10 @@ def main() -> int:
branch = _current_branch()
if not branch:
errors.append("cannot resolve current git branch")
elif not args.ci and branch in {"main", "master"}:
elif branch in {"main", "master"}:
errors.append(f"working branch must not be {branch}")
_check_handover_entry(
branch=branch,
strict=args.strict,
ci_mode=args.ci,
errors=errors,
)
_check_handover_entry(branch=branch, strict=args.strict, errors=errors)
if errors:
print("[FAIL] session handover check failed")

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

@@ -2,8 +2,8 @@
from __future__ import annotations
import math
from dataclasses import dataclass
import math
@dataclass(frozen=True)

View File

@@ -2,11 +2,12 @@
from __future__ import annotations
import math
from dataclasses import dataclass
import math
from random import Random
from typing import Literal
OrderSide = Literal["BUY", "SELL"]
@@ -76,9 +77,7 @@ class BacktestExecutionModel:
reason="execution_failure",
)
slip_mult = 1.0 + (
slippage_bps / 10000.0 if request.side == "BUY" else -slippage_bps / 10000.0
)
slip_mult = 1.0 + (slippage_bps / 10000.0 if request.side == "BUY" else -slippage_bps / 10000.0)
exec_price = request.reference_price * slip_mult
if self._rng.random() < partial_rate:

View File

@@ -8,9 +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, cast
from typing import Literal
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
@@ -23,7 +22,6 @@ class BacktestBar:
low: float
close: float
session_id: str
timestamp: datetime | None = None
@dataclass(frozen=True)
@@ -88,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

@@ -104,7 +104,6 @@ class MarketScanner:
# Store in L7 real-time layer
from datetime import UTC, datetime
timeframe = datetime.now(UTC).isoformat()
self.context_store.set_context(
ContextLayer.L7_REALTIME,
@@ -159,8 +158,12 @@ class MarketScanner:
top_movers = valid_metrics[: self.top_n]
# Detect breakouts and breakdowns
breakouts = [m.stock_code for m in valid_metrics if self.analyzer.is_breakout(m)]
breakdowns = [m.stock_code for m in valid_metrics if self.analyzer.is_breakdown(m)]
breakouts = [
m.stock_code for m in valid_metrics if self.analyzer.is_breakout(m)
]
breakdowns = [
m.stock_code for m in valid_metrics if self.analyzer.is_breakdown(m)
]
logger.info(
"%s scan complete: %d scanned, top momentum=%.1f, %d breakouts, %d breakdowns",
@@ -225,9 +228,10 @@ class MarketScanner:
# If we removed too many, backfill from current watchlist
if len(updated) < len(current_watchlist):
backfill = [code for code in current_watchlist if code not in updated][
: len(current_watchlist) - len(updated)
]
backfill = [
code for code in current_watchlist
if code not in updated
][: len(current_watchlist) - len(updated)]
updated.extend(backfill)
logger.info(

View File

@@ -158,12 +158,7 @@ class SmartVolatilityScanner:
price = latest_close
latest_high = _safe_float(latest.get("high"))
latest_low = _safe_float(latest.get("low"))
if (
latest_close > 0
and latest_high > 0
and latest_low > 0
and latest_high >= latest_low
):
if latest_close > 0 and latest_high > 0 and latest_low > 0 and latest_high >= latest_low:
intraday_range_pct = (latest_high - latest_low) / latest_close * 100.0
if volume <= 0:
volume = _safe_float(latest.get("volume"))
@@ -239,7 +234,9 @@ class SmartVolatilityScanner:
limit=50,
)
except Exception as exc:
logger.warning("Overseas fluctuation ranking failed for %s: %s", market.code, exc)
logger.warning(
"Overseas fluctuation ranking failed for %s: %s", market.code, exc
)
fluct_rows = []
if not fluct_rows:
@@ -253,7 +250,9 @@ class SmartVolatilityScanner:
limit=50,
)
except Exception as exc:
logger.warning("Overseas volume ranking failed for %s: %s", market.code, exc)
logger.warning(
"Overseas volume ranking failed for %s: %s", market.code, exc
)
volume_rows = []
for idx, row in enumerate(volume_rows):
@@ -434,10 +433,16 @@ def _extract_intraday_range_pct(row: dict[str, Any], price: float) -> float:
if price <= 0:
return 0.0
high = _safe_float(
row.get("high") or row.get("ovrs_hgpr") or row.get("stck_hgpr") or row.get("day_hgpr")
row.get("high")
or row.get("ovrs_hgpr")
or row.get("stck_hgpr")
or row.get("day_hgpr")
)
low = _safe_float(
row.get("low") or row.get("ovrs_lwpr") or row.get("stck_lwpr") or row.get("day_lwpr")
row.get("low")
or row.get("ovrs_lwpr")
or row.get("stck_lwpr")
or row.get("day_lwpr")
)
if high <= 0 or low <= 0 or high < low:
return 0.0

View File

@@ -5,11 +5,9 @@ Implements first-touch labeling with upper/lower/time barriers.
from __future__ import annotations
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Literal
from typing import Literal, Sequence
TieBreakMode = Literal["stop_first", "take_first"]
@@ -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,34 +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

@@ -92,7 +92,9 @@ class VolatilityAnalyzer:
recent_tr = true_ranges[-period:]
return sum(recent_tr) / len(recent_tr)
def calculate_price_change(self, current_price: float, past_price: float) -> float:
def calculate_price_change(
self, current_price: float, past_price: float
) -> float:
"""Calculate price change percentage.
Args:
@@ -106,7 +108,9 @@ class VolatilityAnalyzer:
return 0.0
return ((current_price - past_price) / past_price) * 100
def calculate_volume_surge(self, current_volume: float, avg_volume: float) -> float:
def calculate_volume_surge(
self, current_volume: float, avg_volume: float
) -> float:
"""Calculate volume surge ratio.
Args:
@@ -236,7 +240,11 @@ class VolatilityAnalyzer:
Momentum score (0-100)
"""
# Weight recent changes more heavily
weighted_change = price_change_1m * 0.4 + price_change_5m * 0.3 + price_change_15m * 0.2
weighted_change = (
price_change_1m * 0.4 +
price_change_5m * 0.3 +
price_change_15m * 0.2
)
# Volume contribution (normalized to 0-10 scale)
volume_contribution = min(10.0, (volume_surge - 1.0) * 5.0)
@@ -293,11 +301,17 @@ class VolatilityAnalyzer:
if len(close_prices) > 0:
if len(close_prices) >= 1:
price_change_1m = self.calculate_price_change(current_price, close_prices[-1])
price_change_1m = self.calculate_price_change(
current_price, close_prices[-1]
)
if len(close_prices) >= 5:
price_change_5m = self.calculate_price_change(current_price, close_prices[-5])
price_change_5m = self.calculate_price_change(
current_price, close_prices[-5]
)
if len(close_prices) >= 15:
price_change_15m = self.calculate_price_change(current_price, close_prices[-15])
price_change_15m = self.calculate_price_change(
current_price, close_prices[-15]
)
# Calculate volume surge
avg_volume = sum(volumes) / len(volumes) if volumes else current_volume

View File

@@ -7,9 +7,9 @@ This module provides:
- Health monitoring and alerts
"""
from src.backup.cloud_storage import CloudStorage, S3Config
from src.backup.exporter import BackupExporter, ExportFormat
from src.backup.scheduler import BackupPolicy, BackupScheduler
from src.backup.scheduler import BackupScheduler, BackupPolicy
from src.backup.cloud_storage import CloudStorage, S3Config
__all__ = [
"BackupExporter",

View File

@@ -94,9 +94,7 @@ class CloudStorage:
if metadata:
extra_args["Metadata"] = metadata
logger.info(
"Uploading %s to s3://%s/%s", file_path.name, self.config.bucket_name, object_key
)
logger.info("Uploading %s to s3://%s/%s", file_path.name, self.config.bucket_name, object_key)
try:
self.client.upload_file(

View File

@@ -14,14 +14,14 @@ import json
import logging
import sqlite3
from datetime import UTC, datetime
from enum import StrEnum
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class ExportFormat(StrEnum):
class ExportFormat(str, Enum):
"""Supported export formats."""
JSON = "json"
@@ -103,11 +103,15 @@ class BackupExporter:
elif fmt == ExportFormat.CSV:
return self._export_csv(output_dir, timestamp, compress, incremental_since)
elif fmt == ExportFormat.PARQUET:
return self._export_parquet(output_dir, timestamp, compress, incremental_since)
return self._export_parquet(
output_dir, timestamp, compress, incremental_since
)
else:
raise ValueError(f"Unsupported format: {fmt}")
def _get_trades(self, incremental_since: datetime | None = None) -> list[dict[str, Any]]:
def _get_trades(
self, incremental_since: datetime | None = None
) -> list[dict[str, Any]]:
"""Fetch trades from database.
Args:
@@ -160,7 +164,9 @@ class BackupExporter:
data = {
"export_timestamp": datetime.now(UTC).isoformat(),
"incremental_since": (incremental_since.isoformat() if incremental_since else None),
"incremental_since": (
incremental_since.isoformat() if incremental_since else None
),
"record_count": len(trades),
"trades": trades,
}
@@ -278,7 +284,8 @@ class BackupExporter:
import pyarrow.parquet as pq
except ImportError:
raise ImportError(
"pyarrow is required for Parquet export. Install with: pip install pyarrow"
"pyarrow is required for Parquet export. "
"Install with: pip install pyarrow"
)
# Convert to pyarrow table

View File

@@ -14,14 +14,14 @@ import shutil
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class HealthStatus(StrEnum):
class HealthStatus(str, Enum):
"""Health check status."""
HEALTHY = "healthy"
@@ -137,13 +137,9 @@ class HealthMonitor:
used_percent = (stat.used / stat.total) * 100
if stat.free < self.min_disk_space_bytes:
min_disk_gb = self.min_disk_space_bytes / 1024 / 1024 / 1024
return HealthCheckResult(
status=HealthStatus.UNHEALTHY,
message=(
f"Low disk space: {free_gb:.2f} GB free "
f"(minimum: {min_disk_gb:.2f} GB)"
),
message=f"Low disk space: {free_gb:.2f} GB free (minimum: {self.min_disk_space_bytes / 1024 / 1024 / 1024:.2f} GB)",
details={
"free_gb": free_gb,
"total_gb": total_gb,

View File

@@ -12,14 +12,14 @@ import logging
import shutil
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from enum import Enum
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class BackupPolicy(StrEnum):
class BackupPolicy(str, Enum):
"""Backup retention policies."""
DAILY = "daily"
@@ -69,7 +69,9 @@ class BackupScheduler:
for d in [self.daily_dir, self.weekly_dir, self.monthly_dir]:
d.mkdir(parents=True, exist_ok=True)
def create_backup(self, policy: BackupPolicy, verify: bool = True) -> BackupMetadata:
def create_backup(
self, policy: BackupPolicy, verify: bool = True
) -> BackupMetadata:
"""Create a database backup.
Args:
@@ -227,7 +229,9 @@ class BackupScheduler:
return removed
def list_backups(self, policy: BackupPolicy | None = None) -> list[BackupMetadata]:
def list_backups(
self, policy: BackupPolicy | None = None
) -> list[BackupMetadata]:
"""List available backups.
Args:

View File

@@ -13,8 +13,8 @@ import hashlib
import json
import logging
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from dataclasses import dataclass, field
from typing import Any, TYPE_CHECKING
if TYPE_CHECKING:
from src.brain.gemini_client import TradeDecision
@@ -26,7 +26,7 @@ logger = logging.getLogger(__name__)
class CacheEntry:
"""Cached decision with metadata."""
decision: TradeDecision
decision: "TradeDecision"
cached_at: float # Unix timestamp
hit_count: int = 0
market_data_hash: str = ""
@@ -239,7 +239,9 @@ class DecisionCache:
"""
current_time = time.time()
expired_keys = [
k for k, v in self._cache.items() if current_time - v.cached_at > self.ttl_seconds
k
for k, v in self._cache.items()
if current_time - v.cached_at > self.ttl_seconds
]
count = len(expired_keys)

View File

@@ -11,14 +11,14 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import StrEnum
from enum import Enum
from typing import Any
from src.context.layer import ContextLayer
from src.context.store import ContextStore
class DecisionType(StrEnum):
class DecisionType(str, Enum):
"""Type of trading decision being made."""
NORMAL = "normal" # Regular trade decision
@@ -183,7 +183,9 @@ class ContextSelector:
ContextLayer.L1_LEGACY,
]
scores = {layer: self.score_layer_relevance(layer, decision_type) for layer in all_layers}
scores = {
layer: self.score_layer_relevance(layer, decision_type) for layer in all_layers
}
# Filter by minimum score
selected_layers = [layer for layer, score in scores.items() if score >= min_score]

View File

@@ -25,12 +25,12 @@ from typing import Any
from google import genai
from src.brain.cache import DecisionCache
from src.brain.prompt_optimizer import PromptOptimizer
from src.config import Settings
from src.data.news_api import NewsAPI, NewsSentiment
from src.data.economic_calendar import EconomicCalendar
from src.data.market_data import MarketData
from src.data.news_api import NewsAPI, NewsSentiment
from src.brain.cache import DecisionCache
from src.brain.prompt_optimizer import PromptOptimizer
logger = logging.getLogger(__name__)
@@ -159,12 +159,16 @@ class GeminiClient:
return ""
# Check for upcoming high-impact events
upcoming = self._economic_calendar.get_upcoming_events(days_ahead=7, min_impact="HIGH")
upcoming = self._economic_calendar.get_upcoming_events(
days_ahead=7, min_impact="HIGH"
)
if upcoming.high_impact_count == 0:
return ""
lines = [f"Upcoming High-Impact Events: {upcoming.high_impact_count} in next 7 days"]
lines = [
f"Upcoming High-Impact Events: {upcoming.high_impact_count} in next 7 days"
]
if upcoming.next_major_event is not None:
event = upcoming.next_major_event
@@ -176,7 +180,9 @@ class GeminiClient:
# Check for earnings
earnings_date = self._economic_calendar.get_earnings_date(stock_code)
if earnings_date is not None:
lines.append(f" Earnings: {stock_code} on {earnings_date.strftime('%Y-%m-%d')}")
lines.append(
f" Earnings: {stock_code} on {earnings_date.strftime('%Y-%m-%d')}"
)
return "\n".join(lines)
@@ -229,7 +235,9 @@ class GeminiClient:
# Add foreigner net if non-zero
if market_data.get("foreigner_net", 0) != 0:
market_info_lines.append(f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}")
market_info_lines.append(
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
)
market_info = "\n".join(market_info_lines)
@@ -241,7 +249,8 @@ class GeminiClient:
market_info += f"\n\n{external_context}"
json_format = (
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}'
'{"action": "BUY"|"SELL"|"HOLD", '
'"confidence": <int 0-100>, "rationale": "<string>"}'
)
return (
f"You are a professional {market_name} trading analyst.\n"
@@ -280,12 +289,15 @@ class GeminiClient:
# Add foreigner net if non-zero
if market_data.get("foreigner_net", 0) != 0:
market_info_lines.append(f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}")
market_info_lines.append(
f"Foreigner Net Buy/Sell: {market_data['foreigner_net']}"
)
market_info = "\n".join(market_info_lines)
json_format = (
'{"action": "BUY"|"SELL"|"HOLD", "confidence": <int 0-100>, "rationale": "<string>"}'
'{"action": "BUY"|"SELL"|"HOLD", '
'"confidence": <int 0-100>, "rationale": "<string>"}'
)
return (
f"You are a professional {market_name} trading analyst.\n"
@@ -327,19 +339,25 @@ class GeminiClient:
data = json.loads(cleaned)
except json.JSONDecodeError:
logger.warning("Malformed JSON from Gemini — defaulting to HOLD")
return TradeDecision(action="HOLD", confidence=0, rationale="Malformed JSON response")
return TradeDecision(
action="HOLD", confidence=0, rationale="Malformed JSON response"
)
# Validate required fields
if not all(k in data for k in ("action", "confidence", "rationale")):
logger.warning("Missing fields in Gemini response — defaulting to HOLD")
# Preserve raw text in rationale so prompt_override callers (e.g. pre_market_planner)
# can extract their own JSON format from decision.rationale (#245)
return TradeDecision(action="HOLD", confidence=0, rationale=raw)
return TradeDecision(
action="HOLD", confidence=0, rationale=raw
)
action = str(data["action"]).upper()
if action not in VALID_ACTIONS:
logger.warning("Invalid action '%s' from Gemini — defaulting to HOLD", action)
return TradeDecision(action="HOLD", confidence=0, rationale=f"Invalid action: {action}")
return TradeDecision(
action="HOLD", confidence=0, rationale=f"Invalid action: {action}"
)
confidence = int(data["confidence"])
rationale = str(data["rationale"])
@@ -427,7 +445,9 @@ class GeminiClient:
# not a parsed TradeDecision. Skip parse_response to avoid spurious
# "Missing fields" warnings and return the raw response directly. (#247)
if "prompt_override" in market_data:
logger.info("Gemini raw response received (prompt_override, tokens=%d)", token_count)
logger.info(
"Gemini raw response received (prompt_override, tokens=%d)", token_count
)
# Not a trade decision — don't inflate _total_decisions metrics
return TradeDecision(
action="HOLD", confidence=0, rationale=raw, token_count=token_count
@@ -526,7 +546,9 @@ class GeminiClient:
# Batch Decision Making (for daily trading mode)
# ------------------------------------------------------------------
async def decide_batch(self, stocks_data: list[dict[str, Any]]) -> dict[str, TradeDecision]:
async def decide_batch(
self, stocks_data: list[dict[str, Any]]
) -> dict[str, TradeDecision]:
"""Make decisions for multiple stocks in a single API call.
This is designed for daily trading mode to minimize API usage

View File

@@ -179,8 +179,7 @@ class PromptOptimizer:
# Minimal instructions
prompt = (
f"{market_name} trader. Analyze:\n{data_str}\n\n"
"Return JSON: "
'{"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
'Return JSON: {"action":"BUY"|"SELL"|"HOLD","confidence":<0-100>,"rationale":"<text>"}\n'
"Rules: action=BUY/SELL/HOLD, confidence=0-100, rationale=concise. No markdown."
)
else:

View File

@@ -103,8 +103,7 @@ class KISBroker:
ssl_ctx.verify_mode = ssl.CERT_NONE
connector = aiohttp.TCPConnector(ssl=ssl_ctx)
self._session = aiohttp.ClientSession(
timeout=timeout,
connector=connector,
timeout=timeout, connector=connector,
)
return self._session
@@ -225,12 +224,16 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_orderbook failed ({resp.status}): {text}")
raise ConnectionError(
f"get_orderbook failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching orderbook: {exc}") from exc
async def get_current_price(self, stock_code: str) -> tuple[float, float, float]:
async def get_current_price(
self, stock_code: str
) -> tuple[float, float, float]:
"""Fetch current price data for a domestic stock.
Uses the ``inquire-price`` API (FHKST01010100), which works in both
@@ -262,7 +265,9 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_current_price failed ({resp.status}): {text}")
raise ConnectionError(
f"get_current_price failed ({resp.status}): {text}"
)
data = await resp.json()
out = data.get("output", {})
return (
@@ -271,7 +276,9 @@ class KISBroker:
_f(out.get("frgn_ntby_qty")),
)
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching current price: {exc}") from exc
raise ConnectionError(
f"Network error fetching current price: {exc}"
) from exc
async def get_balance(self) -> dict[str, Any]:
"""Fetch current account balance and holdings."""
@@ -301,7 +308,9 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_balance failed ({resp.status}): {text}")
raise ConnectionError(
f"get_balance failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching balance: {exc}") from exc
@@ -360,7 +369,9 @@ class KISBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"send_order failed ({resp.status}): {text}")
raise ConnectionError(
f"send_order failed ({resp.status}): {text}"
)
data = await resp.json()
logger.info(
"Order submitted",
@@ -438,7 +449,9 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"fetch_market_rankings failed ({resp.status}): {text}")
raise ConnectionError(
f"fetch_market_rankings failed ({resp.status}): {text}"
)
data = await resp.json()
# Parse response - output is a list of ranked stocks
@@ -452,16 +465,14 @@ class KISBroker:
rankings = []
for item in data.get("output", [])[:limit]:
rankings.append(
{
rankings.append({
"stock_code": item.get("stck_shrn_iscd") or item.get("mksc_shrn_iscd", ""),
"name": item.get("hts_kor_isnm", ""),
"price": _safe_float(item.get("stck_prpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
"change_rate": _safe_float(item.get("prdy_ctrt", "0")),
"volume_increase_rate": _safe_float(item.get("vol_inrt", "0")),
}
)
})
return rankings
except (TimeoutError, aiohttp.ClientError) as exc:
@@ -511,7 +522,9 @@ class KISBroker:
data = await resp.json()
return data.get("output", []) or []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching domestic pending orders: {exc}") from exc
raise ConnectionError(
f"Network error fetching domestic pending orders: {exc}"
) from exc
async def cancel_domestic_order(
self,
@@ -562,10 +575,14 @@ class KISBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"cancel_domestic_order failed ({resp.status}): {text}")
raise ConnectionError(
f"cancel_domestic_order failed ({resp.status}): {text}"
)
return cast(dict[str, Any], await resp.json())
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error cancelling domestic order: {exc}") from exc
raise ConnectionError(
f"Network error cancelling domestic order: {exc}"
) from exc
async def get_daily_prices(
self,
@@ -592,7 +609,6 @@ class KISBroker:
# Calculate date range (today and N days ago)
from datetime import datetime, timedelta
end_date = datetime.now().strftime("%Y%m%d")
start_date = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d")
@@ -611,7 +627,9 @@ class KISBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_daily_prices failed ({resp.status}): {text}")
raise ConnectionError(
f"get_daily_prices failed ({resp.status}): {text}"
)
data = await resp.json()
# Parse response
@@ -625,16 +643,14 @@ class KISBroker:
prices = []
for item in data.get("output2", []):
prices.append(
{
prices.append({
"date": item.get("stck_bsop_date", ""),
"open": _safe_float(item.get("stck_oprc", "0")),
"high": _safe_float(item.get("stck_hgpr", "0")),
"low": _safe_float(item.get("stck_lwpr", "0")),
"close": _safe_float(item.get("stck_clpr", "0")),
"volume": _safe_float(item.get("acml_vol", "0")),
}
)
})
# Sort oldest to newest (KIS returns newest first)
prices.reverse()

View File

@@ -56,7 +56,9 @@ class OverseasBroker:
"""
self._broker = kis_broker
async def get_overseas_price(self, exchange_code: str, stock_code: str) -> dict[str, Any]:
async def get_overseas_price(
self, exchange_code: str, stock_code: str
) -> dict[str, Any]:
"""
Fetch overseas stock price.
@@ -87,10 +89,14 @@ class OverseasBroker:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_overseas_price failed ({resp.status}): {text}")
raise ConnectionError(
f"get_overseas_price failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching overseas price: {exc}") from exc
raise ConnectionError(
f"Network error fetching overseas price: {exc}"
) from exc
async def fetch_overseas_rankings(
self,
@@ -148,7 +154,9 @@ class OverseasBroker:
ranking_type,
)
return []
raise ConnectionError(f"fetch_overseas_rankings failed ({resp.status}): {text}")
raise ConnectionError(
f"fetch_overseas_rankings failed ({resp.status}): {text}"
)
data = await resp.json()
rows = self._extract_ranking_rows(data)
@@ -163,7 +171,9 @@ class OverseasBroker:
)
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching overseas rankings: {exc}") from exc
raise ConnectionError(
f"Network error fetching overseas rankings: {exc}"
) from exc
async def get_overseas_balance(self, exchange_code: str) -> dict[str, Any]:
"""
@@ -183,7 +193,9 @@ class OverseasBroker:
# TR_ID: 실전 TTTS3012R, 모의 VTTS3012R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 잔고조회' 시트
balance_tr_id = "TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
balance_tr_id = (
"TTTS3012R" if self._broker._settings.MODE == "live" else "VTTS3012R"
)
headers = await self._broker._auth_headers(balance_tr_id)
params = {
"CANO": self._broker._account_no,
@@ -193,16 +205,22 @@ class OverseasBroker:
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-balance"
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-balance"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"get_overseas_balance failed ({resp.status}): {text}")
raise ConnectionError(
f"get_overseas_balance failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching overseas balance: {exc}") from exc
raise ConnectionError(
f"Network error fetching overseas balance: {exc}"
) from exc
async def get_overseas_buying_power(
self,
@@ -229,7 +247,9 @@ class OverseasBroker:
# TR_ID: 실전 TTTS3007R, 모의 VTTS3007R
# Source: 한국투자증권 오픈API 전체문서 (20260221) — '해외주식 매수가능금액조회' 시트
ps_tr_id = "TTTS3007R" if self._broker._settings.MODE == "live" else "VTTS3007R"
ps_tr_id = (
"TTTS3007R" if self._broker._settings.MODE == "live" else "VTTS3007R"
)
headers = await self._broker._auth_headers(ps_tr_id)
params = {
"CANO": self._broker._account_no,
@@ -238,7 +258,9 @@ class OverseasBroker:
"OVRS_ORD_UNPR": f"{price:.2f}",
"ITEM_CD": stock_code,
}
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-psamount"
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-psamount"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
@@ -249,7 +271,9 @@ class OverseasBroker:
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching overseas buying power: {exc}") from exc
raise ConnectionError(
f"Network error fetching overseas buying power: {exc}"
) from exc
async def send_overseas_order(
self,
@@ -306,7 +330,9 @@ class OverseasBroker:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"send_overseas_order failed ({resp.status}): {text}")
raise ConnectionError(
f"send_overseas_order failed ({resp.status}): {text}"
)
data = await resp.json()
rt_cd = data.get("rt_cd", "")
msg1 = data.get("msg1", "")
@@ -331,9 +357,13 @@ class OverseasBroker:
)
return data
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error sending overseas order: {exc}") from exc
raise ConnectionError(
f"Network error sending overseas order: {exc}"
) from exc
async def get_overseas_pending_orders(self, exchange_code: str) -> list[dict[str, Any]]:
async def get_overseas_pending_orders(
self, exchange_code: str
) -> list[dict[str, Any]]:
"""Fetch unfilled (pending) overseas orders for a given exchange.
Args:
@@ -349,7 +379,9 @@ class OverseasBroker:
ConnectionError: On network or API errors (live mode only).
"""
if self._broker._settings.MODE != "live":
logger.debug("Pending orders API (TTTS3018R) not supported in paper mode; returning []")
logger.debug(
"Pending orders API (TTTS3018R) not supported in paper mode; returning []"
)
return []
await self._broker._rate_limiter.acquire()
@@ -366,7 +398,9 @@ class OverseasBroker:
"CTX_AREA_FK200": "",
"CTX_AREA_NK200": "",
}
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/inquire-nccs"
)
try:
async with session.get(url, headers=headers, params=params) as resp:
@@ -381,7 +415,9 @@ class OverseasBroker:
return output
return []
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error fetching pending orders: {exc}") from exc
raise ConnectionError(
f"Network error fetching pending orders: {exc}"
) from exc
async def cancel_overseas_order(
self,
@@ -433,16 +469,22 @@ class OverseasBroker:
headers = await self._broker._auth_headers(tr_id)
headers["hashkey"] = hash_key
url = f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
url = (
f"{self._broker._base_url}/uapi/overseas-stock/v1/trading/order-rvsecncl"
)
try:
async with session.post(url, headers=headers, json=body) as resp:
if resp.status != 200:
text = await resp.text()
raise ConnectionError(f"cancel_overseas_order failed ({resp.status}): {text}")
raise ConnectionError(
f"cancel_overseas_order failed ({resp.status}): {text}"
)
return await resp.json()
except (TimeoutError, aiohttp.ClientError) as exc:
raise ConnectionError(f"Network error cancelling overseas order: {exc}") from exc
raise ConnectionError(
f"Network error cancelling overseas order: {exc}"
) from exc
def _get_currency_code(self, exchange_code: str) -> str:
"""

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)
@@ -124,8 +114,12 @@ class Settings(BaseSettings):
OVERSEAS_RANKING_ENABLED: bool = True
OVERSEAS_RANKING_FLUCT_TR_ID: str = "HHDFS76290000"
OVERSEAS_RANKING_VOLUME_TR_ID: str = "HHDFS76270000"
OVERSEAS_RANKING_FLUCT_PATH: str = "/uapi/overseas-stock/v1/ranking/updown-rate"
OVERSEAS_RANKING_VOLUME_PATH: str = "/uapi/overseas-stock/v1/ranking/volume-surge"
OVERSEAS_RANKING_FLUCT_PATH: str = (
"/uapi/overseas-stock/v1/ranking/updown-rate"
)
OVERSEAS_RANKING_VOLUME_PATH: str = (
"/uapi/overseas-stock/v1/ranking/volume-surge"
)
# Dashboard (optional)
DASHBOARD_ENABLED: bool = False

View File

@@ -222,7 +222,9 @@ class ContextAggregator:
total_pnl = 0.0
for month in months:
monthly_pnl = self.store.get_context(ContextLayer.L4_MONTHLY, month, "monthly_pnl")
monthly_pnl = self.store.get_context(
ContextLayer.L4_MONTHLY, month, "monthly_pnl"
)
if monthly_pnl is not None:
total_pnl += monthly_pnl
@@ -249,7 +251,9 @@ class ContextAggregator:
if quarterly_pnl is not None:
total_pnl += quarterly_pnl
self.store.set_context(ContextLayer.L2_ANNUAL, year, "annual_pnl", round(total_pnl, 2))
self.store.set_context(
ContextLayer.L2_ANNUAL, year, "annual_pnl", round(total_pnl, 2)
)
def aggregate_legacy_from_annual(self) -> None:
"""Aggregate L1 (legacy) context from all L2 (annual) data."""
@@ -276,7 +280,9 @@ class ContextAggregator:
self.store.set_context(
ContextLayer.L1_LEGACY, "LEGACY", "total_pnl", round(total_pnl, 2)
)
self.store.set_context(ContextLayer.L1_LEGACY, "LEGACY", "years_traded", years_traded)
self.store.set_context(
ContextLayer.L1_LEGACY, "LEGACY", "years_traded", years_traded
)
self.store.set_context(
ContextLayer.L1_LEGACY,
"LEGACY",

View File

@@ -3,10 +3,10 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from enum import Enum
class ContextLayer(StrEnum):
class ContextLayer(str, Enum):
"""7-tier context hierarchy from real-time to generational."""
L1_LEGACY = "L1_LEGACY" # Cumulative/generational wisdom

View File

@@ -9,7 +9,7 @@ This module summarizes old context data instead of including raw details:
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from typing import Any
from src.context.layer import ContextLayer

View File

@@ -11,9 +11,8 @@ Order is fixed:
from __future__ import annotations
import inspect
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from typing import Any, Awaitable, Callable
StepCallable = Callable[[], Any | Awaitable[Any]]

View File

@@ -15,7 +15,7 @@ from src.markets.schedule import MarketInfo
_LOW_LIQUIDITY_SESSIONS = {"NXT_AFTER", "US_PRE", "US_DAY", "US_AFTER"}
class OrderPolicyRejectedError(Exception):
class OrderPolicyRejected(Exception):
"""Raised when an order violates session policy."""
def __init__(self, message: str, *, session_id: str, market_code: str) -> None:
@@ -61,9 +61,7 @@ def classify_session_id(market: MarketInfo, now: datetime | None = None) -> str:
def get_session_info(market: MarketInfo, now: datetime | None = None) -> SessionInfo:
session_id = classify_session_id(market, now)
return SessionInfo(
session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS
)
return SessionInfo(session_id=session_id, is_low_liquidity=session_id in _LOW_LIQUIDITY_SESSIONS)
def validate_order_policy(
@@ -78,7 +76,7 @@ def validate_order_policy(
is_market_order = price <= 0
if info.is_low_liquidity and is_market_order:
raise OrderPolicyRejectedError(
raise OrderPolicyRejected(
f"Market order is forbidden in low-liquidity session ({info.session_id})",
session_id=info.session_id,
market_code=market.code,
@@ -86,14 +84,10 @@ def validate_order_policy(
# Guard against accidental unsupported actions.
if order_type not in {"BUY", "SELL"}:
raise OrderPolicyRejectedError(
raise OrderPolicyRejected(
f"Unsupported order_type={order_type}",
session_id=info.session_id,
market_code=market.code,
)
return info
# Backward compatibility alias
OrderPolicyRejected = OrderPolicyRejectedError

View File

@@ -28,7 +28,9 @@ class PriorityTask:
# Task data not used in comparison
task_id: str = field(compare=False)
task_data: dict[str, Any] = field(compare=False, default_factory=dict)
callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(compare=False, default=None)
callback: Callable[[], Coroutine[Any, Any, Any]] | None = field(
compare=False, default=None
)
@dataclass

View File

@@ -25,7 +25,7 @@ class CircuitBreakerTripped(SystemExit):
)
class FatFingerRejectedError(Exception):
class FatFingerRejected(Exception):
"""Raised when an order exceeds the maximum allowed proportion of cash."""
def __init__(self, order_amount: float, total_cash: float, max_pct: float) -> None:
@@ -61,7 +61,7 @@ class RiskManager:
def check_fat_finger(self, order_amount: float, total_cash: float) -> None:
"""Reject orders that exceed the maximum proportion of available cash."""
if total_cash <= 0:
raise FatFingerRejectedError(order_amount, total_cash, self._ff_max_pct)
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
ratio_pct = (order_amount / total_cash) * 100
if ratio_pct > self._ff_max_pct:
@@ -69,7 +69,7 @@ class RiskManager:
"Fat finger check failed",
extra={"order_amount": order_amount},
)
raise FatFingerRejectedError(order_amount, total_cash, self._ff_max_pct)
raise FatFingerRejected(order_amount, total_cash, self._ff_max_pct)
def validate_order(
self,
@@ -81,7 +81,3 @@ class RiskManager:
self.check_circuit_breaker(current_pnl_pct)
self.check_fat_finger(order_amount, total_cash)
logger.info("Order passed risk validation")
# Backward compatibility alias
FatFingerRejected = FatFingerRejectedError

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import json
import os
import sqlite3
from datetime import UTC, datetime
from datetime import UTC, datetime, timezone
from pathlib import Path
from typing import Any
@@ -188,7 +188,10 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
return {
"market": "all",
"combined": combined,
"by_market": [_row_to_performance(row) for row in by_market_rows],
"by_market": [
_row_to_performance(row)
for row in by_market_rows
],
}
row = conn.execute(
@@ -398,7 +401,7 @@ def create_dashboard_app(db_path: str, mode: str = "paper") -> FastAPI:
"""
).fetchall()
now = datetime.now(UTC)
now = datetime.now(timezone.utc)
positions = []
for row in rows:
entry_time_str = row["entry_time"]

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
logger = logging.getLogger(__name__)

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,26 +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(
"""
@@ -184,7 +163,9 @@ def init_db(db_path: str) -> sqlite3.Connection:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_timestamp ON decision_logs(timestamp)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_decision_logs_reviewed ON decision_logs(reviewed)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_reviewed ON decision_logs(reviewed)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_decision_logs_confidence ON decision_logs(confidence)"
)
@@ -309,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
@@ -378,7 +334,9 @@ def get_open_position(
return {"decision_id": row[1], "price": row[2], "quantity": row[3], "timestamp": row[4]}
def get_recent_symbols(conn: sqlite3.Connection, market: str, limit: int = 30) -> list[str]:
def get_recent_symbols(
conn: sqlite3.Connection, market: str, limit: int = 30
) -> list[str]:
"""Return recent unique symbols for a market, newest first."""
cursor = conn.execute(
"""

View File

@@ -90,7 +90,9 @@ class ABTester:
sharpe_ratio = None
if len(pnls) > 1:
mean_return = avg_pnl
std_return = (sum((p - mean_return) ** 2 for p in pnls) / (len(pnls) - 1)) ** 0.5
std_return = (
sum((p - mean_return) ** 2 for p in pnls) / (len(pnls) - 1)
) ** 0.5
if std_return > 0:
sharpe_ratio = mean_return / std_return
@@ -196,7 +198,8 @@ class ABTester:
if meets_criteria:
logger.info(
"Strategy '%s' meets deployment criteria: win_rate=%.2f%%, trades=%d, avg_pnl=%.2f",
"Strategy '%s' meets deployment criteria: "
"win_rate=%.2f%%, trades=%d, avg_pnl=%.2f",
result.winner,
winning_perf.win_rate,
winning_perf.total_trades,

View File

@@ -60,7 +60,9 @@ class DailyReviewer:
if isinstance(scenario_match, dict) and scenario_match:
matched += 1
scenario_match_rate = (
round((matched / total_decisions) * 100, 2) if total_decisions else 0.0
round((matched / total_decisions) * 100, 2)
if total_decisions
else 0.0
)
trade_stats = self._conn.execute(

View File

@@ -9,7 +9,6 @@ This module:
from __future__ import annotations
import ast
import json
import logging
import sqlite3
@@ -29,7 +28,7 @@ from src.logging.decision_logger import DecisionLogger
logger = logging.getLogger(__name__)
STRATEGIES_DIR = Path("src/strategies")
STRATEGY_TEMPLATE = """\
STRATEGY_TEMPLATE = textwrap.dedent("""\
\"\"\"Auto-generated strategy: {name}
Generated at: {timestamp}
@@ -46,7 +45,7 @@ class {class_name}(BaseStrategy):
def evaluate(self, market_data: dict[str, Any]) -> dict[str, Any]:
{body}
"""
""")
class EvolutionOptimizer:
@@ -80,8 +79,7 @@ class EvolutionOptimizer:
# Convert to dict format for analysis
failures = []
for decision in losing_decisions:
failures.append(
{
failures.append({
"decision_id": decision.decision_id,
"timestamp": decision.timestamp,
"stock_code": decision.stock_code,
@@ -94,12 +92,13 @@ class EvolutionOptimizer:
"outcome_accuracy": decision.outcome_accuracy,
"context_snapshot": decision.context_snapshot,
"input_data": decision.input_data,
}
)
})
return failures
def identify_failure_patterns(self, failures: list[dict[str, Any]]) -> dict[str, Any]:
def identify_failure_patterns(
self, failures: list[dict[str, Any]]
) -> dict[str, Any]:
"""Identify patterns in losing decisions.
Analyzes:
@@ -143,8 +142,12 @@ class EvolutionOptimizer:
total_confidence += failure.get("confidence", 0)
total_loss += failure.get("outcome_pnl", 0.0)
patterns["avg_confidence"] = round(total_confidence / len(failures), 2) if failures else 0.0
patterns["avg_loss"] = round(total_loss / len(failures), 2) if failures else 0.0
patterns["avg_confidence"] = (
round(total_confidence / len(failures), 2) if failures else 0.0
)
patterns["avg_loss"] = (
round(total_loss / len(failures), 2) if failures else 0.0
)
# Convert Counters to regular dicts for JSON serialization
patterns["markets"] = dict(patterns["markets"])
@@ -193,8 +196,7 @@ class EvolutionOptimizer:
prompt = (
"You are a quantitative trading strategy developer.\n"
"Analyze these failed trades and their patterns, "
"then generate an improved strategy.\n\n"
"Analyze these failed trades and their patterns, then generate an improved strategy.\n\n"
f"Failure Patterns:\n{json.dumps(patterns, indent=2)}\n\n"
f"Sample Failed Trades (first 5):\n"
f"{json.dumps(failures[:5], indent=2, default=str)}\n\n"
@@ -211,8 +213,7 @@ class EvolutionOptimizer:
try:
response = await self._client.aio.models.generate_content(
model=self._model_name,
contents=prompt,
model=self._model_name, contents=prompt,
)
body = response.text.strip()
except Exception as exc:
@@ -234,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. "
@@ -247,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
@@ -278,7 +271,9 @@ class EvolutionOptimizer:
logger.info("Strategy validation PASSED")
return True
else:
logger.warning("Strategy validation FAILED:\n%s", result.stdout + result.stderr)
logger.warning(
"Strategy validation FAILED:\n%s", result.stdout + result.stderr
)
# Clean up failing strategy
strategy_path.unlink(missing_ok=True)
return False

View File

@@ -187,7 +187,9 @@ class PerformanceTracker:
return metrics
def calculate_improvement_trend(self, metrics_history: list[StrategyMetrics]) -> dict[str, Any]:
def calculate_improvement_trend(
self, metrics_history: list[StrategyMetrics]
) -> dict[str, Any]:
"""Calculate improvement trend from historical metrics.
Args:
@@ -227,7 +229,9 @@ class PerformanceTracker:
"period_count": len(metrics_history),
}
def generate_dashboard(self, strategy_name: str | None = None) -> PerformanceDashboard:
def generate_dashboard(
self, strategy_name: str | None = None
) -> PerformanceDashboard:
"""Generate a comprehensive performance dashboard.
Args:
@@ -256,7 +260,9 @@ class PerformanceTracker:
improvement_trend=improvement_trend,
)
def export_dashboard_json(self, dashboard: PerformanceDashboard) -> str:
def export_dashboard_json(
self, dashboard: PerformanceDashboard
) -> str:
"""Export dashboard as JSON string.
Args:

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 >= ?
@@ -140,7 +135,9 @@ class DecisionLogger:
)
self.conn.commit()
def update_outcome(self, decision_id: str, pnl: float, accuracy: int) -> None:
def update_outcome(
self, decision_id: str, pnl: float, accuracy: int
) -> None:
"""Update the outcome of a decision after trade execution.
Args:
@@ -171,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 = ?
@@ -199,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 >= ?
@@ -226,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],
)

File diff suppressed because it is too large Load Diff

View File

@@ -211,7 +211,9 @@ def get_open_markets(
return is_market_open(market, now)
open_markets = [
MARKETS[code] for code in enabled_markets if code in MARKETS and is_available(MARKETS[code])
MARKETS[code]
for code in enabled_markets
if code in MARKETS and is_available(MARKETS[code])
]
return sorted(open_markets, key=lambda m: m.code)
@@ -280,7 +282,9 @@ def get_next_market_open(
# Calculate next open time for this market
for days_ahead in range(7): # Check next 7 days
check_date = market_now.date() + timedelta(days=days_ahead)
check_datetime = datetime.combine(check_date, market.open_time, tzinfo=market.timezone)
check_datetime = datetime.combine(
check_date, market.open_time, tzinfo=market.timezone
)
# Skip weekends
if check_datetime.weekday() >= 5:

View File

@@ -4,7 +4,7 @@ import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from dataclasses import dataclass, fields
from enum import Enum
from typing import ClassVar
@@ -136,14 +136,14 @@ class TelegramClient:
self._enabled = enabled
self._rate_limiter = LeakyBucket(rate=rate_limit)
self._session: aiohttp.ClientSession | None = None
self._filter = (
notification_filter if notification_filter is not None else NotificationFilter()
)
self._filter = notification_filter if notification_filter is not None else NotificationFilter()
if not enabled:
logger.info("Telegram notifications disabled via configuration")
elif bot_token is None or chat_id is None:
logger.warning("Telegram notifications disabled (missing bot_token or chat_id)")
logger.warning(
"Telegram notifications disabled (missing bot_token or chat_id)"
)
self._enabled = False
else:
logger.info("Telegram notifications enabled for chat_id=%s", chat_id)
@@ -209,12 +209,14 @@ class TelegramClient:
async with session.post(url, json=payload) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error("Telegram API error (status=%d): %s", resp.status, error_text)
logger.error(
"Telegram API error (status=%d): %s", resp.status, error_text
)
return False
logger.debug("Telegram message sent: %s", text[:50])
return True
except TimeoutError:
except asyncio.TimeoutError:
logger.error("Telegram message timeout")
return False
except aiohttp.ClientError as exc:
@@ -303,7 +305,9 @@ class TelegramClient:
NotificationMessage(priority=NotificationPriority.LOW, message=message)
)
async def notify_circuit_breaker(self, pnl_pct: float, threshold: float) -> None:
async def notify_circuit_breaker(
self, pnl_pct: float, threshold: float
) -> None:
"""
Notify circuit breaker activation.
@@ -350,7 +354,9 @@ class TelegramClient:
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
async def notify_system_start(self, mode: str, enabled_markets: list[str]) -> None:
async def notify_system_start(
self, mode: str, enabled_markets: list[str]
) -> None:
"""
Notify system startup.
@@ -363,7 +369,9 @@ class TelegramClient:
mode_emoji = "📝" if mode == "paper" else "💰"
markets_str = ", ".join(enabled_markets)
message = (
f"<b>{mode_emoji} System Started</b>\nMode: {mode.upper()}\nMarkets: {markets_str}"
f"<b>{mode_emoji} System Started</b>\n"
f"Mode: {mode.upper()}\n"
f"Markets: {markets_str}"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
@@ -437,7 +445,11 @@ class TelegramClient:
"""
if not self._filter.playbook:
return
message = f"<b>Playbook Failed</b>\nMarket: {market}\nReason: {reason[:200]}"
message = (
f"<b>Playbook Failed</b>\n"
f"Market: {market}\n"
f"Reason: {reason[:200]}"
)
await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
@@ -457,7 +469,9 @@ class TelegramClient:
if "circuit breaker" in reason.lower()
else NotificationPriority.MEDIUM
)
await self._send_notification(NotificationMessage(priority=priority, message=message))
await self._send_notification(
NotificationMessage(priority=priority, message=message)
)
async def notify_unfilled_order(
self,
@@ -482,7 +496,11 @@ class TelegramClient:
return
# SELL resubmit is high priority — position liquidation at risk.
# BUY cancel is medium priority — only cash is freed.
priority = NotificationPriority.HIGH if action == "SELL" else NotificationPriority.MEDIUM
priority = (
NotificationPriority.HIGH
if action == "SELL"
else NotificationPriority.MEDIUM
)
outcome_emoji = "🔄" if outcome == "resubmitted" else ""
outcome_label = "재주문" if outcome == "resubmitted" else "취소됨"
action_emoji = "🔴" if action == "SELL" else "🟢"
@@ -497,7 +515,9 @@ class TelegramClient:
message = "\n".join(lines)
await self._send_notification(NotificationMessage(priority=priority, message=message))
async def notify_error(self, error_type: str, error_msg: str, context: str) -> None:
async def notify_error(
self, error_type: str, error_msg: str, context: str
) -> None:
"""
Notify system error.
@@ -521,7 +541,9 @@ class TelegramClient:
class TelegramCommandHandler:
"""Handles incoming Telegram commands via long polling."""
def __init__(self, client: TelegramClient, polling_interval: float = 1.0) -> None:
def __init__(
self, client: TelegramClient, polling_interval: float = 1.0
) -> None:
"""
Initialize command handler.
@@ -537,7 +559,9 @@ class TelegramCommandHandler:
self._polling_task: asyncio.Task[None] | None = None
self._running = False
def register_command(self, command: str, handler: Callable[[], Awaitable[None]]) -> None:
def register_command(
self, command: str, handler: Callable[[], Awaitable[None]]
) -> None:
"""
Register a command handler (no arguments).
@@ -648,7 +672,7 @@ class TelegramCommandHandler:
return updates
except TimeoutError:
except asyncio.TimeoutError:
logger.debug("getUpdates timeout (normal)")
return []
except aiohttp.ClientError as exc:
@@ -673,7 +697,9 @@ class TelegramCommandHandler:
# Verify chat_id matches configured chat
chat_id = str(message.get("chat", {}).get("id", ""))
if chat_id != self._client._chat_id:
logger.warning("Ignoring command from unauthorized chat_id: %s", chat_id)
logger.warning(
"Ignoring command from unauthorized chat_id: %s", chat_id
)
return
# Extract command text

View File

@@ -8,12 +8,12 @@ Defines the data contracts for the proactive strategy system:
from __future__ import annotations
from datetime import UTC, date, datetime
from enum import StrEnum
from enum import Enum
from pydantic import BaseModel, Field, field_validator
class ScenarioAction(StrEnum):
class ScenarioAction(str, Enum):
"""Actions that can be taken by scenarios."""
BUY = "BUY"
@@ -22,7 +22,7 @@ class ScenarioAction(StrEnum):
REDUCE_ALL = "REDUCE_ALL"
class MarketOutlook(StrEnum):
class MarketOutlook(str, Enum):
"""AI's assessment of market direction."""
BULLISH = "bullish"
@@ -32,7 +32,7 @@ class MarketOutlook(StrEnum):
BEARISH = "bearish"
class PlaybookStatus(StrEnum):
class PlaybookStatus(str, Enum):
"""Lifecycle status of a playbook."""
PENDING = "pending"

View File

@@ -6,6 +6,7 @@ Designed for the pre-market strategy system (one playbook per market per day).
from __future__ import annotations
import json
import logging
import sqlite3
from datetime import date
@@ -52,10 +53,8 @@ class PlaybookStore:
row_id = cursor.lastrowid or 0
logger.info(
"Saved playbook for %s/%s (%d stocks, %d scenarios)",
playbook.date,
playbook.market,
playbook.stock_count,
playbook.scenario_count,
playbook.date, playbook.market,
playbook.stock_count, playbook.scenario_count,
)
return row_id

View File

@@ -6,10 +6,10 @@ State progression is monotonic (promotion-only) except terminal EXITED.
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from enum import Enum
class PositionState(StrEnum):
class PositionState(str, Enum):
HOLDING = "HOLDING"
BE_LOCK = "BE_LOCK"
ARMED = "ARMED"
@@ -40,7 +40,12 @@ def evaluate_exit_first(inp: StateTransitionInput) -> bool:
EXITED must be evaluated before any promotion.
"""
return inp.hard_stop_hit or inp.trailing_stop_hit or inp.model_exit_signal or inp.be_lock_threat
return (
inp.hard_stop_hit
or inp.trailing_stop_hit
or inp.model_exit_signal
or inp.be_lock_threat
)
def promote_state(current: PositionState, inp: StateTransitionInput) -> PositionState:

View File

@@ -124,14 +124,12 @@ class PreMarketPlanner:
# 4. Parse response
playbook = self._parse_response(
decision.rationale,
today,
market,
candidates,
cross_market,
decision.rationale, today, market, candidates, cross_market,
current_holdings=current_holdings,
)
playbook_with_tokens = playbook.model_copy(update={"token_count": decision.token_count})
playbook_with_tokens = playbook.model_copy(
update={"token_count": decision.token_count}
)
logger.info(
"Generated playbook for %s: %d stocks, %d scenarios, %d tokens",
market,
@@ -148,9 +146,7 @@ class PreMarketPlanner:
return self._empty_playbook(today, market)
def build_cross_market_context(
self,
target_market: str,
today: date | None = None,
self, target_market: str, today: date | None = None,
) -> CrossMarketContext | None:
"""Build cross-market context from the other market's L6 data.
@@ -196,9 +192,7 @@ class PreMarketPlanner:
)
def build_self_market_scorecard(
self,
market: str,
today: date | None = None,
self, market: str, today: date | None = None,
) -> dict[str, Any] | None:
"""Build previous-day scorecard for the same market."""
if today is None:
@@ -326,18 +320,18 @@ class PreMarketPlanner:
f"{context_text}\n"
f"## Instructions\n"
f"Return a JSON object with this exact structure:\n"
f"{{\n"
f'{{\n'
f' "market_outlook": "bullish|neutral_to_bullish|neutral'
f'|neutral_to_bearish|bearish",\n'
f' "global_rules": [\n'
f' {{"condition": "portfolio_pnl_pct < -2.0",'
f' "action": "REDUCE_ALL", "rationale": "..."}}\n'
f" ],\n"
f' ],\n'
f' "stocks": [\n'
f" {{\n"
f' {{\n'
f' "stock_code": "...",\n'
f' "scenarios": [\n'
f" {{\n"
f' {{\n'
f' "condition": {{"rsi_below": 30, "volume_ratio_above": 2.0,'
f' "unrealized_pnl_pct_above": 3.0, "holding_days_above": 5}},\n'
f' "action": "BUY|SELL|HOLD",\n'
@@ -346,11 +340,11 @@ class PreMarketPlanner:
f' "stop_loss_pct": -2.0,\n'
f' "take_profit_pct": 3.0,\n'
f' "rationale": "..."\n'
f" }}\n"
f" ]\n"
f" }}\n"
f" ]\n"
f"}}\n\n"
f' }}\n'
f' ]\n'
f' }}\n'
f' ]\n'
f'}}\n\n'
f"Rules:\n"
f"- Max {max_scenarios} scenarios per stock\n"
f"- Candidates list is the primary source for BUY candidates\n"
@@ -581,7 +575,8 @@ class PreMarketPlanner:
stop_loss_pct=-3.0,
take_profit_pct=5.0,
rationale=(
f"Rule-based BUY: oversold signal, RSI={c.rsi:.0f} (fallback planner)"
f"Rule-based BUY: oversold signal, "
f"RSI={c.rsi:.0f} (fallback planner)"
),
)
)

View File

@@ -107,9 +107,7 @@ class ScenarioEngine:
# 2. Find stock playbook
stock_pb = playbook.get_stock_playbook(stock_code)
if stock_pb is None:
logger.debug(
"No playbook for %s — defaulting to %s", stock_code, playbook.default_action
)
logger.debug("No playbook for %s — defaulting to %s", stock_code, playbook.default_action)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
@@ -137,9 +135,7 @@ class ScenarioEngine:
)
# 4. No match — default action
logger.debug(
"No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action
)
logger.debug("No scenario matched for %s — defaulting to %s", stock_code, playbook.default_action)
return ScenarioMatch(
stock_code=stock_code,
matched_scenario=None,
@@ -202,27 +198,17 @@ class ScenarioEngine:
checks.append(price is not None and price < condition.price_below)
price_change_pct = self._safe_float(market_data.get("price_change_pct"))
if (
condition.price_change_pct_above is not None
or condition.price_change_pct_below is not None
):
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
if "price_change_pct" not in market_data:
self._warn_missing_key("price_change_pct")
if condition.price_change_pct_above is not None:
checks.append(
price_change_pct is not None and price_change_pct > condition.price_change_pct_above
)
checks.append(price_change_pct is not None and price_change_pct > condition.price_change_pct_above)
if condition.price_change_pct_below is not None:
checks.append(
price_change_pct is not None and price_change_pct < condition.price_change_pct_below
)
checks.append(price_change_pct is not None and price_change_pct < condition.price_change_pct_below)
# Position-aware conditions
unrealized_pnl_pct = self._safe_float(market_data.get("unrealized_pnl_pct"))
if (
condition.unrealized_pnl_pct_above is not None
or condition.unrealized_pnl_pct_below is not None
):
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
if "unrealized_pnl_pct" not in market_data:
self._warn_missing_key("unrealized_pnl_pct")
if condition.unrealized_pnl_pct_above is not None:
@@ -241,9 +227,15 @@ class ScenarioEngine:
if "holding_days" not in market_data:
self._warn_missing_key("holding_days")
if condition.holding_days_above is not None:
checks.append(holding_days is not None and holding_days > condition.holding_days_above)
checks.append(
holding_days is not None
and holding_days > condition.holding_days_above
)
if condition.holding_days_below is not None:
checks.append(holding_days is not None and holding_days < condition.holding_days_below)
checks.append(
holding_days is not None
and holding_days < condition.holding_days_below
)
return len(checks) > 0 and all(checks)
@@ -303,15 +295,9 @@ class ScenarioEngine:
details["volume_ratio"] = self._safe_float(market_data.get("volume_ratio"))
if condition.price_above is not None or condition.price_below is not None:
details["current_price"] = self._safe_float(market_data.get("current_price"))
if (
condition.price_change_pct_above is not None
or condition.price_change_pct_below is not None
):
if condition.price_change_pct_above is not None or condition.price_change_pct_below is not None:
details["price_change_pct"] = self._safe_float(market_data.get("price_change_pct"))
if (
condition.unrealized_pnl_pct_above is not None
or condition.unrealized_pnl_pct_below is not None
):
if condition.unrealized_pnl_pct_above is not None or condition.unrealized_pnl_pct_below is not None:
details["unrealized_pnl_pct"] = self._safe_float(market_data.get("unrealized_pnl_pct"))
if condition.holding_days_above is not None or condition.holding_days_below is not None:
details["holding_days"] = self._safe_float(market_data.get("holding_days"))

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

@@ -4,7 +4,8 @@ from __future__ import annotations
import sqlite3
import sys
from datetime import UTC, datetime
import tempfile
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -47,9 +48,7 @@ def temp_db(tmp_path: Path) -> Path:
cursor.executemany(
"""
INSERT INTO trades (
timestamp, stock_code, action, quantity, price, confidence, rationale, pnl
)
INSERT INTO trades (timestamp, stock_code, action, quantity, price, confidence, rationale, pnl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
test_trades,
@@ -74,7 +73,9 @@ class TestBackupExporter:
exporter = BackupExporter(str(temp_db))
output_dir = tmp_path / "exports"
results = exporter.export_all(output_dir, formats=[ExportFormat.JSON], compress=False)
results = exporter.export_all(
output_dir, formats=[ExportFormat.JSON], compress=False
)
assert ExportFormat.JSON in results
assert results[ExportFormat.JSON].exists()
@@ -85,7 +86,9 @@ class TestBackupExporter:
exporter = BackupExporter(str(temp_db))
output_dir = tmp_path / "exports"
results = exporter.export_all(output_dir, formats=[ExportFormat.JSON], compress=True)
results = exporter.export_all(
output_dir, formats=[ExportFormat.JSON], compress=True
)
assert ExportFormat.JSON in results
assert results[ExportFormat.JSON].suffix == ".gz"
@@ -95,13 +98,15 @@ class TestBackupExporter:
exporter = BackupExporter(str(temp_db))
output_dir = tmp_path / "exports"
results = exporter.export_all(output_dir, formats=[ExportFormat.CSV], compress=False)
results = exporter.export_all(
output_dir, formats=[ExportFormat.CSV], compress=False
)
assert ExportFormat.CSV in results
assert results[ExportFormat.CSV].exists()
# Verify CSV content
with open(results[ExportFormat.CSV]) as f:
with open(results[ExportFormat.CSV], "r") as f:
lines = f.readlines()
assert len(lines) == 4 # Header + 3 rows
@@ -141,7 +146,7 @@ class TestBackupExporter:
# Should only have 1 trade (AAPL on Jan 2)
import json
with open(results[ExportFormat.JSON]) as f:
with open(results[ExportFormat.JSON], "r") as f:
data = json.load(f)
assert data["record_count"] == 1
assert data["trades"][0]["stock_code"] == "AAPL"
@@ -402,7 +407,9 @@ class TestBackupExporterAdditional:
assert ExportFormat.JSON in results
assert ExportFormat.CSV in results
def test_export_all_logs_error_on_failure(self, temp_db: Path, tmp_path: Path) -> None:
def test_export_all_logs_error_on_failure(
self, temp_db: Path, tmp_path: Path
) -> None:
"""export_all must log an error and continue when one format fails."""
exporter = BackupExporter(str(temp_db))
# Patch _export_format to raise on JSON, succeed on CSV
@@ -423,7 +430,9 @@ class TestBackupExporterAdditional:
assert ExportFormat.JSON not in results
assert ExportFormat.CSV in results
def test_export_csv_empty_trades_no_compress(self, empty_db: Path, tmp_path: Path) -> None:
def test_export_csv_empty_trades_no_compress(
self, empty_db: Path, tmp_path: Path
) -> None:
"""CSV export with no trades and compress=False must write header row only."""
exporter = BackupExporter(str(empty_db))
results = exporter.export_all(
@@ -437,7 +446,9 @@ class TestBackupExporterAdditional:
content = out.read_text()
assert "timestamp" in content
def test_export_csv_empty_trades_compressed(self, empty_db: Path, tmp_path: Path) -> None:
def test_export_csv_empty_trades_compressed(
self, empty_db: Path, tmp_path: Path
) -> None:
"""CSV export with no trades and compress=True must write gzipped header."""
import gzip
@@ -454,7 +465,9 @@ class TestBackupExporterAdditional:
content = f.read()
assert "timestamp" in content
def test_export_csv_with_data_compressed(self, temp_db: Path, tmp_path: Path) -> None:
def test_export_csv_with_data_compressed(
self, temp_db: Path, tmp_path: Path
) -> None:
"""CSV export with data and compress=True must write gzipped rows."""
import gzip
@@ -479,7 +492,6 @@ class TestBackupExporterAdditional:
with patch.dict(sys.modules, {"pyarrow": None, "pyarrow.parquet": None}):
try:
import pyarrow # noqa: F401
pytest.skip("pyarrow is installed; cannot test ImportError path")
except ImportError:
pass
@@ -545,7 +557,9 @@ class TestCloudStorage:
importlib.reload(m)
m.CloudStorage(s3_config)
def test_upload_file_success(self, mock_boto3_module, s3_config, tmp_path: Path) -> None:
def test_upload_file_success(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file must call client.upload_file and return the object key."""
from src.backup.cloud_storage import CloudStorage
@@ -558,7 +572,9 @@ class TestCloudStorage:
assert key == "backups/backup.json.gz"
storage.client.upload_file.assert_called_once()
def test_upload_file_default_key(self, mock_boto3_module, s3_config, tmp_path: Path) -> None:
def test_upload_file_default_key(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file without object_key must use the filename as key."""
from src.backup.cloud_storage import CloudStorage
@@ -570,7 +586,9 @@ class TestCloudStorage:
assert key == "myfile.gz"
def test_upload_file_not_found(self, mock_boto3_module, s3_config, tmp_path: Path) -> None:
def test_upload_file_not_found(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""upload_file must raise FileNotFoundError for missing files."""
from src.backup.cloud_storage import CloudStorage
@@ -593,7 +611,9 @@ class TestCloudStorage:
with pytest.raises(RuntimeError, match="network error"):
storage.upload_file(test_file)
def test_download_file_success(self, mock_boto3_module, s3_config, tmp_path: Path) -> None:
def test_download_file_success(
self, mock_boto3_module, s3_config, tmp_path: Path
) -> None:
"""download_file must call client.download_file and return local path."""
from src.backup.cloud_storage import CloudStorage
@@ -617,8 +637,11 @@ class TestCloudStorage:
with pytest.raises(RuntimeError, match="timeout"):
storage.download_file("key", tmp_path / "dest.gz")
def test_list_files_returns_objects(self, mock_boto3_module, s3_config) -> None:
def test_list_files_returns_objects(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must return parsed file metadata from S3 response."""
from datetime import timezone
from src.backup.cloud_storage import CloudStorage
@@ -628,7 +651,7 @@ class TestCloudStorage:
{
"Key": "backups/a.gz",
"Size": 1024,
"LastModified": datetime(2026, 1, 1, tzinfo=UTC),
"LastModified": datetime(2026, 1, 1, tzinfo=timezone.utc),
"ETag": '"abc123"',
}
]
@@ -639,7 +662,9 @@ class TestCloudStorage:
assert files[0]["key"] == "backups/a.gz"
assert files[0]["size_bytes"] == 1024
def test_list_files_empty_bucket(self, mock_boto3_module, s3_config) -> None:
def test_list_files_empty_bucket(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must return empty list when bucket has no objects."""
from src.backup.cloud_storage import CloudStorage
@@ -649,7 +674,9 @@ class TestCloudStorage:
files = storage.list_files()
assert files == []
def test_list_files_propagates_error(self, mock_boto3_module, s3_config) -> None:
def test_list_files_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""list_files must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
@@ -659,7 +686,9 @@ class TestCloudStorage:
with pytest.raises(RuntimeError):
storage.list_files()
def test_delete_file_success(self, mock_boto3_module, s3_config) -> None:
def test_delete_file_success(
self, mock_boto3_module, s3_config
) -> None:
"""delete_file must call client.delete_object with the correct key."""
from src.backup.cloud_storage import CloudStorage
@@ -669,7 +698,9 @@ class TestCloudStorage:
Bucket="test-bucket", Key="backups/old.gz"
)
def test_delete_file_propagates_error(self, mock_boto3_module, s3_config) -> None:
def test_delete_file_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""delete_file must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage
@@ -679,8 +710,11 @@ class TestCloudStorage:
with pytest.raises(RuntimeError):
storage.delete_file("backups/old.gz")
def test_get_storage_stats_success(self, mock_boto3_module, s3_config) -> None:
def test_get_storage_stats_success(
self, mock_boto3_module, s3_config
) -> None:
"""get_storage_stats must aggregate file sizes correctly."""
from datetime import timezone
from src.backup.cloud_storage import CloudStorage
@@ -690,13 +724,13 @@ class TestCloudStorage:
{
"Key": "a.gz",
"Size": 1024 * 1024,
"LastModified": datetime(2026, 1, 1, tzinfo=UTC),
"LastModified": datetime(2026, 1, 1, tzinfo=timezone.utc),
"ETag": '"x"',
},
{
"Key": "b.gz",
"Size": 1024 * 1024,
"LastModified": datetime(2026, 1, 2, tzinfo=UTC),
"LastModified": datetime(2026, 1, 2, tzinfo=timezone.utc),
"ETag": '"y"',
},
]
@@ -707,7 +741,9 @@ class TestCloudStorage:
assert stats["total_size_bytes"] == 2 * 1024 * 1024
assert stats["total_size_mb"] == pytest.approx(2.0)
def test_get_storage_stats_on_error(self, mock_boto3_module, s3_config) -> None:
def test_get_storage_stats_on_error(
self, mock_boto3_module, s3_config
) -> None:
"""get_storage_stats must return error dict without raising on failure."""
from src.backup.cloud_storage import CloudStorage
@@ -718,7 +754,9 @@ class TestCloudStorage:
assert "error" in stats
assert stats["total_files"] == 0
def test_verify_connection_success(self, mock_boto3_module, s3_config) -> None:
def test_verify_connection_success(
self, mock_boto3_module, s3_config
) -> None:
"""verify_connection must return True when head_bucket succeeds."""
from src.backup.cloud_storage import CloudStorage
@@ -726,7 +764,9 @@ class TestCloudStorage:
result = storage.verify_connection()
assert result is True
def test_verify_connection_failure(self, mock_boto3_module, s3_config) -> None:
def test_verify_connection_failure(
self, mock_boto3_module, s3_config
) -> None:
"""verify_connection must return False when head_bucket raises."""
from src.backup.cloud_storage import CloudStorage
@@ -736,7 +776,9 @@ class TestCloudStorage:
result = storage.verify_connection()
assert result is False
def test_enable_versioning(self, mock_boto3_module, s3_config) -> None:
def test_enable_versioning(
self, mock_boto3_module, s3_config
) -> None:
"""enable_versioning must call put_bucket_versioning."""
from src.backup.cloud_storage import CloudStorage
@@ -744,7 +786,9 @@ class TestCloudStorage:
storage.enable_versioning()
storage.client.put_bucket_versioning.assert_called_once()
def test_enable_versioning_propagates_error(self, mock_boto3_module, s3_config) -> None:
def test_enable_versioning_propagates_error(
self, mock_boto3_module, s3_config
) -> None:
"""enable_versioning must re-raise exceptions from the boto3 client."""
from src.backup.cloud_storage import CloudStorage

View File

@@ -323,8 +323,7 @@ class TestPromptOverride:
# Verify the custom prompt was sent, not a built prompt
mock_generate.assert_called_once()
actual_prompt = mock_generate.call_args[1].get(
"contents",
mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None,
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
)
assert actual_prompt == custom_prompt
# Raw response preserved in rationale without parse_response (#247)
@@ -386,8 +385,7 @@ class TestPromptOverride:
await client.decide(market_data)
actual_prompt = mock_generate.call_args[1].get(
"contents",
mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None,
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
)
# The custom prompt must be used, not the compressed prompt
assert actual_prompt == custom_prompt
@@ -413,8 +411,7 @@ class TestPromptOverride:
await client.decide(market_data)
actual_prompt = mock_generate.call_args[1].get(
"contents",
mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None,
"contents", mock_generate.call_args[0][1] if len(mock_generate.call_args[0]) > 1 else None
)
# Should contain stock code from build_prompt, not be a custom override
assert "005930" in actual_prompt

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -99,10 +99,7 @@ class TestTokenManagement:
mock_resp_403 = AsyncMock()
mock_resp_403.status = 403
mock_resp_403.text = AsyncMock(
return_value=(
'{"error_code":"EGW00133","error_description":'
'"접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"}'
)
return_value='{"error_code":"EGW00133","error_description":"접근토큰 발급 잠시 후 다시 시도하세요(1분당 1회)"}'
)
mock_resp_403.__aenter__ = AsyncMock(return_value=mock_resp_403)
mock_resp_403.__aexit__ = AsyncMock(return_value=False)
@@ -235,7 +232,9 @@ class TestRateLimiter:
mock_order_resp.__aenter__ = AsyncMock(return_value=mock_order_resp)
mock_order_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash_resp, mock_order_resp]):
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash_resp, mock_order_resp]
):
with patch.object(
broker._rate_limiter, "acquire", new_callable=AsyncMock
) as mock_acquire:
@@ -406,7 +405,7 @@ class TestFetchMarketRankings:
# ---------------------------------------------------------------------------
from src.broker.kis_api import kr_round_down, kr_tick_unit # noqa: E402
from src.broker.kis_api import kr_tick_unit, kr_round_down # noqa: E402
class TestKrTickUnit:
@@ -539,7 +538,9 @@ class TestSendOrderTickRounding:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1, price=188150)
order_call = mock_post.call_args_list[1]
@@ -562,7 +563,9 @@ class TestSendOrderTickRounding:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1, price=50000)
order_call = mock_post.call_args_list[1]
@@ -584,7 +587,9 @@ class TestSendOrderTickRounding:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1, price=0)
order_call = mock_post.call_args_list[1]
@@ -623,7 +628,9 @@ class TestTRIDBranchingDomestic:
broker = self._make_broker(settings, "paper")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": {}})
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
@@ -638,7 +645,9 @@ class TestTRIDBranchingDomestic:
broker = self._make_broker(settings, "live")
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output1": [], "output2": {}})
mock_resp.json = AsyncMock(
return_value={"output1": [], "output2": {}}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
@@ -663,7 +672,9 @@ class TestTRIDBranchingDomestic:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -684,7 +695,9 @@ class TestTRIDBranchingDomestic:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "BUY", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -705,7 +718,9 @@ class TestTRIDBranchingDomestic:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -726,7 +741,9 @@ class TestTRIDBranchingDomestic:
mock_order.__aenter__ = AsyncMock(return_value=mock_order)
mock_order.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.send_order("005930", "SELL", 1)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -771,7 +788,9 @@ class TestGetDomesticPendingOrders:
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_live_mode_calls_tttc0084r_with_correct_params(self, settings) -> None:
async def test_live_mode_calls_tttc0084r_with_correct_params(
self, settings
) -> None:
"""Live mode must call TTTC0084R with INQR_DVSN_1/2 and paging params."""
broker = self._make_broker(settings, "live")
pending = [{"odno": "001", "pdno": "005930", "psbl_qty": "10"}]
@@ -853,7 +872,9 @@ class TestCancelDomesticOrder:
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -865,7 +886,9 @@ class TestCancelDomesticOrder:
broker = self._make_broker(settings, "paper")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
order_headers = mock_post.call_args_list[1][1].get("headers", {})
@@ -877,7 +900,9 @@ class TestCancelDomesticOrder:
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 5)
body = mock_post.call_args_list[1][1].get("json", {})
@@ -891,7 +916,9 @@ class TestCancelDomesticOrder:
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD123", "BRN456", 3)
body = mock_post.call_args_list[1][1].get("json", {})
@@ -905,7 +932,9 @@ class TestCancelDomesticOrder:
broker = self._make_broker(settings, "live")
mock_hash, mock_order = self._make_post_mocks({"rt_cd": "0"})
with patch("aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]) as mock_post:
with patch(
"aiohttp.ClientSession.post", side_effect=[mock_hash, mock_order]
) as mock_post:
await broker.cancel_domestic_order("005930", "ORD001", "BRNO01", 2)
order_headers = mock_post.call_args_list[1][1].get("headers", {})

View File

@@ -77,7 +77,9 @@ class TestContextStore:
# Latest by updated_at, which should be the last one set
assert latest == "2026-02-02"
def test_delete_old_contexts(self, store: ContextStore, db_conn: sqlite3.Connection) -> None:
def test_delete_old_contexts(
self, store: ContextStore, db_conn: sqlite3.Connection
) -> None:
"""Test deleting contexts older than a cutoff date."""
# Insert contexts with specific old timestamps
# (bypassing set_context which uses current time)
@@ -168,7 +170,9 @@ class TestContextAggregator:
log_trade(db_conn, "035720", "HOLD", 75, "Wait", quantity=0, price=0, pnl=0)
# Manually set timestamps to the target date
db_conn.execute(f"UPDATE trades SET timestamp = '{date}T10:00:00+00:00'")
db_conn.execute(
f"UPDATE trades SET timestamp = '{date}T10:00:00+00:00'"
)
db_conn.commit()
# Aggregate
@@ -190,10 +194,18 @@ class TestContextAggregator:
week = "2026-W06"
# Set daily contexts
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "total_pnl_KR", 100.0)
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "total_pnl_KR", 200.0)
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-02", "avg_confidence_KR", 80.0)
aggregator.store.set_context(ContextLayer.L6_DAILY, "2026-02-03", "avg_confidence_KR", 85.0)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-02", "total_pnl_KR", 100.0
)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-03", "total_pnl_KR", 200.0
)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-02", "avg_confidence_KR", 80.0
)
aggregator.store.set_context(
ContextLayer.L6_DAILY, "2026-02-03", "avg_confidence_KR", 85.0
)
# Aggregate
aggregator.aggregate_weekly_from_daily(week)
@@ -211,9 +223,15 @@ class TestContextAggregator:
month = "2026-02"
# Set weekly contexts
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W05", "weekly_pnl_KR", 100.0)
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W06", "weekly_pnl_KR", 200.0)
aggregator.store.set_context(ContextLayer.L5_WEEKLY, "2026-W07", "weekly_pnl_KR", 150.0)
aggregator.store.set_context(
ContextLayer.L5_WEEKLY, "2026-W05", "weekly_pnl_KR", 100.0
)
aggregator.store.set_context(
ContextLayer.L5_WEEKLY, "2026-W06", "weekly_pnl_KR", 200.0
)
aggregator.store.set_context(
ContextLayer.L5_WEEKLY, "2026-W07", "weekly_pnl_KR", 150.0
)
# Aggregate
aggregator.aggregate_monthly_from_weekly(month)
@@ -298,7 +316,6 @@ class TestContextAggregator:
store = aggregator.store
assert store.get_context(ContextLayer.L6_DAILY, date, "total_pnl_KR") == 1000.0
from datetime import date as date_cls
trade_date = date_cls.fromisoformat(date)
iso_year, iso_week, _ = trade_date.isocalendar()
trade_week = f"{iso_year}-W{iso_week:02d}"
@@ -307,9 +324,7 @@ class TestContextAggregator:
trade_quarter = f"{trade_date.year}-Q{(trade_date.month - 1) // 3 + 1}"
trade_year = str(trade_date.year)
assert store.get_context(ContextLayer.L4_MONTHLY, trade_month, "monthly_pnl") == 1000.0
assert (
store.get_context(ContextLayer.L3_QUARTERLY, trade_quarter, "quarterly_pnl") == 1000.0
)
assert store.get_context(ContextLayer.L3_QUARTERLY, trade_quarter, "quarterly_pnl") == 1000.0
assert store.get_context(ContextLayer.L2_ANNUAL, trade_year, "annual_pnl") == 1000.0
@@ -414,7 +429,9 @@ class TestContextSummarizer:
# summarize_layer
# ------------------------------------------------------------------
def test_summarize_layer_no_data(self, summarizer: ContextSummarizer) -> None:
def test_summarize_layer_no_data(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer with no data must return the 'No data' sentinel."""
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
assert result["count"] == 0
@@ -431,12 +448,15 @@ class TestContextSummarizer:
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
assert "total_entries" in result
def test_summarize_layer_with_dict_values(self, summarizer: ContextSummarizer) -> None:
def test_summarize_layer_with_dict_values(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer must handle dict values by extracting numeric subkeys."""
store = summarizer.store
# set_context serialises the value as JSON, so passing a dict works
store.set_context(
ContextLayer.L6_DAILY, "2026-02-01", "metrics", {"win_rate": 65.0, "label": "good"}
ContextLayer.L6_DAILY, "2026-02-01", "metrics",
{"win_rate": 65.0, "label": "good"}
)
result = summarizer.summarize_layer(ContextLayer.L6_DAILY)
@@ -444,7 +464,9 @@ class TestContextSummarizer:
# numeric subkey "win_rate" should appear as "metrics.win_rate"
assert "metrics.win_rate" in result
def test_summarize_layer_with_string_values(self, summarizer: ContextSummarizer) -> None:
def test_summarize_layer_with_string_values(
self, summarizer: ContextSummarizer
) -> None:
"""summarize_layer must count string values separately."""
store = summarizer.store
# set_context stores string values as JSON-encoded strings
@@ -458,7 +480,9 @@ class TestContextSummarizer:
# rolling_window_summary
# ------------------------------------------------------------------
def test_rolling_window_summary_basic(self, summarizer: ContextSummarizer) -> None:
def test_rolling_window_summary_basic(
self, summarizer: ContextSummarizer
) -> None:
"""rolling_window_summary must return the expected structure."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "pnl", 500.0)
@@ -468,16 +492,22 @@ class TestContextSummarizer:
assert "recent_data" in result
assert "historical_summary" in result
def test_rolling_window_summary_no_older_data(self, summarizer: ContextSummarizer) -> None:
def test_rolling_window_summary_no_older_data(
self, summarizer: ContextSummarizer
) -> None:
"""rolling_window_summary with summarize_older=False skips history."""
result = summarizer.rolling_window_summary(ContextLayer.L6_DAILY, summarize_older=False)
result = summarizer.rolling_window_summary(
ContextLayer.L6_DAILY, summarize_older=False
)
assert result["historical_summary"] == {}
# ------------------------------------------------------------------
# aggregate_to_higher_layer
# ------------------------------------------------------------------
def test_aggregate_to_higher_layer_mean(self, summarizer: ContextSummarizer) -> None:
def test_aggregate_to_higher_layer_mean(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'mean' via dict subkeys returns average."""
store = summarizer.store
# Use different outer keys but same inner metric key so get_all_contexts
@@ -490,7 +520,9 @@ class TestContextSummarizer:
)
assert result == pytest.approx(150.0)
def test_aggregate_to_higher_layer_sum(self, summarizer: ContextSummarizer) -> None:
def test_aggregate_to_higher_layer_sum(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'sum' must return the total."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
@@ -501,7 +533,9 @@ class TestContextSummarizer:
)
assert result == pytest.approx(300.0)
def test_aggregate_to_higher_layer_max(self, summarizer: ContextSummarizer) -> None:
def test_aggregate_to_higher_layer_max(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'max' must return the maximum."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
@@ -512,7 +546,9 @@ class TestContextSummarizer:
)
assert result == pytest.approx(200.0)
def test_aggregate_to_higher_layer_min(self, summarizer: ContextSummarizer) -> None:
def test_aggregate_to_higher_layer_min(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with 'min' must return the minimum."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "day1", {"pnl": 100.0})
@@ -523,7 +559,9 @@ class TestContextSummarizer:
)
assert result == pytest.approx(100.0)
def test_aggregate_to_higher_layer_no_data(self, summarizer: ContextSummarizer) -> None:
def test_aggregate_to_higher_layer_no_data(
self, summarizer: ContextSummarizer
) -> None:
"""aggregate_to_higher_layer with no matching key must return None."""
result = summarizer.aggregate_to_higher_layer(
ContextLayer.L6_DAILY, ContextLayer.L5_WEEKLY, "nonexistent", "mean"
@@ -547,7 +585,9 @@ class TestContextSummarizer:
# create_compact_summary + format_summary_for_prompt
# ------------------------------------------------------------------
def test_create_compact_summary(self, summarizer: ContextSummarizer) -> None:
def test_create_compact_summary(
self, summarizer: ContextSummarizer
) -> None:
"""create_compact_summary must produce a dict keyed by layer value."""
store = summarizer.store
store.set_context(ContextLayer.L6_DAILY, "2026-02-01", "pnl", 100.0)
@@ -575,7 +615,9 @@ class TestContextSummarizer:
text = summarizer.format_summary_for_prompt(summary)
assert text == ""
def test_format_summary_non_dict_value(self, summarizer: ContextSummarizer) -> None:
def test_format_summary_non_dict_value(
self, summarizer: ContextSummarizer
) -> None:
"""format_summary_for_prompt must render non-dict values as plain text."""
summary = {
"daily": {

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import json
import sqlite3
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
@@ -17,6 +16,8 @@ from src.evolution.daily_review import DailyReviewer
from src.evolution.scorecard import DailyScorecard
from src.logging.decision_logger import DecisionLogger
from datetime import UTC, datetime
TODAY = datetime.now(UTC).strftime("%Y-%m-%d")
@@ -52,8 +53,7 @@ def _log_decision(
def test_generate_scorecard_market_scoped(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
logger = DecisionLogger(db_conn)
@@ -134,8 +134,7 @@ def test_generate_scorecard_market_scoped(
def test_generate_scorecard_top_winners_and_losers(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
logger = DecisionLogger(db_conn)
@@ -169,8 +168,7 @@ def test_generate_scorecard_top_winners_and_losers(
def test_generate_scorecard_empty_day(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
scorecard = reviewer.generate_scorecard(TODAY, "KR")
@@ -186,8 +184,7 @@ def test_generate_scorecard_empty_day(
@pytest.mark.asyncio
async def test_generate_lessons_without_gemini_returns_empty(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store, gemini_client=None)
lessons = await reviewer.generate_lessons(
@@ -209,8 +206,7 @@ async def test_generate_lessons_without_gemini_returns_empty(
@pytest.mark.asyncio
async def test_generate_lessons_parses_json_array(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(
@@ -237,8 +233,7 @@ async def test_generate_lessons_parses_json_array(
@pytest.mark.asyncio
async def test_generate_lessons_fallback_to_lines(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(
@@ -265,8 +260,7 @@ async def test_generate_lessons_fallback_to_lines(
@pytest.mark.asyncio
async def test_generate_lessons_handles_gemini_error(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
mock_gemini = MagicMock()
mock_gemini.decide = AsyncMock(side_effect=RuntimeError("boom"))
@@ -290,8 +284,7 @@ async def test_generate_lessons_handles_gemini_error(
def test_store_scorecard_in_context(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
scorecard = DailyScorecard(
@@ -323,8 +316,7 @@ def test_store_scorecard_in_context(
def test_store_scorecard_key_is_market_scoped(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
kr = DailyScorecard(
@@ -365,8 +357,7 @@ def test_store_scorecard_key_is_market_scoped(
def test_generate_scorecard_handles_invalid_context_snapshot(
db_conn: sqlite3.Connection,
context_store: ContextStore,
db_conn: sqlite3.Connection, context_store: ContextStore,
) -> None:
reviewer = DailyReviewer(db_conn, context_store)
db_conn.execute(

View File

@@ -355,7 +355,6 @@ def test_positions_empty_when_no_trades(tmp_path: Path) -> None:
def _seed_cb_context(conn: sqlite3.Connection, pnl_pct: float, market: str = "KR") -> None:
import json as _json
conn.execute(
"INSERT OR REPLACE INTO system_metrics (key, value, updated_at) VALUES (?, ?, ?)",
(

View File

@@ -79,7 +79,7 @@ class TestNewsAPI:
# Mock the fetch to avoid real API call
with patch.object(api, "_fetch_news", new_callable=AsyncMock) as mock_fetch:
mock_fetch.return_value = None
await api.get_news_sentiment("AAPL")
result = await api.get_news_sentiment("AAPL")
# Should have attempted refetch since cache expired
mock_fetch.assert_called_once_with("AAPL")
@@ -111,7 +111,9 @@ class TestNewsAPI:
"source": "Reuters",
"time_published": "2026-02-04T10:00:00",
"url": "https://example.com/1",
"ticker_sentiment": [{"ticker": "AAPL", "ticker_sentiment_score": "0.85"}],
"ticker_sentiment": [
{"ticker": "AAPL", "ticker_sentiment_score": "0.85"}
],
"overall_sentiment_score": "0.75",
},
{
@@ -120,7 +122,9 @@ class TestNewsAPI:
"source": "Bloomberg",
"time_published": "2026-02-04T09:00:00",
"url": "https://example.com/2",
"ticker_sentiment": [{"ticker": "AAPL", "ticker_sentiment_score": "-0.3"}],
"ticker_sentiment": [
{"ticker": "AAPL", "ticker_sentiment_score": "-0.3"}
],
"overall_sentiment_score": "-0.2",
},
]
@@ -657,9 +661,7 @@ class TestGeminiClientWithExternalData:
)
# Mock the Gemini API call
with patch.object(
client._client.aio.models, "generate_content", new_callable=AsyncMock
) as mock_gen:
with patch.object(client._client.aio.models, "generate_content", new_callable=AsyncMock) as mock_gen:
mock_response = MagicMock()
mock_response.text = '{"action": "BUY", "confidence": 85, "rationale": "Good news"}'
mock_gen.return_value = mock_response

View File

@@ -1,9 +1,9 @@
"""Tests for database helper functions."""
import os
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:
@@ -204,8 +204,7 @@ def test_mode_migration_adds_column_to_existing_db() -> None:
assert "strategy_pnl" in columns
assert "fx_pnl" in columns
migrated = conn.execute(
"SELECT pnl, strategy_pnl, fx_pnl, session_id "
"FROM trades WHERE stock_code='AAPL' LIMIT 1"
"SELECT pnl, strategy_pnl, fx_pnl, session_id FROM trades WHERE stock_code='AAPL' LIMIT 1"
).fetchone()
assert migrated is not None
assert migrated[0] == 123.45
@@ -330,87 +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,10 +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()
@@ -60,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:
@@ -88,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:
@@ -300,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",
@@ -309,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

@@ -208,9 +208,7 @@ def test_identify_failure_patterns_empty(optimizer: EvolutionOptimizer) -> None:
@pytest.mark.asyncio
async def test_generate_strategy_creates_file(
optimizer: EvolutionOptimizer, tmp_path: Path
) -> None:
async def test_generate_strategy_creates_file(optimizer: EvolutionOptimizer, tmp_path: Path) -> None:
"""Test that generate_strategy creates a strategy file."""
failures = [
{
@@ -236,9 +234,7 @@ async def test_generate_strategy_creates_file(
return {"action": "HOLD", "confidence": 50, "rationale": "Waiting"}
"""
with patch.object(
optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)
):
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)
@@ -249,59 +245,6 @@ async def test_generate_strategy_creates_file(
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."""
@@ -321,7 +264,6 @@ def test_get_performance_summary() -> None:
"""Test getting performance summary from trades table."""
# Create a temporary database with trades
import tempfile
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
tmp_path = tmp.name
@@ -616,9 +558,7 @@ def test_calculate_improvement_trend_declining(performance_tracker: PerformanceT
assert trend["pnl_change"] == -250.0
def test_calculate_improvement_trend_insufficient_data(
performance_tracker: PerformanceTracker,
) -> None:
def test_calculate_improvement_trend_insufficient_data(performance_tracker: PerformanceTracker) -> None:
"""Test improvement trend with insufficient data."""
metrics = [
StrategyMetrics(
@@ -732,9 +672,7 @@ async def test_full_evolution_pipeline(optimizer: EvolutionOptimizer, tmp_path:
mock_response = Mock()
mock_response.text = 'return {"action": "HOLD", "confidence": 50, "rationale": "Test"}'
with patch.object(
optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)
):
with patch.object(optimizer._client.aio.models, "generate_content", new=AsyncMock(return_value=mock_response)):
with patch("src.evolution.optimizer.STRATEGIES_DIR", tmp_path):
with patch("subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")

View File

@@ -103,7 +103,9 @@ class TestSetupLogging:
"""setup_logging must attach a JSON handler to the root logger."""
setup_logging(level=logging.DEBUG)
root = logging.getLogger()
json_handlers = [h for h in root.handlers if isinstance(h.formatter, JSONFormatter)]
json_handlers = [
h for h in root.handlers if isinstance(h.formatter, JSONFormatter)
]
assert len(json_handlers) == 1
assert root.level == logging.DEBUG

File diff suppressed because it is too large Load Diff

View File

@@ -173,7 +173,9 @@ class TestGetNextMarketOpen:
"""Should find next Monday opening when called on weekend."""
# Saturday 2026-02-07 12:00 UTC
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, open_time = get_next_market_open(enabled_markets=["KR"], now=test_time)
market, open_time = get_next_market_open(
enabled_markets=["KR"], now=test_time
)
assert market.code == "KR"
# Monday 2026-02-09 09:00 KST
expected = datetime(2026, 2, 9, 9, 0, tzinfo=ZoneInfo("Asia/Seoul"))
@@ -183,7 +185,9 @@ class TestGetNextMarketOpen:
"""Should find next day opening when called after market close."""
# Monday 2026-02-02 16:00 KST (after close)
test_time = datetime(2026, 2, 2, 16, 0, tzinfo=ZoneInfo("Asia/Seoul"))
market, open_time = get_next_market_open(enabled_markets=["KR"], now=test_time)
market, open_time = get_next_market_open(
enabled_markets=["KR"], now=test_time
)
assert market.code == "KR"
# Tuesday 2026-02-03 09:00 KST
expected = datetime(2026, 2, 3, 9, 0, tzinfo=ZoneInfo("Asia/Seoul"))
@@ -193,7 +197,9 @@ class TestGetNextMarketOpen:
"""Should find earliest opening market among multiple."""
# Saturday 2026-02-07 12:00 UTC
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, open_time = get_next_market_open(enabled_markets=["KR", "US_NASDAQ"], now=test_time)
market, open_time = get_next_market_open(
enabled_markets=["KR", "US_NASDAQ"], now=test_time
)
# Monday 2026-02-09: KR opens at 09:00 KST = 00:00 UTC
# Monday 2026-02-09: US opens at 09:30 EST = 14:30 UTC
# KR opens first
@@ -208,7 +214,9 @@ class TestGetNextMarketOpen:
def test_get_next_market_open_invalid_market(self) -> None:
"""Should skip invalid market codes."""
test_time = datetime(2026, 2, 7, 12, 0, tzinfo=ZoneInfo("UTC"))
market, _ = get_next_market_open(enabled_markets=["INVALID", "KR"], now=test_time)
market, _ = get_next_market_open(
enabled_markets=["INVALID", "KR"], now=test_time
)
assert market.code == "KR"
def test_get_next_market_open_prefers_extended_session(self) -> None:

View File

@@ -8,7 +8,7 @@ import aiohttp
import pytest
from src.broker.kis_api import KISBroker
from src.broker.overseas import _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP, OverseasBroker
from src.broker.overseas import OverseasBroker, _PRICE_EXCHANGE_MAP, _RANKING_EXCHANGE_MAP
from src.config import Settings
@@ -85,27 +85,25 @@ class TestConfigDefaults:
assert mock_settings.OVERSEAS_RANKING_VOLUME_TR_ID == "HHDFS76270000"
def test_fluct_path(self, mock_settings: Settings) -> None:
assert (
mock_settings.OVERSEAS_RANKING_FLUCT_PATH
== "/uapi/overseas-stock/v1/ranking/updown-rate"
)
assert mock_settings.OVERSEAS_RANKING_FLUCT_PATH == "/uapi/overseas-stock/v1/ranking/updown-rate"
def test_volume_path(self, mock_settings: Settings) -> None:
assert (
mock_settings.OVERSEAS_RANKING_VOLUME_PATH
== "/uapi/overseas-stock/v1/ranking/volume-surge"
)
assert mock_settings.OVERSEAS_RANKING_VOLUME_PATH == "/uapi/overseas-stock/v1/ranking/volume-surge"
class TestFetchOverseasRankings:
"""Test fetch_overseas_rankings method."""
@pytest.mark.asyncio
async def test_fluctuation_uses_correct_params(self, overseas_broker: OverseasBroker) -> None:
async def test_fluctuation_uses_correct_params(
self, overseas_broker: OverseasBroker
) -> None:
"""Fluctuation ranking should use HHDFS76290000, updown-rate path, and correct params."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "AAPL", "name": "Apple"}]})
mock_resp.json = AsyncMock(
return_value={"output": [{"symb": "AAPL", "name": "Apple"}]}
)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
@@ -134,11 +132,15 @@ class TestFetchOverseasRankings:
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76290000")
@pytest.mark.asyncio
async def test_volume_uses_correct_params(self, overseas_broker: OverseasBroker) -> None:
async def test_volume_uses_correct_params(
self, overseas_broker: OverseasBroker
) -> None:
"""Volume ranking should use HHDFS76270000, volume-surge path, and correct params."""
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]})
mock_resp.json = AsyncMock(
return_value={"output": [{"symb": "TSLA", "name": "Tesla"}]}
)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
@@ -167,7 +169,9 @@ class TestFetchOverseasRankings:
overseas_broker._broker._auth_headers.assert_called_with("HHDFS76270000")
@pytest.mark.asyncio
async def test_404_returns_empty_list(self, overseas_broker: OverseasBroker) -> None:
async def test_404_returns_empty_list(
self, overseas_broker: OverseasBroker
) -> None:
"""HTTP 404 should return empty list (fallback) instead of raising."""
mock_resp = AsyncMock()
mock_resp.status = 404
@@ -182,7 +186,9 @@ class TestFetchOverseasRankings:
assert result == []
@pytest.mark.asyncio
async def test_non_404_error_raises(self, overseas_broker: OverseasBroker) -> None:
async def test_non_404_error_raises(
self, overseas_broker: OverseasBroker
) -> None:
"""Non-404 HTTP errors should raise ConnectionError."""
mock_resp = AsyncMock()
mock_resp.status = 500
@@ -197,7 +203,9 @@ class TestFetchOverseasRankings:
await overseas_broker.fetch_overseas_rankings("NASD")
@pytest.mark.asyncio
async def test_empty_response_returns_empty(self, overseas_broker: OverseasBroker) -> None:
async def test_empty_response_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""Empty output in response should return empty list."""
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -212,14 +220,18 @@ class TestFetchOverseasRankings:
assert result == []
@pytest.mark.asyncio
async def test_ranking_disabled_returns_empty(self, overseas_broker: OverseasBroker) -> None:
async def test_ranking_disabled_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""When OVERSEAS_RANKING_ENABLED=False, should return empty immediately."""
overseas_broker._broker._settings.OVERSEAS_RANKING_ENABLED = False
result = await overseas_broker.fetch_overseas_rankings("NASD")
assert result == []
@pytest.mark.asyncio
async def test_limit_truncates_results(self, overseas_broker: OverseasBroker) -> None:
async def test_limit_truncates_results(
self, overseas_broker: OverseasBroker
) -> None:
"""Results should be truncated to the specified limit."""
rows = [{"symb": f"SYM{i}"} for i in range(20)]
mock_resp = AsyncMock()
@@ -235,7 +247,9 @@ class TestFetchOverseasRankings:
assert len(result) == 5
@pytest.mark.asyncio
async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None:
async def test_network_error_raises(
self, overseas_broker: OverseasBroker
) -> None:
"""Network errors should raise ConnectionError."""
cm = MagicMock()
cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("timeout"))
@@ -250,7 +264,9 @@ class TestFetchOverseasRankings:
await overseas_broker.fetch_overseas_rankings("NASD")
@pytest.mark.asyncio
async def test_exchange_code_mapping_applied(self, overseas_broker: OverseasBroker) -> None:
async def test_exchange_code_mapping_applied(
self, overseas_broker: OverseasBroker
) -> None:
"""All major exchanges should use mapped codes in API params."""
for original, mapped in [("NASD", "NAS"), ("NYSE", "NYS"), ("AMEX", "AMS")]:
mock_resp = AsyncMock()
@@ -282,9 +298,7 @@ class TestGetOverseasPrice:
mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp))
_setup_broker_mocks(overseas_broker, mock_session)
overseas_broker._broker._auth_headers = AsyncMock(
return_value={"authorization": "Bearer t"}
)
overseas_broker._broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"})
result = await overseas_broker.get_overseas_price("NASD", "AAPL")
assert result["output"]["last"] == "150.00"
@@ -516,14 +530,11 @@ class TestPriceExchangeMap:
def test_price_map_equals_ranking_map(self) -> None:
assert _PRICE_EXCHANGE_MAP is _RANKING_EXCHANGE_MAP
@pytest.mark.parametrize(
"original,expected",
[
@pytest.mark.parametrize("original,expected", [
("NASD", "NAS"),
("NYSE", "NYS"),
("AMEX", "AMS"),
],
)
])
def test_us_exchange_code_mapping(self, original: str, expected: str) -> None:
assert _PRICE_EXCHANGE_MAP[original] == expected
@@ -563,7 +574,9 @@ class TestOrderRtCdCheck:
return OverseasBroker(broker)
@pytest.mark.asyncio
async def test_success_rt_cd_returns_data(self, overseas_broker: OverseasBroker) -> None:
async def test_success_rt_cd_returns_data(
self, overseas_broker: OverseasBroker
) -> None:
"""rt_cd='0' → order accepted, data returned."""
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -577,7 +590,9 @@ class TestOrderRtCdCheck:
assert result["rt_cd"] == "0"
@pytest.mark.asyncio
async def test_error_rt_cd_returns_data_with_msg(self, overseas_broker: OverseasBroker) -> None:
async def test_error_rt_cd_returns_data_with_msg(
self, overseas_broker: OverseasBroker
) -> None:
"""rt_cd != '0' → order rejected, data still returned (caller checks rt_cd)."""
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -608,7 +623,6 @@ class TestPaperOverseasCash:
def test_env_override(self) -> None:
import os
os.environ["PAPER_OVERSEAS_CASH"] = "25000"
settings = Settings(
KIS_APP_KEY="k",
@@ -621,7 +635,6 @@ class TestPaperOverseasCash:
def test_zero_disables_fallback(self) -> None:
import os
os.environ["PAPER_OVERSEAS_CASH"] = "0"
settings = Settings(
KIS_APP_KEY="k",
@@ -809,7 +822,9 @@ class TestGetOverseasPendingOrders:
"""Tests for get_overseas_pending_orders method."""
@pytest.mark.asyncio
async def test_paper_mode_returns_empty(self, overseas_broker: OverseasBroker) -> None:
async def test_paper_mode_returns_empty(
self, overseas_broker: OverseasBroker
) -> None:
"""Paper mode should immediately return [] without any API call."""
# Default mock_settings has MODE="paper"
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
@@ -840,7 +855,9 @@ class TestGetOverseasPendingOrders:
overseas_broker._broker._auth_headers = mock_auth_headers # type: ignore[method-assign]
pending_orders = [{"odno": "001", "pdno": "AAPL", "sll_buy_dvsn_cd": "02", "nccs_qty": "5"}]
pending_orders = [
{"odno": "001", "pdno": "AAPL", "sll_buy_dvsn_cd": "02", "nccs_qty": "5"}
]
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"output": pending_orders})
@@ -862,7 +879,9 @@ class TestGetOverseasPendingOrders:
assert captured_params[0]["OVRS_EXCG_CD"] == "NASD"
@pytest.mark.asyncio
async def test_live_mode_connection_error(self, overseas_broker: OverseasBroker) -> None:
async def test_live_mode_connection_error(
self, overseas_broker: OverseasBroker
) -> None:
"""Network error in live mode should raise ConnectionError."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
@@ -907,41 +926,55 @@ class TestCancelOverseasOrder:
return captured_tr_ids, mock_session
@pytest.mark.asyncio
async def test_us_live_uses_tttt1004u(self, overseas_broker: OverseasBroker) -> None:
async def test_us_live_uses_tttt1004u(
self, overseas_broker: OverseasBroker
) -> None:
"""US exchange in live mode should use TTTT1004U."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
captured, _ = self._setup_cancel_mocks(overseas_broker, {"rt_cd": "0", "msg1": "OK"})
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5)
assert "TTTT1004U" in captured
@pytest.mark.asyncio
async def test_us_paper_uses_vttt1004u(self, overseas_broker: OverseasBroker) -> None:
async def test_us_paper_uses_vttt1004u(
self, overseas_broker: OverseasBroker
) -> None:
"""US exchange in paper mode should use VTTT1004U."""
# Default mock_settings has MODE="paper"
captured, _ = self._setup_cancel_mocks(overseas_broker, {"rt_cd": "0", "msg1": "OK"})
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("NASD", "AAPL", "ORD001", 5)
assert "VTTT1004U" in captured
@pytest.mark.asyncio
async def test_hk_live_uses_ttts1003u(self, overseas_broker: OverseasBroker) -> None:
async def test_hk_live_uses_ttts1003u(
self, overseas_broker: OverseasBroker
) -> None:
"""SEHK exchange in live mode should use TTTS1003U."""
overseas_broker._broker._settings = overseas_broker._broker._settings.model_copy(
update={"MODE": "live"}
)
captured, _ = self._setup_cancel_mocks(overseas_broker, {"rt_cd": "0", "msg1": "OK"})
captured, _ = self._setup_cancel_mocks(
overseas_broker, {"rt_cd": "0", "msg1": "OK"}
)
await overseas_broker.cancel_overseas_order("SEHK", "0700", "ORD002", 10)
assert "TTTS1003U" in captured
@pytest.mark.asyncio
async def test_cancel_sets_rvse_cncl_dvsn_cd_02(self, overseas_broker: OverseasBroker) -> None:
async def test_cancel_sets_rvse_cncl_dvsn_cd_02(
self, overseas_broker: OverseasBroker
) -> None:
"""Cancel body must include RVSE_CNCL_DVSN_CD='02' and OVRS_ORD_UNPR='0'."""
captured_body: list[dict] = []
@@ -972,7 +1005,9 @@ class TestCancelOverseasOrder:
assert captured_body[0]["ORGN_ODNO"] == "ORD003"
@pytest.mark.asyncio
async def test_cancel_sets_hashkey_header(self, overseas_broker: OverseasBroker) -> None:
async def test_cancel_sets_hashkey_header(
self, overseas_broker: OverseasBroker
) -> None:
"""hashkey must be set in the request headers."""
captured_headers: list[dict] = []
overseas_broker._broker._get_hash_key = AsyncMock(return_value="test_hash") # type: ignore[method-assign]

View File

@@ -78,7 +78,9 @@ def _gemini_response_json(
"rationale": "Near circuit breaker",
}
]
return json.dumps({"market_outlook": outlook, "global_rules": global_rules, "stocks": stocks})
return json.dumps(
{"market_outlook": outlook, "global_rules": global_rules, "stocks": stocks}
)
def _make_planner(
@@ -562,12 +564,8 @@ class TestBuildPrompt:
def test_prompt_contains_cross_market(self) -> None:
planner = _make_planner()
cross = CrossMarketContext(
market="US",
date="2026-02-07",
total_pnl=1.5,
win_rate=60,
index_change_pct=0.8,
lessons=["Cut losses early"],
market="US", date="2026-02-07", total_pnl=1.5,
win_rate=60, index_change_pct=0.8, lessons=["Cut losses early"],
)
prompt = planner._build_prompt("KR", [_candidate()], {}, None, cross)
@@ -685,7 +683,9 @@ class TestSmartFallbackPlaybook:
)
def test_momentum_candidate_gets_buy_on_volume(self) -> None:
candidates = [_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)]
candidates = [
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
@@ -707,7 +707,9 @@ class TestSmartFallbackPlaybook:
assert sell_sc.condition.price_change_pct_below == -3.0
def test_oversold_candidate_gets_buy_on_rsi(self) -> None:
candidates = [_candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5)]
candidates = [
_candidate(code="005930", signal="oversold", rsi=22.0, volume_ratio=3.5)
]
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(
@@ -774,7 +776,9 @@ class TestSmartFallbackPlaybook:
def test_empty_candidates_returns_empty_playbook(self) -> None:
settings = self._make_settings()
pb = PreMarketPlanner._smart_fallback_playbook(date(2026, 2, 17), "US_AMEX", [], settings)
pb = PreMarketPlanner._smart_fallback_playbook(
date(2026, 2, 17), "US_AMEX", [], settings
)
assert pb.stock_count == 0
@@ -810,14 +814,19 @@ class TestSmartFallbackPlaybook:
planner = _make_planner()
planner._gemini.decide = AsyncMock(side_effect=ConnectionError("429 quota exceeded"))
# momentum candidate
candidates = [_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)]
candidates = [
_candidate(code="CHOW", signal="momentum", volume_ratio=13.64, rsi=100.0)
]
pb = await planner.generate_playbook("US_AMEX", candidates, today=date(2026, 2, 18))
pb = await planner.generate_playbook(
"US_AMEX", candidates, today=date(2026, 2, 18)
)
# Should NOT be all-SELL defensive; should have BUY for momentum
assert pb.stock_count == 1
buy_scenarios = [
s for s in pb.stock_playbooks[0].scenarios if s.action == ScenarioAction.BUY
s for s in pb.stock_playbooks[0].scenarios
if s.action == ScenarioAction.BUY
]
assert len(buy_scenarios) == 1
assert buy_scenarios[0].condition.volume_ratio_above == 2.0 # VOL_MULTIPLIER default

View File

@@ -14,7 +14,7 @@ from src.strategy.models import (
StockPlaybook,
StockScenario,
)
from src.strategy.scenario_engine import ScenarioEngine
from src.strategy.scenario_engine import ScenarioEngine, ScenarioMatch
@pytest.fixture
@@ -162,10 +162,8 @@ class TestEvaluateCondition:
def test_mixed_invalid_types_no_exception(self, engine: ScenarioEngine) -> None:
"""Various invalid types should not raise exceptions."""
cond = StockCondition(
rsi_below=30.0,
volume_ratio_above=2.0,
price_above=100,
price_change_pct_below=-1.0,
rsi_below=30.0, volume_ratio_above=2.0,
price_above=100, price_change_pct_below=-1.0,
)
data = {
"rsi": [25], # list
@@ -358,7 +356,9 @@ class TestEvaluate:
def test_match_details_populated(self, engine: ScenarioEngine) -> None:
pb = _playbook(scenarios=[_scenario(rsi_below=30.0, volume_ratio_above=2.0)])
result = engine.evaluate(pb, "005930", {"rsi": 25.0, "volume_ratio": 3.0}, {})
result = engine.evaluate(
pb, "005930", {"rsi": 25.0, "volume_ratio": 3.0}, {}
)
assert result.match_details.get("rsi") == 25.0
assert result.match_details.get("volume_ratio") == 3.0
@@ -381,9 +381,7 @@ class TestEvaluate:
),
StockPlaybook(
stock_code="MSFT",
scenarios=[
_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80)
],
scenarios=[_scenario(rsi_above=75.0, action=ScenarioAction.SELL, confidence=80)],
),
],
)
@@ -452,42 +450,58 @@ class TestEvaluate:
class TestPositionAwareConditions:
"""Tests for unrealized_pnl_pct and holding_days condition fields."""
def test_evaluate_condition_unrealized_pnl_above_matches(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_unrealized_pnl_above_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should match when P&L exceeds threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 5.0}) is True
def test_evaluate_condition_unrealized_pnl_above_no_match(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_unrealized_pnl_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_above should NOT match when P&L is below threshold."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": 2.0}) is False
def test_evaluate_condition_unrealized_pnl_below_matches(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_unrealized_pnl_below_matches(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should match when P&L is under threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -3.5}) is True
def test_evaluate_condition_unrealized_pnl_below_no_match(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_unrealized_pnl_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""unrealized_pnl_pct_below should NOT match when P&L is above threshold."""
condition = StockCondition(unrealized_pnl_pct_below=-2.0)
assert engine.evaluate_condition(condition, {"unrealized_pnl_pct": -1.0}) is False
def test_evaluate_condition_holding_days_above_matches(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_holding_days_above_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should match when position held longer than threshold."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 7}) is True
def test_evaluate_condition_holding_days_above_no_match(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_holding_days_above_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_above should NOT match when position held shorter."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {"holding_days": 3}) is False
def test_evaluate_condition_holding_days_below_matches(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_holding_days_below_matches(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should match when position held fewer days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 1}) is True
def test_evaluate_condition_holding_days_below_no_match(self, engine: ScenarioEngine) -> None:
def test_evaluate_condition_holding_days_below_no_match(
self, engine: ScenarioEngine
) -> None:
"""holding_days_below should NOT match when held more days."""
condition = StockCondition(holding_days_below=3)
assert engine.evaluate_condition(condition, {"holding_days": 5}) is False
@@ -499,33 +513,33 @@ class TestPositionAwareConditions:
holding_days_above=5,
)
# Both met → match
assert (
engine.evaluate_condition(
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 7},
)
is True
)
) is True
# Only pnl met → no match
assert (
engine.evaluate_condition(
assert engine.evaluate_condition(
condition,
{"unrealized_pnl_pct": 4.5, "holding_days": 3},
)
is False
)
) is False
def test_missing_unrealized_pnl_does_not_match(self, engine: ScenarioEngine) -> None:
def test_missing_unrealized_pnl_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing unrealized_pnl_pct key should not match the condition."""
condition = StockCondition(unrealized_pnl_pct_above=3.0)
assert engine.evaluate_condition(condition, {}) is False
def test_missing_holding_days_does_not_match(self, engine: ScenarioEngine) -> None:
def test_missing_holding_days_does_not_match(
self, engine: ScenarioEngine
) -> None:
"""Missing holding_days key should not match the condition."""
condition = StockCondition(holding_days_above=5)
assert engine.evaluate_condition(condition, {}) is False
def test_match_details_includes_position_fields(self, engine: ScenarioEngine) -> None:
def test_match_details_includes_position_fields(
self, engine: ScenarioEngine
) -> None:
"""match_details should include position fields when condition specifies them."""
pb = _playbook(
scenarios=[

View File

@@ -1,100 +0,0 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
def _load_module():
script_path = Path(__file__).resolve().parents[1] / "scripts" / "session_handover_check.py"
spec = importlib.util.spec_from_file_location("session_handover_check", 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_ci_mode_skips_date_branch_and_merge_gate(monkeypatch, tmp_path) -> None:
module = _load_module()
handover = tmp_path / "session-handover.md"
handover.write_text(
"\n".join(
[
"### 2000-01-01 | session=test",
"- branch: feature/other-branch",
"- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md",
"- open_issues_reviewed: #1",
"- next_ticket: #123",
"- process_gate_checked: process_ticket=#1 merged_to_feature_branch=no",
]
),
encoding="utf-8",
)
monkeypatch.setattr(module, "HANDOVER_LOG", handover)
errors: list[str] = []
module._check_handover_entry(
branch="feature/current-branch",
strict=True,
ci_mode=True,
errors=errors,
)
assert errors == []
def test_ci_mode_still_blocks_tbd_next_ticket(monkeypatch, tmp_path) -> None:
module = _load_module()
handover = tmp_path / "session-handover.md"
handover.write_text(
"\n".join(
[
"### 2000-01-01 | session=test",
"- branch: feature/other-branch",
"- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md",
"- open_issues_reviewed: #1",
"- next_ticket: #TBD",
"- process_gate_checked: process_ticket=#1 merged_to_feature_branch=no",
]
),
encoding="utf-8",
)
monkeypatch.setattr(module, "HANDOVER_LOG", handover)
errors: list[str] = []
module._check_handover_entry(
branch="feature/current-branch",
strict=True,
ci_mode=True,
errors=errors,
)
assert "latest handover entry must not use placeholder next_ticket (#TBD)" in errors
def test_non_ci_strict_enforces_date_branch_and_merge_gate(monkeypatch, tmp_path) -> None:
module = _load_module()
handover = tmp_path / "session-handover.md"
handover.write_text(
"\n".join(
[
"### 2000-01-01 | session=test",
"- branch: feature/other-branch",
"- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md",
"- open_issues_reviewed: #1",
"- next_ticket: #123",
"- process_gate_checked: process_ticket=#1 merged_to_feature_branch=no",
]
),
encoding="utf-8",
)
monkeypatch.setattr(module, "HANDOVER_LOG", handover)
errors: list[str] = []
module._check_handover_entry(
branch="feature/current-branch",
strict=True,
ci_mode=False,
errors=errors,
)
assert any("must contain today's UTC date" in e for e in errors)
assert any("must target current branch" in e for e in errors)
assert any("merged_to_feature_branch=no" in e for e in errors)

View File

@@ -2,9 +2,8 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.analysis.smart_scanner import ScanCandidate, SmartVolatilityScanner
from src.analysis.volatility import VolatilityAnalyzer
@@ -201,7 +200,9 @@ class TestSmartVolatilityScanner:
assert len(candidates) <= scanner.top_n
@pytest.mark.asyncio
async def test_get_stock_codes(self, scanner: SmartVolatilityScanner) -> None:
async def test_get_stock_codes(
self, scanner: SmartVolatilityScanner
) -> None:
"""Test extraction of stock codes from candidates."""
candidates = [
ScanCandidate(

View File

@@ -19,6 +19,7 @@ from src.strategy.models import (
StockScenario,
)
# ---------------------------------------------------------------------------
# StockCondition
# ---------------------------------------------------------------------------

View File

@@ -5,11 +5,7 @@ from unittest.mock import AsyncMock, patch
import aiohttp
import pytest
from src.notifications.telegram_client import (
NotificationFilter,
NotificationPriority,
TelegramClient,
)
from src.notifications.telegram_client import NotificationFilter, NotificationPriority, TelegramClient
class TestTelegramClientInit:
@@ -17,7 +13,9 @@ class TestTelegramClientInit:
def test_disabled_via_flag(self) -> None:
"""Client disabled via enabled=False flag."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=False)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=False
)
assert client._enabled is False
def test_disabled_missing_token(self) -> None:
@@ -32,7 +30,9 @@ class TestTelegramClientInit:
def test_enabled_with_credentials(self) -> None:
"""Client enabled when credentials provided."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
assert client._enabled is True
@@ -42,7 +42,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_send_message_success(self) -> None:
"""send_message returns True on successful send."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -74,7 +76,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_send_message_api_error(self) -> None:
"""send_message returns False on API error."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 400
@@ -89,7 +93,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_send_message_with_markdown(self) -> None:
"""send_message supports different parse modes."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -122,7 +128,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_trade_execution_format(self) -> None:
"""Trade notification has correct format."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -155,7 +163,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_playbook_generated_format(self) -> None:
"""Playbook generated notification has expected fields."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -180,7 +190,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_scenario_matched_format(self) -> None:
"""Scenario matched notification has expected fields."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -205,7 +217,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_playbook_failed_format(self) -> None:
"""Playbook failed notification has expected fields."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -226,7 +240,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_circuit_breaker_priority(self) -> None:
"""Circuit breaker uses CRITICAL priority."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -244,7 +260,9 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_api_error_handling(self) -> None:
"""API errors logged but don't crash."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 400
@@ -259,19 +277,25 @@ class TestNotificationSending:
@pytest.mark.asyncio
async def test_timeout_handling(self) -> None:
"""Timeouts logged but don't crash."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
with patch(
"aiohttp.ClientSession.post",
side_effect=aiohttp.ClientError("Connection timeout"),
):
# Should not raise exception
await client.notify_error(error_type="Test Error", error_msg="Test", context="test")
await client.notify_error(
error_type="Test Error", error_msg="Test", context="test"
)
@pytest.mark.asyncio
async def test_session_management(self) -> None:
"""Session created and reused correctly."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
# Session should be None initially
assert client._session is None
@@ -300,7 +324,9 @@ class TestRateLimiting:
"""Rate limiter delays rapid requests."""
import time
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True, rate_limit=2.0)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, rate_limit=2.0
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -327,7 +353,9 @@ class TestMessagePriorities:
@pytest.mark.asyncio
async def test_low_priority_uses_info_emoji(self) -> None:
"""LOW priority uses emoji."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -343,7 +371,9 @@ class TestMessagePriorities:
@pytest.mark.asyncio
async def test_critical_priority_uses_alarm_emoji(self) -> None:
"""CRITICAL priority uses 🚨 emoji."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -359,7 +389,9 @@ class TestMessagePriorities:
@pytest.mark.asyncio
async def test_playbook_generated_priority(self) -> None:
"""Playbook generated uses MEDIUM priority emoji."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -380,7 +412,9 @@ class TestMessagePriorities:
@pytest.mark.asyncio
async def test_playbook_failed_priority(self) -> None:
"""Playbook failed uses HIGH priority emoji."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -399,7 +433,9 @@ class TestMessagePriorities:
@pytest.mark.asyncio
async def test_scenario_matched_priority(self) -> None:
"""Scenario matched uses HIGH priority emoji."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_resp = AsyncMock()
mock_resp.status = 200
@@ -424,7 +460,9 @@ class TestClientCleanup:
@pytest.mark.asyncio
async def test_close_closes_session(self) -> None:
"""close() closes the HTTP session."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
mock_session = AsyncMock()
mock_session.closed = False
@@ -437,7 +475,9 @@ class TestClientCleanup:
@pytest.mark.asyncio
async def test_close_handles_no_session(self) -> None:
"""close() handles None session gracefully."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True
)
# Should not raise exception
await client.close()
@@ -495,12 +535,8 @@ class TestNotificationFilter:
)
with patch("aiohttp.ClientSession.post") as mock_post:
await client.notify_trade_execution(
stock_code="005930",
market="KR",
action="BUY",
quantity=10,
price=70000.0,
confidence=85.0,
stock_code="005930", market="KR", action="BUY",
quantity=10, price=70000.0, confidence=85.0
)
mock_post.assert_not_called()
@@ -520,13 +556,8 @@ class TestNotificationFilter:
async def test_circuit_breaker_always_sends_regardless_of_filter(self) -> None:
"""notify_circuit_breaker always sends (no filter flag)."""
nf = NotificationFilter(
trades=False,
market_open_close=False,
fat_finger=False,
system_events=False,
playbook=False,
scenario_match=False,
errors=False,
trades=False, market_open_close=False, fat_finger=False,
system_events=False, playbook=False, scenario_match=False, errors=False,
)
client = TelegramClient(
bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf
@@ -586,7 +617,7 @@ class TestNotificationFilter:
nf = NotificationFilter()
assert nf.set_flag("unknown_key", False) is False
def test_as_dict_keys_match_keys(self) -> None:
def test_as_dict_keys_match_KEYS(self) -> None:
"""as_dict() returns every key defined in KEYS."""
nf = NotificationFilter()
d = nf.as_dict()
@@ -609,17 +640,10 @@ class TestNotificationFilter:
def test_set_notification_all_on(self) -> None:
"""set_notification('all', True) enables every filter flag."""
client = TelegramClient(
bot_token="123:abc",
chat_id="456",
enabled=True,
bot_token="123:abc", chat_id="456", enabled=True,
notification_filter=NotificationFilter(
trades=False,
market_open_close=False,
scenario_match=False,
fat_finger=False,
system_events=False,
playbook=False,
errors=False,
trades=False, market_open_close=False, scenario_match=False,
fat_finger=False, system_events=False, playbook=False, errors=False,
),
)
assert client.set_notification("all", True) is True

View File

@@ -357,7 +357,8 @@ class TestTradingControlCommands:
pause_event.set()
await client.send_message(
"<b>▶️ Trading Resumed</b>\n\nTrading operations have been restarted."
"<b>▶️ Trading Resumed</b>\n\n"
"Trading operations have been restarted."
)
handler.register_command("resume", mock_resume)
@@ -525,7 +526,9 @@ class TestStatusCommands:
async def mock_status_error() -> None:
"""Mock /status handler with error."""
await client.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve trading status.")
await client.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve trading status."
)
handler.register_command("status", mock_status_error)
@@ -600,7 +603,10 @@ class TestStatusCommands:
async def mock_positions_empty() -> None:
"""Mock /positions handler with no positions."""
message = "<b>💼 Account Summary</b>\n\nNo balance information available."
message = (
"<b>💼 Account Summary</b>\n\n"
"No balance information available."
)
await client.send_message(message)
handler.register_command("positions", mock_positions_empty)
@@ -633,7 +639,9 @@ class TestStatusCommands:
async def mock_positions_error() -> None:
"""Mock /positions handler with error."""
await client.send_message("<b>⚠️ Error</b>\n\nFailed to retrieve positions.")
await client.send_message(
"<b>⚠️ Error</b>\n\nFailed to retrieve positions."
)
handler.register_command("positions", mock_positions_error)

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,118 +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

View File

@@ -80,7 +80,9 @@ class TestVolatilityAnalyzer:
# ATR should be roughly the average true range
assert 3.0 <= atr <= 6.0
def test_calculate_atr_insufficient_data(self, volatility_analyzer: VolatilityAnalyzer) -> None:
def test_calculate_atr_insufficient_data(
self, volatility_analyzer: VolatilityAnalyzer
) -> None:
"""Test ATR with insufficient data returns 0."""
high_prices = [110.0, 112.0]
low_prices = [105.0, 107.0]
@@ -118,13 +120,17 @@ class TestVolatilityAnalyzer:
surge = volatility_analyzer.calculate_volume_surge(1000.0, 0.0)
assert surge == 1.0
def test_calculate_pv_divergence_bullish(self, volatility_analyzer: VolatilityAnalyzer) -> None:
def test_calculate_pv_divergence_bullish(
self, volatility_analyzer: VolatilityAnalyzer
) -> None:
"""Test bullish price-volume divergence."""
# Price up + Volume up = bullish
divergence = volatility_analyzer.calculate_pv_divergence(5.0, 2.0)
assert divergence > 0.0
def test_calculate_pv_divergence_bearish(self, volatility_analyzer: VolatilityAnalyzer) -> None:
def test_calculate_pv_divergence_bearish(
self, volatility_analyzer: VolatilityAnalyzer
) -> None:
"""Test bearish price-volume divergence."""
# Price up + Volume down = bearish divergence
divergence = volatility_analyzer.calculate_pv_divergence(5.0, 0.5)
@@ -138,7 +144,9 @@ class TestVolatilityAnalyzer:
divergence = volatility_analyzer.calculate_pv_divergence(-5.0, 2.0)
assert divergence < 0.0
def test_calculate_momentum_score(self, volatility_analyzer: VolatilityAnalyzer) -> None:
def test_calculate_momentum_score(
self, volatility_analyzer: VolatilityAnalyzer
) -> None:
"""Test momentum score calculation."""
score = volatility_analyzer.calculate_momentum_score(
price_change_1m=5.0,
@@ -492,7 +500,9 @@ class TestMarketScanner:
# Should keep all current stocks since they're all in top movers
assert set(updated) == set(current_watchlist)
def test_get_updated_watchlist_max_replacements(self, scanner: MarketScanner) -> None:
def test_get_updated_watchlist_max_replacements(
self, scanner: MarketScanner
) -> None:
"""Test that max_replacements limit is respected."""
current_watchlist = ["000660", "035420", "005490"]
@@ -546,6 +556,8 @@ class TestMarketScanner:
active_count = 0
peak_count = 0
original_scan = scanner.scan_stock
async def tracking_scan(code: str, market: Any) -> VolatilityMetrics:
nonlocal active_count, peak_count
active_count += 1