18 Commits

Author SHA1 Message Date
agentson
96d2c97fe7 analysis: apply execution-adjusted cost model in v2 backtest pipeline (#368)
Some checks failed
Gitea CI / test (push) Failing after 5s
Gitea CI / test (pull_request) Failing after 5s
2026-03-02 03:39:57 +09:00
4710aa2d66 Merge pull request 'test: add session-boundary risk reload e2e regressions (#376)' (#386) from feature/issue-376-session-boundary-e2e into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 32s
Reviewed-on: #386
2026-03-02 03:33:19 +09:00
agentson
ca9e1ad0e2 test: harden session-risk global reset isolation
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 03:30:46 +09:00
agentson
928e60877c test: add session-boundary risk reload e2e regressions (#376)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 03:23:58 +09:00
16ddc22d14 Merge pull request 'blackout: persist session_id across queued intent lifecycle (#375)' (#385) from feature/issue-375-queued-intent-session-id into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #385
2026-03-02 03:20:18 +09:00
agentson
4f21117eca blackout: simplify recovery session_id binding to queued value
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 35s
2026-03-02 03:17:28 +09:00
agentson
8e02b1ea4f blackout: persist session_id across queued intent lifecycle (#375)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 03:09:33 +09:00
ccceb38483 Merge pull request 'blackout: enforce bounded oldest-drop queue policy on overflow (#371)' (#384) from feature/issue-371-blackout-queue-overflow into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 32s
Reviewed-on: #384
2026-03-02 03:07:12 +09:00
agentson
96e5de7c5d test: align blackout queue mocks with overflow counter contract
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s
2026-03-02 03:03:35 +09:00
agentson
7959b749c7 blackout: enforce bounded oldest-drop queue policy on overflow (#371)
All checks were successful
Gitea CI / test (push) Successful in 33s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 02:57:08 +09:00
f7e242d147 Merge pull request 'trade: apply runtime strategy/fx pnl split on sell paths (#370)' (#383) from feature/issue-370-fx-pnl-runtime-split into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #383
2026-03-02 02:53:04 +09:00
agentson
589cc42e00 docs: bump requirements registry metadata for push governance sync
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s
2026-03-02 02:50:08 +09:00
agentson
920630e30e docs/main: clarify fx context behavior and rate-key provenance
Some checks failed
Gitea CI / test (push) Failing after 5s
Gitea CI / test (pull_request) Successful in 34s
2026-03-02 02:44:49 +09:00
agentson
d4f37ee392 trade: apply runtime strategy/fx pnl split on sell paths (#370)
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 32s
2026-03-02 02:35:54 +09:00
3914f24872 Merge pull request 'backtest: reflect cost/execution effects in fold scoring (#368)' (#382) from feature/issue-368-backtest-cost-exec into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 33s
Reviewed-on: #382
2026-03-02 02:30:45 +09:00
agentson
ed713fdf40 style: wrap long helper signature in backtest pipeline
All checks were successful
Gitea CI / test (push) Successful in 32s
Gitea CI / test (pull_request) Successful in 33s
2026-03-02 02:24:01 +09:00
agentson
c27decb6b1 backtest: reflect cost/execution effects in fold scoring (#368)
Some checks failed
Gitea CI / test (push) Failing after 6s
Gitea CI / test (pull_request) Failing after 5s
2026-03-02 02:10:08 +09:00
8ac7436953 Merge pull request 'docs: resync implementation audit status with actual code gaps (#373)' (#380) from feature/issue-373-audit-sync into feature/v3-session-policy-stream
All checks were successful
Gitea CI / test (push) Successful in 36s
Reviewed-on: #380
2026-03-02 02:06:40 +09:00
12 changed files with 849 additions and 36 deletions

View File

@@ -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 운용은 환율 손익 분리 추적과 통화 버퍼 정책을 포함해야 한다.

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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}

View File

@@ -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

View File

@@ -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 = ?

View File

@@ -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,
) )

View File

@@ -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"])

View File

@@ -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

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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 + 회귀 테스트를 함께 반영한다.