"""HORIZON_CLASSIFICATION_V1 — 종목별 투자 기간 분류기. data_feed 및 fundamental_multifactor_v3 결과를 결합하여 각 보유 종목의 투자 기간(단/중/장기)을 결정론적으로 분류한다. 분류 결정 트리: LONG ← 핵심 주도주(005930/000660) + 펀더멘털 B등급 MID ← 그 외의 펀더멘털 C/D등급 또는 중립 구간 SHORT ← 과열/약세가 동시에 강한 종목(고RSI, 강한 음의 이격도, 고ATR) ETF ← ETF 종목 UNKNOWN ← 데이터 부족 출력: Temp/horizon_classification_v1.json """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] DEFAULT_JSON = ROOT / "GatherTradingData.json" DEFAULT_FUND = ROOT / "Temp" / "fundamental_multifactor_v3.json" DEFAULT_OUT = ROOT / "Temp" / "horizon_classification_v1.json" CORE_LONG_TICKERS = {"005930", "000660"} def _load(path: Path) -> dict[str, Any]: if not path.exists(): return {} try: d = json.loads(path.read_text(encoding="utf-8")) except Exception: return {} return d if isinstance(d, dict) else {} def _rows(v: Any) -> list[dict[str, Any]]: if isinstance(v, list): return [x for x in v if isinstance(x, dict)] if isinstance(v, str): try: return _rows(json.loads(v)) except Exception: return [] return [] def _f(v: Any, default: float = 0.0) -> float: try: return float(v) except Exception: return default def _classify_horizon( ticker: str, grade: str, disparity: float, atr_pct: float, rsi14: float, is_etf: bool, ) -> str: """결정론적 horizon 분류.""" if is_etf: return "ETF" # 핵심 주도주는 장기 호라이즌으로 고정 if ticker in CORE_LONG_TICKERS and grade == "B": return "LONG" # 과열 신호 → 단기 if rsi14 > 70 or disparity > 15: return "SHORT" # 펀더멘털 F → 단기 또는 알 수 없음 if grade == "F": return "SHORT" # 강한 약세/변동성 조합은 단기 if grade == "B" and disparity <= -8 and rsi14 < 45 and atr_pct >= 7.0: return "SHORT" if grade == "C" and disparity <= -12 and rsi14 < 40 and atr_pct >= 9.0: return "SHORT" # 펀더멘털 A/B + 기술적 조건 → 장기 if grade in ("A", "B") and abs(disparity) <= 5 and atr_pct <= 3.0: return "LONG" # 펀더멘털 C/D → 중기 if grade in ("C", "D"): return "MID" # 펀더멘털 A/B + 이격도 5~15% → 중기 (추가 상승 여력 모니터링) if grade in ("A", "B") and abs(disparity) <= 15: return "MID" return "UNKNOWN" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--json", default=str(DEFAULT_JSON)) ap.add_argument("--fund", default=str(DEFAULT_FUND)) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() jp = Path(args.json) fp = Path(args.fund) op = Path(args.out) if not jp.is_absolute(): jp = ROOT / jp if not fp.is_absolute(): fp = ROOT / fp if not op.is_absolute(): op = ROOT / op payload = _load(jp) data = payload.get("data") if isinstance(payload.get("data"), dict) else {} df_list = _rows(data.get("data_feed")) # 펀더멘털 등급 조회 fund_rows = _rows(_load(fp).get("rows")) fund_map = {str(r.get("ticker") or ""): r for r in fund_rows} rows = [] summary: dict[str, int] = {"SHORT": 0, "MID": 0, "LONG": 0, "ETF": 0, "UNKNOWN": 0} for r in df_list: t = str(r.get("Ticker") or r.get("ticker") or "") name = r.get("Name") or r.get("name") or "" disparity = _f(r.get("Disparity")) atr_pct = _f(r.get("ATR20_Pct")) rsi14 = _f(r.get("RSI14"), 50.0) fund_info = fund_map.get(t, {}) grade = str(fund_info.get("grade") or "F") is_etf = bool(fund_info.get("is_etf")) or grade == "ETF" hz = _classify_horizon(t, grade, disparity, atr_pct, rsi14, is_etf) summary[hz] = summary.get(hz, 0) + 1 rows.append({ "ticker": t, "name": name, "horizon": hz, "fundamental_grade": grade, "disparity_pct": round(disparity, 2), "atr20_pct": round(atr_pct, 2), "rsi14": round(rsi14, 1), "formula_id": "HORIZON_CLASSIFICATION_V1", }) # horizon allocation (비ETF 기준) non_etf = [r for r in rows if r["horizon"] != "ETF"] total_non_etf = len(non_etf) or 1 allocation_pct = { "SHORT": round(summary.get("SHORT", 0) / total_non_etf * 100, 1), "MID": round(summary.get("MID", 0) / total_non_etf * 100, 1), "LONG": round(summary.get("LONG", 0) / total_non_etf * 100, 1), } classified_pct = allocation_pct["SHORT"] + allocation_pct["MID"] + allocation_pct["LONG"] gate = "PASS" if classified_pct >= 80 else ("CAUTION" if rows else "FAIL") out = { "formula_id": "HORIZON_CLASSIFICATION_V1", "gate": gate, "rows": rows, "row_count": len(rows), "summary": summary, "allocation_pct": allocation_pct, "classified_pct": classified_pct, } op.parent.mkdir(parents=True, exist_ok=True) op.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({ "formula_id": out["formula_id"], "gate": gate, "summary": summary, "allocation_pct": allocation_pct, }, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())