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_OUT = ROOT / "Temp" / "ratchet_trailing_general_v1.json" def _load(path: Path) -> dict[str, Any]: 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) -> float: try: return float(v) except Exception: return 0.0 def _breakeven_ratchet_price(avg_cost: float) -> int | None: if avg_cost <= 0: return None # BREAKEVEN_RATCHET_V1: 세후/호가 반올림 이전의 보수적 손익분기 래칫 기준선 return int(round(avg_cost * 1.005, 0)) 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() jp = Path(args.json) op = Path(args.out) if not jp.is_absolute(): jp = ROOT / jp if not op.is_absolute(): op = ROOT / op payload = _load(jp) data = payload.get("data") if isinstance(payload.get("data"), dict) else {} h = data.get("_harness_context") if isinstance(data.get("_harness_context"), dict) else {} # [NF5] PROFIT_GIVEBACK_RATCHET_FACTOR_V1: 수익률 국면별 k 계수 (calibration_registry.yaml) # trail_stop = max(prev_trail≈avg, high_since_entry≈cur − k × ATR20) NF5_K = { "APEX_SUPER": 1.0, # NF5_K_APEX_SUPER: profit >= 50% "APEX_TRAILING": 1.5, # NF5_K_APEX_TRAILING: profit 40~50% "PROFIT_LOCK_30": 2.0, # NF5_K_PROFIT_LOCK_30: profit 20~40% "NEUTRAL": 2.5, # NF5_K_NEUTRAL: profit < 20% } def _nf5_k(profit_pct: float) -> tuple[float, str]: if profit_pct >= 50.0: return NF5_K["APEX_SUPER"], "APEX_SUPER" if profit_pct >= 40.0: return NF5_K["APEX_TRAILING"], "APEX_TRAILING" if profit_pct >= 20.0: return NF5_K["PROFIT_LOCK_30"], "PROFIT_LOCK_30" return NF5_K["NEUTRAL"], "NEUTRAL" prices = _rows(h.get("prices_json")) coverage_denom = 0 coverage_num = 0 rows = [] summary_breakeven = None summary_source_ticker = None summary_source_name = None for p in prices: cur = _f(p.get("current_price") or p.get("current_price_krw")) avg = _f( p.get("avg_price") or p.get("average_cost") or p.get("avg_cost_krw") or p.get("avg_cost") ) atr = _f(p.get("atr20") or p.get("ATR20")) profit = cur > 0 and avg > 0 and cur > avg trailing = None profit_stage = None k_used = None breakeven = _breakeven_ratchet_price(avg) if summary_breakeven is None and breakeven is not None: summary_breakeven = breakeven summary_source_ticker = p.get("ticker") summary_source_name = p.get("name") if profit: coverage_denom += 1 profit_pct = (cur - avg) / avg * 100.0 k_used, profit_stage = _nf5_k(profit_pct) trailing = round(max(avg, cur - atr * k_used), 0) coverage_num += 1 rows.append({ "ticker": p.get("ticker"), "name": p.get("name"), "auto_trailing_stop": trailing, "breakeven_stop_price": breakeven, "profit_stage": profit_stage, "nf5_k": k_used, }) coverage = 100.0 if coverage_denom == 0 else round((coverage_num / coverage_denom) * 100.0, 2) out = { "formula_id": "RATCHET_TRAILING_GENERAL_V1", "gate": "PASS" if coverage >= 99.0 else "CAUTION", "coverage_pct": coverage, "rows": rows, "breakeven_formula_id": "BREAKEVEN_RATCHET_V1", "breakeven_stop_price": summary_breakeven, "breakeven_source_ticker": summary_source_ticker, "breakeven_source_name": summary_source_name, } 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"], "coverage_pct": out["coverage_pct"], "gate": out["gate"]}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())