diff --git a/README.md b/README.md index 0f4409dd..a05f4788 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,6 @@ Generic paper entry도 동일한 실행 경계 원칙을 따른다. `main.py --m Target-weight pilot 승인/재시도 보강: `tools/paper_pilot_control.py --enable`은 `target_weight_*` 후보의 pilot auth를 쓰기 전에 target-weight readiness audit을 다시 실행해 운영자가 요청한 cap이 현재 plan, launch readiness, 유동성 preflight, 비용 반영 pre-trade risk를 만족하는지 검증합니다. 유동성 diagnostics가 없으면 fail-closed로 차단하고, 이미 주문이 완료된 same-candidate/trade-day 세션은 `--allow-rerun`을 줘도 재실행하지 않습니다. `--allow-rerun`은 부분 실행이나 중단된 세션 복구용으로만 사용합니다. -Target-weight 실행 차단 기록: `--execute`가 pilot cap validation에서 막히면 주문·체결·증거 수집 없이 session JSON artifact에 차단 사유를 남깁니다. runtime pilot session은 쓰지 않아 cap을 고친 뒤 같은 거래일 계획을 다시 점검할 수 있습니다. +Target-weight 실행 차단 기록: `--execute`가 pilot cap validation에서 막히면 주문·체결·증거 수집 없이 session JSON artifact에 차단 사유를 남깁니다. runtime pilot session은 쓰지 않아 cap을 고친 뒤 같은 거래일 계획을 다시 점검할 수 있습니다. 또한 `plan.trade_day`와 KST 기준 실제 실행일이 다르면 stale `--as-of-date` 실행으로 보고 주문, 체결 대조, 승격 증거 수집 전에 fail-closed로 차단합니다. -Target-weight 승격 증거 보강: target-weight 계열 전략은 일반 `execution_backed=True` paper record만으로 promotion evidence day를 채우지 않습니다. `pilot_paper`/authorized record가 target-weight plan과 execution proof를 포함하고, record date와 plan trade day가 일치하며, liquidity/pre-trade risk/order result/fill/position reconciliation complete 및 plan/execution params hash 일치를 만족한 날만 승격 카운트에 들어갑니다. 60영업일 전체 verified pilot evidence는 하나의 params hash로 고정되어야 하며, live gate도 canonical metadata의 params hash와 paper evidence params hash가 다르면 target-weight live 전환을 차단합니다. +Target-weight 승격 증거 보강: target-weight 계열 전략은 일반 `execution_backed=True` paper record만으로 promotion evidence day를 채우지 않습니다. `pilot_paper`/authorized record가 target-weight plan과 execution proof를 포함하고, record date와 plan trade day가 일치하며, `execution_trade_day_allowed=True`, liquidity/pre-trade risk/order result/fill/position reconciliation complete 및 plan/execution params hash 일치를 만족한 날만 승격 카운트에 들어갑니다. 60영업일 전체 verified pilot evidence는 하나의 params hash로 고정되어야 하며, live gate도 canonical metadata의 params hash와 paper evidence params hash가 다르면 target-weight live 전환을 차단합니다. diff --git a/core/paper_evidence.py b/core/paper_evidence.py index cb4b96fe..e792472a 100644 --- a/core/paper_evidence.py +++ b/core/paper_evidence.py @@ -1193,6 +1193,7 @@ def _target_weight_record_proof_status(strategy: str, record: dict) -> tuple[boo required_execution_flags = ( "complete", + "execution_trade_day_allowed", "liquidity_complete", "pre_trade_risk_complete", "order_result_complete", diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 1e68b1e0..cea2222a 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -709,7 +709,7 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | ✅ **target-weight rank-hysteresis 후보 추가** | `hold_rank_buffer`로 기존 보유 종목이 top-N 밖으로 소폭 밀려도 버퍼 안이면 유지. best=`target_weight_rotation_top5_60_120_floor0_hold3`, return=+278.57%, raw excess=+129.18%p, Sharpe=1.65, WF positive/Sh+ 100%, turnover/year=807.8%. turnover 병목은 해소했지만 MDD=-28.25%라 research-only | | ✅ **target-weight benchmark-risk overlay 후보 추가** | KS11 SMA/낙폭/변동성 risk-off 구간에 부분 노출을 줄이는 후보 6개 추가. best=`target_weight_rotation_top5_60_120_floor0_hold3_risk60_35`, return=+210.24%, raw excess=+60.85%p, exposure-matched excess=+130.96%p, Sharpe=1.60, PF=5.73, MDD=-19.24%, turnover/year=858.0%, WF positive/Sh+ 100%로 research sweep 기준 `provisional_paper_candidate` 도달 | | ✅ **target-weight canonical bridge 추가** | `tools/evaluate_and_promote.py --canonical`이 `target_weight_rotation_top5_60_120_floor0_hold3_risk60_35`를 동일 후보 ID/params hash로 재평가하고 `reports/promotion/*` canonical bundle에 기록. `promotion_result.json`에서 `provisional_paper_candidate` 확인 | -| ✅ **target-weight paper/pilot adapter 추가** | `core/target_weight_rotation.py` + `tools/target_weight_rotation_pilot.py`로 직전 거래일 점수 기반 목표비중 plan을 만들고 pilot cap을 plan-level로 검증. `OrderExecutor.execute_buy_quantity()`로 paper-only exact quantity 매수를 지원. 실행형 `pilot_paper` evidence는 같은 candidate/trade_day의 기존 pilot session artifact가 없고, 주문 제출 직전 실제 paper position이 계획 입력 장부 `position_quantities_before`와 일치하고, 유동성 preflight와 비용 반영 pre-trade risk 검증을 통과하고, 계획 주문 전부 성공(`executed == planned`, failed/skipped/halted 없음) 후 주문 결과 payload와 당일 `TradeHistory` fill 집계의 종목/방향/수량이 plan과 일치하며, 실제 paper position 전체가 리밸런싱 후 `target_quantities_after` 장부와 일치하고 계획 밖 양수 포지션이 없을 때만 수집한다. 같은 날짜 evidence가 이미 있으면 canonical record가 `pilot_paper`, `pilot_authorized=True`, execution-backed, target-weight plan/params hash 일치, liquidity/pre-trade-risk/order/fill/position complete 조건을 만족할 때만 재사용한다. 중복 실행은 기본 차단하며 명시 재시도는 `--allow-rerun`으로만 허용한다. 실행 전 포지션 드리프트/유동성 preflight 실패/pre-trade risk 실패/부분 실행/중단/주문 결과 불일치/체결 기록 불일치/기존 evidence 검증 실패/포지션 불일치는 세션 artifact에 차단 사유를 남긴 뒤 승격 증거에서 제외한다. live 모드는 계속 거부 | +| ✅ **target-weight paper/pilot adapter 추가** | `core/target_weight_rotation.py` + `tools/target_weight_rotation_pilot.py`로 직전 거래일 점수 기반 목표비중 plan을 만들고 pilot cap을 plan-level로 검증. `OrderExecutor.execute_buy_quantity()`로 paper-only exact quantity 매수를 지원. 실행형 `pilot_paper` evidence는 같은 candidate/trade_day의 기존 pilot session artifact가 없고, 주문 제출 직전 실제 paper position이 계획 입력 장부 `position_quantities_before`와 일치하고, `execution_trade_day_allowed=True`, 유동성 preflight와 비용 반영 pre-trade risk 검증을 통과하고, 계획 주문 전부 성공(`executed == planned`, failed/skipped/halted 없음) 후 주문 결과 payload와 당일 `TradeHistory` fill 집계의 종목/방향/수량이 plan과 일치하며, 실제 paper position 전체가 리밸런싱 후 `target_quantities_after` 장부와 일치하고 계획 밖 양수 포지션이 없을 때만 수집한다. 같은 날짜 evidence가 이미 있으면 canonical record가 `pilot_paper`, `pilot_authorized=True`, execution-backed, target-weight plan/params hash 일치, execution trade-day allowed, liquidity/pre-trade-risk/order/fill/position complete 조건을 만족할 때만 재사용한다. 중복 실행은 기본 차단하며 명시 재시도는 `--allow-rerun`으로만 허용한다. 실행 전 포지션 드리프트/실행일 불일치/유동성 preflight 실패/pre-trade risk 실패/부분 실행/중단/주문 결과 불일치/체결 기록 불일치/기존 evidence 검증 실패/포지션 불일치는 세션 artifact에 차단 사유를 남긴 뒤 승격 증거에서 제외한다. live 모드는 계속 거부 | | ✅ **target-weight shadow proof 추가** | dry-run에서 `--record-shadow-evidence`를 켜면 `append_shadow_plan_evidence()`가 non-promotable `shadow_bootstrap` record를 남김. `execution_backed=False`, excess=null이라 promotion은 오염하지 않고, launch readiness의 clean final day만 채운다. `--shadow-days 3` 또는 `--shadow-start-date/--shadow-end-date`로 여러 날짜 shadow bootstrap을 한 번에 누적할 수 있고, `--shadow-days N`은 휴장/데이터 공백으로 같은 거래일에 매핑되면 과거 평일을 추가 스캔해 N개 고유 resolved trade_day 충족을 목표로 한다. 목표 미달 또는 날짜별 실패가 있으면 CLI는 non-zero로 종료해 불완전한 증거가 자동화에서 성공으로 처리되지 않게 한다. 같은 실행에서 launch readiness JSON/MD와 pilot runbook을 생성하며 session/batch artifact에는 기본 cap preview, plan 기반 최소/추천 cap, enable 명령, launch artifact 경로를 기록 | | ✅ **target-weight readiness audit 추가** | `tools/target_weight_rotation_pilot.py --readiness-audit`로 주문 제출, shadow evidence 기록, pilot session 저장 없이 capped pilot 직전 상태를 JSON artifact와 Markdown 운영 리포트로 점검. clean shadow/launch readiness, active pilot auth와 cap validation, 추천 cap, 중복 session idempotency, 실행 전 position drift, 유동성 preflight, 비용 반영 pre-trade risk, 다음 조치(`cap approval` 또는 `execute capped paper pilot`)를 함께 판정한다. Markdown 리포트는 shadow 수집/audit 재실행/추천 cap 승인/capped paper 실행 명령을 함께 남기며, cap 승인 준비가 안 된 blocker는 non-zero 종료로 자동화가 진행하지 못하게 한다 | | ✅ **target-weight 60영업일 pilot manifest 추가** | `tools/target_weight_rotation_pilot.py --readiness-audit`가 60영업일 `pilot_paper` 증거 누적 기준, 추천 cap, 차단 사유, 실행 명령을 담은 `target_weight_paper_experiment_manifest_*.json`을 함께 생성한다. `reports/paper_experiment_manifest.json`에도 기존 scoring 실험과 별도 target-weight capped paper pilot 기준을 기록해 운영 흐름을 분리한다 | @@ -722,6 +722,7 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | ✅ **target-weight liquidity preflight 추가** | `core.target_weight_rotation.build_target_weight_plan()`이 주문 종목별 최근 20일 평균 거래대금을 plan diagnostics에 기록하고, `tools/target_weight_rotation_pilot.py`가 주문별 notional이 평균 거래대금의 기본 5%(`--max-order-adv-pct`)를 초과하거나 유동성 diagnostics가 누락되면 readiness audit과 `--execute`를 fail-closed 차단한다. 차단 결과는 session artifact, readiness JSON/MD, pilot session의 `target_weight_execution.liquidity_check`에 남기며 기존 pilot evidence 재사용도 liquidity-complete 조건을 요구한다 | | ✅ **target-weight pre-trade risk 추가** | `RiskManager.calculate_transaction_costs()`의 수수료/세금/동적 슬리피지 예상 체결가를 재사용해 plan 전체 주문을 제출 전 시뮬레이션한다. 예상 현금 부족, 최소 현금비중, 최대 투자비중, 종목별 최대 비중, 최대 보유 종목 수 위반은 readiness audit과 `--execute`에서 fail-closed 차단하고, session/readiness/pilot evidence snapshot에 `pre_trade_risk_check`와 cost summary를 남긴다. 매도 주문은 plan diagnostics의 기존 평균매입가를 사용해 세금/양도세 옵션과도 연결된다 | | ✅ **target-weight completed rerun block 추가** | same-candidate/trade-day pilot session artifact가 이미 `execution_complete=True`이고 실제 주문 실행 수량이 있으면 `--allow-rerun`을 줘도 재실행을 차단한다. `--allow-rerun`은 부분 실행/중단 세션 복구에만 사용해 완료된 실행 증거가 중복 주문으로 오염되지 않게 한다 | +| ✅ **target-weight 실행일 guard 추가** | `--execute`는 `plan.trade_day`와 KST 기준 실제 실행일이 다르면 주문 제출, 실행 전 포지션 조회, 체결 대조, pilot evidence 수집 전에 fail-closed로 차단한다. 차단 사유와 `execution_trade_day_check`는 session artifact와 target-weight execution proof에 남겨 stale `--as-of-date` 실행이 승격 증거를 오염하지 않게 한다 | | ✅ **pilot entry fail-closed audit 추가** | `check_pilot_entry()`의 모든 blocked/allowed 결과를 `pilot_audit.jsonl`에 기록하고, runtime/evidence/notifier/order-count/position-count/gross-exposure guard 예외는 pilot entry 차단으로 처리 | | ✅ **generic paper entry guard 추가** | `main.py --mode paper`, scheduler auto-entry, `execute_buy_quantity()` 모두 preflight/runtime 확인 실패 시 BUY를 fail-closed 차단. blocked runtime의 pilot override는 `check_pilot_entry()` 재검증을 통과해야 하며 SELL은 exit-safe 유지 | | ✅ **canonical 평가 입력 snapshot 추가** | `tools/evaluate_and_promote.py --canonical`이 선정 종목의 유동성 데이터 범위, 벤치마크 데이터 범위, 데이터 수집 오류, `data_snapshot_hash`를 `run_metadata.json`에 기록한다. 평가 예외는 단순 0% 성과와 구분해 `evaluation_status=failed`로 남긴다 | @@ -855,4 +856,4 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 > 📌 **상세 설계·지표 공식·전략 로직·시스템 진단**: `quant_trader_design.md` > **문서 버전**: v5.2 -> **최종 수정**: 2026-05-07 (target-weight promotion proof guard 반영) +> **최종 수정**: 2026-05-07 (target-weight 실행일 guard 반영) diff --git a/reports/daily_ops_checklist.md b/reports/daily_ops_checklist.md index 98db7ebf..7236cd63 100644 --- a/reports/daily_ops_checklist.md +++ b/reports/daily_ops_checklist.md @@ -52,6 +52,7 @@ - [ ] Verified pilot days 확인: ____ / 60 - [ ] Invalid execution days가 0인지 확인 - [ ] Liquidity / Pre-trade risk가 PASS인지 확인 +- [ ] capped paper 실행 전 `plan.trade_day`와 KST 기준 실행일이 같은지 확인 - [ ] 다음 명령이 cap 승인인지, capped paper 실행인지 기록 ### 기록 diff --git a/reports/paper_experiment_manifest.json b/reports/paper_experiment_manifest.json index 61c9cf8f..fd94fa73 100644 --- a/reports/paper_experiment_manifest.json +++ b/reports/paper_experiment_manifest.json @@ -54,6 +54,7 @@ }, "required_execution_checks": [ "params_hash_match", + "execution_trade_day_allowed", "pre_execution_positions_complete", "liquidity_complete", "pre_trade_risk_complete", diff --git a/tests/test_paper_evidence.py b/tests/test_paper_evidence.py index 6d06ff20..7c5ee38a 100644 --- a/tests/test_paper_evidence.py +++ b/tests/test_paper_evidence.py @@ -1374,6 +1374,7 @@ def test_target_weight_promotion_counts_verified_pilot_execution(self, evidence_ "target_weight_execution": { "complete": True, "params_hash": params_hash, + "execution_trade_day_allowed": True, "liquidity_complete": True, "pre_trade_risk_complete": True, "order_result_complete": True, @@ -1436,6 +1437,7 @@ def test_target_weight_promotion_blocks_mixed_params_hash(self, evidence_dir): "target_weight_execution": { "complete": True, "params_hash": params_hash, + "execution_trade_day_allowed": True, "liquidity_complete": True, "pre_trade_risk_complete": True, "order_result_complete": True, @@ -1497,6 +1499,7 @@ def test_target_weight_promotion_blocks_trade_day_mismatch(self, evidence_dir): "target_weight_execution": { "complete": True, "params_hash": params_hash, + "execution_trade_day_allowed": True, "liquidity_complete": True, "pre_trade_risk_complete": True, "order_result_complete": True, diff --git a/tests/test_target_weight_paper_adapter.py b/tests/test_target_weight_paper_adapter.py index 85e2f95d..47d4102c 100644 --- a/tests/test_target_weight_paper_adapter.py +++ b/tests/test_target_weight_paper_adapter.py @@ -2,6 +2,7 @@ import subprocess import sys from dataclasses import replace +from datetime import datetime from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -64,6 +65,10 @@ def _params(): } +def _plan_execution_now(): + return datetime(2026, 4, 10, 9, 0) + + def _adapter_plan(): from core.target_weight_rotation import TargetWeightOrder, TargetWeightPlan @@ -194,6 +199,30 @@ def _complete_fills(plan): ] +def test_validate_execution_trade_day_allows_same_kst_day(): + from tools.target_weight_rotation_pilot import validate_execution_trade_day + + check = validate_execution_trade_day(_adapter_plan(), now=_plan_execution_now()) + + assert check["allowed"] is True + assert check["execution_day"] == "2026-04-10" + assert check["plan_trade_day"] == "2026-04-10" + + +def test_validate_execution_trade_day_blocks_stale_plan(): + from tools.target_weight_rotation_pilot import validate_execution_trade_day + + check = validate_execution_trade_day( + _adapter_plan(), + now=datetime(2026, 4, 11, 9, 0), + ) + + assert check["allowed"] is False + assert check["complete"] is False + assert check["execution_day"] == "2026-04-11" + assert "target_weight_execution_trade_day_mismatch" in check["reason"] + + def _existing_pilot_evidence_record(plan): return { "date": plan.trade_day, @@ -213,6 +242,7 @@ def _existing_pilot_evidence_record(plan): "params_hash": plan.params_hash, "planned_orders": len(plan.orders), "idempotency_allowed": True, + "execution_trade_day_allowed": True, "pre_execution_complete": True, "liquidity_complete": True, "pre_trade_risk_complete": True, @@ -992,6 +1022,24 @@ def test_verify_existing_pilot_evidence_rejects_missing_pre_trade_risk(monkeypat } +def test_verify_existing_pilot_evidence_rejects_missing_execution_trade_day_check(monkeypatch): + import core.paper_evidence as pe + from tools.target_weight_rotation_pilot import verify_existing_pilot_evidence_record + + plan = _adapter_plan() + record = _existing_pilot_evidence_record(plan) + del record["pilot_caps_snapshot"]["target_weight_execution"]["execution_trade_day_allowed"] + monkeypatch.setattr(pe, "get_canonical_records", lambda strategy: [record]) + + verification = verify_existing_pilot_evidence_record(plan) + + assert verification["valid"] is False + assert "target_weight_existing_evidence_invalid" in verification["reason"] + assert {item["field"] for item in verification["mismatches"]} == { + "target_weight_execution.execution_trade_day_allowed" + } + + def test_preview_plan_against_caps_flags_default_pilot_caps(): from tools.target_weight_rotation_pilot import build_preview_caps, preview_plan_against_caps @@ -1064,6 +1112,7 @@ def test_build_target_weight_experiment_manifest_freezes_pilot_flow(): assert manifest["candidate_snapshot"]["params_hash"] == plan.params_hash assert manifest["evidence_policy"]["required_provenance"]["evidence_mode"] == "pilot_paper" assert manifest["evidence_policy"]["target_weight_execution_required"]["fill_complete"] is True + assert manifest["evidence_policy"]["target_weight_execution_required"]["execution_trade_day_allowed"] is True assert "shadow_bootstrap" in manifest["evidence_policy"]["blocked_evidence"] assert manifest["risk_controls"]["liquidity_max_order_adv_pct"] == 3.5 assert manifest["current_decision"]["ready_for_cap_approval"] is True @@ -1101,6 +1150,7 @@ def test_summarize_target_weight_evidence_progress_counts_verified_days(monkeypa "target_weight_execution": { "complete": True, "params_hash": params_hash, + "execution_trade_day_allowed": True, "liquidity_complete": True, "pre_trade_risk_complete": True, "order_result_complete": True, @@ -1914,6 +1964,7 @@ def test_run_pilot_blocks_evidence_when_execution_incomplete(monkeypatch, tmp_pa collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) @@ -1975,6 +2026,7 @@ def test_run_pilot_blocks_evidence_when_position_reconciliation_fails(monkeypatc collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) reconciliation = result["execution_evidence"]["position_reconciliation"] @@ -2039,6 +2091,7 @@ def test_run_pilot_blocks_evidence_when_order_result_reconciliation_fails(monkey collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) reconciliation = result["execution_evidence"]["order_result_reconciliation"] @@ -2106,6 +2159,7 @@ def test_run_pilot_blocks_evidence_when_fill_reconciliation_fails(monkeypatch, t collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) reconciliation = result["execution_evidence"]["fill_reconciliation"] @@ -2174,6 +2228,7 @@ def test_run_pilot_records_artifact_when_pilot_cap_validation_blocks(monkeypatch collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) artifact = json.loads(Path(result["artifact_path"]).read_text(encoding="utf-8")) @@ -2240,6 +2295,7 @@ def test_run_pilot_blocks_order_submission_when_starting_positions_drift(monkeyp collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) pre_reconciliation = result["execution_evidence"]["pre_execution_reconciliation"] @@ -2324,6 +2380,7 @@ def test_run_pilot_blocks_order_submission_when_liquidity_preflight_fails(monkey collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) assert result["liquidity_check"]["complete"] is False @@ -2398,6 +2455,7 @@ def test_run_pilot_blocks_order_submission_when_pre_trade_risk_fails(monkeypatch collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) assert result["pre_trade_risk_check"]["complete"] is False @@ -2411,6 +2469,85 @@ def test_run_pilot_blocks_order_submission_when_pre_trade_risk_fails(monkeypatch assert saved_sessions[0]["target_weight_execution"]["pre_trade_risk_complete"] is False +def test_run_pilot_blocks_stale_trade_day_before_order_submission(monkeypatch, tmp_path): + import core.paper_evidence as pe + import core.paper_pilot as pp + import tools.target_weight_rotation_pilot as twp + + plan = _adapter_plan() + monkeypatch.setattr(pp, "RUNTIME_DIR", tmp_path / "paper_runtime") + monkeypatch.setattr(twp, "build_plan", lambda **kwargs: plan) + monkeypatch.setattr( + pp, + "check_pilot_entry", + lambda *args, **kwargs: SimpleNamespace( + allowed=True, + reason="ok", + remaining_orders=10, + remaining_exposure=10_000_000, + caps_snapshot={ + "max_orders_per_day": 10, + "max_concurrent_positions": 10, + "max_notional_per_trade": 2_000_000, + "max_gross_exposure": 10_000_000, + }, + ), + ) + monkeypatch.setattr( + pp, + "save_pilot_session_artifact", + lambda **kwargs: pytest.fail("stale trade day must not write runtime pilot session"), + ) + monkeypatch.setattr( + twp, + "check_execution_idempotency", + lambda *args, **kwargs: pytest.fail("stale trade day must block before idempotency"), + ) + monkeypatch.setattr( + twp, + "execute_plan", + lambda *args, **kwargs: pytest.fail("stale trade day must not submit orders"), + ) + monkeypatch.setattr( + twp, + "_load_positions", + lambda account_key: pytest.fail("stale trade day must not read positions"), + ) + monkeypatch.setattr( + twp, + "load_paper_trade_fills", + lambda plan: pytest.fail("stale trade day must not reconcile fills"), + ) + monkeypatch.setattr( + pe, + "collect_daily_evidence", + lambda **kwargs: pytest.fail("stale trade day must not collect pilot evidence"), + ) + + result = twp.run_pilot( + execute=True, + collect_evidence=True, + as_of_date="2026-04-10", + output_dir=tmp_path / "sessions", + config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=datetime(2026, 4, 11, 9, 0), + ) + payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) + + assert result["execution_trade_day_check"]["allowed"] is False + assert result["execution_idempotency"] is None + assert result["execution"]["executed"] == 0 + assert result["execution"]["skipped"] == len(plan.orders) + assert result["execution"]["halted"] is True + assert result["execution"]["details"][0]["status"] == "skipped_execution_trade_day_mismatch" + assert result["execution_evidence"]["execution_trade_day_allowed"] is False + assert result["execution_evidence"]["complete"] is False + assert result["evidence_collection"]["status"] == "blocked" + assert "target_weight_execution_trade_day_mismatch" in result["evidence_collection"]["reason"] + assert payload["execution_trade_day_check"]["allowed"] is False + assert payload["execution"]["execution_trade_day_check"]["allowed"] is False + + def test_run_pilot_blocks_duplicate_execute_session(monkeypatch, tmp_path): import core.paper_evidence as pe import core.paper_pilot as pp @@ -2477,6 +2614,7 @@ def test_run_pilot_blocks_duplicate_execute_session(monkeypatch, tmp_path): collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) @@ -2586,6 +2724,7 @@ def test_run_pilot_blocks_completed_rerun_even_when_allowed(monkeypatch, tmp_pat allow_rerun=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) assert result["execution_idempotency"]["allowed"] is False @@ -2668,6 +2807,7 @@ def test_run_pilot_allows_duplicate_session_with_explicit_rerun(monkeypatch, tmp allow_rerun=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) assert result["execution_idempotency"]["allowed"] is True @@ -2735,6 +2875,7 @@ def collect_daily_evidence(**kwargs): collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) assert result["execution_evidence"]["complete"] is True @@ -2806,6 +2947,7 @@ def test_run_pilot_accepts_verified_already_recorded_pilot_evidence(monkeypatch, collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) @@ -2867,6 +3009,7 @@ def test_run_pilot_blocks_unverified_already_recorded_evidence(monkeypatch, tmp_ collect_evidence=True, output_dir=tmp_path / "sessions", config=SimpleNamespace(trading={"mode": "paper"}), + execution_now=_plan_execution_now(), ) payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) diff --git a/tools/target_weight_rotation_pilot.py b/tools/target_weight_rotation_pilot.py index 8d20d805..be853850 100644 --- a/tools/target_weight_rotation_pilot.py +++ b/tools/target_weight_rotation_pilot.py @@ -10,7 +10,7 @@ import os import sys from dataclasses import asdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -41,6 +41,7 @@ DEFAULT_LIQUIDITY_LOOKBACK_DAYS = 20 DEFAULT_MAX_ORDER_ADV_PCT = 5.0 TARGET_WEIGHT_PILOT_TARGET_DAYS = 60 +KST = timezone(timedelta(hours=9)) def _stable_manifest_hash(payload: dict[str, Any]) -> str: @@ -78,6 +79,48 @@ def _date_range(start_date: str, end_date: str) -> list[str]: return dates +def _execution_day(now: datetime | None = None) -> str: + current = now or datetime.now(KST) + if current.tzinfo is not None: + current = current.astimezone(KST) + return current.date().strftime("%Y-%m-%d") + + +def execution_trade_day_check_not_required() -> dict[str, Any]: + return { + "checked": False, + "allowed": True, + "complete": True, + "reason": "execution trade-day check not required", + } + + +def validate_execution_trade_day( + plan: TargetWeightPlan, + *, + now: datetime | None = None, +) -> dict[str, Any]: + execution_day = _execution_day(now) + allowed = plan.trade_day == execution_day + reason = "target-weight execution trade day matches current KST date" + if not allowed: + reason = ( + "target_weight_execution_trade_day_mismatch: " + f"plan_trade_day={plan.trade_day} execution_day={execution_day} " + f"plan_as_of_date={plan.as_of_date}; rerun with current market data before --execute" + ) + return { + "checked": True, + "allowed": allowed, + "complete": allowed, + "reason": reason, + "plan_trade_day": plan.trade_day, + "plan_as_of_date": plan.as_of_date, + "execution_day": execution_day, + "timezone": "Asia/Seoul", + } + + def _recent_weekday_dates(end_date: str, count: int) -> list[str]: if count <= 0: raise ValueError("shadow days must be positive") @@ -773,6 +816,32 @@ def blocked_execution_for_pilot_validation( } +def blocked_execution_for_trade_day_mismatch( + plan: TargetWeightPlan, + execution_trade_day_check: dict[str, Any], +) -> dict[str, Any]: + reason = execution_trade_day_check.get( + "reason", + "target_weight_execution_trade_day_mismatch", + ) + return { + "executed": 0, + "skipped": len(plan.orders), + "failed": 0, + "halted": True, + "halt_reason": reason, + "execution_trade_day_check": execution_trade_day_check, + "details": [ + { + "order": asdict(order), + "status": "skipped_execution_trade_day_mismatch", + "reason": reason, + } + for order in plan.orders + ], + } + + def blocked_execution_for_liquidity( plan: TargetWeightPlan, liquidity_check: dict[str, Any], @@ -1080,6 +1149,7 @@ def failed_fill_reconciliation(plan: TargetWeightPlan, error: Exception) -> dict def summarize_execution_for_evidence( plan: TargetWeightPlan, execution: dict[str, Any], + execution_trade_day_check: dict[str, Any] | None = None, execution_idempotency: dict[str, Any] | None = None, pre_execution_reconciliation: dict[str, Any] | None = None, liquidity_check: dict[str, Any] | None = None, @@ -1113,6 +1183,11 @@ def summarize_execution_for_evidence( "allowed": True, "reason": "execution idempotency check not required", } + trade_day_check = ( + execution_trade_day_check + or execution.get("execution_trade_day_check") + or execution_trade_day_check_not_required() + ) pre_reconciliation = pre_execution_reconciliation or execution.get("pre_execution_reconciliation") or { "checked": False, "complete": True, @@ -1147,6 +1222,7 @@ def summarize_execution_for_evidence( "unexpected_positions": [], } idempotency_allowed = bool(idempotency.get("allowed", False)) + execution_trade_day_allowed = bool(trade_day_check.get("allowed", False)) pre_execution_complete = bool(pre_reconciliation.get("complete", False)) liquidity_complete = bool(liquidity.get("complete", False)) pre_trade_risk_complete = bool(pre_trade_risk.get("complete", False)) @@ -1154,6 +1230,7 @@ def summarize_execution_for_evidence( position_complete = bool(reconciliation.get("complete", False)) complete = ( idempotency_allowed + and execution_trade_day_allowed and pre_execution_complete and liquidity_complete and pre_trade_risk_complete @@ -1164,6 +1241,8 @@ def summarize_execution_for_evidence( reason = "all planned target-weight orders executed" if not idempotency_allowed: reason = idempotency.get("reason", "target_weight_duplicate_execution_attempt") + elif not execution_trade_day_allowed: + reason = trade_day_check.get("reason", "target_weight_execution_trade_day_mismatch") elif not pre_execution_complete: reason = pre_reconciliation.get("reason", "target_weight_pre_execution_position_drift") elif not liquidity_complete: @@ -1189,6 +1268,7 @@ def summarize_execution_for_evidence( "complete": complete, "reason": reason, "idempotency_allowed": idempotency_allowed, + "execution_trade_day_allowed": execution_trade_day_allowed, "pre_execution_complete": pre_execution_complete, "liquidity_complete": liquidity_complete, "pre_trade_risk_complete": pre_trade_risk_complete, @@ -1202,6 +1282,7 @@ def summarize_execution_for_evidence( "skipped_orders": skipped, "halted": halted, "halt_reason": execution.get("halt_reason", ""), + "execution_trade_day_check": trade_day_check, "execution_idempotency": idempotency, "pre_execution_reconciliation": pre_reconciliation, "liquidity_check": liquidity, @@ -1221,6 +1302,7 @@ def build_pilot_evidence_caps_snapshot( plan: TargetWeightPlan, validation: Any, execution: dict[str, Any], + execution_trade_day_check: dict[str, Any] | None = None, execution_idempotency: dict[str, Any] | None = None, pre_execution_reconciliation: dict[str, Any] | None = None, liquidity_check: dict[str, Any] | None = None, @@ -1246,6 +1328,7 @@ def build_pilot_evidence_caps_snapshot( caps["target_weight_execution"] = summarize_execution_for_evidence( plan, execution, + execution_trade_day_check=execution_trade_day_check, execution_idempotency=execution_idempotency, pre_execution_reconciliation=pre_execution_reconciliation, liquidity_check=liquidity_check, @@ -1314,6 +1397,11 @@ def verify_existing_pilot_evidence_record(plan: TargetWeightPlan) -> dict[str, A ("target_weight_execution.complete", target_execution.get("complete"), True), ("target_weight_execution.planned_orders", target_execution.get("planned_orders"), len(plan.orders)), ("target_weight_execution.idempotency_allowed", target_execution.get("idempotency_allowed"), True), + ( + "target_weight_execution.execution_trade_day_allowed", + target_execution.get("execution_trade_day_allowed"), + True, + ), ("target_weight_execution.pre_execution_complete", target_execution.get("pre_execution_complete"), True), ("target_weight_execution.liquidity_complete", target_execution.get("liquidity_complete"), True), ("target_weight_execution.pre_trade_risk_complete", target_execution.get("pre_trade_risk_complete"), True), @@ -1363,6 +1451,7 @@ def write_session_artifact( pre_trade_risk_check: dict[str, Any], execution: dict[str, Any], dry_run: bool, + execution_trade_day_check: dict[str, Any] | None = None, execution_idempotency: dict[str, Any] | None = None, fill_reconciliation: dict[str, Any] | None = None, shadow_evidence: dict[str, Any] | None = None, @@ -1384,6 +1473,9 @@ def write_session_artifact( "liquidity_check": liquidity_check, "pre_trade_risk_check": pre_trade_risk_check, "execution": execution, + "execution_trade_day_check": ( + execution_trade_day_check or execution_trade_day_check_not_required() + ), "execution_idempotency": execution_idempotency or {"checked": False}, "fill_reconciliation": fill_reconciliation or {"checked": False}, "shadow_evidence": shadow_evidence or {"attempted": False, "recorded": False}, @@ -2094,6 +2186,7 @@ def build_target_weight_experiment_manifest( }, "target_weight_execution_required": { "params_hash_match": True, + "execution_trade_day_allowed": True, "pre_execution_positions_complete": True, "liquidity_complete": True, "pre_trade_risk_complete": True, @@ -2928,6 +3021,7 @@ def run_pilot( output_dir: Path = DEFAULT_OUTPUT_DIR, config: Any | None = None, collector: Any | None = None, + execution_now: datetime | None = None, ) -> dict[str, Any]: from config.config_loader import Config from core.paper_pilot import check_pilot_entry, save_pilot_session_artifact @@ -2969,15 +3063,24 @@ def run_pilot( pre_trade_risk_check = failed_pre_trade_risk_validation(plan, exc) dry_run = not execute + execution_trade_day_check = execution_trade_day_check_not_required() + if execute: + execution_trade_day_check = validate_execution_trade_day(plan, now=execution_now) + execution_idempotency = None - if execute and validation.allowed: + if execute and validation.allowed and execution_trade_day_check["allowed"]: execution_idempotency = check_execution_idempotency( plan, allow_rerun=allow_rerun, ) pre_execution_reconciliation = None - if execute and execution_idempotency and execution_idempotency["allowed"]: + if ( + execute + and execution_trade_day_check["allowed"] + and execution_idempotency + and execution_idempotency["allowed"] + ): try: pre_execution_reconciliation = reconcile_plan_starting_positions( plan, @@ -2992,6 +3095,8 @@ def run_pilot( if execute and not validation.allowed: execution = blocked_execution_for_pilot_validation(plan, validation) + elif execute and not execution_trade_day_check["allowed"]: + execution = blocked_execution_for_trade_day_mismatch(plan, execution_trade_day_check) elif execute and execution_idempotency and not execution_idempotency["allowed"]: execution = blocked_execution_for_duplicate_execution(plan, execution_idempotency) elif execute and pre_execution_reconciliation and not pre_execution_reconciliation["complete"]: @@ -3018,6 +3123,7 @@ def run_pilot( if ( execute and validation.allowed + and execution_trade_day_check["allowed"] and (execution_idempotency is None or execution_idempotency["allowed"]) and (pre_execution_reconciliation is None or pre_execution_reconciliation["complete"]) and liquidity_check["complete"] @@ -3042,6 +3148,7 @@ def run_pilot( execution_evidence = summarize_execution_for_evidence( plan, execution, + execution_trade_day_check=execution_trade_day_check, execution_idempotency=execution_idempotency, pre_execution_reconciliation=pre_execution_reconciliation, liquidity_check=liquidity_check, @@ -3056,6 +3163,7 @@ def run_pilot( plan, validation, execution, + execution_trade_day_check=execution_trade_day_check, execution_idempotency=execution_idempotency, pre_execution_reconciliation=pre_execution_reconciliation, liquidity_check=liquidity_check, @@ -3076,7 +3184,11 @@ def run_pilot( "evidence_block_reason": "" if execution_evidence["complete"] else execution_evidence["reason"], "target_weight_execution": execution_evidence, } - if validation.allowed and (execution_idempotency is None or execution_idempotency["allowed"]): + if ( + validation.allowed + and execution_trade_day_check["allowed"] + and (execution_idempotency is None or execution_idempotency["allowed"]) + ): save_pilot_session_artifact( strategy=candidate_id, date=plan.trade_day, @@ -3159,6 +3271,7 @@ def run_pilot( pre_trade_risk_check=pre_trade_risk_check, execution=execution, dry_run=dry_run, + execution_trade_day_check=execution_trade_day_check, execution_idempotency=execution_idempotency, fill_reconciliation=fill_reconciliation, shadow_evidence=shadow_evidence_summary, @@ -3176,6 +3289,7 @@ def run_pilot( "liquidity_check": liquidity_check, "pre_trade_risk_check": pre_trade_risk_check, "execution": execution, + "execution_trade_day_check": execution_trade_day_check, "execution_idempotency": execution_idempotency, "fill_reconciliation": fill_reconciliation, "execution_evidence": execution_evidence,