"""build_scores_harness_v1.py — SCORES_HARNESS_V1 프롬프트 §3.6/3.7/4.2 — 기존 하네스 출력에서 4개 스코어(smart_money / liquidity / momentum / risk)를 결정론적으로 집계하고, 투자기간별 가중치가 적용된 final_score를 산출. 산출물: Temp/scores_harness_v1.json 산식(프롬프트 §4.2, 투자기간 가중치): SHORT: final = 0.10*F + 0.20*SM + 0.25*L + 0.30*M + 0.05*V - 0.10*R MID/LONG: final = 0.35*F + 0.10*SM + 0.10*L + 0.10*M + 0.25*V - 0.10*R DEFAULT: final = 0.25*F + 0.20*SM + 0.15*L + 0.15*M + 0.15*V - 0.10*R 원칙: 데이터 없는 값은 만들지 않는다. 미충족 항목은 not_available / insufficient_data. """ from __future__ import annotations import argparse import json from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] TEMP = ROOT / "Temp" DEFAULT_JSON = ROOT / "GatherTradingData.json" DEFAULT_OUT = TEMP / "scores_harness_v1.json" FORMULA_ID = "SCORES_HARNESS_V1" NA = "not_available" # §4.2 투자기간별 가중치 WEIGHTS = { "SHORT": dict(F=0.10, SM=0.20, L=0.25, M=0.30, V=0.05, R=-0.10), "MID": dict(F=0.35, SM=0.10, L=0.10, M=0.10, V=0.25, R=-0.10), "LONG": dict(F=0.35, SM=0.10, L=0.10, M=0.10, V=0.25, R=-0.10), "ETF": dict(F=0.15, SM=0.20, L=0.25, M=0.25, V=0.05, R=-0.10), "DEFAULT": dict(F=0.25, SM=0.20, L=0.15, M=0.15, V=0.15, R=-0.10), } def _load(path: Path) -> Any: if not path.exists(): return {} try: return json.loads(path.read_text(encoding="utf-8")) except Exception: return {} def _f(v: Any, default: float | None = None) -> float | None: try: return float(v) except Exception: return default def _extract_harness_root(payload: Any) -> dict[str, Any]: if not isinstance(payload, dict): return {} h = payload.get("hApex") dc = (payload.get("data") or {}).get("_harness_context") if isinstance(h, dict) and isinstance(dc, dict): m = dict(dc); m.update(h); return m return h if isinstance(h, dict) else dc if isinstance(dc, dict) else payload # ── 스마트머니 스코어 집계 ───────────────────────────────────────────────── def _smart_money_score(smf: dict) -> float | None: """smart_money_flow_signal_v2 → 포트폴리오 평균 smart_money_score (0~100).""" rows = smf.get("rows") or [] scores = [_f(r.get("smart_money_score")) for r in rows if isinstance(r, dict)] valid = [s for s in scores if s is not None] return round(sum(valid) / len(valid), 1) if valid else None # ── 유동성 스코어 집계 ─────────────────────────────────────────────────────── _LIQ_MAP = {"DEEP": 90, "NORMAL": 65, "THIN": 35, "ILLIQUID": 10} def _liquidity_score(liq: dict) -> float | None: """liquidity_flow_signal_v1 → 포트폴리오 평균 유동성 스코어 (0~100).""" rows = liq.get("rows") or [] raw = [_f(r.get("liquidity_score")) for r in rows if isinstance(r, dict)] valid_raw = [v for v in raw if v is not None] if valid_raw: return round(sum(valid_raw) / len(valid_raw), 1) # label 기반 프록시 labels = [r.get("liquidity_label") for r in rows if isinstance(r, dict)] mapped = [_LIQ_MAP[l] for l in labels if l in _LIQ_MAP] return round(sum(mapped) / len(mapped), 1) if mapped else None # ── 모멘텀 스코어 집계 ────────────────────────────────────────────────────── def _momentum_score(pac: dict, pae: dict, pred: dict) -> float | None: """portfolio_alpha_confidence_per_ticker(pac_score) + predictive_alpha_engine(direction_confidence) → 모멘텀 프록시 (0~100). pac_score 범위: -100~100 → 0~100 정규화. direction_confidence 범위: 0~100. t5 정확도로 보정. """ rows = pac.get("rows") or [] pac_vals = [_f(r.get("pac_score")) for r in rows if isinstance(r, dict)] valid_pac = [v for v in pac_vals if v is not None] pae_rows = pae.get("rows") or [] dir_confs = [_f(r.get("direction_confidence")) for r in pae_rows if isinstance(r, dict)] valid_dir = [v for v in dir_confs if v is not None] parts = [] if valid_pac: # pac_score: -100~100 → 정규화 to 0~100 avg_pac = sum(valid_pac) / len(valid_pac) parts.append((avg_pac + 100.0) / 2.0) if valid_dir: parts.append(sum(valid_dir) / len(valid_dir)) if not parts: return None avg = sum(parts) / len(parts) # t5 정확도로 보정 (t5=50 → 0.75 유지, t5=100 → 1.0) t5 = _f(pred.get("t5_op_rate")) if t5 is not None: scale = 0.5 + 0.5 * max(0.0, min(1.0, (t5 - 50.0) / 50.0)) return round(max(0.0, min(100.0, avg * scale)), 1) return round(max(0.0, min(100.0, avg)), 1) # ── 리스크 스코어 집계 ────────────────────────────────────────────────────── def _risk_score(harness: dict) -> float | None: """total_heat_pct + portfolio_beta + macro_risk_score → 합성 리스크 스코어 (0~100, 높을수록 위험).""" heat = _f(harness.get("total_heat_pct")) beta = _f(harness.get("portfolio_beta")) macro = _f(harness.get("macro_risk_score")) parts = [] # Heat: 0%=0점, 15%+=100점 if heat is not None: parts.append(min(100.0, heat / 15.0 * 100.0)) # Beta: 0.5=0점, 2.0=100점 if beta is not None: parts.append(min(100.0, max(0.0, (beta - 0.5) / 1.5 * 100.0))) # Macro: 그대로 (0~100) if macro is not None: parts.append(min(100.0, max(0.0, float(macro)))) return round(sum(parts) / len(parts), 1) if parts else None # ── 밸류에이션 스코어 ───────────────────────────────────────────────────── def _valuation_score(fund: dict) -> float | None: """fundamental_multifactor_v3 rows valuation 서브스코어 평균 (0~100).""" rows = fund.get("rows") or [] non_etf = [r for r in rows if isinstance(r, dict) and not r.get("is_etf")] vals = [_f((r.get("breakdown") or {}).get("valuation")) for r in non_etf] valid = [v for v in vals if v is not None and v > 0] return round(sum(valid) / len(valid) * 5, 1) if valid else None # 0~20 → 0~100 # ── 펀더멘털 스코어 ────────────────────────────────────────────────────────── def _fundamental_score(fund: dict) -> tuple[float | None, str]: rows = fund.get("rows") or [] non_etf = [r for r in rows if isinstance(r, dict) and not r.get("is_etf")] scores = [_f(r.get("score")) for r in non_etf if _f(r.get("score")) is not None] if not scores: return None, "ROE/OPM/OCF/FCF 전면 결측 — insufficient_data" return round(sum(scores) / len(scores), 1), "partial — core factor (ROE/OPM/OCF/FCF) 결측" # ── 최종 스코어 산출 ───────────────────────────────────────────────────────── def _final_score(F, SM, L, M, V, R, horizon: str) -> dict[str, Any]: w = WEIGHTS.get(horizon, WEIGHTS["DEFAULT"]) components = dict(F=F, SM=SM, L=L, M=M, V=V, R=R) missing = [k for k, v in components.items() if v is None] if len(missing) >= 3: return {"value": NA, "note": f"충분한 스코어 없음(missing={missing})", "horizon": horizon} # 사용 가능한 값만으로 비례 재가중 avail_w = {k: abs(wv) for k, wv in w.items() if components[k] is not None} total_w = sum(avail_w.values()) if total_w <= 0: return {"value": NA, "note": "가중치 합 0", "horizon": horizon} score = 0.0 for k, wv in w.items(): v = components[k] if v is None: continue eff_w = (abs(wv) / total_w) * (1 if wv > 0 else -1) score += eff_w * v return { "value": round(score, 1), "horizon": horizon, "weights_used": w, "missing_components": missing, "note": f"가중 합산 (missing={missing}가 제외되어 재정규화됨)" if missing else "전체 컴포넌트 사용", "formula": "F×wF + SM×wSM + L×wL + M×wM + V×wV + R×wR (§4.2)", } def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--json", default=str(DEFAULT_JSON)) ap.add_argument("--out", default=str(DEFAULT_OUT)) args = ap.parse_args() json_path = Path(args.json) if not json_path.is_absolute(): json_path = ROOT / json_path out_path = Path(args.out) if not out_path.is_absolute(): out_path = ROOT / args.out payload = _load(json_path) harness = _extract_harness_root(payload) smf = _load(TEMP / "smart_money_flow_signal_v2.json") liq = _load(TEMP / "liquidity_flow_signal_v1.json") pac = _load(TEMP / "portfolio_alpha_confidence_per_ticker_v1.json") pae = _load(TEMP / "predictive_alpha_engine_v2.json") pred = _load(TEMP / "prediction_accuracy_harness_v2.json") fund = _load(TEMP / "fundamental_multifactor_v3.json") horizon_cls = _load(TEMP / "horizon_classification_v1.json") # 지배적 투자기간 (비중 최대 버킷) alloc = horizon_cls.get("allocation_pct") or {} dominant_horizon = max(alloc, key=lambda k: alloc.get(k, 0)) if alloc else "DEFAULT" sm = _smart_money_score(smf) liq_s = _liquidity_score(liq) mom = _momentum_score(pac, pae, pred) risk = _risk_score(harness) val = _valuation_score(fund) f_s, f_note = _fundamental_score(fund) final = _final_score(f_s, sm, liq_s, mom, val, risk, dominant_horizon) result = { "formula_id": FORMULA_ID, "dominant_horizon": dominant_horizon, "scores": { "fundamental_score": f_s if f_s is not None else NA, "fundamental_note": f_note, "smart_money_score": sm if sm is not None else NA, "smart_money_source": "smart_money_flow_signal_v2.json (per-ticker avg)", "liquidity_score": liq_s if liq_s is not None else NA, "liquidity_source": "liquidity_flow_signal_v1.json (label proxy)", "momentum_score": mom if mom is not None else NA, "momentum_source": "pac_score(PAC) + direction_confidence(PAE) × t5_op_rate 보정", "valuation_score": val if val is not None else NA, "valuation_source": "fundamental_multifactor_v3 breakdown.valuation (부분)", "risk_score": risk if risk is not None else NA, "risk_components": { "total_heat_pct": harness.get("total_heat_pct"), "portfolio_beta": harness.get("portfolio_beta"), "macro_risk_score": harness.get("macro_risk_score"), }, }, "final_score": final, "formula_ref": "spec/13b_harness_formulas.yaml § SCORES_HARNESS_V1 (§4.2)", "weight_table": WEIGHTS, } out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") fs_v = final.get("value") print( f"[{FORMULA_ID}] horizon={dominant_horizon} " f"F={f_s} SM={sm} L={liq_s} M={mom} V={val} R={risk} " f"-> final_score={fs_v} -> {out_path}" ) return 0 if __name__ == "__main__": raise SystemExit(main())