#!/usr/bin/env python3 """ build_p2_01_live_outcome_ledger.py ──────────────────────────────────────────────────────────────────────── P2_01: 실전 결과 피드백 루프 — Live Outcome Ledger 구성 목적: live_validation_score = 0 → 30 (T+20 실측 표본 30건 누적) 구조: - 매 신호 생성 시 ledger에 1행 append - T+5/T+20 결과 자동 채움 (GAS 트레이딩 캘린더 사용) - is_replay=true 행은 live_t20_evaluated_count에서 절대 제외 출력: - Temp/live_outcome_ledger_v1.json (운영 원장) - Temp/p2_01_outcome_schema.json (스키마) """ from __future__ import annotations import json import sys from pathlib import Path from datetime import datetime from typing import Any, Optional ROOT = Path(__file__).resolve().parent.parent # 출력 파일 OUTPUT_LEDGER = ROOT / "Temp" / "live_outcome_ledger_v1.json" OUTPUT_SCHEMA = ROOT / "Temp" / "p2_01_outcome_schema.json" if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"): sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1) def build_outcome_schema() -> dict: """Live Outcome Ledger 스키마 정의.""" schema = { "schema_version": "live_outcome_ledger_v1", "description": "실전 신호 결과 원장 — T+5/T+20 성과 기록용", "fields": { "signal_id": { "type": "string", "description": "유일한 신호 ID (e.g., 'SIG-20260625-000660-BUY-001')", "required": True }, "generated_at": { "type": "datetime (ISO 8601)", "description": "신호 생성 시점 (KST)", "required": True, "example": "2026-06-25T14:30:00+09:00" }, "ticker": { "type": "string", "description": "종목 코드", "required": True, "example": "000660" }, "action": { "type": "enum", "allowed": ["BUY", "SELL", "HOLD", "TRIM", "EXIT_100"], "description": "신호 액션", "required": True }, "horizon_style": { "type": "enum", "allowed": ["SCALP", "SWING", "MOMENTUM", "POSITION"], "description": "매매 스타일 (투자 기간)", "required": True }, "entry_price": { "type": "float", "description": "진입 가격 (KRW)", "required": True }, "stop_price": { "type": "float", "description": "손절가 (KRW)", "required": True }, "tp_price": { "type": "float", "description": "익절가 (KRW)", "required": True }, "position_size": { "type": "integer", "description": "포지션 수량 (주)", "required": True }, "t5_return": { "type": "float (nullable)", "description": "T+5 수익률 (%) — 영업일 기준", "required": False, "note": "T+5 미도달 시 null" }, "t20_return": { "type": "float (nullable)", "description": "T+20 수익률 (%) — 영업일 기준", "required": False, "note": "T+20 미도달 시 null" }, "max_adverse_excursion": { "type": "float (nullable)", "description": "MAE: 진입 후 최악 손실 (%)", "required": False }, "max_favorable_excursion": { "type": "float (nullable)", "description": "MFE: 진입 후 최고 수익 (%)", "required": False }, "hit_stop": { "type": "boolean (nullable)", "description": "손절 발동 여부", "required": False }, "hit_tp": { "type": "boolean (nullable)", "description": "익절 발동 여부", "required": False }, "decision_correct": { "type": "boolean (nullable)", "description": "판단 정확도 (T+5 양수 = true)", "required": False }, "is_replay": { "type": "boolean", "description": "리플레이 표본 여부 (true면 live에서 제외)", "required": True, "default": False, "critical": "is_replay=true 행은 절대 live_t20_evaluated_count에 포함 금지" }, "exit_reason": { "type": "string", "allowed": ["STOP_HIT", "TP_HIT", "TIME_STOP", "MANUAL_EXIT", "PENDING"], "description": "포지션 종료 사유", "required": False }, "notes": { "type": "string", "description": "추가 메모", "required": False } }, "rules": { "rule_1": "is_replay=false AND t20_return != null 인 행만 live_t20_evaluated_count에 카운트", "rule_2": "t20_return >= 0 이면 decision_correct=true", "rule_3": "모든 T+20 행이 채워지면 calibration_promotion 트리거" } } return schema def build_initial_ledger() -> dict: """초기 Live Outcome Ledger 생성.""" ledger = { "schema_version": "live_outcome_ledger_v1", "created_at": datetime.now().isoformat(), "updated_at": datetime.now().isoformat(), "total_signals": 0, "live_t20_evaluated_count": 0, "live_t20_samples": [], "replay_excluded_count": 0, "calibration_state": "UNVALIDATED", "rows": [] } return ledger def add_sample_rows(ledger: dict) -> dict: """샘플 행 추가 (테스트용).""" # 샘플 1: 리플레이 표본 (제외됨) sample_1 = { "signal_id": "SIG-20260612-000660-BUY-001", "generated_at": "2026-06-12T14:30:00+09:00", "ticker": "000660", "action": "BUY", "horizon_style": "MOMENTUM", "entry_price": 2100000.0, "stop_price": 2000000.0, "tp_price": 2300000.0, "position_size": 1, "t5_return": 4.76, "t20_return": 2.38, "max_adverse_excursion": -2.0, "max_favorable_excursion": 9.5, "hit_stop": False, "hit_tp": False, "decision_correct": True, "is_replay": True, "exit_reason": "TIME_STOP", "notes": "Replay sample from backtest" } # 샘플 2: Live 표본 1 sample_2 = { "signal_id": "SIG-20260620-005930-SWING-001", "generated_at": "2026-06-20T10:15:00+09:00", "ticker": "005930", "action": "BUY", "horizon_style": "SWING", "entry_price": 70000.0, "stop_price": 68000.0, "tp_price": 75000.0, "position_size": 100, "t5_return": 3.5, "t20_return": 1.8, "max_adverse_excursion": -1.5, "max_favorable_excursion": 6.2, "hit_stop": False, "hit_tp": False, "decision_correct": True, "is_replay": False, "exit_reason": "TIME_STOP", "notes": "Live execution" } # 샘플 3: Live 표본 2 (T+20 미완료) sample_3 = { "signal_id": "SIG-20260622-064350-POSITION-001", "generated_at": "2026-06-22T11:45:00+09:00", "ticker": "064350", "action": "BUY", "horizon_style": "POSITION", "entry_price": 200000.0, "stop_price": 190000.0, "tp_price": 230000.0, "position_size": 50, "t5_return": None, "t20_return": None, "max_adverse_excursion": None, "max_favorable_excursion": None, "hit_stop": False, "hit_tp": False, "decision_correct": None, "is_replay": False, "exit_reason": "PENDING", "notes": "T+5/T+20 pending" } ledger["rows"].append(sample_1) ledger["rows"].append(sample_2) ledger["rows"].append(sample_3) # 통계 업데이트 ledger["total_signals"] = len(ledger["rows"]) live_samples = [r for r in ledger["rows"] if not r.get("is_replay", False)] t20_evaluated = [r for r in live_samples if r.get("t20_return") is not None] ledger["live_t20_evaluated_count"] = len(t20_evaluated) ledger["live_t20_samples"] = [ { "signal_id": r["signal_id"], "t20_return": r["t20_return"], "decision_correct": r["decision_correct"] } for r in t20_evaluated ] ledger["replay_excluded_count"] = len(ledger["rows"]) - len(live_samples) # Calibration state 판정 if ledger["live_t20_evaluated_count"] >= 30: ledger["calibration_state"] = "CALIBRATED" elif ledger["live_t20_evaluated_count"] >= 10: ledger["calibration_state"] = "PROVISIONAL" else: ledger["calibration_state"] = "UNVALIDATED" return ledger def main() -> int: print("=" * 80) print(" P2_01: Live Outcome Ledger 구성 및 초기화") print("=" * 80) # 스키마 생성 schema = build_outcome_schema() OUTPUT_SCHEMA.write_text( json.dumps(schema, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"\n✓ 스키마 저장: {OUTPUT_SCHEMA.name}") print(f" 필드 수: {len(schema['fields'])}") print(f" 주요 제약: {len(schema['rules'])} 가지") # Ledger 생성 및 샘플 추가 ledger = build_initial_ledger() ledger = add_sample_rows(ledger) OUTPUT_LEDGER.write_text( json.dumps(ledger, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"\n✓ Ledger 저장: {OUTPUT_LEDGER.name}") # 통계 출력 print(f"\n[현재 상태]") print(f" 전체 신호: {ledger['total_signals']}") print(f" Live T+20 평가됨: {ledger['live_t20_evaluated_count']} / 30 목표") print(f" Replay 제외됨: {ledger['replay_excluded_count']}") print(f" Calibration: {ledger['calibration_state']}") print(f"\n[샘플 행]") for i, row in enumerate(ledger["rows"], 1): status = "🔄 REPLAY" if row.get("is_replay") else f"📊 LIVE(T+20: {row.get('t20_return')}%)" print(f" {i}. {row['signal_id']} — {status}") print(f"\n[다음 단계]") print(f" 1. GAS 트레이딩 캘린더로 T+5/T+20 자동 계산") print(f" 2. 매 신호마다 ledger에 1행 append") print(f" 3. 30건 누적 후 calibration_state = CALIBRATED") return 0 if __name__ == "__main__": sys.exit(main())