#!/usr/bin/env python3 """ Optuna 완료 후 — gated TopN + mode_combo + 실매(참고) 호가/휩쏘/트레일 후처리. 실매 엔진·봉 정합은 건드리지 않는다. JSON·웹 표시 + apply 시 합의 트레일만. 실매 행은 과적합%에 넣지 않는다. """ from __future__ import annotations import logging import statistics from typing import Any, Callable, Dict, List, Optional, Tuple from kis_trader.utils.env import get_env_bool, get_env_int logger = logging.getLogger("optuna_postprocess_topn") EvalFn = Callable[[Dict[str, Any]], Optional[Dict[str, Any]]] def _krw_int(v: Any) -> int: try: x = float(v) except (TypeError, ValueError): return 0 if x != x or abs(x) >= 1e15: return 0 return int(x) def resolve_post_top_n(default: int = 5) -> int: return max(1, int(get_env_int("OPTUNA_POST_TOP_N", int(default)))) def _include_mode() -> bool: return bool(get_env_bool("OPTUNA_POST_INCLUDE_MODE", True)) def _include_live() -> bool: return bool(get_env_bool("OPTUNA_POST_INCLUDE_LIVE", True)) def _run_ob_whipsaw_full() -> bool: """Optuna 최종 저장 기본 ON. 웹 light 경로는 호출측에서 False.""" return bool(get_env_bool("OPTUNA_POST_RUN_OB_WHIPSAW", True)) def _pop_trades(result: Optional[Dict[str, Any]]) -> Tuple[Optional[Dict[str, Any]], List[Dict[str, Any]]]: if not isinstance(result, dict): return result, [] fills = result.pop("_trades", None) if not isinstance(fills, list): fills = [] return result, fills def _slim_stats(st: Any) -> Dict[str, Any]: if not isinstance(st, dict): return {} out = dict(st) if "pnl" in out: out["pnl"] = _krw_int(out.get("pnl")) if "pnl_diff" in out: out["pnl_diff"] = _krw_int(out.get("pnl_diff")) return out def _slim_axis(ax: Any) -> Dict[str, Any]: if not isinstance(ax, dict): return {"ok": False, "reason": "none", "params": {}, "recommended_stats": {}} rs = _slim_stats(ax.get("recommended_stats")) os_ = _slim_stats(ax.get("orig_stats")) return { "ok": bool(ax.get("ok")), "reason": ax.get("reason") or "", "params": dict(ax.get("params") or {}), "recommended_stats": rs, "orig_stats": os_, "n_trials": ax.get("n_trials"), } def _slim_ob(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]: if not isinstance(rec, dict): return {"ok": False, "reason": "none"} out = { "ok": bool(rec.get("ok")), "reason": rec.get("reason") or "", "trade_count": int(rec.get("trade_count") or 0), "params": dict(rec.get("params") or {}), "orig_stats": _slim_stats(rec.get("orig_stats")), "recommended_stats": _slim_stats(rec.get("recommended_stats")), "entry": _slim_axis(rec.get("entry")), "exit": _slim_axis(rec.get("exit")), "stop": _slim_axis(rec.get("stop")), } return out def _slim_ws(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]: return _slim_ob(rec) def _slim_trail(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]: if not isinstance(rec, dict): return {"ok": False, "reason": "none", "arm_krw": 0, "anchor_krw": 0, "tiers": ""} return { "ok": bool(rec.get("ok")), "reason": rec.get("reason") or "", "prefix": rec.get("prefix"), "arm_krw": _krw_int(rec.get("arm_krw")), "anchor_krw": _krw_int(rec.get("anchor_krw")), "tiers": rec.get("tiers") or "", "mode": rec.get("mode") or "trailing", "enabled": bool(rec.get("enabled")), } def _combo_from_row(row: Dict[str, Any]) -> Dict[str, Any]: p = row.get("params") or row.get("merged_params") or {} return dict(p) if isinstance(p, dict) else {} def _replay_fills(evaluate_fn: Optional[EvalFn], combo: Dict[str, Any], lg: logging.Logger) -> List[Dict[str, Any]]: if evaluate_fn is None or not combo: return [] try: raw = evaluate_fn(dict(combo)) _res, fills = _pop_trades(raw) return fills except Exception as exc: lg.warning("⚠️ TopN 백테 재실행 실패: %s", exc) return [] def _trail_for_pnl(strategy: str, pnl: Any) -> Dict[str, Any]: from kis_trader.backtest.optuna_daily_trail_recommend import recommend_daily_trail_tiers v = float(pnl or 0) return recommend_daily_trail_tiers( top_pnls=[v] if v > 0 else [], mode_pnl=v if v > 0 else None, best_pnl=v if v > 0 else None, strategy=strategy, ) def _ob_for_anchor( *, strategy: str, out_data: Dict[str, Any], fills: Optional[List[Dict[str, Any]]], live: bool, lg: logging.Logger, n_trials: int = 0, ) -> Dict[str, Any]: from kis_trader.backtest.optuna_orderbook_recommend import recommend_orderbook_parameters hist = ( out_data.get("universe_history_source") or out_data.get("_universe_history_source") or out_data.get("history_source") ) ob_src = out_data.get("ob_source") or out_data.get("orderbook_source") try: rec = recommend_orderbook_parameters( strategy=strategy, n_trials=int(n_trials or 0), history_source=hist, ob_source=ob_src, log=lg, raw_fills=None if live else (fills or []), date_from=str(out_data.get("start") or "") or None, date_to=str(out_data.get("end") or "") or None, ) except Exception as exc: return {"ok": False, "reason": str(exc)} return _slim_ob(rec) def _ws_for_anchor( *, strategy: str, out_data: Dict[str, Any], fills: Optional[List[Dict[str, Any]]], live: bool, lg: logging.Logger, ) -> Dict[str, Any]: from kis_trader.backtest.optuna_whipsaw_recommend import recommend_whipsaw_parameters strat_u = str(strategy or "").strip().upper() try: rec = recommend_whipsaw_parameters( strategy=strat_u, n_trials=0, log=lg, raw_fills=None if live else (fills or []), date_from=str(out_data.get("start") or "") or None, date_to=str(out_data.get("end") or "") or None, market="US" if "US" in strat_u else "KR", ) except Exception as exc: return {"ok": False, "reason": str(exc)} return _slim_ws(rec) def _median_num(vals: List[Any]) -> Optional[float]: nums: List[float] = [] for v in vals: try: nums.append(float(v)) except (TypeError, ValueError): continue if not nums: return None return float(statistics.median(nums)) def _mode_val(vals: List[Any]) -> Any: clean = [v for v in vals if v is not None] if not clean: return None try: return statistics.mode(clean) except statistics.StatisticsError: return clean[0] def _consensus_axis(pool: List[Dict[str, Any]], axis: str, keys: Tuple[str, ...], bool_keys: Tuple[str, ...], int_keys: Tuple[str, ...]) -> Dict[str, Any]: params_list: List[Dict[str, Any]] = [] stats_pnls: List[int] = [] stats_cnt: List[int] = [] for a in pool: ob = a.get("orderbook") or {} nested = ob.get(axis) if isinstance(ob.get(axis), dict) else {} if nested.get("ok"): params_list.append(dict(nested.get("params") or {})) rs = nested.get("recommended_stats") or {} stats_pnls.append(_krw_int(rs.get("pnl"))) stats_cnt.append(int(rs.get("count") or 0)) elif axis == "entry" and ob.get("ok") and not nested: # 구 JSON: 합쳐진 params p = dict(ob.get("params") or {}) if p.get("orderbook_max_spread_pct") is not None: params_list.append(p) cons: Dict[str, Any] = {} if params_list: for k in keys: vs = [p.get(k) for p in params_list if k in p] if not vs: continue if k in bool_keys: cons[k] = bool(_mode_val([bool(x) for x in vs])) elif k in int_keys: m = _median_num(vs) cons[k] = int(m) if m is not None else vs[0] else: m = _median_num(vs) cons[k] = round(m, 4) if m is not None else vs[0] rec_st: Dict[str, Any] = {} if stats_pnls: rec_st = { "count": int(statistics.median(stats_cnt)) if stats_cnt else 0, "pnl": _krw_int(statistics.median(stats_pnls)), } return {"ok": bool(cons), "params": cons, "n": len(params_list), "recommended_stats": rec_st} def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]: """gated+mode 만. live 제외. 축=entry/exit/stop/whipsaw/trail.""" pool = [a for a in anchors if str(a.get("role") or "") != "live"] entry = _consensus_axis( pool, "entry", ("orderbook_filter_enabled", "orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio", "orderbook_entry_ask_max_mult"), ("orderbook_filter_enabled",), (), ) exit_c = _consensus_axis( pool, "exit", ("exit_ob_enabled", "exit_ob_min_hold_bars", "exit_ob_min_profit_pct", "exit_ob_ratio_min", "exit_ob_ma_window"), ("exit_ob_enabled",), ("exit_ob_min_hold_bars", "exit_ob_ma_window"), ) stop_c = _consensus_axis( pool, "stop", ("stop_ob_enabled", "stop_ob_min_hold_bars", "stop_ob_min_loss_pct", "stop_ob_ratio_min", "stop_ob_ma_window"), ("stop_ob_enabled",), ("stop_ob_min_hold_bars", "stop_ob_ma_window"), ) ws_params_list = [ dict((a.get("whipsaw") or {}).get("params") or {}) for a in pool if (a.get("whipsaw") or {}).get("ok") ] trail_ok = [a.get("trail") or {} for a in pool if (a.get("trail") or {}).get("ok")] ws_cons: Dict[str, Any] = {} if ws_params_list: for k in ("whipsaw_subbar_sec", "whipsaw_lookback_sec", "whipsaw_dip_pct", "whipsaw_filter_enabled"): vs = [p.get(k) for p in ws_params_list if k in p] if not vs: continue if k == "whipsaw_filter_enabled": ws_cons[k] = True elif k == "whipsaw_dip_pct": m = _median_num(vs) ws_cons[k] = round(m, 4) if m is not None else vs[0] else: m = _median_num(vs) ws_cons[k] = int(m) if m is not None else vs[0] trail_cons: Dict[str, Any] = {"ok": False} if trail_ok: arms = [_krw_int(t.get("arm_krw")) for t in trail_ok] med_arm = int(statistics.median(arms)) if arms else 0 tiers_vals = [str(t.get("tiers") or "") for t in trail_ok if t.get("tiers")] tiers = _mode_val(tiers_vals) if tiers_vals else "" prefix = str(trail_ok[0].get("prefix") or "") trail_cons = { "ok": med_arm > 0 and bool(tiers), "prefix": prefix, "arm_krw": med_arm, "anchor_krw": _krw_int(statistics.median([_krw_int(t.get("anchor_krw")) for t in trail_ok])), "tiers": tiers or "", "mode": "trailing", "enabled": True, "note": "gated+mode 합의(median/최빈). 실매 행 제외. 타점 적용과 별도 버튼.", } ob_merged = dict(entry.get("params") or {}) ob_merged.update(exit_c.get("params") or {}) ob_merged.update(stop_c.get("params") or {}) return { "entry": entry, "exit": exit_c, "stop": stop_c, "orderbook": {"ok": bool(ob_merged), "params": ob_merged, "n": entry.get("n") or 0}, "whipsaw": {"ok": bool(ws_cons), "params": ws_cons, "n": len(ws_params_list)}, "trail": trail_cons, "strategy": strategy, "note": "합의=Top5(+mode) median/최빈. 실매 참고행은 제외.", } def _dispersion_points(vals: List[float]) -> Tuple[float, str]: if len(vals) < 2: return 0.0, "표본 1 이하" med = abs(statistics.median(vals)) or 1.0 try: iqr = statistics.quantiles(vals, n=4)[2] - statistics.quantiles(vals, n=4)[0] except Exception: iqr = max(vals) - min(vals) rel = abs(iqr) / med if rel >= 0.5: return 8.0, f"상대IQR {rel:.2f} (제각각)" if rel >= 0.25: return 4.0, f"상대IQR {rel:.2f}" return 0.0, f"상대IQR {rel:.2f} (비슷)" def _postprocess_overfit_extra(anchors: List[Dict[str, Any]]) -> Tuple[float, List[Dict[str, Any]]]: pool = [a for a in anchors if str(a.get("role") or "") != "live"] factors: List[Dict[str, Any]] = [] extra = 0.0 spreads = [] ratios = [] exit_ratios: List[float] = [] stop_ratios: List[float] = [] dips = [] arms = [] for a in pool: op = (a.get("orderbook") or {}).get("params") or {} entry_p = ((a.get("orderbook") or {}).get("entry") or {}).get("params") or op exit_p = ((a.get("orderbook") or {}).get("exit") or {}).get("params") or {} stop_p = ((a.get("orderbook") or {}).get("stop") or {}).get("params") or {} if (a.get("orderbook") or {}).get("ok") or ((a.get("orderbook") or {}).get("entry") or {}).get("ok"): if entry_p.get("orderbook_max_spread_pct") is not None: spreads.append(float(entry_p["orderbook_max_spread_pct"])) if entry_p.get("orderbook_min_bid_ask_ratio") is not None: ratios.append(float(entry_p["orderbook_min_bid_ask_ratio"])) if exit_p.get("exit_ob_ratio_min") is not None: exit_ratios.append(float(exit_p["exit_ob_ratio_min"])) if stop_p.get("stop_ob_ratio_min") is not None: stop_ratios.append(float(stop_p["stop_ob_ratio_min"])) wp = (a.get("whipsaw") or {}).get("params") or {} if (a.get("whipsaw") or {}).get("ok") and wp.get("whipsaw_dip_pct") is not None: dips.append(float(wp["whipsaw_dip_pct"])) if (a.get("trail") or {}).get("ok"): arms.append(float((a.get("trail") or {}).get("arm_krw") or 0)) for fid, label, seq in ( ("ob_spread_disp", "후처리 진입 스프레드 분산", spreads), ("ob_ratio_disp", "후처리 진입 잔량비 분산", ratios), ("ob_exit_ratio_disp", "후처리 익절호가 OR 분산", exit_ratios), ("ob_stop_ratio_disp", "후처리 손절호가 OR 분산", stop_ratios), ("ws_dip_disp", "후처리 휩쏘 dip 분산", dips), ("trail_arm_disp", "후처리 트레일 ARM 분산", arms), ): pts, detail = _dispersion_points(seq) if seq else (0.0, "해당 후처리 없음") extra += pts factors.append({"id": fid, "label": label, "points": pts, "detail": detail}) extra = max(0.0, min(25.0, extra)) return extra, factors def _verdict(risk: float) -> Tuple[str, str]: if risk >= 70.0: return "비권장", "위험 · 비권장" if risk >= 40.0: return "주의", "주의" return "상대적으로낮음", "상대적으로 낮음" def attach_topn_postprocess( out_data: Dict[str, Any], *, evaluate_fn: Optional[EvalFn] = None, mode_fills: Optional[List[Dict[str, Any]]] = None, log: Optional[logging.Logger] = None, run_ob_whipsaw: Optional[bool] = None, ob_n_trials: int = 0, ) -> Dict[str, Any]: """ out_data 에 postprocess_by_anchor / consensus / apply_overfit_pct 기록. evaluate_fn 있으면 gated(+mode 미캐시) 백테 1회씩 재실행해 체결→호가/휩쏘. 없으면 트레일만(구 JSON 웹 요약). 실매 엔진 호출 없음. """ lg = log or logger data = out_data or {} strat = str(data.get("strategy") or "momentum").strip().lower() strat_u = strat.upper() if strat_u in ("TAIL", "SHORT"): strat_u = "TAIL" elif strat_u in ("SCALPING", "SCALP"): strat_u = "SCALP" top_n = resolve_post_top_n(5) do_ob = _run_ob_whipsaw_full() if run_ob_whipsaw is None else bool(run_ob_whipsaw) gated = list(data.get("results_gated") or [])[:top_n] anchors: List[Dict[str, Any]] = [] for i, row in enumerate(gated, start=1): combo = _combo_from_row(row) fills: List[Dict[str, Any]] = [] if do_ob and evaluate_fn is not None: lg.info("📌 [후처리] gated#%d 백테 재실행 (호가/휩쏘 체결)", i) fills = _replay_fills(evaluate_fn, combo, lg) pnl = row.get("total_pnl") trail = _slim_trail(_trail_for_pnl(strat, pnl)) ob = {"ok": False, "reason": "light_skip"} ws = {"ok": False, "reason": "light_skip"} if do_ob: if fills: ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials) ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg) elif evaluate_fn is None: ob = {"ok": False, "reason": "no_replay_fills"} ws = {"ok": False, "reason": "no_replay_fills"} else: ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0} ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0} anchors.append({ "id": f"gated#{i}", "role": "gated", "rank": i, "optuna_trial_number": row.get("optuna_trial_number") or row.get("_trial_number"), "total_pnl": _krw_int(pnl), "total_trades": int(row.get("total_trades") or 0), "orderbook": ob, "whipsaw": ws, "trail": trail, "note": "사후합격 후보", }) if _include_mode(): mc = data.get("mode_combo") or {} bt = mc.get("backtest") or {} mode_pnl = bt.get("total_pnl") fills_m = list(mode_fills or []) if do_ob and not fills_m and evaluate_fn is not None: mode_params = dict(mc.get("params") or {}) if mode_params: lg.info("📌 [후처리] mode_combo 백테 재실행") fills_m = _replay_fills(evaluate_fn, mode_params, lg) trail = _slim_trail(_trail_for_pnl(strat, mode_pnl)) ob = {"ok": False, "reason": "light_skip"} ws = {"ok": False, "reason": "light_skip"} if do_ob: if fills_m: ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills_m, live=False, lg=lg, n_trials=ob_n_trials) ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills_m, live=False, lg=lg) else: ob = {"ok": False, "reason": "no_replay_fills"} ws = {"ok": False, "reason": "no_replay_fills"} anchors.append({ "id": "mode", "role": "mode", "rank": None, "optuna_trial_number": None, "total_pnl": _krw_int(mode_pnl), "total_trades": int(bt.get("total_trades") or 0), "orderbook": ob, "whipsaw": ws, "trail": trail, "note": "축별 최빈 조각 모음(trial 없음)", }) if _include_live(): live_ob = {"ok": False, "reason": "light_skip"} live_ws = {"ok": False, "reason": "light_skip"} if do_ob: live_ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg, n_trials=ob_n_trials) live_ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=None, live=True, lg=lg) live_pnl = None if (live_ob.get("orig_stats") or {}).get("pnl") is not None: live_pnl = live_ob["orig_stats"]["pnl"] elif (live_ws.get("orig_stats") or {}).get("pnl") is not None: live_pnl = live_ws["orig_stats"]["pnl"] trail = _slim_trail(_trail_for_pnl(strat, live_pnl)) anchors.append({ "id": "live", "role": "live", "rank": None, "optuna_trial_number": None, "total_pnl": _krw_int(live_pnl), "total_trades": int((live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("count") or 0), "orderbook": live_ob, "whipsaw": live_ws, "trail": trail, "note": "실매 trade_history 참고 — Optuna 칸과 섞지 않음 · 과적합% 제외", }) consensus = _consensus_from_anchors(anchors, strat) extra, extra_factors = _postprocess_overfit_extra(anchors) base_risk = 0.0 try: from kis_trader.backtest.optuna_common import build_optuna_overfit_diagnostics diag = build_optuna_overfit_diagnostics(data) base_risk = float(diag.get("overfit_risk_pct") or 0) data["overfit_diagnostics"] = diag except Exception as exc: lg.warning("⚠️ overfit_diagnostics 재계산 실패: %s", exc) diag = data.get("overfit_diagnostics") or {} try: base_risk = float(diag.get("overfit_risk_pct") or 0) except (TypeError, ValueError): base_risk = 0.0 apply_pct = max(0.0, min(100.0, round(base_risk + extra, 1))) verd, verd_ui = _verdict(apply_pct) payload = { "postprocess_by_anchor": anchors, "postprocess_consensus": consensus, "apply_overfit_pct": apply_pct, "apply_overfit_verdict": verd, "apply_overfit_verdict_ui": verd_ui, "apply_overfit_base_pct": round(base_risk, 1), "apply_overfit_post_extra": round(extra, 1), "apply_overfit_factors": extra_factors, "apply_overfit_note": ( "과적합%=이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정). AI 아님. " "실매 참고행은 점수에 넣지 않음. 열 아래 적용=그 순위 누적(까지). 트레일은 별도." ), "run_ob_whipsaw": bool(do_ob), } data["postprocess_topn"] = payload data["apply_overfit_pct"] = apply_pct data["apply_overfit_verdict"] = verd # 하위호환: 기존 단일 키 = 합의 (apply 스크립트·웹 구표) if consensus.get("orderbook", {}).get("ok"): data["orderbook_recommend"] = { "ok": True, "strategy": strat_u, "params": consensus["orderbook"]["params"], "note": "TopN 합의(gated+mode). 실매 단독 아님.", } if consensus.get("whipsaw", {}).get("ok"): data["whipsaw_recommend"] = { "ok": True, "strategy": strat_u, "params": consensus["whipsaw"]["params"], "note": "TopN 합의(gated+mode). 실매 단독 아님.", } trc = consensus.get("trail") or {} if trc.get("ok"): data["daily_trail_recommend"] = { "ok": True, "strategy": strat, "prefix": trc.get("prefix"), "arm_krw": trc.get("arm_krw"), "anchor_krw": trc.get("anchor_krw"), "tiers": trc.get("tiers"), "mode": "trailing", "enabled": True, "note": trc.get("note"), } mc = data.get("mode_combo") if isinstance(mc, dict): mc["daily_trail_recommend"] = data["daily_trail_recommend"] if data.get("orderbook_recommend"): mc["orderbook_recommend"] = data["orderbook_recommend"] if data.get("whipsaw_recommend"): mc["whipsaw_recommend"] = data["whipsaw_recommend"] lg.info( "📌 [후처리 TopN] anchors=%d · 과적합%%=%.1f(%s) · ob_whipsaw=%s", len(anchors), apply_pct, verd, do_ob, ) return data _UPTO_ORDER = ("base", "entry", "exit", "stop", "whipsaw") def pick_postprocess_anchor( data: Dict[str, Any], source: str, rank: int, ) -> Optional[Dict[str, Any]]: topn = data.get("postprocess_topn") if isinstance(data, dict) else None anchors = list((topn or {}).get("postprocess_by_anchor") or []) src = str(source or "gated").strip().lower() rk = max(1, int(rank or 1)) if src == "mode": for a in anchors: if str(a.get("role") or "") == "mode": return a return None if src == "live": return None for a in anchors: if str(a.get("role") or "") == "gated" and int(a.get("rank") or 0) == rk: return a return None def build_upto_env_patch( *, data: Dict[str, Any], source: str, rank: int, upto: str, strategy: str, ) -> Tuple[Dict[str, str], List[str]]: """ 누적 패치: base는 호출측 apply_params_to_db. 여기선 entry/exit/stop/whipsaw env만. 반환: (patch, notes) """ from kis_trader.backtest.optuna_orderbook_recommend import ( build_entry_ob_env_patch, build_exit_ob_env_patch, build_stop_ob_env_patch, ) from kis_trader.backtest.optuna_whipsaw_recommend import build_whipsaw_env_patch u = str(upto or "base").strip().lower() if u not in _UPTO_ORDER: raise ValueError(f"upto 는 {_UPTO_ORDER} 만 (got {upto})") notes: List[str] = [] strat_u = str(strategy or data.get("strategy") or "").strip().upper() if strat_u in ("TAIL", "SHORT"): strat_u = "TAIL" elif strat_u in ("SCALPING", "SCALP"): strat_u = "SCALP" elif strat_u in ("US_MOMENTUM",): strat_u = "US_MOMENTUM" elif strat_u in ("MOMENTUM",): strat_u = "MOMENTUM" anchor = pick_postprocess_anchor(data, source, rank) if not anchor: notes.append("후처리 앵커 없음(구 JSON이면 재실행 필요)") return {}, notes ob = dict(anchor.get("orderbook") or {}) ob["strategy"] = strat_u ws = dict(anchor.get("whipsaw") or {}) ws["strategy"] = strat_u patch: Dict[str, str] = {} idx = _UPTO_ORDER.index(u) if u == "base": patch.update(_later_axes_off_patch(strat_u, u)) notes.append("뒤쪽 후처리축 ENABLED=false (숫자는 유지)") return patch, notes if idx >= _UPTO_ORDER.index("entry"): ep = build_entry_ob_env_patch(ob, strat_u) if ep: patch.update(ep) else: notes.append("진입호가 추천 없음") if idx >= _UPTO_ORDER.index("exit"): xp = build_exit_ob_env_patch(ob, strat_u) if xp: patch.update(xp) elif strat_u not in ("MOMENTUM", "BREAKOUT"): notes.append("익절호가 해당없음") else: notes.append("익절호가 추천 없음") if idx >= _UPTO_ORDER.index("stop"): sp = build_stop_ob_env_patch(ob, strat_u) if sp: patch.update(sp) elif strat_u not in ("MOMENTUM", "BREAKOUT"): notes.append("손절호가 해당없음") else: notes.append("손절호가 추천 없음") if idx >= _UPTO_ORDER.index("whipsaw"): wp = build_whipsaw_env_patch(ws) if wp: patch.update(wp) elif strat_u in ("TAIL", "SHORT", "BREAKOUT"): notes.append("휩쏘 DB 스킵(전략 특성)") else: notes.append("휩쏘 추천 없음") off = _later_axes_off_patch(strat_u, u) patch.update(off) if off: notes.append("선택 열 이후 축 ENABLED=false (숫자는 유지)") return patch, notes def _later_axes_off_patch(strat_u: str, upto: str) -> Dict[str, str]: """선택 열 이후는 끄기만. 스프레드·OR·dip 숫자는 안 지움.""" from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx pfx = _env_pfx(strat_u) if not pfx: return {} u = str(upto or "base").strip().lower() if u not in _UPTO_ORDER: return {} idx = _UPTO_ORDER.index(u) patch: Dict[str, str] = {} if idx < _UPTO_ORDER.index("entry"): patch[f"{pfx}_ORDERBOOK_FILTER_ENABLED"] = "false" if idx < _UPTO_ORDER.index("exit") and pfx in ("MOMENTUM", "BREAKOUT"): patch[f"{pfx}_EXIT_OB_ENABLED"] = "false" if idx < _UPTO_ORDER.index("stop") and pfx in ("MOMENTUM", "BREAKOUT"): patch[f"{pfx}_STOP_OB_ENABLED"] = "false" if idx < _UPTO_ORDER.index("whipsaw") and pfx == "MOMENTUM": patch[f"{pfx}_WHIPSAW_FILTER_ENABLED"] = "false" return patch