Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`은 부분 실행이나 중단된 세션 복구용으로만 사용합니다.

Expand Down
45 changes: 37 additions & 8 deletions core/paper_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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일) ──
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions docs/PROJECT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 일치를 만족한 날만 승격 카운트에 포함한다 |
Expand Down
33 changes: 33 additions & 0 deletions reports/paper_experiment_manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 체크."
}
28 changes: 27 additions & 1 deletion tests/test_paper_pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, [
Expand Down Expand Up @@ -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"},
])

Expand All @@ -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 = [
Expand Down
Loading
Loading