Compare commits
18 Commits
200bc82a27
...
96d2c97fe7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96d2c97fe7 | ||
| 4710aa2d66 | |||
|
|
ca9e1ad0e2 | ||
|
|
928e60877c | ||
| 16ddc22d14 | |||
|
|
4f21117eca | ||
|
|
8e02b1ea4f | ||
| ccceb38483 | |||
|
|
96e5de7c5d | ||
|
|
7959b749c7 | ||
| f7e242d147 | |||
|
|
589cc42e00 | ||
|
|
920630e30e | ||
|
|
d4f37ee392 | ||
| 3914f24872 | |||
|
|
ed713fdf40 | ||
|
|
c27decb6b1 | ||
| 8ac7436953 |
@@ -1,6 +1,6 @@
|
|||||||
<!--
|
<!--
|
||||||
Doc-ID: DOC-REQ-001
|
Doc-ID: DOC-REQ-001
|
||||||
Version: 1.0.3
|
Version: 1.0.7
|
||||||
Status: active
|
Status: active
|
||||||
Owner: strategy
|
Owner: strategy
|
||||||
Updated: 2026-03-02
|
Updated: 2026-03-02
|
||||||
@@ -26,7 +26,7 @@ Updated: 2026-03-02
|
|||||||
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
- `REQ-V3-001`: 모든 신호/주문/로그는 `session_id`를 포함해야 한다.
|
||||||
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
- `REQ-V3-002`: 세션 전환 시 리스크 파라미터 재로딩이 수행되어야 한다.
|
||||||
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
- `REQ-V3-003`: 브로커 블랙아웃 시간대에는 신규 주문이 금지되어야 한다.
|
||||||
- `REQ-V3-004`: 블랙아웃 중 신호는 Queue에 적재되고, 복구 후 유효성 재검증을 거친다.
|
- `REQ-V3-004`: 블랙아웃 중 신호는 bounded Queue에 적재되며, 포화 시 oldest-drop 정책으로 최신 intent를 보존하고 복구 후 유효성 재검증을 거친다.
|
||||||
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
- `REQ-V3-005`: 저유동 세션(`NXT_AFTER`, `US_PRE`, `US_DAY`, `US_AFTER`)은 시장가 주문 금지다.
|
||||||
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
- `REQ-V3-006`: 백테스트 체결가는 불리한 방향 체결 가정을 기본으로 한다.
|
||||||
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
- `REQ-V3-007`: US 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Updated: 2026-03-02
|
|||||||
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ⚠️ 부분 (`#369`) |
|
| REQ-V2-004 | 4중 청산 로직 (Hard/BE/ATR Trailing/Model) | `src/strategy/exit_rules.py` | ⚠️ 부분 (`#369`) |
|
||||||
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
| REQ-V2-005 | Triple Barrier 라벨링 | `src/analysis/triple_barrier.py` | ✅ 완료 |
|
||||||
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
| REQ-V2-006 | Walk-Forward + Purge/Embargo 검증 | `src/analysis/walk_forward_split.py` | ✅ 완료 |
|
||||||
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py` | ⚠️ 부분 (`#368`) |
|
| REQ-V2-007 | 비용/슬리피지/체결실패 모델 필수 | `src/analysis/backtest_cost_guard.py`, `src/analysis/backtest_pipeline.py` | ✅ 완료 |
|
||||||
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ⚠️ 부분 (`#377`) |
|
| REQ-V2-008 | Kill Switch 실행 순서 (Block→Cancel→Refresh→Reduce→Snapshot) | `src/core/kill_switch.py` | ⚠️ 부분 (`#377`) |
|
||||||
|
|
||||||
### 1.3 v3 구현 상태: 부분 완료 (2026-03-02 기준)
|
### 1.3 v3 구현 상태: 부분 완료 (2026-03-02 기준)
|
||||||
@@ -43,12 +43,12 @@ Updated: 2026-03-02
|
|||||||
| REQ-ID | 요구사항 | 상태 | 비고 |
|
| REQ-ID | 요구사항 | 상태 | 비고 |
|
||||||
|--------|----------|------|------|
|
|--------|----------|------|------|
|
||||||
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
| REQ-V3-001 | 모든 신호/주문/로그에 session_id 포함 | ⚠️ 부분 | 큐 intent에 `session_id` 누락 (`#375`) |
|
||||||
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ⚠️ 부분 | 구현 존재, 세션 경계 E2E 회귀 보강 필요 (`#376`) |
|
| REQ-V3-002 | 세션 전환 훅 + 리스크 파라미터 재로딩 | ✅ 완료 | 세션 경계 E2E 회귀(override 적용/해제 + 재로딩 실패 폴백) 보강 (`#376`) |
|
||||||
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
| REQ-V3-003 | 블랙아웃 윈도우 정책 | ✅ 완료 | `src/core/blackout_manager.py` |
|
||||||
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화 시 intent 유실 경로 존재 (`#371`), 재검증 강화를 `#328`에서 추적 |
|
| REQ-V3-004 | 블랙아웃 큐 + 복구 시 재검증 | ⚠️ 부분 | 큐 포화는 oldest-drop 정책으로 정합화 (`#371`), 재검증 강화는 `#328` 추적 |
|
||||||
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
| REQ-V3-005 | 저유동 세션 시장가 금지 | ✅ 완료 | `src/core/order_policy.py` |
|
||||||
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
| REQ-V3-006 | 보수적 백테스트 체결 (불리 방향) | ✅ 완료 | `src/analysis/backtest_execution_model.py` |
|
||||||
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 스키마 존재, 런타임 분리 계산/전달 미적용 (`#370`) |
|
| REQ-V3-007 | FX 손익 분리 (전략 PnL vs 환율 PnL) | ⚠️ 부분 | 런타임 분리 계산/전달 적용 (`#370`), buy-side `fx_rate` 미관측 시 `fx_pnl=0` fallback |
|
||||||
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py` — `_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
|
| REQ-V3-008 | 오버나잇 예외 vs Kill Switch 우선순위 | ✅ 완료 | `src/main.py` — `_should_force_exit_for_overnight()`, `_apply_staged_exit_override_for_hold()` |
|
||||||
|
|
||||||
### 1.4 운영 거버넌스: 부분 완료 (2026-03-02 재평가)
|
### 1.4 운영 거버넌스: 부분 완료 (2026-03-02 재평가)
|
||||||
@@ -80,13 +80,13 @@ Updated: 2026-03-02
|
|||||||
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
|
- **해소**: #326 머지 — `log_trade()` 호출 시 런타임 `session_id` 명시적 전달
|
||||||
- **요구사항**: REQ-V3-001
|
- **요구사항**: REQ-V3-001
|
||||||
|
|
||||||
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ⚠️ 부분 해소 (#327)
|
### GAP-3: 세션 전환 시 리스크 파라미터 재로딩 없음 → ✅ 해소 (#327, #376)
|
||||||
|
|
||||||
- **위치**: `src/main.py`, `src/config.py`
|
- **위치**: `src/main.py`, `src/config.py`
|
||||||
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
|
- **해소 내용**: #327 머지 — `SESSION_RISK_PROFILES_JSON` 기반 세션별 파라미터 재로딩 메커니즘 구현
|
||||||
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
|
- `SESSION_RISK_RELOAD_ENABLED=true` 시 세션 경계에서 파라미터 재로딩
|
||||||
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
|
- 재로딩 실패 시 기존 파라미터 유지 (안전 폴백)
|
||||||
- **잔여 갭**: 세션 경계 실시간 전환 E2E 통합 테스트 보강 필요 (`test_main.py`에 설정 오버라이드/폴백 단위 테스트는 존재)
|
- **해소**: 세션 경계 E2E 회귀 테스트를 추가해 override 적용/해제, 재로딩 실패 시 폴백 유지를 검증함 (`#376`)
|
||||||
- **요구사항**: REQ-V3-002
|
- **요구사항**: REQ-V3-002
|
||||||
|
|
||||||
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ⚠️ 부분 해소 (#324, #328, #371)
|
### GAP-4: 블랙아웃 복구 DB 기록 + 재검증 → ⚠️ 부분 해소 (#324, #328, #371)
|
||||||
@@ -95,7 +95,7 @@ Updated: 2026-03-02
|
|||||||
- **현 상태**:
|
- **현 상태**:
|
||||||
- #324 추적 범위(DB 기록)는 구현 경로가 존재
|
- #324 추적 범위(DB 기록)는 구현 경로가 존재
|
||||||
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
|
- #328 범위(가격/세션 재검증 강화)는 추적 이슈 오픈 상태
|
||||||
- #371: 큐 포화 시 intent 유실 경로가 남아 있어 `REQ-V3-004`를 완료로 보기 어려움
|
- #371: 큐 포화 정책을 oldest-drop으로 명시/구현해 최신 intent 유실 경로를 제거
|
||||||
- **요구사항**: REQ-V3-004
|
- **요구사항**: REQ-V3-004
|
||||||
|
|
||||||
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
### GAP-5: 시간장벽이 봉 개수 고정 → ✅ 해소 (#329)
|
||||||
@@ -107,10 +107,12 @@ Updated: 2026-03-02
|
|||||||
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
|
- `max_holding_bars` deprecated 경고 유지 (하위 호환)
|
||||||
- **요구사항**: REQ-V2-005 / v3 확장
|
- **요구사항**: REQ-V2-005 / v3 확장
|
||||||
|
|
||||||
### GAP-6 (신규): FX PnL 분리 미완료 (MEDIUM — 부분 구현)
|
### GAP-6 (신규): FX PnL 분리 부분 해소 (MEDIUM)
|
||||||
|
|
||||||
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
|
- **위치**: `src/db.py` (`fx_pnl`, `strategy_pnl` 컬럼 존재)
|
||||||
- **문제**: 스키마와 함수는 존재하지만 런타임 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 전달이 누락됨 (`#370`)
|
- **현 상태**: 런타임 SELL 경로에서 `strategy_pnl`/`fx_pnl` 분리 계산 및 전달을 적용함 (`#370`).
|
||||||
|
- **운영 메모**: `trading_cycle`은 scanner 기반 `selection_context`에 `fx_rate`를 추가하고, `run_daily_session`은 scanner 컨텍스트 없이 `fx_rate` 스냅샷만 기록한다.
|
||||||
|
- **잔여**: 과거 BUY 레코드에 `fx_rate`가 없으면 해외 구간도 `fx_pnl=0` fallback으로 기록됨.
|
||||||
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
|
- **영향**: USD 거래에서 환율 손익과 전략 손익이 분리되지 않아 성과 분석 부정확
|
||||||
- **요구사항**: REQ-V3-007
|
- **요구사항**: REQ-V3-007
|
||||||
|
|
||||||
@@ -392,8 +394,7 @@ Phase 3 (중기): v3 세션 최적화
|
|||||||
|
|
||||||
### 테스트 미존재 (잔여)
|
### 테스트 미존재 (잔여)
|
||||||
|
|
||||||
- ❌ 세션 전환 훅 콜백 (GAP-3 잔여)
|
- ✅ 세션 전환 훅 콜백/세션 경계 리스크 재로딩 E2E 회귀 (`#376`)
|
||||||
- ❌ 세션 경계 리스크 파라미터 재로딩 단위 테스트 (GAP-3 잔여)
|
|
||||||
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
|
- ❌ 실거래 경로 ↔ v2 상태기계 통합 테스트 (피처 공급 포함)
|
||||||
- ❌ FX PnL 운영 활성화 검증 (GAP-6)
|
- ❌ FX PnL 운영 활성화 검증 (GAP-6)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class BacktestCostModel:
|
|||||||
commission_bps: float | None = None
|
commission_bps: float | None = None
|
||||||
slippage_bps_by_session: dict[str, float] | None = None
|
slippage_bps_by_session: dict[str, float] | None = None
|
||||||
failure_rate_by_session: dict[str, float] | None = None
|
failure_rate_by_session: dict[str, float] | None = None
|
||||||
|
partial_fill_rate_by_session: dict[str, float] | None = None
|
||||||
unfavorable_fill_required: bool = True
|
unfavorable_fill_required: bool = True
|
||||||
|
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ def validate_backtest_cost_model(
|
|||||||
|
|
||||||
slippage = model.slippage_bps_by_session or {}
|
slippage = model.slippage_bps_by_session or {}
|
||||||
failure = model.failure_rate_by_session or {}
|
failure = model.failure_rate_by_session or {}
|
||||||
|
partial_fill = model.partial_fill_rate_by_session or {}
|
||||||
|
|
||||||
missing_slippage = [s for s in required_sessions if s not in slippage]
|
missing_slippage = [s for s in required_sessions if s not in slippage]
|
||||||
if missing_slippage:
|
if missing_slippage:
|
||||||
@@ -44,9 +46,19 @@ def validate_backtest_cost_model(
|
|||||||
f"missing failure_rate_by_session for sessions: {', '.join(missing_failure)}"
|
f"missing failure_rate_by_session for sessions: {', '.join(missing_failure)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
missing_partial_fill = [s for s in required_sessions if s not in partial_fill]
|
||||||
|
if missing_partial_fill:
|
||||||
|
raise ValueError(
|
||||||
|
"missing partial_fill_rate_by_session for sessions: "
|
||||||
|
f"{', '.join(missing_partial_fill)}"
|
||||||
|
)
|
||||||
|
|
||||||
for sess, bps in slippage.items():
|
for sess, bps in slippage.items():
|
||||||
if not math.isfinite(bps) or bps < 0:
|
if not math.isfinite(bps) or bps < 0:
|
||||||
raise ValueError(f"slippage bps must be >= 0 for session={sess}")
|
raise ValueError(f"slippage bps must be >= 0 for session={sess}")
|
||||||
for sess, rate in failure.items():
|
for sess, rate in failure.items():
|
||||||
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
||||||
raise ValueError(f"failure rate must be within [0,1] for session={sess}")
|
raise ValueError(f"failure rate must be within [0,1] for session={sess}")
|
||||||
|
for sess, rate in partial_fill.items():
|
||||||
|
if not math.isfinite(rate) or rate < 0 or rate > 1:
|
||||||
|
raise ValueError(f"partial fill rate must be within [0,1] for session={sess}")
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ from statistics import mean
|
|||||||
from typing import Literal, cast
|
from typing import Literal, cast
|
||||||
|
|
||||||
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
|
from src.analysis.backtest_cost_guard import BacktestCostModel, validate_backtest_cost_model
|
||||||
|
from src.analysis.backtest_execution_model import (
|
||||||
|
BacktestExecutionModel,
|
||||||
|
ExecutionAssumptions,
|
||||||
|
ExecutionRequest,
|
||||||
|
)
|
||||||
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
|
from src.analysis.triple_barrier import TripleBarrierSpec, label_with_triple_barrier
|
||||||
from src.analysis.walk_forward_split import WalkForwardFold, generate_walk_forward_splits
|
from src.analysis.walk_forward_split import WalkForwardFold, generate_walk_forward_splits
|
||||||
|
|
||||||
@@ -40,6 +45,7 @@ class WalkForwardConfig:
|
|||||||
class BaselineScore:
|
class BaselineScore:
|
||||||
name: Literal["B0", "B1", "M1"]
|
name: Literal["B0", "B1", "M1"]
|
||||||
accuracy: float
|
accuracy: float
|
||||||
|
cost_adjusted_accuracy: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -50,6 +56,10 @@ class BacktestFoldResult:
|
|||||||
train_label_distribution: dict[int, int]
|
train_label_distribution: dict[int, int]
|
||||||
test_label_distribution: dict[int, int]
|
test_label_distribution: dict[int, int]
|
||||||
baseline_scores: list[BaselineScore]
|
baseline_scores: list[BaselineScore]
|
||||||
|
execution_adjusted_avg_return_bps: float
|
||||||
|
execution_adjusted_trade_count: int
|
||||||
|
execution_rejected_count: int
|
||||||
|
execution_partial_count: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -84,6 +94,14 @@ def run_v2_backtest_pipeline(
|
|||||||
else sorted({bar.session_id for bar in bars})
|
else sorted({bar.session_id for bar in bars})
|
||||||
)
|
)
|
||||||
validate_backtest_cost_model(model=cost_model, required_sessions=resolved_sessions)
|
validate_backtest_cost_model(model=cost_model, required_sessions=resolved_sessions)
|
||||||
|
execution_model = BacktestExecutionModel(
|
||||||
|
ExecutionAssumptions(
|
||||||
|
slippage_bps_by_session=cost_model.slippage_bps_by_session or {},
|
||||||
|
failure_rate_by_session=cost_model.failure_rate_by_session or {},
|
||||||
|
partial_fill_rate_by_session=cost_model.partial_fill_rate_by_session or {},
|
||||||
|
seed=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
highs = [float(bar.high) for bar in bars]
|
highs = [float(bar.high) for bar in bars]
|
||||||
lows = [float(bar.low) for bar in bars]
|
lows = [float(bar.low) for bar in bars]
|
||||||
@@ -115,6 +133,8 @@ def run_v2_backtest_pipeline(
|
|||||||
).label
|
).label
|
||||||
|
|
||||||
ordered_labels = [labels_by_bar_index[idx] for idx in normalized_entries]
|
ordered_labels = [labels_by_bar_index[idx] for idx in normalized_entries]
|
||||||
|
ordered_sessions = [bars[idx].session_id for idx in normalized_entries]
|
||||||
|
ordered_prices = [bars[idx].close for idx in normalized_entries]
|
||||||
folds = generate_walk_forward_splits(
|
folds = generate_walk_forward_splits(
|
||||||
n_samples=len(normalized_entries),
|
n_samples=len(normalized_entries),
|
||||||
train_size=walk_forward.train_size,
|
train_size=walk_forward.train_size,
|
||||||
@@ -129,8 +149,37 @@ def run_v2_backtest_pipeline(
|
|||||||
for fold_idx, fold in enumerate(folds):
|
for fold_idx, fold in enumerate(folds):
|
||||||
train_labels = [ordered_labels[i] for i in fold.train_indices]
|
train_labels = [ordered_labels[i] for i in fold.train_indices]
|
||||||
test_labels = [ordered_labels[i] for i in fold.test_indices]
|
test_labels = [ordered_labels[i] for i in fold.test_indices]
|
||||||
|
test_sessions = [ordered_sessions[i] for i in fold.test_indices]
|
||||||
|
test_prices = [ordered_prices[i] for i in fold.test_indices]
|
||||||
if not test_labels:
|
if not test_labels:
|
||||||
continue
|
continue
|
||||||
|
execution_model = _build_execution_model(cost_model=cost_model, fold_seed=fold_idx)
|
||||||
|
execution_return_model = _build_execution_model(
|
||||||
|
cost_model=cost_model,
|
||||||
|
fold_seed=fold_idx,
|
||||||
|
)
|
||||||
|
b0_pred = _baseline_b0_pred(train_labels)
|
||||||
|
m1_pred = _m1_pred(train_labels)
|
||||||
|
execution_returns_bps: list[float] = []
|
||||||
|
execution_rejected = 0
|
||||||
|
execution_partial = 0
|
||||||
|
for rel_idx in fold.test_indices:
|
||||||
|
entry_bar_index = normalized_entries[rel_idx]
|
||||||
|
bar = bars[entry_bar_index]
|
||||||
|
trade = _simulate_execution_adjusted_return_bps(
|
||||||
|
execution_model=execution_return_model,
|
||||||
|
bar=bar,
|
||||||
|
label=ordered_labels[rel_idx],
|
||||||
|
side=side,
|
||||||
|
spec=triple_barrier_spec,
|
||||||
|
commission_bps=float(cost_model.commission_bps or 0.0),
|
||||||
|
)
|
||||||
|
if trade["status"] == "REJECTED":
|
||||||
|
execution_rejected += 1
|
||||||
|
continue
|
||||||
|
execution_returns_bps.append(float(trade["return_bps"]))
|
||||||
|
if trade["status"] == "PARTIAL":
|
||||||
|
execution_partial += 1
|
||||||
fold_results.append(
|
fold_results.append(
|
||||||
BacktestFoldResult(
|
BacktestFoldResult(
|
||||||
fold_index=fold_idx,
|
fold_index=fold_idx,
|
||||||
@@ -139,13 +188,49 @@ def run_v2_backtest_pipeline(
|
|||||||
train_label_distribution=_label_dist(train_labels),
|
train_label_distribution=_label_dist(train_labels),
|
||||||
test_label_distribution=_label_dist(test_labels),
|
test_label_distribution=_label_dist(test_labels),
|
||||||
baseline_scores=[
|
baseline_scores=[
|
||||||
BaselineScore(name="B0", accuracy=_baseline_b0(train_labels, test_labels)),
|
BaselineScore(
|
||||||
BaselineScore(name="B1", accuracy=_score_constant(1, test_labels)),
|
name="B0",
|
||||||
|
accuracy=_score_constant(b0_pred, test_labels),
|
||||||
|
cost_adjusted_accuracy=_score_with_execution(
|
||||||
|
prediction=b0_pred,
|
||||||
|
actual=test_labels,
|
||||||
|
sessions=test_sessions,
|
||||||
|
reference_prices=test_prices,
|
||||||
|
execution_model=execution_model,
|
||||||
|
commission_bps=float(cost_model.commission_bps or 0.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BaselineScore(
|
||||||
|
name="B1",
|
||||||
|
accuracy=_score_constant(1, test_labels),
|
||||||
|
cost_adjusted_accuracy=_score_with_execution(
|
||||||
|
prediction=1,
|
||||||
|
actual=test_labels,
|
||||||
|
sessions=test_sessions,
|
||||||
|
reference_prices=test_prices,
|
||||||
|
execution_model=execution_model,
|
||||||
|
commission_bps=float(cost_model.commission_bps or 0.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
BaselineScore(
|
BaselineScore(
|
||||||
name="M1",
|
name="M1",
|
||||||
accuracy=_score_constant(_m1_pred(train_labels), test_labels),
|
accuracy=_score_constant(m1_pred, test_labels),
|
||||||
|
cost_adjusted_accuracy=_score_with_execution(
|
||||||
|
prediction=m1_pred,
|
||||||
|
actual=test_labels,
|
||||||
|
sessions=test_sessions,
|
||||||
|
reference_prices=test_prices,
|
||||||
|
execution_model=execution_model,
|
||||||
|
commission_bps=float(cost_model.commission_bps or 0.0),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
execution_adjusted_avg_return_bps=(
|
||||||
|
mean(execution_returns_bps) if execution_returns_bps else 0.0
|
||||||
|
),
|
||||||
|
execution_adjusted_trade_count=len(execution_returns_bps),
|
||||||
|
execution_rejected_count=execution_rejected,
|
||||||
|
execution_partial_count=execution_partial,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -176,12 +261,15 @@ def _score_constant(pred: int, actual: Sequence[int]) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _baseline_b0(train_labels: Sequence[int], test_labels: Sequence[int]) -> float:
|
def _baseline_b0(train_labels: Sequence[int], test_labels: Sequence[int]) -> float:
|
||||||
|
return _score_constant(_baseline_b0_pred(train_labels), test_labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _baseline_b0_pred(train_labels: Sequence[int]) -> int:
|
||||||
if not train_labels:
|
if not train_labels:
|
||||||
return _score_constant(0, test_labels)
|
return 0
|
||||||
# Majority-class baseline from training fold.
|
# Majority-class baseline from training fold.
|
||||||
choices = (-1, 0, 1)
|
choices = (-1, 0, 1)
|
||||||
pred = max(choices, key=lambda c: train_labels.count(c))
|
return max(choices, key=lambda c: train_labels.count(c))
|
||||||
return _score_constant(pred, test_labels)
|
|
||||||
|
|
||||||
|
|
||||||
def _m1_pred(train_labels: Sequence[int]) -> int:
|
def _m1_pred(train_labels: Sequence[int]) -> int:
|
||||||
@@ -190,6 +278,56 @@ def _m1_pred(train_labels: Sequence[int]) -> int:
|
|||||||
return train_labels[-1]
|
return train_labels[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_execution_model(
|
||||||
|
*,
|
||||||
|
cost_model: BacktestCostModel,
|
||||||
|
fold_seed: int,
|
||||||
|
) -> BacktestExecutionModel:
|
||||||
|
return BacktestExecutionModel(
|
||||||
|
ExecutionAssumptions(
|
||||||
|
slippage_bps_by_session=dict(cost_model.slippage_bps_by_session or {}),
|
||||||
|
failure_rate_by_session=dict(cost_model.failure_rate_by_session or {}),
|
||||||
|
partial_fill_rate_by_session=dict(cost_model.partial_fill_rate_by_session or {}),
|
||||||
|
seed=fold_seed,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _score_with_execution(
|
||||||
|
*,
|
||||||
|
prediction: int,
|
||||||
|
actual: Sequence[int],
|
||||||
|
sessions: Sequence[str],
|
||||||
|
reference_prices: Sequence[float],
|
||||||
|
execution_model: BacktestExecutionModel,
|
||||||
|
commission_bps: float,
|
||||||
|
) -> float:
|
||||||
|
if not actual:
|
||||||
|
return 0.0
|
||||||
|
contributions: list[float] = []
|
||||||
|
for label, session_id, reference_price in zip(actual, sessions, reference_prices, strict=True):
|
||||||
|
if prediction == 0:
|
||||||
|
contributions.append(1.0 if label == 0 else 0.0)
|
||||||
|
continue
|
||||||
|
side = "BUY" if prediction > 0 else "SELL"
|
||||||
|
execution = execution_model.simulate(
|
||||||
|
ExecutionRequest(
|
||||||
|
side=side,
|
||||||
|
session_id=session_id,
|
||||||
|
qty=100,
|
||||||
|
reference_price=reference_price,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if execution.status == "REJECTED":
|
||||||
|
contributions.append(0.0)
|
||||||
|
continue
|
||||||
|
fill_ratio = execution.filled_qty / 100.0
|
||||||
|
cost_penalty = min(0.99, (commission_bps + execution.slippage_bps) / 10000.0)
|
||||||
|
correctness = 1.0 if prediction == label else 0.0
|
||||||
|
contributions.append(correctness * fill_ratio * (1.0 - cost_penalty))
|
||||||
|
return mean(contributions)
|
||||||
|
|
||||||
|
|
||||||
def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> str:
|
def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> str:
|
||||||
sess_key = "_".join(sessions)
|
sess_key = "_".join(sessions)
|
||||||
return f"v2p-e{n_entries}-f{n_folds}-s{sess_key}"
|
return f"v2p-e{n_entries}-f{n_folds}-s{sess_key}"
|
||||||
@@ -198,3 +336,58 @@ def _build_run_id(*, n_entries: int, n_folds: int, sessions: Sequence[str]) -> s
|
|||||||
def fold_has_leakage(fold: WalkForwardFold) -> bool:
|
def fold_has_leakage(fold: WalkForwardFold) -> bool:
|
||||||
"""Utility for tests/verification: True when train/test overlap exists."""
|
"""Utility for tests/verification: True when train/test overlap exists."""
|
||||||
return bool(set(fold.train_indices).intersection(fold.test_indices))
|
return bool(set(fold.train_indices).intersection(fold.test_indices))
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_execution_adjusted_return_bps(
|
||||||
|
*,
|
||||||
|
execution_model: BacktestExecutionModel,
|
||||||
|
bar: BacktestBar,
|
||||||
|
label: int,
|
||||||
|
side: int,
|
||||||
|
spec: TripleBarrierSpec,
|
||||||
|
commission_bps: float,
|
||||||
|
) -> dict[str, float | str]:
|
||||||
|
qty = 100
|
||||||
|
entry_req = ExecutionRequest(
|
||||||
|
side="BUY" if side == 1 else "SELL",
|
||||||
|
session_id=bar.session_id,
|
||||||
|
qty=qty,
|
||||||
|
reference_price=float(bar.close),
|
||||||
|
)
|
||||||
|
entry_fill = execution_model.simulate(entry_req)
|
||||||
|
if entry_fill.status == "REJECTED":
|
||||||
|
return {"status": "REJECTED", "return_bps": 0.0}
|
||||||
|
|
||||||
|
exit_qty = entry_fill.filled_qty
|
||||||
|
if label == 1:
|
||||||
|
gross_return_bps = spec.take_profit_pct * 10000.0
|
||||||
|
elif label == -1:
|
||||||
|
gross_return_bps = -spec.stop_loss_pct * 10000.0
|
||||||
|
else:
|
||||||
|
gross_return_bps = 0.0
|
||||||
|
|
||||||
|
if side == 1:
|
||||||
|
exit_price = float(bar.close) * (1.0 + gross_return_bps / 10000.0)
|
||||||
|
else:
|
||||||
|
exit_price = float(bar.close) * (1.0 - gross_return_bps / 10000.0)
|
||||||
|
|
||||||
|
exit_req = ExecutionRequest(
|
||||||
|
side="SELL" if side == 1 else "BUY",
|
||||||
|
session_id=bar.session_id,
|
||||||
|
qty=exit_qty,
|
||||||
|
reference_price=max(0.01, exit_price),
|
||||||
|
)
|
||||||
|
exit_fill = execution_model.simulate(exit_req)
|
||||||
|
if exit_fill.status == "REJECTED":
|
||||||
|
return {"status": "REJECTED", "return_bps": 0.0}
|
||||||
|
|
||||||
|
fill_ratio = min(entry_fill.filled_qty, exit_fill.filled_qty) / qty
|
||||||
|
cost_bps = (
|
||||||
|
float(entry_fill.slippage_bps)
|
||||||
|
+ float(exit_fill.slippage_bps)
|
||||||
|
+ (2.0 * float(commission_bps))
|
||||||
|
)
|
||||||
|
net_return_bps = (gross_return_bps * fill_ratio) - cost_bps
|
||||||
|
is_partial = entry_fill.status == "PARTIAL" or exit_fill.status == "PARTIAL"
|
||||||
|
status = "PARTIAL" if is_partial else "FILLED"
|
||||||
|
return {"status": status, "return_bps": net_return_bps}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class BlackoutWindow:
|
|||||||
class QueuedOrderIntent:
|
class QueuedOrderIntent:
|
||||||
market_code: str
|
market_code: str
|
||||||
exchange_code: str
|
exchange_code: str
|
||||||
|
session_id: str
|
||||||
stock_code: str
|
stock_code: str
|
||||||
order_type: str
|
order_type: str
|
||||||
quantity: int
|
quantity: int
|
||||||
@@ -68,11 +69,16 @@ class BlackoutOrderManager:
|
|||||||
self._queue: deque[QueuedOrderIntent] = deque()
|
self._queue: deque[QueuedOrderIntent] = deque()
|
||||||
self._was_blackout = False
|
self._was_blackout = False
|
||||||
self._max_queue_size = max_queue_size
|
self._max_queue_size = max_queue_size
|
||||||
|
self._overflow_drop_count = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
return len(self._queue)
|
return len(self._queue)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def overflow_drop_count(self) -> int:
|
||||||
|
return self._overflow_drop_count
|
||||||
|
|
||||||
def in_blackout(self, now: datetime | None = None) -> bool:
|
def in_blackout(self, now: datetime | None = None) -> bool:
|
||||||
if not self.enabled or not self._windows:
|
if not self.enabled or not self._windows:
|
||||||
return False
|
return False
|
||||||
@@ -81,8 +87,11 @@ class BlackoutOrderManager:
|
|||||||
return any(window.contains(kst_now) for window in self._windows)
|
return any(window.contains(kst_now) for window in self._windows)
|
||||||
|
|
||||||
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
def enqueue(self, intent: QueuedOrderIntent) -> bool:
|
||||||
if len(self._queue) >= self._max_queue_size:
|
if self._max_queue_size <= 0:
|
||||||
return False
|
return False
|
||||||
|
if len(self._queue) >= self._max_queue_size:
|
||||||
|
self._queue.popleft()
|
||||||
|
self._overflow_drop_count += 1
|
||||||
self._queue.append(intent)
|
self._queue.append(intent)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ def get_latest_buy_trade(
|
|||||||
if exchange_code:
|
if exchange_code:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT decision_id, price, quantity
|
SELECT decision_id, price, quantity, selection_context
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE stock_code = ?
|
WHERE stock_code = ?
|
||||||
AND market = ?
|
AND market = ?
|
||||||
@@ -339,7 +339,7 @@ def get_latest_buy_trade(
|
|||||||
else:
|
else:
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT decision_id, price, quantity
|
SELECT decision_id, price, quantity, selection_context
|
||||||
FROM trades
|
FROM trades
|
||||||
WHERE stock_code = ?
|
WHERE stock_code = ?
|
||||||
AND market = ?
|
AND market = ?
|
||||||
|
|||||||
165
src/main.py
165
src/main.py
@@ -128,6 +128,84 @@ def _resolve_sell_qty_for_pnl(*, sell_qty: int | None, buy_qty: int | None) -> i
|
|||||||
return max(0, int(buy_qty or 0))
|
return max(0, int(buy_qty or 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_fx_rate_from_sources(*sources: dict[str, Any] | None) -> float | None:
|
||||||
|
"""Best-effort FX rate extraction from broker payloads."""
|
||||||
|
# KIS overseas payloads expose exchange-rate fields with varying key names
|
||||||
|
# across endpoints/responses (price, balance, buying power). Keep this list
|
||||||
|
# centralised so schema drifts can be patched in one place.
|
||||||
|
rate_keys = (
|
||||||
|
"frst_bltn_exrt",
|
||||||
|
"bass_exrt",
|
||||||
|
"ovrs_exrt",
|
||||||
|
"aply_xchg_rt",
|
||||||
|
"xchg_rt",
|
||||||
|
"exchange_rate",
|
||||||
|
"fx_rate",
|
||||||
|
)
|
||||||
|
for source in sources:
|
||||||
|
if not isinstance(source, dict):
|
||||||
|
continue
|
||||||
|
for key in rate_keys:
|
||||||
|
rate = safe_float(source.get(key), 0.0)
|
||||||
|
if rate > 0:
|
||||||
|
return rate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_trade_pnl_components(
|
||||||
|
*,
|
||||||
|
market: MarketInfo,
|
||||||
|
trade_pnl: float,
|
||||||
|
buy_price: float,
|
||||||
|
sell_price: float,
|
||||||
|
quantity: int,
|
||||||
|
buy_fx_rate: float | None = None,
|
||||||
|
sell_fx_rate: float | None = None,
|
||||||
|
) -> tuple[float, float]:
|
||||||
|
"""Split total trade pnl into strategy/fx components.
|
||||||
|
|
||||||
|
For overseas symbols, use buy/sell FX rates when both are available.
|
||||||
|
Otherwise preserve backward-compatible behaviour (all strategy pnl).
|
||||||
|
"""
|
||||||
|
if trade_pnl == 0.0:
|
||||||
|
return 0.0, 0.0
|
||||||
|
if market.is_domestic:
|
||||||
|
return trade_pnl, 0.0
|
||||||
|
|
||||||
|
if (
|
||||||
|
buy_fx_rate is not None
|
||||||
|
and sell_fx_rate is not None
|
||||||
|
and buy_fx_rate > 0
|
||||||
|
and sell_fx_rate > 0
|
||||||
|
and quantity > 0
|
||||||
|
and buy_price > 0
|
||||||
|
and sell_price > 0
|
||||||
|
):
|
||||||
|
buy_notional = buy_price * quantity
|
||||||
|
fx_return = (sell_fx_rate - buy_fx_rate) / buy_fx_rate
|
||||||
|
fx_pnl = buy_notional * fx_return
|
||||||
|
strategy_pnl = trade_pnl - fx_pnl
|
||||||
|
return strategy_pnl, fx_pnl
|
||||||
|
|
||||||
|
return trade_pnl, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_buy_fx_rate(buy_trade: dict[str, Any] | None) -> float | None:
|
||||||
|
if not buy_trade:
|
||||||
|
return None
|
||||||
|
raw_ctx = buy_trade.get("selection_context")
|
||||||
|
if not isinstance(raw_ctx, str) or not raw_ctx.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
decoded = json.loads(raw_ctx)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
|
return None
|
||||||
|
rate = safe_float(decoded.get("fx_rate"), 0.0)
|
||||||
|
return rate if rate > 0 else None
|
||||||
|
|
||||||
|
|
||||||
def _compute_kr_dynamic_stop_loss_pct(
|
def _compute_kr_dynamic_stop_loss_pct(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo | None = None,
|
market: MarketInfo | None = None,
|
||||||
@@ -926,6 +1004,7 @@ async def build_overseas_symbol_universe(
|
|||||||
def _build_queued_order_intent(
|
def _build_queued_order_intent(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo,
|
market: MarketInfo,
|
||||||
|
session_id: str,
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
order_type: str,
|
order_type: str,
|
||||||
quantity: int,
|
quantity: int,
|
||||||
@@ -935,6 +1014,7 @@ def _build_queued_order_intent(
|
|||||||
return QueuedOrderIntent(
|
return QueuedOrderIntent(
|
||||||
market_code=market.code,
|
market_code=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
|
session_id=session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -947,6 +1027,7 @@ def _build_queued_order_intent(
|
|||||||
def _maybe_queue_order_intent(
|
def _maybe_queue_order_intent(
|
||||||
*,
|
*,
|
||||||
market: MarketInfo,
|
market: MarketInfo,
|
||||||
|
session_id: str,
|
||||||
stock_code: str,
|
stock_code: str,
|
||||||
order_type: str,
|
order_type: str,
|
||||||
quantity: int,
|
quantity: int,
|
||||||
@@ -956,9 +1037,11 @@ def _maybe_queue_order_intent(
|
|||||||
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
if not BLACKOUT_ORDER_MANAGER.in_blackout():
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
before_overflow_drops = BLACKOUT_ORDER_MANAGER.overflow_drop_count
|
||||||
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
queued = BLACKOUT_ORDER_MANAGER.enqueue(
|
||||||
_build_queued_order_intent(
|
_build_queued_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
|
session_id=session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=order_type,
|
order_type=order_type,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -967,6 +1050,7 @@ def _maybe_queue_order_intent(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if queued:
|
if queued:
|
||||||
|
after_overflow_drops = BLACKOUT_ORDER_MANAGER.overflow_drop_count
|
||||||
logger.warning(
|
logger.warning(
|
||||||
(
|
(
|
||||||
"Blackout active: queued order intent %s %s (%s) "
|
"Blackout active: queued order intent %s %s (%s) "
|
||||||
@@ -980,9 +1064,22 @@ def _maybe_queue_order_intent(
|
|||||||
source,
|
source,
|
||||||
BLACKOUT_ORDER_MANAGER.pending_count,
|
BLACKOUT_ORDER_MANAGER.pending_count,
|
||||||
)
|
)
|
||||||
|
if after_overflow_drops > before_overflow_drops:
|
||||||
|
logger.error(
|
||||||
|
(
|
||||||
|
"Blackout queue overflow policy applied: evicted oldest intent "
|
||||||
|
"to keep latest %s %s (%s) source=%s pending=%d total_evicted=%d"
|
||||||
|
),
|
||||||
|
order_type,
|
||||||
|
stock_code,
|
||||||
|
market.code,
|
||||||
|
source,
|
||||||
|
BLACKOUT_ORDER_MANAGER.pending_count,
|
||||||
|
after_overflow_drops,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Blackout queue full: dropped order intent %s %s (%s) qty=%d source=%s",
|
"Blackout queue unavailable: could not queue order intent %s %s (%s) qty=%d source=%s",
|
||||||
order_type,
|
order_type,
|
||||||
stock_code,
|
stock_code,
|
||||||
market.code,
|
market.code,
|
||||||
@@ -1115,7 +1212,6 @@ async def process_blackout_recovery_orders(
|
|||||||
|
|
||||||
accepted = result.get("rt_cd", "0") == "0"
|
accepted = result.get("rt_cd", "0") == "0"
|
||||||
if accepted:
|
if accepted:
|
||||||
runtime_session_id = get_session_info(market).session_id
|
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
stock_code=intent.stock_code,
|
stock_code=intent.stock_code,
|
||||||
@@ -1127,7 +1223,7 @@ async def process_blackout_recovery_orders(
|
|||||||
pnl=0.0,
|
pnl=0.0,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=runtime_session_id,
|
session_id=intent.session_id,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
|
"Recovered queued order executed: %s %s (%s) qty=%d price=%.4f source=%s",
|
||||||
@@ -1372,6 +1468,7 @@ async def trading_cycle(
|
|||||||
_session_risk_overrides(market=market, settings=settings)
|
_session_risk_overrides(market=market, settings=settings)
|
||||||
|
|
||||||
# 1. Fetch market data
|
# 1. Fetch market data
|
||||||
|
balance_info: dict[str, Any] = {}
|
||||||
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
|
price_output: dict[str, Any] = {} # Populated for overseas markets; used for fallback metrics
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
|
current_price, price_change_pct, foreigner_net = await broker.get_current_price(stock_code)
|
||||||
@@ -1394,8 +1491,6 @@ async def trading_cycle(
|
|||||||
balance_info = output2[0]
|
balance_info = output2[0]
|
||||||
elif isinstance(output2, dict):
|
elif isinstance(output2, dict):
|
||||||
balance_info = output2
|
balance_info = output2
|
||||||
else:
|
|
||||||
balance_info = {}
|
|
||||||
|
|
||||||
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
total_eval = safe_float(balance_info.get("frcr_evlu_tota", "0") or "0")
|
||||||
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
purchase_total = safe_float(balance_info.get("frcr_buy_amt_smtl", "0") or "0")
|
||||||
@@ -1815,6 +1910,9 @@ async def trading_cycle(
|
|||||||
quantity = 0
|
quantity = 0
|
||||||
trade_price = current_price
|
trade_price = current_price
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
|
buy_trade: dict[str, Any] | None = None
|
||||||
|
buy_price = 0.0
|
||||||
|
sell_qty = 0
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
logger.critical(
|
logger.critical(
|
||||||
@@ -1962,6 +2060,7 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
|
session_id=runtime_session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -2009,6 +2108,7 @@ async def trading_cycle(
|
|||||||
return
|
return
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
|
session_id=runtime_session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -2129,6 +2229,26 @@ async def trading_cycle(
|
|||||||
"signal": candidate.signal,
|
"signal": candidate.signal,
|
||||||
"score": candidate.score,
|
"score": candidate.score,
|
||||||
}
|
}
|
||||||
|
sell_fx_rate = _extract_fx_rate_from_sources(price_output, balance_info)
|
||||||
|
if sell_fx_rate is not None and not market.is_domestic:
|
||||||
|
if selection_context is None:
|
||||||
|
selection_context = {"fx_rate": sell_fx_rate}
|
||||||
|
else:
|
||||||
|
selection_context["fx_rate"] = sell_fx_rate
|
||||||
|
|
||||||
|
strategy_pnl: float | None = None
|
||||||
|
fx_pnl: float | None = None
|
||||||
|
if decision.action == "SELL" and order_succeeded:
|
||||||
|
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
|
||||||
|
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
||||||
|
market=market,
|
||||||
|
trade_pnl=trade_pnl,
|
||||||
|
buy_price=buy_price,
|
||||||
|
sell_price=trade_price,
|
||||||
|
quantity=sell_qty or quantity,
|
||||||
|
buy_fx_rate=buy_fx_rate,
|
||||||
|
sell_fx_rate=sell_fx_rate,
|
||||||
|
)
|
||||||
|
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
@@ -2139,6 +2259,8 @@ async def trading_cycle(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=trade_price,
|
price=trade_price,
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
|
strategy_pnl=strategy_pnl,
|
||||||
|
fx_pnl=fx_pnl,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=runtime_session_id,
|
session_id=runtime_session_id,
|
||||||
@@ -2737,6 +2859,7 @@ async def run_daily_session(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
balance_info: dict[str, Any] = {}
|
||||||
if market.is_domestic:
|
if market.is_domestic:
|
||||||
output2 = balance_data.get("output2", [{}])
|
output2 = balance_data.get("output2", [{}])
|
||||||
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
total_eval = safe_float(output2[0].get("tot_evlu_amt", "0")) if output2 else 0
|
||||||
@@ -2991,6 +3114,9 @@ async def run_daily_session(
|
|||||||
quantity = 0
|
quantity = 0
|
||||||
trade_price = stock_data["current_price"]
|
trade_price = stock_data["current_price"]
|
||||||
trade_pnl = 0.0
|
trade_pnl = 0.0
|
||||||
|
buy_trade: dict[str, Any] | None = None
|
||||||
|
buy_price = 0.0
|
||||||
|
sell_qty = 0
|
||||||
order_succeeded = True
|
order_succeeded = True
|
||||||
if decision.action in ("BUY", "SELL"):
|
if decision.action in ("BUY", "SELL"):
|
||||||
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
if KILL_SWITCH.new_orders_blocked and decision.action == "BUY":
|
||||||
@@ -3143,6 +3269,7 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
|
session_id=runtime_session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -3180,6 +3307,7 @@ async def run_daily_session(
|
|||||||
continue
|
continue
|
||||||
if _maybe_queue_order_intent(
|
if _maybe_queue_order_intent(
|
||||||
market=market,
|
market=market,
|
||||||
|
session_id=runtime_session_id,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
order_type=decision.action,
|
order_type=decision.action,
|
||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
@@ -3273,6 +3401,30 @@ async def run_daily_session(
|
|||||||
# Log trade (skip if order was rejected by API)
|
# Log trade (skip if order was rejected by API)
|
||||||
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
if decision.action in ("BUY", "SELL") and not order_succeeded:
|
||||||
continue
|
continue
|
||||||
|
strategy_pnl: float | None = None
|
||||||
|
fx_pnl: float | None = None
|
||||||
|
selection_context: dict[str, Any] | None = None
|
||||||
|
if decision.action == "SELL" and order_succeeded:
|
||||||
|
buy_fx_rate = _extract_buy_fx_rate(buy_trade)
|
||||||
|
sell_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
|
||||||
|
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
||||||
|
market=market,
|
||||||
|
trade_pnl=trade_pnl,
|
||||||
|
buy_price=buy_price,
|
||||||
|
sell_price=trade_price,
|
||||||
|
quantity=sell_qty or quantity,
|
||||||
|
buy_fx_rate=buy_fx_rate,
|
||||||
|
sell_fx_rate=sell_fx_rate,
|
||||||
|
)
|
||||||
|
if sell_fx_rate is not None and not market.is_domestic:
|
||||||
|
# Daily path does not carry scanner candidate metrics, so this
|
||||||
|
# context intentionally stores FX snapshot only.
|
||||||
|
selection_context = {"fx_rate": sell_fx_rate}
|
||||||
|
elif not market.is_domestic:
|
||||||
|
snapshot_fx_rate = _extract_fx_rate_from_sources(balance_info, stock_data)
|
||||||
|
if snapshot_fx_rate is not None:
|
||||||
|
# BUY/HOLD in daily path: persist FX snapshot for later SELL split.
|
||||||
|
selection_context = {"fx_rate": snapshot_fx_rate}
|
||||||
log_trade(
|
log_trade(
|
||||||
conn=db_conn,
|
conn=db_conn,
|
||||||
stock_code=stock_code,
|
stock_code=stock_code,
|
||||||
@@ -3282,9 +3434,12 @@ async def run_daily_session(
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
price=trade_price,
|
price=trade_price,
|
||||||
pnl=trade_pnl,
|
pnl=trade_pnl,
|
||||||
|
strategy_pnl=strategy_pnl,
|
||||||
|
fx_pnl=fx_pnl,
|
||||||
market=market.code,
|
market=market.code,
|
||||||
exchange_code=market.exchange_code,
|
exchange_code=market.exchange_code,
|
||||||
session_id=runtime_session_id,
|
session_id=runtime_session_id,
|
||||||
|
selection_context=selection_context,
|
||||||
decision_id=decision_id,
|
decision_id=decision_id,
|
||||||
mode=settings.MODE,
|
mode=settings.MODE,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ def test_valid_backtest_cost_model_passes() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.1, "US_PRE": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
|
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
|
||||||
@@ -20,6 +21,7 @@ def test_missing_required_slippage_session_raises() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.1, "US_PRE": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="missing slippage_bps_by_session.*US_PRE"):
|
with pytest.raises(ValueError, match="missing slippage_bps_by_session.*US_PRE"):
|
||||||
@@ -31,6 +33,7 @@ def test_missing_required_failure_rate_session_raises() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01},
|
failure_rate_by_session={"KRX_REG": 0.01},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.1, "US_PRE": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="missing failure_rate_by_session.*US_PRE"):
|
with pytest.raises(ValueError, match="missing failure_rate_by_session.*US_PRE"):
|
||||||
@@ -42,6 +45,7 @@ def test_invalid_failure_rate_range_raises() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 1.2},
|
failure_rate_by_session={"KRX_REG": 1.2},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="failure rate must be within"):
|
with pytest.raises(ValueError, match="failure rate must be within"):
|
||||||
@@ -53,6 +57,7 @@ def test_unfavorable_fill_requirement_cannot_be_disabled() -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
||||||
unfavorable_fill_required=False,
|
unfavorable_fill_required=False,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="unfavorable_fill_required must be True"):
|
with pytest.raises(ValueError, match="unfavorable_fill_required must be True"):
|
||||||
@@ -65,6 +70,7 @@ def test_non_finite_commission_rejected(bad_commission: float) -> None:
|
|||||||
commission_bps=bad_commission,
|
commission_bps=bad_commission,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="commission_bps"):
|
with pytest.raises(ValueError, match="commission_bps"):
|
||||||
@@ -77,7 +83,33 @@ def test_non_finite_slippage_rejected(bad_slippage: float) -> None:
|
|||||||
commission_bps=5.0,
|
commission_bps=5.0,
|
||||||
slippage_bps_by_session={"KRX_REG": bad_slippage},
|
slippage_bps_by_session={"KRX_REG": bad_slippage},
|
||||||
failure_rate_by_session={"KRX_REG": 0.02},
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
with pytest.raises(ValueError, match="slippage bps"):
|
with pytest.raises(ValueError, match="slippage bps"):
|
||||||
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
|
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_required_partial_fill_session_raises() -> None:
|
||||||
|
model = BacktestCostModel(
|
||||||
|
commission_bps=5.0,
|
||||||
|
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
||||||
|
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.1},
|
||||||
|
unfavorable_fill_required=True,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="missing partial_fill_rate_by_session.*US_PRE"):
|
||||||
|
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG", "US_PRE"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad_partial_fill", [float("nan"), float("inf"), float("-inf"), -0.1, 1.1])
|
||||||
|
def test_invalid_partial_fill_rate_rejected(bad_partial_fill: float) -> None:
|
||||||
|
model = BacktestCostModel(
|
||||||
|
commission_bps=5.0,
|
||||||
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
|
failure_rate_by_session={"KRX_REG": 0.02},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": bad_partial_fill},
|
||||||
|
unfavorable_fill_required=True,
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="partial fill rate must be within"):
|
||||||
|
validate_backtest_cost_model(model=model, required_sessions=["KRX_REG"])
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def _cost_model() -> BacktestCostModel:
|
|||||||
commission_bps=3.0,
|
commission_bps=3.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
slippage_bps_by_session={"KRX_REG": 10.0, "US_PRE": 50.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
failure_rate_by_session={"KRX_REG": 0.01, "US_PRE": 0.08},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.05, "US_PRE": 0.2},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,6 +72,10 @@ def test_pipeline_happy_path_returns_fold_and_artifact_contract() -> None:
|
|||||||
assert names == {"B0", "B1", "M1"}
|
assert names == {"B0", "B1", "M1"}
|
||||||
for score in fold.baseline_scores:
|
for score in fold.baseline_scores:
|
||||||
assert 0.0 <= score.accuracy <= 1.0
|
assert 0.0 <= score.accuracy <= 1.0
|
||||||
|
assert 0.0 <= score.cost_adjusted_accuracy <= 1.0
|
||||||
|
assert fold.execution_adjusted_trade_count >= 0
|
||||||
|
assert fold.execution_rejected_count >= 0
|
||||||
|
assert fold.execution_partial_count >= 0
|
||||||
|
|
||||||
|
|
||||||
def test_pipeline_cost_guard_fail_fast() -> None:
|
def test_pipeline_cost_guard_fail_fast() -> None:
|
||||||
@@ -78,6 +83,7 @@ def test_pipeline_cost_guard_fail_fast() -> None:
|
|||||||
commission_bps=3.0,
|
commission_bps=3.0,
|
||||||
slippage_bps_by_session={"KRX_REG": 10.0},
|
slippage_bps_by_session={"KRX_REG": 10.0},
|
||||||
failure_rate_by_session={"KRX_REG": 0.01},
|
failure_rate_by_session={"KRX_REG": 0.01},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.05},
|
||||||
unfavorable_fill_required=True,
|
unfavorable_fill_required=True,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -166,3 +172,49 @@ def test_pipeline_rejects_minutes_spec_when_timestamp_missing() -> None:
|
|||||||
assert "BacktestBar.timestamp is required" in str(exc)
|
assert "BacktestBar.timestamp is required" in str(exc)
|
||||||
else:
|
else:
|
||||||
raise AssertionError("expected timestamp validation error")
|
raise AssertionError("expected timestamp validation error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_fold_scores_reflect_cost_and_execution_effects() -> None:
|
||||||
|
cfg = dict(
|
||||||
|
bars=_bars(),
|
||||||
|
entry_indices=[0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
|
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=4,
|
||||||
|
test_size=2,
|
||||||
|
step_size=2,
|
||||||
|
purge_size=1,
|
||||||
|
embargo_size=1,
|
||||||
|
min_train_size=3,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
optimistic = BacktestCostModel(
|
||||||
|
commission_bps=0.0,
|
||||||
|
slippage_bps_by_session={"KRX_REG": 0.0, "US_PRE": 0.0},
|
||||||
|
failure_rate_by_session={"KRX_REG": 0.0, "US_PRE": 0.0},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.0, "US_PRE": 0.0},
|
||||||
|
unfavorable_fill_required=True,
|
||||||
|
)
|
||||||
|
conservative = BacktestCostModel(
|
||||||
|
commission_bps=10.0,
|
||||||
|
slippage_bps_by_session={"KRX_REG": 30.0, "US_PRE": 80.0},
|
||||||
|
failure_rate_by_session={"KRX_REG": 0.2, "US_PRE": 0.4},
|
||||||
|
partial_fill_rate_by_session={"KRX_REG": 0.5, "US_PRE": 0.7},
|
||||||
|
unfavorable_fill_required=True,
|
||||||
|
)
|
||||||
|
optimistic_out = run_v2_backtest_pipeline(cost_model=optimistic, **cfg)
|
||||||
|
conservative_out = run_v2_backtest_pipeline(cost_model=conservative, **cfg)
|
||||||
|
|
||||||
|
assert optimistic_out.folds and conservative_out.folds
|
||||||
|
optimistic_score = optimistic_out.folds[0].baseline_scores[1].cost_adjusted_accuracy
|
||||||
|
conservative_score = conservative_out.folds[0].baseline_scores[1].cost_adjusted_accuracy
|
||||||
|
assert conservative_score < optimistic_score
|
||||||
|
|
||||||
|
optimistic_avg_return = optimistic_out.folds[0].execution_adjusted_avg_return_bps
|
||||||
|
conservative_avg_return = conservative_out.folds[0].execution_adjusted_avg_return_bps
|
||||||
|
assert conservative_avg_return < optimistic_avg_return
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def test_recovery_batch_only_after_blackout_exit() -> None:
|
|||||||
intent = QueuedOrderIntent(
|
intent = QueuedOrderIntent(
|
||||||
market_code="KR",
|
market_code="KR",
|
||||||
exchange_code="KRX",
|
exchange_code="KRX",
|
||||||
|
session_id="KRX_REG",
|
||||||
stock_code="005930",
|
stock_code="005930",
|
||||||
order_type="BUY",
|
order_type="BUY",
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@@ -64,6 +65,7 @@ def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
|
|||||||
intent = QueuedOrderIntent(
|
intent = QueuedOrderIntent(
|
||||||
market_code="KR",
|
market_code="KR",
|
||||||
exchange_code="KRX",
|
exchange_code="KRX",
|
||||||
|
session_id="KRX_REG",
|
||||||
stock_code="005930",
|
stock_code="005930",
|
||||||
order_type="BUY",
|
order_type="BUY",
|
||||||
quantity=1,
|
quantity=1,
|
||||||
@@ -79,3 +81,54 @@ def test_requeued_intent_is_processed_next_non_blackout_cycle() -> None:
|
|||||||
manager.requeue(first_batch[0])
|
manager.requeue(first_batch[0])
|
||||||
second_batch = manager.pop_recovery_batch(outside_blackout)
|
second_batch = manager.pop_recovery_batch(outside_blackout)
|
||||||
assert len(second_batch) == 1
|
assert len(second_batch) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_overflow_drops_oldest_and_keeps_latest() -> None:
|
||||||
|
manager = BlackoutOrderManager(
|
||||||
|
enabled=True,
|
||||||
|
windows=parse_blackout_windows_kst("23:30-00:10"),
|
||||||
|
max_queue_size=2,
|
||||||
|
)
|
||||||
|
first = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
session_id="KRX_REG",
|
||||||
|
stock_code="000001",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
source="first",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
second = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
session_id="KRX_REG",
|
||||||
|
stock_code="000002",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=101.0,
|
||||||
|
source="second",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
third = QueuedOrderIntent(
|
||||||
|
market_code="KR",
|
||||||
|
exchange_code="KRX",
|
||||||
|
session_id="KRX_REG",
|
||||||
|
stock_code="000003",
|
||||||
|
order_type="SELL",
|
||||||
|
quantity=2,
|
||||||
|
price=102.0,
|
||||||
|
source="third",
|
||||||
|
queued_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.enqueue(first)
|
||||||
|
assert manager.enqueue(second)
|
||||||
|
assert manager.enqueue(third)
|
||||||
|
assert manager.pending_count == 2
|
||||||
|
assert manager.overflow_drop_count == 1
|
||||||
|
|
||||||
|
outside_blackout = datetime(2026, 1, 1, 15, 20, tzinfo=UTC)
|
||||||
|
batch = manager.pop_recovery_batch(outside_blackout)
|
||||||
|
assert [intent.stock_code for intent in batch] == ["000002", "000003"]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for main trading loop integration."""
|
"""Tests for main trading loop integration."""
|
||||||
|
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
|
from typing import Any
|
||||||
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -9,6 +10,7 @@ import src.main as main_module
|
|||||||
from src.config import Settings
|
from src.config import Settings
|
||||||
from src.context.layer import ContextLayer
|
from src.context.layer import ContextLayer
|
||||||
from src.context.scheduler import ScheduleResult
|
from src.context.scheduler import ScheduleResult
|
||||||
|
from src.core.blackout_manager import BlackoutOrderManager
|
||||||
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
from src.core.order_policy import OrderPolicyRejected, get_session_info
|
||||||
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
from src.core.risk_manager import CircuitBreakerTripped, FatFingerRejected
|
||||||
from src.db import init_db, log_trade
|
from src.db import init_db, log_trade
|
||||||
@@ -33,6 +35,7 @@ from src.main import (
|
|||||||
_extract_held_qty_from_balance,
|
_extract_held_qty_from_balance,
|
||||||
_handle_market_close,
|
_handle_market_close,
|
||||||
_inject_staged_exit_features,
|
_inject_staged_exit_features,
|
||||||
|
_maybe_queue_order_intent,
|
||||||
_resolve_market_setting,
|
_resolve_market_setting,
|
||||||
_resolve_sell_qty_for_pnl,
|
_resolve_sell_qty_for_pnl,
|
||||||
_retry_connection,
|
_retry_connection,
|
||||||
@@ -40,6 +43,7 @@ from src.main import (
|
|||||||
_run_evolution_loop,
|
_run_evolution_loop,
|
||||||
_should_block_overseas_buy_for_fx_buffer,
|
_should_block_overseas_buy_for_fx_buffer,
|
||||||
_should_force_exit_for_overnight,
|
_should_force_exit_for_overnight,
|
||||||
|
_split_trade_pnl_components,
|
||||||
_start_dashboard_server,
|
_start_dashboard_server,
|
||||||
_stoploss_cooldown_minutes,
|
_stoploss_cooldown_minutes,
|
||||||
_trigger_emergency_kill_switch,
|
_trigger_emergency_kill_switch,
|
||||||
@@ -102,22 +106,22 @@ def _make_sell_match(stock_code: str = "005930") -> ScenarioMatch:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _reset_kill_switch_state() -> None:
|
def _reset_kill_switch_state() -> None:
|
||||||
"""Prevent cross-test leakage from global kill-switch state."""
|
"""Prevent cross-test leakage from global kill-switch state."""
|
||||||
|
def _reset_session_risk_globals() -> None:
|
||||||
|
_SESSION_RISK_LAST_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
||||||
|
_SESSION_RISK_PROFILES_MAP.clear()
|
||||||
|
main_module._SESSION_RISK_PROFILES_RAW = "{}"
|
||||||
|
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
_SESSION_RISK_LAST_BY_MARKET.clear()
|
_reset_session_risk_globals()
|
||||||
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
|
||||||
_SESSION_RISK_PROFILES_MAP.clear()
|
|
||||||
main_module._SESSION_RISK_PROFILES_RAW = "__reset__"
|
|
||||||
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
yield
|
yield
|
||||||
KILL_SWITCH.clear_block()
|
KILL_SWITCH.clear_block()
|
||||||
_RUNTIME_EXIT_STATES.clear()
|
_RUNTIME_EXIT_STATES.clear()
|
||||||
_RUNTIME_EXIT_PEAKS.clear()
|
_RUNTIME_EXIT_PEAKS.clear()
|
||||||
_SESSION_RISK_LAST_BY_MARKET.clear()
|
_reset_session_risk_globals()
|
||||||
_SESSION_RISK_OVERRIDES_BY_MARKET.clear()
|
|
||||||
_SESSION_RISK_PROFILES_MAP.clear()
|
|
||||||
main_module._SESSION_RISK_PROFILES_RAW = "__reset__"
|
|
||||||
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
_STOPLOSS_REENTRY_COOLDOWN_UNTIL.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -3181,6 +3185,13 @@ async def test_sell_order_uses_broker_balance_qty_not_db() -> None:
|
|||||||
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
|
updated_buy = decision_logger.get_decision_by_id(buy_decision_id)
|
||||||
assert updated_buy is not None
|
assert updated_buy is not None
|
||||||
assert updated_buy.outcome_pnl == -25.0
|
assert updated_buy.outcome_pnl == -25.0
|
||||||
|
sell_row = db_conn.execute(
|
||||||
|
"SELECT pnl, strategy_pnl, fx_pnl FROM trades WHERE action='SELL' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert sell_row is not None
|
||||||
|
assert sell_row[0] == -25.0
|
||||||
|
assert sell_row[1] == -25.0
|
||||||
|
assert sell_row[2] == 0.0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -4598,6 +4609,23 @@ def test_fx_buffer_guard_applies_only_to_us_and_respects_boundary() -> None:
|
|||||||
assert required_jp == 0.0
|
assert required_jp == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_trade_pnl_components_overseas_fx_split_preserves_total() -> None:
|
||||||
|
market = MagicMock()
|
||||||
|
market.is_domestic = False
|
||||||
|
strategy_pnl, fx_pnl = _split_trade_pnl_components(
|
||||||
|
market=market,
|
||||||
|
trade_pnl=20.0,
|
||||||
|
buy_price=100.0,
|
||||||
|
sell_price=110.0,
|
||||||
|
quantity=2,
|
||||||
|
buy_fx_rate=1200.0,
|
||||||
|
sell_fx_rate=1260.0,
|
||||||
|
)
|
||||||
|
assert strategy_pnl == 10.0
|
||||||
|
assert fx_pnl == 10.0
|
||||||
|
assert strategy_pnl + fx_pnl == pytest.approx(20.0)
|
||||||
|
|
||||||
|
|
||||||
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
|
# run_daily_session — daily CB baseline (daily_start_eval) tests (issue #207)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -6351,6 +6379,225 @@ async def test_us_min_price_filter_not_applied_to_kr_market() -> None:
|
|||||||
broker.send_order.assert_called_once()
|
broker.send_order.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_boundary_reloads_us_min_price_override_in_trading_cycle() -> None:
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
|
||||||
|
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "7.0", "rate": "0.0"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_buying_power = AsyncMock(
|
||||||
|
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
MODE="paper",
|
||||||
|
PAPER_OVERSEAS_CASH=50000.0,
|
||||||
|
US_MIN_PRICE=5.0,
|
||||||
|
USD_BUFFER_MIN=1000.0,
|
||||||
|
SESSION_RISK_RELOAD_ENABLED=True,
|
||||||
|
SESSION_RISK_PROFILES_JSON=(
|
||||||
|
'{"US_PRE": {"US_MIN_PRICE": 8.0}, "US_DAY": {"US_MIN_PRICE": 5.0}}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
current_session = {"id": "US_PRE"}
|
||||||
|
|
||||||
|
def _session_info(_: Any) -> MagicMock:
|
||||||
|
return MagicMock(session_id=current_session["id"])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("src.main.get_open_position", return_value=None),
|
||||||
|
patch("src.main.get_session_info", side_effect=_session_info),
|
||||||
|
):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
assert overseas_broker.send_overseas_order.call_count == 0
|
||||||
|
|
||||||
|
current_session["id"] = "US_DAY"
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overseas_broker.send_overseas_order.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_boundary_falls_back_when_profile_reload_fails() -> None:
|
||||||
|
db_conn = init_db(":memory:")
|
||||||
|
decision_logger = DecisionLogger(db_conn)
|
||||||
|
|
||||||
|
broker = MagicMock()
|
||||||
|
broker.get_balance = AsyncMock(return_value={"output1": [], "output2": [{}]})
|
||||||
|
|
||||||
|
overseas_broker = MagicMock()
|
||||||
|
overseas_broker.get_overseas_price = AsyncMock(
|
||||||
|
return_value={"output": {"last": "7.0", "rate": "0.0"}}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_balance = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"output1": [],
|
||||||
|
"output2": [{"frcr_evlu_tota": "10000", "frcr_buy_amt_smtl": "0"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
overseas_broker.get_overseas_buying_power = AsyncMock(
|
||||||
|
return_value={"output": {"ovrs_ord_psbl_amt": "10000"}}
|
||||||
|
)
|
||||||
|
overseas_broker.send_overseas_order = AsyncMock(return_value={"rt_cd": "0", "msg1": "OK"})
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.name = "NASDAQ"
|
||||||
|
market.code = "US_NASDAQ"
|
||||||
|
market.exchange_code = "NASD"
|
||||||
|
market.is_domestic = False
|
||||||
|
|
||||||
|
telegram = MagicMock()
|
||||||
|
telegram.notify_trade_execution = AsyncMock()
|
||||||
|
telegram.notify_fat_finger = AsyncMock()
|
||||||
|
telegram.notify_circuit_breaker = AsyncMock()
|
||||||
|
telegram.notify_scenario_matched = AsyncMock()
|
||||||
|
|
||||||
|
settings = Settings(
|
||||||
|
KIS_APP_KEY="k",
|
||||||
|
KIS_APP_SECRET="s",
|
||||||
|
KIS_ACCOUNT_NO="12345678-01",
|
||||||
|
GEMINI_API_KEY="g",
|
||||||
|
MODE="paper",
|
||||||
|
PAPER_OVERSEAS_CASH=50000.0,
|
||||||
|
US_MIN_PRICE=5.0,
|
||||||
|
USD_BUFFER_MIN=1000.0,
|
||||||
|
SESSION_RISK_RELOAD_ENABLED=True,
|
||||||
|
SESSION_RISK_PROFILES_JSON='{"US_PRE": {"US_MIN_PRICE": 8.0}}',
|
||||||
|
)
|
||||||
|
|
||||||
|
current_session = {"id": "US_PRE"}
|
||||||
|
|
||||||
|
def _session_info(_: Any) -> MagicMock:
|
||||||
|
return MagicMock(session_id=current_session["id"])
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("src.main.get_open_position", return_value=None),
|
||||||
|
patch("src.main.get_session_info", side_effect=_session_info),
|
||||||
|
):
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
assert overseas_broker.send_overseas_order.call_count == 0
|
||||||
|
|
||||||
|
settings.SESSION_RISK_PROFILES_JSON = "{invalid-json"
|
||||||
|
current_session["id"] = "US_DAY"
|
||||||
|
await trading_cycle(
|
||||||
|
broker=broker,
|
||||||
|
overseas_broker=overseas_broker,
|
||||||
|
scenario_engine=MagicMock(evaluate=MagicMock(return_value=_make_buy_match("AAPL"))),
|
||||||
|
playbook=_make_playbook("US_NASDAQ"),
|
||||||
|
risk=MagicMock(validate_order=MagicMock(), check_circuit_breaker=MagicMock()),
|
||||||
|
db_conn=db_conn,
|
||||||
|
decision_logger=decision_logger,
|
||||||
|
context_store=MagicMock(
|
||||||
|
get_latest_timeframe=MagicMock(return_value=None),
|
||||||
|
set_context=MagicMock(),
|
||||||
|
),
|
||||||
|
criticality_assessor=MagicMock(
|
||||||
|
assess_market_conditions=MagicMock(return_value=MagicMock(value="NORMAL")),
|
||||||
|
get_timeout=MagicMock(return_value=5.0),
|
||||||
|
),
|
||||||
|
telegram=telegram,
|
||||||
|
market=market,
|
||||||
|
stock_code="AAPL",
|
||||||
|
scan_candidates={},
|
||||||
|
settings=settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert overseas_broker.send_overseas_order.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_overnight_policy_prioritizes_killswitch_over_exception() -> None:
|
def test_overnight_policy_prioritizes_killswitch_over_exception() -> None:
|
||||||
market = MagicMock()
|
market = MagicMock()
|
||||||
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
|
with patch("src.main.get_session_info", return_value=MagicMock(session_id="US_AFTER")):
|
||||||
@@ -6475,6 +6722,7 @@ async def test_blackout_queues_order_and_skips_submission() -> None:
|
|||||||
blackout_manager.in_blackout.return_value = True
|
blackout_manager.in_blackout.return_value = True
|
||||||
blackout_manager.enqueue.return_value = True
|
blackout_manager.enqueue.return_value = True
|
||||||
blackout_manager.pending_count = 1
|
blackout_manager.pending_count = 1
|
||||||
|
blackout_manager.overflow_drop_count = 0
|
||||||
|
|
||||||
with patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager):
|
with patch("src.main.BLACKOUT_ORDER_MANAGER", blackout_manager):
|
||||||
await trading_cycle(
|
await trading_cycle(
|
||||||
@@ -6504,6 +6752,43 @@ async def test_blackout_queues_order_and_skips_submission() -> None:
|
|||||||
blackout_manager.enqueue.assert_called_once()
|
blackout_manager.enqueue.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blackout_queue_overflow_keeps_latest_intent() -> None:
|
||||||
|
manager = BlackoutOrderManager(enabled=True, windows=[], max_queue_size=1)
|
||||||
|
manager.in_blackout = lambda now=None: True # type: ignore[method-assign]
|
||||||
|
|
||||||
|
market = MagicMock()
|
||||||
|
market.code = "KR"
|
||||||
|
market.exchange_code = "KRX"
|
||||||
|
|
||||||
|
with patch("src.main.BLACKOUT_ORDER_MANAGER", manager):
|
||||||
|
assert _maybe_queue_order_intent(
|
||||||
|
market=market,
|
||||||
|
session_id="KRX_REG",
|
||||||
|
stock_code="005930",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=1,
|
||||||
|
price=100.0,
|
||||||
|
source="test-first",
|
||||||
|
)
|
||||||
|
assert _maybe_queue_order_intent(
|
||||||
|
market=market,
|
||||||
|
session_id="KRX_REG",
|
||||||
|
stock_code="000660",
|
||||||
|
order_type="BUY",
|
||||||
|
quantity=2,
|
||||||
|
price=200.0,
|
||||||
|
source="test-second",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert manager.pending_count == 1
|
||||||
|
assert manager.overflow_drop_count == 1
|
||||||
|
manager.in_blackout = lambda now=None: False # type: ignore[method-assign]
|
||||||
|
batch = manager.pop_recovery_batch()
|
||||||
|
assert len(batch) == 1
|
||||||
|
assert batch[0].stock_code == "000660"
|
||||||
|
assert batch[0].session_id == "KRX_REG"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
||||||
"""Recovery must execute queued intents that pass revalidation."""
|
"""Recovery must execute queued intents that pass revalidation."""
|
||||||
@@ -6525,6 +6810,7 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
|
intent.session_id = "NXT_AFTER"
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -6555,7 +6841,7 @@ async def test_process_blackout_recovery_executes_valid_intents() -> None:
|
|||||||
assert row is not None
|
assert row is not None
|
||||||
assert row[0] == "BUY"
|
assert row[0] == "BUY"
|
||||||
assert row[1] == 1
|
assert row[1] == 1
|
||||||
assert row[2] == "KRX_REG"
|
assert row[2] == "NXT_AFTER"
|
||||||
assert row[3].startswith("[blackout-recovery]")
|
assert row[3].startswith("[blackout-recovery]")
|
||||||
|
|
||||||
|
|
||||||
@@ -6580,6 +6866,7 @@ async def test_process_blackout_recovery_drops_policy_rejected_intent() -> None:
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
|
intent.session_id = "KRX_REG"
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -6629,6 +6916,7 @@ async def test_process_blackout_recovery_drops_intent_on_excessive_price_drift()
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
|
intent.session_id = "US_PRE"
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -6679,6 +6967,7 @@ async def test_process_blackout_recovery_drops_overseas_intent_on_excessive_pric
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
|
intent.session_id = "KRX_REG"
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
@@ -6728,6 +7017,7 @@ async def test_process_blackout_recovery_requeues_intent_when_price_lookup_fails
|
|||||||
intent.quantity = 1
|
intent.quantity = 1
|
||||||
intent.price = 100.0
|
intent.price = 100.0
|
||||||
intent.source = "test"
|
intent.source = "test"
|
||||||
|
intent.session_id = "KRX_REG"
|
||||||
intent.attempts = 0
|
intent.attempts = 0
|
||||||
|
|
||||||
blackout_manager = MagicMock()
|
blackout_manager = MagicMock()
|
||||||
|
|||||||
@@ -89,3 +89,19 @@
|
|||||||
- next_ticket: #316
|
- next_ticket: #316
|
||||||
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
- risks_or_notes: 모니터 판정을 liveness 중심에서 policy invariant(FORBIDDEN) 중심으로 전환
|
- risks_or_notes: 모니터 판정을 liveness 중심에서 policy invariant(FORBIDDEN) 중심으로 전환
|
||||||
|
|
||||||
|
### 2026-03-01 | session=codex-v3-stream-next-ticket
|
||||||
|
- branch: feature/v3-session-policy-stream
|
||||||
|
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
||||||
|
- open_issues_reviewed: #368, #369, #370, #371, #374, #375, #376, #377, #381
|
||||||
|
- next_ticket: #368
|
||||||
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
|
- risks_or_notes: 비블로킹 소견은 합당성(정확성/안정성/유지보수성) 기준으로 반영하고, 미반영 시 근거를 코멘트로 남긴다.
|
||||||
|
|
||||||
|
### 2026-03-01 | session=codex-issue368-start
|
||||||
|
- branch: feature/issue-368-backtest-cost-execution
|
||||||
|
- docs_checked: docs/workflow.md, docs/commands.md, docs/agent-constraints.md
|
||||||
|
- open_issues_reviewed: #368
|
||||||
|
- next_ticket: #368
|
||||||
|
- process_gate_checked: process_ticket=#306,#308 merged_to_feature_branch=yes
|
||||||
|
- risks_or_notes: TASK-V2-012 구현 갭 보완을 위해 cost guard + execution-adjusted fold metric + 회귀 테스트를 함께 반영한다.
|
||||||
|
|||||||
Reference in New Issue
Block a user