#!/usr/bin/env python3 """ kis_trader/backtest/optuna_mode_combo.py — Optuna Top-N 최빈 조합 추출·실측 백테 ================================================================================ 파람서치 종료 후 JSON/로그에 넣기 위한 공통 유틸. 기준: 1) results 중 total_pnl 있는 행만 2) PnL 내림차순 Top-N (기본 20, env OPTUNA_MODE_TOP_N) 3) 축별 단순 최빈(표수, PnL 가중 없음) → mode_combo 4) evaluate_fn(mode_combo) 로 1회 실측 백테 (게이트는 호출측 min_* 에 따름) apply 는 하지 않음 — 확인용 리포트만. """ from __future__ import annotations import logging from collections import Counter from typing import Any, Callable, Dict, List, Optional from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int from kis_trader.backtest.optuna_tpe_common import finalize_ratchet_combo logger = logging.getLogger("optuna_mode_combo") EvalFn = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]] def resolve_mode_top_n(default: int = 20) -> int: """Top-N — env OPTUNA_MODE_TOP_N (기본 20).""" n = int(get_env_int("OPTUNA_MODE_TOP_N", int(default))) return max(1, n) def resolve_mode_pool_kind() -> str: """ mode_combo / mode Top10 / 2차 그리드 밴드 풀. positive(기본)=PnL>0 전체 · gated=results_gated · top_n=Top-N PnL. """ raw = str(get_env_from_db("OPTUNA_MODE_POOL", "positive") or "positive").strip().lower() if raw in ("positive", "gated", "top_n"): return raw return "positive" def _valid_pnl_rows(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: return [ r for r in (results or []) if r.get("total_pnl") is not None and abs(float(r.get("total_pnl") or 0)) < 1e15 ] def select_mode_pool_rows( results: List[Dict[str, Any]], *, data: Optional[Dict[str, Any]] = None, top_n: Optional[int] = None, ) -> List[Dict[str, Any]]: """mode_combo·밴드·2차 narrow 공통 trial 풀.""" kind = resolve_mode_pool_kind() n = int(top_n) if top_n is not None else resolve_mode_top_n(20) rows = _valid_pnl_rows(results) if kind == "gated" and data: gated = [r for r in list(data.get("results_gated") or []) if isinstance(r, dict)] gated = _valid_pnl_rows(gated) if gated: return gated if kind == "positive": pos = [r for r in rows if float(r.get("total_pnl") or 0) > 0] if pos: return pos rows.sort( key=lambda r: ( -float(r.get("total_pnl") or 0), -float(r.get("win_rate") or 0), -int(r.get("total_trades") or 0), ) ) return rows[: max(1, n)] rows.sort( key=lambda r: ( -float(r.get("total_pnl") or 0), -float(r.get("win_rate") or 0), -int(r.get("total_trades") or 0), ) ) return rows[: max(1, n)] def mode_combo_from_results( results: List[Dict[str, Any]], *, top_n: int = 20, grid_keys: Optional[List[str]] = None, params_key: str = "params", data: Optional[Dict[str, Any]] = None, pool_rows: Optional[List[Dict[str, Any]]] = None, ) -> Dict[str, Any]: """ 풀(PnL 양수 전체 등) 축별 최빈 → mode_combo + 빈도 메타. Returns: { "top_n": int, "pool_size": int, "pool_kind": str, "params": {축: 최빈값}, "freq": {축: {"value": ..., "count": n, "of": pool}}, "top_pnls": [...], } """ pool_kind = resolve_mode_pool_kind() pool = list(pool_rows) if pool_rows is not None else select_mode_pool_rows( results, data=data, top_n=top_n, ) if not pool: return { "top_n": int(top_n), "pool_size": 0, "pool_kind": pool_kind, "params": {}, "freq": {}, "top_pnls": [], } # 축 집합: grid_keys 우선, 없으면 Top pool params 합집합 keys: List[str] = [] if grid_keys: keys = [k for k in grid_keys if k] if not keys: seen = set() for r in pool: for k in (r.get(params_key) or {}).keys(): if k not in seen: seen.add(k) keys.append(k) params: Dict[str, Any] = {} freq: Dict[str, Any] = {} for k in keys: c: Counter = Counter() samples: Dict[str, Any] = {} for r in pool: v = (r.get(params_key) or {}).get(k) if v is None and params_key != "merged_params": v = (r.get("merged_params") or {}).get(k) s = str(v) c[s] += 1 samples.setdefault(s, v) if not c: continue best_s, cnt = c.most_common(1)[0] params[k] = samples[best_s] freq[k] = {"value": params[k], "count": int(cnt), "of": len(pool)} return { "top_n": int(top_n), "pool_size": len(pool), "pool_kind": pool_kind, "params": params, "freq": freq, "top_pnls": [float(r.get("total_pnl") or 0) for r in pool[:10]], } def _bt_summary(result: Optional[Dict[str, Any]]) -> Dict[str, Any]: """실측 요약 — 사후합격/안정 Top 표와 같은 일별 안정 필드도 유지. (예전엔 pnl·trades·wr·pf만 남겨 mode 표에 손실일·최악일·안정점수가 — 로 비었음) """ if not result: return { "ok": False, "total_pnl": None, "total_trades": None, "win_rate": None, "pf": None, "note": "evaluate returned None (게이트·0건·invalid)", } out: Dict[str, Any] = { "ok": True, "total_pnl": float(result.get("total_pnl") or 0), "total_trades": int(result.get("total_trades") or 0), "win_rate": float(result.get("win_rate") or 0), "pf": float(result.get("pf") or 0) if result.get("pf") is not None else None, "score": float(result.get("score") or 0) if result.get("score") is not None else None, } # attach_daily_stability 가 evaluate_* 에 붙인 키 — 웹 mode 표 컬럼용 for k in ( "stability_score", "n_losing_days", "n_active_days", "worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std", "stability_lambda", "daily_pnl", ): if k in result and result.get(k) is not None: out[k] = result.get(k) return out def _attach_best_trial_trades( out_data: Dict[str, Any], evaluate_fn: EvalFn, *, log: logging.Logger, ) -> None: """results[0](#1 best) 체결을 export 시 1회 재실측해 JSON에 남김 (정합 diff용).""" from kis_trader.backtest.optuna_common import slim_trades_for_optuna_json res0 = (out_data.get("results") or [None])[0] if not isinstance(res0, dict): return combo = dict(res0.get("params") or {}) if not combo: return try: raw = evaluate_fn(dict(combo)) except Exception as exc: log.warning("⚠️ best 체결 재실측 예외: %s", exc) return if not isinstance(raw, dict): log.warning("⚠️ best 체결 재실측 실패(게이트/None)") return fills = list(raw.pop("_trades", None) or []) slim = slim_trades_for_optuna_json(fills) res0["_trades"] = slim out_data["best_trial_trades"] = slim # trial 기록값과 export 시점 재실측이 다르면 바로 보이게 out_data["best_trial_reeval"] = { "total_pnl": raw.get("total_pnl"), "total_trades": raw.get("total_trades"), "win_rate": raw.get("win_rate"), "pf": raw.get("pf"), "mdd": raw.get("mdd"), "recorded_total_pnl": res0.get("total_pnl"), "recorded_total_trades": res0.get("total_trades"), "delta_pnl": ( float(raw.get("total_pnl") or 0) - float(res0.get("total_pnl") or 0) ), "delta_trades": ( int(raw.get("total_trades") or 0) - int(res0.get("total_trades") or 0) ), "note": ( "export 직후 동일 evaluate_fn 재실측. " "delta≠0 이면 trial 기록과 엔진/데이터 드리프트." ), } log.info( "🧾 [best 체결저장] n=%s | reeval_pnl=%s recorded_pnl=%s Δ=%+.0f", len(slim), raw.get("total_pnl"), res0.get("total_pnl"), float(raw.get("total_pnl") or 0) - float(res0.get("total_pnl") or 0), ) def enrich_out_data_with_mode_combo( out_data: Dict[str, Any], *, evaluate_fn: Optional[EvalFn] = None, top_n: Optional[int] = None, grid_keys: Optional[List[str]] = None, params_key: str = "params", log: Optional[logging.Logger] = None, on_partial_save: Optional[Callable[[Dict[str, Any]], None]] = None, ) -> Dict[str, Any]: """ out_data['results'] 기준 최빈 추출 → (선택) 실측 백테 → out_data['mode_combo'] 기록 + 로그. evaluate_fn: mode params → evaluate_*_param_combo 결과 dict 또는 None. on_partial_save: 최빈 params 기록 직후(실측 전) 호출 — JSON에 mode_combo가 남도록. """ lg = log or logger n = int(top_n) if top_n is not None else resolve_mode_top_n(20) keys = grid_keys or list(out_data.get("grid_keys") or []) mode_meta = mode_combo_from_results( list(out_data.get("results") or []), top_n=n, grid_keys=keys or None, params_key=params_key, data=out_data, ) # 래칫 숫자축 최빈 → 엔진용 ratchet_tiers 재조립 (불일치 방지) strat = str(out_data.get("strategy") or "").strip().lower() off_tok = "off" if strat == "tail" else "" mode_params = finalize_ratchet_combo( dict(mode_meta.get("params") or {}), off_token=off_tok, ) report: Dict[str, Any] = { "method": "pool_per_axis_mode", "top_n": mode_meta["top_n"], "pool_size": mode_meta["pool_size"], "pool_kind": mode_meta.get("pool_kind") or resolve_mode_pool_kind(), "params": mode_params, "freq": mode_meta["freq"], "top_pnls": mode_meta["top_pnls"], "backtest": None, "vs_best": None, "note": "trial 번호 없음(축별 최빈 조립). optuna_best_trial_number 와 별개.", } best_pnl = None best_tr = None res0 = (out_data.get("results") or [None])[0] if res0: best_pnl = float(res0.get("total_pnl") or 0) best_tr = int(res0.get("total_trades") or 0) lg.info( "📊 [mode] pool(%s) 최빈 추출 | pool=%d | top_pnls=%s", mode_meta.get("pool_kind") or resolve_mode_pool_kind(), mode_meta["pool_size"], mode_meta["top_pnls"][:5], ) if mode_params: # 축별 빈도 요약 (짧게) bits = [] for k, meta in list(mode_meta["freq"].items())[:12]: bits.append(f"{k}={meta['value']}({meta['count']}/{meta['of']})") lg.info("📊 [mode] params(일부): %s", " | ".join(bits)) if "ratchet_tiers" in mode_params: lg.info("📊 [mode] ratchet_tiers 재조립=%r", mode_params.get("ratchet_tiers")) # 실측 전에 먼저 JSON에 박아 둠 (실측 중 죽어도 mode_combo.params 는 남음) out_data["mode_combo"] = report if on_partial_save is not None: try: on_partial_save(out_data) except Exception as exc: lg.warning("⚠️ mode_combo 부분저장 실패: %s", exc) # #1 best 체결 — mode 실측 전에 저장 (후처리가 evaluate를 여러 번 돌려도 best는 1회) if evaluate_fn is not None: _attach_best_trial_trades(out_data, evaluate_fn, log=lg) mode_fills: List[Dict[str, Any]] = [] if evaluate_fn is not None and mode_params: try: from kis_trader.backtest.optuna_common import slim_trades_for_optuna_json bt = evaluate_fn(dict(mode_params)) if isinstance(bt, dict): mode_fills = list(bt.pop("_trades", None) or []) report["backtest"] = _bt_summary(bt) # 후처리용 raw fills 는 mode_fills 로 유지 + JSON에는 슬림 저장 if report["backtest"].get("ok") and isinstance(report.get("backtest"), dict): report["backtest"]["_trades"] = slim_trades_for_optuna_json(mode_fills) if report["backtest"].get("ok"): lg.info( "🧪 [mode] 실측 백테 | pnl=%s | trades=%s | wr=%.1f%% | pf=%s | _trades=%s", report["backtest"]["total_pnl"], report["backtest"]["total_trades"], float(report["backtest"]["win_rate"] or 0), report["backtest"].get("pf"), len(report["backtest"].get("_trades") or []), ) else: lg.warning("🧪 [mode] 실측 백테 실패/게이트: %s", report["backtest"].get("note")) except Exception as exc: report["backtest"] = {"ok": False, "error": str(exc)} lg.warning("🧪 [mode] 실측 백테 예외: %s", exc) mode_fills = [] if best_pnl is not None and report.get("backtest") and report["backtest"].get("ok"): mode_pnl = float(report["backtest"]["total_pnl"] or 0) best_wr = float(res0.get("win_rate") or 0) if res0 else None best_pf = float(res0.get("pf") or 0) if res0 and res0.get("pf") is not None else None mode_wr = report["backtest"].get("win_rate") mode_pf = report["backtest"].get("pf") report["vs_best"] = { "best_pnl": best_pnl, "best_trades": best_tr, "best_wr": best_wr, "best_pf": best_pf, "mode_pnl": mode_pnl, "mode_trades": report["backtest"].get("total_trades"), "mode_wr": float(mode_wr) if mode_wr is not None else None, "mode_pf": float(mode_pf) if mode_pf is not None else None, "delta_pnl": round(mode_pnl - best_pnl, 2), "delta_wr": ( round(float(mode_wr) - float(best_wr), 2) if mode_wr is not None and best_wr is not None else None ), "delta_pf": ( round(float(mode_pf) - float(best_pf), 2) if mode_pf is not None and best_pf is not None else None ), } lg.info( "📐 [mode vs #1] best_pnl=%s (%s건 wr=%.1f%% pf=%s) | " "mode_pnl=%s (%s건 wr=%.1f%% pf=%s) | Δpnl=%+.0f Δwr=%+.1f", best_pnl, best_tr, float(best_wr or 0), best_pf, mode_pnl, report["backtest"].get("total_trades"), float(mode_wr or 0), mode_pf, mode_pnl - best_pnl, (float(mode_wr) - float(best_wr)) if mode_wr is not None and best_wr is not None else 0.0, ) out_data["mode_combo"] = report # TopN 후처리(호가/휩쏘/트레일) — 실매 엔진 미변경. 합의값이 기존 단일 recommend 키를 대체. try: from kis_trader.backtest.optuna_postprocess_topn import attach_topn_postprocess attach_topn_postprocess( out_data, evaluate_fn=evaluate_fn, mode_fills=mode_fills, log=lg, run_ob_whipsaw=None, # TPE 호가축 ON 이면 사후 8방 기본 OFF (_run_ob_whipsaw_full) ) except Exception as exc: lg.warning("⚠️ TopN 후처리 첨부 실패: %s", exc) try: from kis_trader.backtest.optuna_daily_trail_recommend import ( attach_daily_trail_recommend, ) attach_daily_trail_recommend(out_data, log=lg) except Exception as exc2: lg.warning("⚠️ daily_trail_recommend 폴백 실패: %s", exc2) # mode Top10 — 웹 표·apply source=consensus (구 JSON은 웹에서 재계산) try: from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n ui_n = resolve_post_top_n(10) mode_rows, mode_meta = build_results_mode_consensus_tier( list(out_data.get("results_all") or out_data.get("results") or []), top_n=ui_n, grid_keys=keys or None, params_key=params_key, data=out_data, ) out_data["results_mode"] = mode_rows out_data["mode_consensus_meta"] = mode_meta except Exception as exc: lg.warning("⚠️ results_mode(mode Top10) 첨부 실패: %s", exc) return out_data def _percentile_sorted(sorted_vals: List[float], p: float) -> float: if not sorted_vals: return 0.0 if len(sorted_vals) == 1: return sorted_vals[0] idx = (len(sorted_vals) - 1) * p lo = int(idx) hi = min(lo + 1, len(sorted_vals) - 1) w = idx - lo return sorted_vals[lo] * (1.0 - w) + sorted_vals[hi] * w def _row_param_value(row: Dict[str, Any], key: str, *, params_key: str = "params") -> Any: params = row.get(params_key) or row.get("merged_params") or {} if not isinstance(params, dict): params = {} v = params.get(key) if v is None and params_key != "merged_params": v = (row.get("merged_params") or {}).get(key) return v def _coerce_numeric(v: Any) -> Optional[float]: if isinstance(v, bool): return 1.0 if v else 0.0 try: return float(v) except (TypeError, ValueError): return None def _build_mode_band_profile( pool: List[Dict[str, Any]], keys: List[str], *, params_key: str = "params", ) -> Dict[str, Dict[str, Any]]: """ Top pool 각 축 — 숫자면 p25~p75 밴드(흔한 구간), 아니면 categorical mode. """ profile: Dict[str, Dict[str, Any]] = {} for k in keys: raw_vals: List[Any] = [] for row in pool: v = _row_param_value(row, k, params_key=params_key) if v is not None: raw_vals.append(v) if not raw_vals: continue nums: List[float] = [] all_numeric = True for v in raw_vals: n = _coerce_numeric(v) if n is None: all_numeric = False break nums.append(n) if all_numeric and nums: s = sorted(nums) p25 = _percentile_sorted(s, 0.25) p50 = _percentile_sorted(s, 0.50) p75 = _percentile_sorted(s, 0.75) iqr = max(p75 - p25, abs(p50) * 0.05, 1e-9) profile[k] = { "kind": "numeric", "p25": p25, "p50": p50, "p75": p75, "iqr": iqr, } else: c: Counter = Counter(str(v) for v in raw_vals) mode_s, _cnt = c.most_common(1)[0] sample = next(v for v in raw_vals if str(v) == mode_s) profile[k] = {"kind": "categorical", "mode": sample} return profile def _trial_band_proximity( row: Dict[str, Any], profile: Dict[str, Dict[str, Any]], *, params_key: str = "params", ) -> Dict[str, Any]: """ trial ↔ pool 흔한 구간(p25~p75) 근접도. 100%=모든 축이 밴드 안 또는 매우 가까움. (구: 축값 완전 일치 개수 — tp 22% vs 4% 뒤섞임 원인) """ decay_iqr = max(0.1, float(get_env_float("OPTUNA_MODE_BAND_DECAY_IQR", 1.5))) scores: List[float] = [] in_band = 0 total = 0 err_sum = 0.0 for k, band in profile.items(): rv = _row_param_value(row, k, params_key=params_key) if rv is None: continue total += 1 if band.get("kind") == "numeric": nv = _coerce_numeric(rv) if nv is None: mode_v = band.get("mode") if mode_v is not None: axis_s = 1.0 if str(rv) == str(mode_v) else 0.0 else: axis_s = 0.0 if axis_s >= 1.0: in_band += 1 else: err_sum += 1.0 scores.append(axis_s) continue p25 = float(band["p25"]) p50 = float(band["p50"]) p75 = float(band["p75"]) iqr = float(band["iqr"]) if p25 <= nv <= p75: axis_s = 1.0 in_band += 1 err_sum += 0.0 else: dist = (p25 - nv) if nv < p25 else (nv - p75) axis_s = max(0.0, 1.0 - dist / (iqr * decay_iqr)) err_sum += dist / iqr else: mode_v = band.get("mode") axis_s = 1.0 if str(rv) == str(mode_v) else 0.0 if axis_s >= 1.0: in_band += 1 err_sum += 0.0 if axis_s >= 1.0 else 1.0 scores.append(axis_s) pct = (sum(scores) / float(len(scores)) * 100.0) if scores else 0.0 mean_err = (err_sum / float(total)) if total else 0.0 return { "matched": in_band, "total": total, "pct": round(pct, 1), "mean_band_err": round(mean_err, 4), } def _trial_consensus_match( row: Dict[str, Any], mode_params: Dict[str, Any], *, params_key: str = "params", ) -> Dict[str, Any]: """trial params 가 mode(축별 최빈) 와 몇 축 일치하는지.""" params = row.get(params_key) or row.get("merged_params") or {} if not isinstance(params, dict): params = {} matched = 0 total = 0 for k, mv in (mode_params or {}).items(): rv = params.get(k) if rv is None and params_key != "merged_params": rv = (row.get("merged_params") or {}).get(k) if rv is None: continue total += 1 if str(rv) == str(mv): matched += 1 pct = (float(matched) / float(total) * 100.0) if total else 0.0 return {"matched": matched, "total": total, "pct": round(pct, 1)} def build_results_mode_consensus_tier( results: List[Dict[str, Any]], *, top_n: int = 10, mode_top_n: Optional[int] = None, grid_keys: Optional[List[str]] = None, params_key: str = "params", data: Optional[Dict[str, Any]] = None, ) -> tuple[List[Dict[str, Any]], Dict[str, Any]]: """ mode Top10 — 풀(PnL 양수 전체 등) p25~p75 밴드에 **가장 가까운** trial 순. 밴드는 pool 전체에서 계산 · 후보는 pool 전 trial(양수 전체)에서 근접도 순. mode_combo(1회 실측·trial 없음)와 달리 **실제 trial 번호**가 있음. """ mode_n = int(mode_top_n) if mode_top_n is not None else resolve_mode_top_n(20) pool_kind = resolve_mode_pool_kind() learn_pool = select_mode_pool_rows(list(results or []), data=data, top_n=mode_n) mode_meta = mode_combo_from_results( list(results or []), top_n=mode_n, grid_keys=grid_keys, params_key=params_key, pool_rows=learn_pool, ) meta: Dict[str, Any] = { "mode_top_n": mode_n, "mode_pool_size": int(mode_meta.get("pool_size") or 0), "mode_pool_kind": pool_kind, "mode_params_keys": len(mode_meta.get("params") or {}), "scoring": "band_proximity_p25_p75", "note": f"pool={pool_kind} · 축별 p25~p75 밴드 근접도(오차↓) · OPTUNA_MODE_POOL", } if not mode_meta.get("pool_size"): meta["note"] = "mode pool 없음 — results·grid_keys 확인" return [], meta candidate_pool = list(learn_pool) keys: List[str] = [] if grid_keys: keys = [k for k in grid_keys if k] if not keys: seen: set = set() for r in learn_pool: for k in (r.get(params_key) or {}).keys(): if k not in seen: seen.add(k) keys.append(k) profile = _build_mode_band_profile(learn_pool, keys, params_key=params_key) meta["band_axes"] = len(profile) scored: List[Dict[str, Any]] = [] for r in candidate_pool: m = _trial_band_proximity(r, profile, params_key=params_key) row = dict(r) row["consensus_match_pct"] = m["pct"] row["consensus_match_n"] = m["matched"] row["consensus_match_of"] = m["total"] row["consensus_band_err"] = m.get("mean_band_err") scored.append(row) scored.sort( key=lambda r: ( -float(r.get("consensus_match_pct") or 0), float(r.get("consensus_band_err") or 999.0), -float(r.get("total_pnl") or 0), -float(r.get("score") or 0), ) ) return scored[: max(1, int(top_n))], meta