diff --git a/README.md b/README.md index df433fe0..f6fd7e78 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,9 @@ Paper Evidence 체계 — `core/paper_evidence.py` 일별 22개 지표 자동 현재 scoring은 관찰용 paper_only로 강등되었습니다. 신규 우선 실험은 risk-adjusted 기준을 통과한 후보만 대상으로 하며, Paper Evidence 체계로 승격/강등 근거를 자동 수집합니다. Paper 운영 도구: `tools/run_paper_evidence_pipeline.py` (backfill/finalize/package), `tools/paper_preflight.py`, `tools/paper_launch_readiness.py`, `tools/paper_pilot_control.py`, `tools/research_candidate_sweep.py`, `tools/target_weight_rotation_pilot.py`. target-weight 후보는 capped pilot 승인 전 `tools/target_weight_rotation_pilot.py --record-shadow-evidence`, `--shadow-days 3`, 또는 `--shadow-start-date YYYY-MM-DD --shadow-end-date YYYY-MM-DD`로 dry-run plan artifact, shadow readiness evidence, launch readiness artifact, plan 기반 cap 추천과 enable 명령이 포함된 pilot runbook을 먼저 누적합니다. 이후 `--readiness-audit`로 주문 제출이나 shadow/pilot evidence 기록 없이 clean shadow, launch readiness, active pilot caps, 중복 세션, 실행 전 포지션 드리프트, 추천 cap 충족 여부, 최근 평균 거래대금 대비 주문 비율, 비용 반영 후 현금/투자비중 한도를 JSON artifact와 Markdown 운영 리포트로 점검합니다. 유동성 preflight는 기본적으로 주문별 20일 평균 거래대금의 5% 초과를 차단하며 `--max-order-adv-pct`로 조정할 수 있습니다. pre-trade risk 검증은 주문별 수수료/세금/동적 슬리피지를 반영한 예상 체결가로 현금 부족, 최소 현금비중, 총투자비중, 종목별 비중, 최대 보유 종목 수 위반을 실행 전에 차단합니다. Markdown 리포트에는 shadow 수집, audit 재실행, 추천 cap 승인, capped paper 실행 명령이 함께 기록됩니다. `--shadow-days N`은 휴장/데이터 공백으로 같은 거래일에 매핑되는 경우 과거 평일을 추가 스캔해 N개 고유 resolved trade_day 충족을 목표로 하며, 목표 미달이나 날짜별 실패가 있으면 CLI가 non-zero로 종료해 자동화가 불완전한 증거를 성공으로 처리하지 못하게 합니다. 실행형 pilot evidence는 같은 candidate/trade_day의 기존 pilot session artifact가 없고, 주문 제출 직전 실제 paper position이 계획 입력 장부 `position_quantities_before`와 일치하고, 유동성 preflight와 pre-trade risk 검증을 통과하고, 모든 계획 주문이 성공하며, 성공 주문 결과와 당일 `TradeHistory` fill 집계의 종목/방향/수량이 계획과 일치하고, 실행 후 실제 paper position 전체가 리밸런싱 후 `target_quantities_after` 장부와 일치하고 계획 밖 양수 포지션이 없을 때만 `pilot_paper`로 수집됩니다. 같은 날짜 evidence가 이미 있으면 기존 canonical record도 `pilot_paper`/authorized/target-weight complete/liquidity-complete/pre-trade-risk-complete/fill-complete 조건을 통과해야 재사용합니다. 중복 실행은 기본 차단하며 운영자가 명시적으로 재시도해야 할 때만 `--allow-rerun`을 사용합니다. 부분 실행/중단/중복 실행/주문 결과 불일치/체결 기록 불일치/기존 evidence 검증 실패/실행 전 포지션 드리프트/유동성 preflight 실패/pre-trade risk 실패/실행 후 포지션 불일치는 execution-backed 승격 증거에서 제외됩니다. -Generic paper entry도 동일한 실행 경계 원칙을 따른다. `main.py --mode paper`, scheduler auto-entry, fixed-quantity paper BUY 모두 preflight status와 runtime state를 주문 생성 전에 확인하고, 확인 실패는 fail-closed로 차단한다. blocked runtime에서 pilot authorization이 활성화되어도 `check_pilot_entry()`를 다시 통과해야 하며, SELL/exit 경로는 포지션 정리를 위해 계속 허용한다. +Target-weight 60영업일 pilot manifest: `--readiness-audit`는 운영 리포트와 함께 `target_weight_paper_experiment_manifest_*.json`을 생성해 후보 snapshot, 추천 cap, 차단 사유, 실행 명령, 승격 증거 조건을 고정합니다. 공식 `reports/paper_experiment_manifest.json`도 기존 scoring 60영업일 실험과 target-weight capped paper pilot을 분리해 기록합니다. + +Generic paper entry도 동일한 실행 경계 원칙을 따른다. `main.py --mode paper`, scheduler auto-entry, fixed-quantity paper BUY 모두 preflight status와 runtime state를 주문 생성 전에 확인하고, 확인 실패는 fail-closed로 차단한다. blocked runtime에서 pilot authorization이 활성화되어도 `check_pilot_entry()`를 다시 통과해야 하며, SELL/exit 경로는 포지션 정리를 위해 계속 허용한다. pilot evidence freshness는 달력일이 아니라 주말과 한국장 휴장일을 제외한 영업일 기준으로 계산합니다. Target-weight pilot 승인/재시도 보강: `tools/paper_pilot_control.py --enable`은 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`은 부분 실행이나 중단된 세션 복구용으로만 사용합니다. diff --git a/core/paper_pilot.py b/core/paper_pilot.py index c96781cd..f55ea259 100644 --- a/core/paper_pilot.py +++ b/core/paper_pilot.py @@ -15,7 +15,7 @@ import json from dataclasses import dataclass, asdict -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Optional @@ -41,6 +41,29 @@ def _coerce_date(value: str | datetime | None = None) -> datetime: return datetime.strptime(value, "%Y-%m-%d") +def _business_days_between(start_date: str, end_date: str) -> int: + """start 다음 날부터 end까지의 한국장 영업일 수를 센다.""" + start = datetime.strptime(start_date, "%Y-%m-%d").date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() + if end <= start: + return 0 + try: + from core.trading_hours import _load_holidays + + holidays = _load_holidays() + except Exception: + holidays = set() + + count = 0 + day = start + timedelta(days=1) + while day <= end: + day_text = day.strftime("%Y-%m-%d") + if day.weekday() < 5 and day_text not in holidays: + count += 1 + day += timedelta(days=1) + return count + + @dataclass class PilotAuthorization: strategy: str @@ -275,13 +298,15 @@ def check_pilot_entry( ) latest_date = eligible[-1].get("date", "") - days_stale = (datetime.strptime(today, "%Y-%m-%d") - - datetime.strptime(latest_date, "%Y-%m-%d")).days if latest_date else 999 + days_stale = _business_days_between(latest_date, today) if latest_date else 999 if days_stale > PILOT_MAX_EVIDENCE_STALE_DAYS: return _pilot_check_result( strategy, allowed=False, - reason=f"evidence stale ({days_stale}d > {PILOT_MAX_EVIDENCE_STALE_DAYS}d) — collect evidence first", + reason=( + f"evidence stale ({days_stale} business days > " + f"{PILOT_MAX_EVIDENCE_STALE_DAYS}) — collect evidence first" + ), auth=auth, caps=caps, ) @@ -733,13 +758,16 @@ def compute_launch_readiness(strategy: str, as_of_date: str | datetime | None = if eligible: evidence_date = eligible[-1].get("date", "") try: - days_stale = (datetime.strptime(today, "%Y-%m-%d") - - datetime.strptime(evidence_date, "%Y-%m-%d")).days + days_stale = _business_days_between(evidence_date, today) evidence_fresh = days_stale <= PILOT_MAX_EVIDENCE_STALE_DAYS except ValueError: pass if not evidence_fresh: - detail = f"stale {days_stale}d" if days_stale is not None else "no evidence" + detail = ( + f"stale {days_stale} business days" + if days_stale is not None + else "no evidence" + ) blockers.append(f"evidence_freshness: {detail}") # ── 3. benchmark final ratio (최근 5일) ── @@ -809,6 +837,7 @@ def compute_launch_readiness(strategy: str, as_of_date: str | datetime | None = "evidence_fresh": evidence_fresh, "evidence_date": evidence_date, "evidence_stale_days": days_stale, + "evidence_stale_unit": "business_days", "benchmark_ready": benchmark_ready, "benchmark_final_ratio": benchmark_final_ratio, "notifier_ready": notifier_ready, @@ -853,7 +882,7 @@ def generate_launch_readiness_artifact(strategy: str) -> tuple[Path, Path]: f">= {lr['clean_final_days_required']}", lr["clean_final_days_current"] >= lr["clean_final_days_required"]), _lr_check("Evidence fresh", lr["evidence_date"] or "N/A", - f"<= {PILOT_MAX_EVIDENCE_STALE_DAYS}d stale", + f"<= {PILOT_MAX_EVIDENCE_STALE_DAYS} business days stale", lr["evidence_fresh"]), _lr_check("Benchmark final ratio", f"{lr['benchmark_final_ratio']:.0%}" if lr['benchmark_final_ratio'] is not None else "N/A", diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index 9b7441a0..81d72fd9 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -712,6 +712,8 @@ full paper 신규 BUY는 preflight status artifact와 runtime state가 모두 | ✅ **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 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 기준을 기록해 운영 흐름을 분리한다 | +| ✅ **pilot freshness 영업일 기준 보정** | `check_pilot_entry()`와 launch readiness가 evidence stale 여부를 달력일이 아니라 한국장 영업일 기준으로 계산한다. 긴 주말·휴장일 이후 정상적인 paper/pilot 흐름이 불필요하게 차단되지 않도록 `evidence_stale_unit=business_days`를 함께 남긴다 | | ✅ **target-weight pilot enable guard 추가** | `tools/paper_pilot_control.py --enable`이 target-weight 후보를 승인할 때 pilot auth 기록 전에 readiness audit을 재실행하고, 운영자가 요청한 cap이 현재 plan/launch readiness/유동성 preflight/비용 반영 pre-trade risk를 만족할 때만 승인한다. stale plan이나 추천 cap 미충족 상태에서 auth만 먼저 열리는 경로를 차단한다 | | ✅ **target-weight cap validation artifact 추가** | `--execute`가 pilot cap validation에서 막혀도 예외로 조기 종료하지 않고 session JSON artifact에 차단 사유, skipped orders, evidence block reason을 남긴다. 주문/체결/증거 수집/runtime pilot session 저장은 하지 않아 cap 조정 후 재점검할 수 있다 | | ✅ **target-weight promotion proof guard 추가** | promotion package와 live gate가 target-weight 계열 전략의 promotable day를 일반 `execution_backed=True` record로 인정하지 않는다. `pilot_paper`/authorized record가 target-weight plan/execution proof를 포함하고 liquidity/pre-trade/order/fill/position complete와 plan/execution params hash 일치를 만족한 날만 승격 카운트에 포함한다 | diff --git a/reports/paper_experiment_manifest.json b/reports/paper_experiment_manifest.json index 1866522a..61c9cf8f 100644 --- a/reports/paper_experiment_manifest.json +++ b/reports/paper_experiment_manifest.json @@ -37,5 +37,38 @@ "signal_only": "python main.py --mode schedule --strategy scoring", "full_paper": "QUANT_AUTO_ENTRY=true python main.py --mode schedule --strategy scoring" }, + "target_weight_pilot": { + "candidate_id": "target_weight_rotation_top5_60_120_floor0_hold3_risk60_35", + "mode": "capped paper pilot", + "paper_days_target": 60, + "status": "canonical provisional_paper_candidate", + "live_mode": "blocked", + "shadow_bootstrap_required_days": 3, + "evidence_policy": { + "promotable_evidence_mode": "pilot_paper", + "required_provenance": { + "execution_backed": true, + "evidence_mode": "pilot_paper", + "session_mode": "pilot_paper", + "pilot_authorized": true + }, + "required_execution_checks": [ + "params_hash_match", + "pre_execution_positions_complete", + "liquidity_complete", + "pre_trade_risk_complete", + "order_result_complete", + "fill_complete", + "position_reconciliation_complete" + ] + }, + "run_modes": { + "shadow_bootstrap": "python tools/target_weight_rotation_pilot.py --shadow-days 3", + "readiness_audit": "python tools/target_weight_rotation_pilot.py --readiness-audit", + "enable_caps": "python tools/paper_pilot_control.py --strategy target_weight_rotation_top5_60_120_floor0_hold3_risk60_35 --enable --from YYYY-MM-DD --to YYYY-MM-DD --max-orders N --max-positions N --max-notional N --max-exposure N --reason \"target-weight capped paper pilot\"", + "execute_capped_paper": "python tools/target_weight_rotation_pilot.py --execute --collect-evidence" + }, + "operator_notes": "기존 scoring 60영업일 실험과 별개로 target-weight 후보의 실행 증거를 누적한다. readiness audit이 생성하는 target_weight_paper_experiment_manifest_*.json의 추천 cap과 차단 사유를 기준으로 승인·실행한다." + }, "operator_notes": "scoring experimental 상태. 모든 전략 검증 미통과. live 전환 불가. 60영업일 관측 후 GoLive 체크." } diff --git a/tests/test_paper_pilot.py b/tests/test_paper_pilot.py index b957ea6e..63b0ffc9 100644 --- a/tests/test_paper_pilot.py +++ b/tests/test_paper_pilot.py @@ -643,6 +643,28 @@ def test_approved_strategies_not_changed(self, evidence_dir, runtime_dir, fresh_ class TestPilotEvidenceFreshness: + def test_business_day_freshness_ignores_weekends_and_holidays(self, evidence_dir, runtime_dir, fresh_db): + """긴 주말/휴장일은 stale 영업일 수에 포함하지 않는다.""" + _seed_v2(evidence_dir, PILOT_STRATEGY, [ + {"date": "2026-01-26", "benchmark_status": "final"}, + ]) + + from core.paper_pilot import enable_pilot, check_pilot_entry, compute_launch_readiness + + (runtime_dir).mkdir(parents=True, exist_ok=True) + (runtime_dir / "notifier_health.json").write_text( + json.dumps({"discord_configured": True}), encoding="utf-8") + + enable_pilot(PILOT_STRATEGY, "2026-01-01", "2026-02-28") + + result = check_pilot_entry(PILOT_STRATEGY, as_of_date="2026-02-03") + readiness = compute_launch_readiness(PILOT_STRATEGY, as_of_date="2026-02-03") + + assert result.allowed is True + assert readiness["evidence_fresh"] is True + assert readiness["evidence_stale_days"] == 3 + assert readiness["evidence_stale_unit"] == "business_days" + def test_stale_evidence_blocks_pilot(self, evidence_dir, runtime_dir, fresh_db): """evidence가 너무 오래됨 → pilot entry blocked.""" _seed_v2(evidence_dir, PILOT_STRATEGY, [ @@ -721,7 +743,7 @@ def test_pilot_entry_sets_session_context(self, evidence_dir, runtime_dir, fresh latest = datetime.now() - timedelta(days=1) earlier = latest - timedelta(days=3) _seed_v2(evidence_dir, PILOT_STRATEGY, [ - {"date": earlier.strftime("%Y-%m-%d"), "same_universe_excess": None, "benchmark_status": "failed"}, + {"date": earlier.strftime("%Y-%m-%d"), "same_universe_excess": None, "benchmark_status": "final"}, {"date": latest.strftime("%Y-%m-%d"), "same_universe_excess": 0.05, "benchmark_status": "final"}, ]) @@ -733,6 +755,10 @@ def test_pilot_entry_sets_session_context(self, evidence_dir, runtime_dir, fresh valid_from = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d") valid_to = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d") enable_pilot(PILOT_STRATEGY, valid_from, valid_to, max_orders=5) + from core.paper_preflight import run_preflight + run_preflight(PILOT_STRATEGY, datetime.now().strftime("%Y-%m-%d")) + (runtime_dir / "notifier_health.json").write_text( + json.dumps({"discord_configured": True}), encoding="utf-8") sched = self._make_scheduler(PILOT_STRATEGY, evidence_dir, runtime_dir) sched._entry_candidates = [ diff --git a/tests/test_target_weight_paper_adapter.py b/tests/test_target_weight_paper_adapter.py index c8160d26..a7c07ea5 100644 --- a/tests/test_target_weight_paper_adapter.py +++ b/tests/test_target_weight_paper_adapter.py @@ -966,6 +966,44 @@ def test_recommend_pilot_caps_matches_target_weight_plan(): assert "--max-notional 1260000 --max-exposure 3360000" in rec["enable_command"] +def test_build_target_weight_experiment_manifest_freezes_pilot_flow(): + from tools.target_weight_rotation_pilot import ( + build_target_weight_experiment_manifest, + recommend_pilot_caps, + ) + + plan = _adapter_plan() + audit = { + "ready_for_cap_approval": True, + "ready_for_capped_pilot": False, + "next_action": "enable pilot with suggested caps, then rerun readiness audit", + "blocking_reasons": ["pilot_authorization: no active capped pilot authorization"], + "warning_reasons": ["preview_caps: current preview blocked"], + } + manifest = build_target_weight_experiment_manifest( + plan=plan, + cap_recommendation=recommend_pilot_caps(plan), + readiness_audit=audit, + max_order_adv_pct=3.5, + ) + + assert manifest["artifact_type"] == "target_weight_paper_experiment_manifest" + assert manifest["candidate_id"] == plan.candidate_id + assert manifest["mode"] == "capped_paper_pilot" + assert manifest["live_enabled"] is False + assert manifest["target_pilot_days"] == 60 + assert len(manifest["manifest_hash"]) == 64 + 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 "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 + assert "enable pilot" in manifest["current_decision"]["next_action"] + assert "--execute --collect-evidence" in manifest["operator_commands"]["execute_capped_paper"] + assert manifest["no_order_safety"]["orders_submitted"] is False + + def test_assess_plan_liquidity_blocks_large_adv_order(): from tools.target_weight_rotation_pilot import assess_plan_liquidity @@ -1357,6 +1395,7 @@ def test_run_pilot_readiness_audit_writes_no_order_artifact(monkeypatch, tmp_pat audit = result["audit"] payload = json.loads(result["artifact_path"].read_text(encoding="utf-8")) report_text = result["report_path"].read_text(encoding="utf-8") + manifest = json.loads(result["experiment_manifest_path"].read_text(encoding="utf-8")) assert audit["ready_for_cap_approval"] is True assert audit["ready_for_capped_pilot"] is False @@ -1382,6 +1421,14 @@ def test_run_pilot_readiness_audit_writes_no_order_artifact(monkeypatch, tmp_pat assert "## Pre-trade Risk" in report_text assert "## Operator Commands" in report_text assert "--max-notional 1260000 --max-exposure 3360000" in report_text + assert result["experiment_manifest_path"].exists() + assert manifest["artifact_type"] == "target_weight_paper_experiment_manifest" + assert manifest["candidate_id"] == plan.candidate_id + assert manifest["current_decision"]["ready_for_cap_approval"] is True + assert manifest["current_decision"]["ready_for_capped_pilot"] is False + assert manifest["risk_controls"]["pilot_caps"]["max_orders_per_day"] == 3 + assert manifest["evidence_policy"]["pilot_paper_days_required"] == 60 + assert manifest["no_order_safety"]["manifest_only"] is True def test_run_pilot_readiness_audit_blocks_liquidity_preflight(monkeypatch, tmp_path): diff --git a/tools/target_weight_rotation_pilot.py b/tools/target_weight_rotation_pilot.py index 86c7711d..aff43e87 100644 --- a/tools/target_weight_rotation_pilot.py +++ b/tools/target_weight_rotation_pilot.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import hashlib import json import math import os @@ -39,6 +40,18 @@ DEFAULT_SHADOW_SCAN_MULTIPLIER = 5 DEFAULT_LIQUIDITY_LOOKBACK_DAYS = 20 DEFAULT_MAX_ORDER_ADV_PCT = 5.0 +TARGET_WEIGHT_PILOT_TARGET_DAYS = 60 + + +def _stable_manifest_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() def _split_symbols(raw: str | None) -> list[str] | None: @@ -2036,6 +2049,113 @@ def build_pilot_readiness_audit( } +def build_target_weight_experiment_manifest( + *, + plan: TargetWeightPlan, + cap_recommendation: dict[str, Any], + readiness_audit: dict[str, Any] | None = None, + target_pilot_days: int = TARGET_WEIGHT_PILOT_TARGET_DAYS, + max_order_adv_pct: float = DEFAULT_MAX_ORDER_ADV_PCT, +) -> dict[str, Any]: + """target-weight 후보의 60영업일 paper 운용 기준 manifest를 만든다.""" + commands = _build_readiness_operator_commands(plan, cap_recommendation) + manifest = { + "artifact_type": "target_weight_paper_experiment_manifest", + "schema_version": 1, + "generated_at": datetime.now().isoformat(), + "candidate_id": plan.candidate_id, + "mode": "capped_paper_pilot", + "live_enabled": False, + "objective": ( + "target-weight 후보를 제한된 paper pilot으로 운용하며 " + "실제 주문 기반 60영업일 승격 증거를 누적한다." + ), + "target_pilot_days": int(target_pilot_days), + "plan_summary": _plan_summary(plan), + "candidate_snapshot": { + "params_hash": plan.params_hash, + "symbols": list(plan.symbols), + "benchmark_symbol": (plan.diagnostics or {}).get("benchmark_symbol"), + "score_day": plan.score_day, + "trade_day": plan.trade_day, + "target_exposure": plan.target_exposure, + "base_target_exposure": plan.base_target_exposure, + "risk_off": plan.risk_off, + }, + "evidence_policy": { + "shadow_clean_days_required": 3, + "pilot_paper_days_required": int(target_pilot_days), + "promotable_evidence_mode": "pilot_paper", + "required_provenance": { + "execution_backed": True, + "evidence_mode": "pilot_paper", + "session_mode": "pilot_paper", + "pilot_authorized": True, + }, + "target_weight_execution_required": { + "params_hash_match": True, + "pre_execution_positions_complete": True, + "liquidity_complete": True, + "pre_trade_risk_complete": True, + "order_result_complete": True, + "fill_complete": True, + "position_reconciliation_complete": True, + }, + "blocked_evidence": [ + "shadow_bootstrap", + "legacy record without provenance", + "partial or halted execution", + "duplicate completed execution rerun", + "position drift before or after execution", + ], + }, + "risk_controls": { + "pilot_caps": cap_recommendation.get("suggested_caps", {}), + "cap_buffer_pct": cap_recommendation.get("buffer_pct"), + "liquidity_max_order_adv_pct": float(max_order_adv_pct), + "pre_trade_cost_check": True, + "idempotency_check": True, + "live_mode_refused": True, + }, + "operator_commands": commands, + "current_decision": { + "ready_for_cap_approval": bool( + (readiness_audit or {}).get("ready_for_cap_approval", False) + ), + "ready_for_capped_pilot": bool( + (readiness_audit or {}).get("ready_for_capped_pilot", False) + ), + "next_action": (readiness_audit or {}).get( + "next_action", + "run readiness audit before enabling pilot", + ), + "blocking_reasons": list((readiness_audit or {}).get("blocking_reasons") or []), + "warning_reasons": list((readiness_audit or {}).get("warning_reasons") or []), + }, + "no_order_safety": { + "orders_submitted": False, + "paper_evidence_recorded": False, + "pilot_session_written": False, + "manifest_only": True, + }, + } + manifest["manifest_hash"] = _stable_manifest_hash(manifest) + return manifest + + +def write_target_weight_experiment_manifest( + manifest: dict[str, Any], + *, + output_dir: Path = DEFAULT_OUTPUT_DIR, +) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + candidate_id = str(manifest["candidate_id"]) + trade_day = str((manifest.get("plan_summary") or {}).get("trade_day") or "unknown") + path = output_dir / f"target_weight_paper_experiment_manifest_{candidate_id}_{trade_day}.json" + path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + return path + + def _pilot_readiness_audit_path_stem(audit: dict[str, Any]) -> str: return f"target_weight_pilot_readiness_audit_{audit['candidate_id']}_{audit['trade_day']}" @@ -2242,11 +2362,23 @@ def run_pilot_readiness_audit( ) artifact_path = write_pilot_readiness_audit_artifact(audit, output_dir=output_dir) report_path = write_pilot_readiness_audit_report(audit, output_dir=output_dir) + experiment_manifest = build_target_weight_experiment_manifest( + plan=plan, + cap_recommendation=cap_recommendation, + readiness_audit=audit, + max_order_adv_pct=max_order_adv_pct, + ) + experiment_manifest_path = write_target_weight_experiment_manifest( + experiment_manifest, + output_dir=output_dir, + ) return { "plan": plan, "audit": audit, "artifact_path": artifact_path, "report_path": report_path, + "experiment_manifest": experiment_manifest, + "experiment_manifest_path": experiment_manifest_path, } @@ -3020,6 +3152,7 @@ def main() -> None: print(" enable command: see report") print(f" artifact: {result['artifact_path']}") print(f" report: {result['report_path']}") + print(f" experiment manifest: {result['experiment_manifest_path']}") if not audit["ready_for_cap_approval"]: raise SystemExit(1) return