"""qualitative_sell_strategy_v1 자체 평가 루프 — "한 번 만들고 끝"이 아니라 결정이 실제로 가치를 보존했는지 사후 검증한다(30년 시니어 퀀트의 핵심 습관: 판단 → 결과 → 재보정). 기존 T+5/T+20 outcome ledger(proposal_evaluation_history)와 별개로, qualitative_sell_strategy_store_v1.db에 쌓인 SQLite 시계열을 사용한다 — GAS/xlsx와 무관하므로 이 모듈만의 독립 평가 루프를 구성해도 기존 시스템과 충돌하지 않는다. 판정 기준(가치보존 관점, 기계적 승률 게임이 아님): - EXIT_REVIEW_FULL / TRIM_REVIEW_PARTIAL(매도방향) → 이후 가격이 하락했으면 "가치보존 성공"(매도가 손실을 막았다). 상승했으면 "기회비용 발생"(조급한 매도). - HOLD_ADD_CONVICTION(지지방향) → 이후 가격이 상승했으면 성공. - HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION → 방향성 주장이 없으므로 평가 대상 제외. 표본이 부족하면(DATA_GATED) 추정하지 않고 명시적으로 보류한다 — honest_proof_score와 동일한 원칙(spec/algorithm_guidance_proof 계열). """ from __future__ import annotations import argparse import datetime as dt import json import sqlite3 import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from src.quant_engine.qualitative_sell_strategy_store_v1 import QualitativeSellStoreSpec, resolve_store_path MIN_HOLDING_DAYS = 5 # T+5 수준 — 너무 짧으면 노이즈, 너무 길면 표본 희소 MIN_SAMPLE_FOR_HIT_RATE = 10 # 이보다 적으면 hit_rate를 신뢰 구간 없이 표기하지 않음(DATA_GATED) def _scoreable_direction(action: str) -> int | None: if action in {"EXIT_REVIEW_FULL", "TRIM_REVIEW_PARTIAL"}: return -1 # 매도 방향 — 가격 하락이 "성공" if action == "HOLD_ADD_CONVICTION": return 1 # 지지 방향 — 가격 상승이 "성공" return None # HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION — 평가 제외 def load_scoreable_decisions(db_path: Path, min_age_days: int = MIN_HOLDING_DAYS) -> list[dict[str, Any]]: if not db_path.exists(): return [] cutoff = (dt.date.today() - dt.timedelta(days=min_age_days)).isoformat() conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row try: rows = conn.execute( "SELECT code, generated_at, action, conviction, market_regime, composite_score " "FROM sell_strategy_results WHERE generated_at <= ? ORDER BY generated_at", (cutoff,), ).fetchall() return [dict(row) for row in rows] finally: conn.close() def evaluate_decision(decision: dict[str, Any], price_at_decision: float, price_after: float) -> dict[str, Any] | None: direction = _scoreable_direction(decision["action"]) if direction is None or not price_at_decision or price_at_decision <= 0: return None realized_return_pct = (price_after / price_at_decision - 1.0) * 100.0 success = (direction * realized_return_pct) > 0 # 방향 일치 시 성공 return { **decision, "price_at_decision": price_at_decision, "price_after": price_after, "realized_return_pct": round(realized_return_pct, 4), "success": success, } def build_accuracy_report(db_path: Path, price_lookup: dict[str, dict[str, float]]) -> dict[str, Any]: """price_lookup: {code: {generated_at_date_iso: close_price}} — 호출측이 실제 가격 히스토리(fetch_naver_market_data_v1 등)로 조립해 주입한다. 이 함수는 가격을 추정하지 않는다 — 주어진 값만 사용.""" decisions = load_scoreable_decisions(db_path) evaluated: list[dict[str, Any]] = [] skipped_no_price = 0 for decision in decisions: prices = price_lookup.get(decision["code"], {}) decision_date = decision["generated_at"][:10] price_at = prices.get(decision_date) future_date = (dt.date.fromisoformat(decision_date) + dt.timedelta(days=MIN_HOLDING_DAYS)).isoformat() price_after = prices.get(future_date) if price_at is None or price_after is None: skipped_no_price += 1 continue result = evaluate_decision(decision, price_at, price_after) if result is not None: evaluated.append(result) scored = [e for e in evaluated if e is not None] if len(scored) < MIN_SAMPLE_FOR_HIT_RATE: return { "status": "DATA_GATED", "scored_sample_count": len(scored), "min_sample_required": MIN_SAMPLE_FOR_HIT_RATE, "note": "표본 부족 — hit_rate를 산출하지 않음(추정 금지). 결정 누적과 가격 매칭이 더 필요.", "skipped_no_price": skipped_no_price, } hit_rate_pct = round(100.0 * sum(1 for e in scored if e["success"]) / len(scored), 2) return { "status": "OK", "scored_sample_count": len(scored), "hit_rate_pct": hit_rate_pct, "evaluations": scored, "skipped_no_price": skipped_no_price, } def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--sqlite-db", type=Path, default=ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db") ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)") ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.") ap.add_argument("--price-lookup-json", type=Path, default=None, help='{"code": {"YYYY-MM-DD": close_price, ...}} 형식 — 미지정 시 가격 매칭 없이 표본 카운트만 보고') args = ap.parse_args() db_path = resolve_store_path( QualitativeSellStoreSpec( backend=args.store_backend, location=args.store_location or args.sqlite_db, ), ROOT, ) price_lookup: dict[str, dict[str, float]] = {} if args.price_lookup_json and args.price_lookup_json.exists(): price_lookup = json.loads(args.price_lookup_json.read_text(encoding="utf-8")) report = build_accuracy_report(db_path, price_lookup) print(json.dumps(report, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())