Files
kis_bot/kis_trader/backtest/optuna_postprocess_topn.py
Your Name 0ecac7cb95 이번에 들어간 내용
한투 호가 = 2번째 앱키 전용
키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger.

LS RAM 합집합
후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로.

분봉 쓰레기 → 다음 소스 봉 통째
그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true.

파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개.

같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
2026-08-19 22:11:31 +09:00

1206 lines
45 KiB
Python

#!/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 _include_stable() -> bool:
return bool(get_env_bool("OPTUNA_POST_INCLUDE_STABLE", 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"),
"combo_id": ax.get("combo_id") or "",
"mask": dict(ax.get("mask") or {}),
}
def _slim_ob(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not isinstance(rec, dict):
return {"ok": False, "reason": "none"}
combos_in = rec.get("combos") if isinstance(rec.get("combos"), dict) else {}
combos_out = {str(k): _slim_axis(v) for k, v in combos_in.items()}
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")),
"combos": combos_out,
}
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] = []
stats_wrs: List[float] = []
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))
try:
stats_wrs.append(float(rs.get("win_rate")))
except (TypeError, ValueError):
pass
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)),
}
if stats_wrs:
rec_st["win_rate"] = round(float(statistics.median(stats_wrs)), 1)
return {"ok": bool(cons), "params": cons, "n": len(params_list), "recommended_stats": rec_st}
_COMBO_LABELS: Dict[str, str] = {
"base": "000 타점만",
"e": "100 진입만",
"x": "010 익절만",
"s": "001 손절만",
"ex": "110 진입+익절",
"es": "101 진입+손절",
"xs": "011 익절+손절",
"exs": "111 전부",
}
# 호가 8방 = 진입×익절×손절 (2³). 휩쏘는 8방 밖.
_COMBO_IDS = ("base", "e", "x", "s", "ex", "es", "xs", "exs")
_COMBO_MASK = {
"base": (False, False, False),
"e": (True, False, False),
"x": (False, True, False),
"s": (False, False, True),
"ex": (True, True, False),
"es": (True, False, True),
"xs": (False, True, True),
"exs": (True, True, True),
}
def _median_params(params_list: List[Dict[str, Any]]) -> Dict[str, Any]:
if not params_list:
return {}
all_keys: set = set()
for p in params_list:
all_keys.update(p.keys())
bool_keys = {
"orderbook_filter_enabled", "exit_ob_enabled", "stop_ob_enabled",
"whipsaw_filter_enabled", "entry_on", "exit_on", "stop_on",
}
int_keys = {
"exit_ob_min_hold_bars", "exit_ob_ma_window",
"stop_ob_min_hold_bars", "stop_ob_ma_window",
"whipsaw_subbar_sec", "whipsaw_lookback_sec",
}
cons: Dict[str, Any] = {}
for k in all_keys:
vs = [p[k] for p in params_list if k in p and p[k] is not None]
if not vs:
continue
if k in bool_keys or all(isinstance(v, bool) for v in vs):
cons[k] = sum(1 for v in vs if v) >= (len(vs) / 2.0)
elif k in int_keys or all(isinstance(v, int) and not isinstance(v, bool) for v in vs):
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]
return cons
def _anchor_combos_map(ob: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
"""앵커 orderbook → combo_id dict. 구 JSON은 e/x/s 단독만 복원."""
if not isinstance(ob, dict):
return {}
raw = ob.get("combos")
if isinstance(raw, dict) and raw:
return {str(k): dict(v) for k, v in raw.items() if isinstance(v, dict)}
out: Dict[str, Dict[str, Any]] = {}
for cid, nest_key in (("e", "entry"), ("x", "exit"), ("s", "stop")):
nested = ob.get(nest_key)
if isinstance(nested, dict) and nested.get("ok"):
out[cid] = nested
if not out and ob.get("ok") and ob.get("params"):
out["e"] = {"ok": True, "params": dict(ob.get("params") or {}), "recommended_stats": ob.get("recommended_stats") or {}}
return out
def _combo_median_stats(recs: List[Dict[str, Any]]) -> Dict[str, Any]:
stats_pnls: List[int] = []
stats_cnt: List[int] = []
stats_wrs: List[float] = []
for r in recs:
rs = r.get("recommended_stats") or {}
stats_pnls.append(_krw_int(rs.get("pnl")))
stats_cnt.append(int(rs.get("count") or 0))
try:
stats_wrs.append(float(rs.get("win_rate")))
except (TypeError, ValueError):
pass
if not stats_pnls:
return {}
out: Dict[str, Any] = {
"count": int(statistics.median(stats_cnt)) if stats_cnt else 0,
"pnl": _krw_int(statistics.median(stats_pnls)),
}
if stats_wrs:
out["win_rate"] = round(float(statistics.median(stats_wrs)), 1)
return out
def _axis_slice_from_combo(
combo_params: Dict[str, Any],
*,
use: bool,
axis: str,
n: int,
rec_st: Dict[str, Any],
) -> Dict[str, Any]:
if not use:
return {"ok": False, "params": {}, "recommended_stats": {}, "n": 0}
p = dict(combo_params or {})
if axis == "entry":
keys = ("orderbook_filter_enabled", "orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio", "orderbook_entry_ask_max_mult")
elif axis == "exit":
keys = ("exit_ob_enabled", "exit_ob_min_hold_bars", "exit_ob_min_profit_pct", "exit_ob_ratio_min", "exit_ob_ma_window")
else:
keys = ("stop_ob_enabled", "stop_ob_min_hold_bars", "stop_ob_min_loss_pct", "stop_ob_ratio_min", "stop_ob_ma_window")
sub = {k: p[k] for k in keys if k in p}
ok = bool(sub) and (axis != "entry" or sub.get("orderbook_max_spread_pct") is not None)
return {"ok": ok, "params": sub, "recommended_stats": dict(rec_st), "n": n}
def _consensus_from_anchors(anchors: List[Dict[str, Any]], strategy: str) -> Dict[str, Any]:
"""gated+mode. live·stable 제외. 호가=8방 중 median PnL 최고 방 + median 파라미터."""
pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]
by_combo: Dict[str, List[Dict[str, Any]]] = {cid: [] for cid in _COMBO_IDS}
for a in pool:
combos = _anchor_combos_map(a.get("orderbook") or {})
for cid in _COMBO_IDS:
c = combos.get(cid)
if isinstance(c, dict) and c.get("ok"):
by_combo[cid].append(c)
best_cid: Optional[str] = None
best_med_pnl = -10**15
for cid in _COMBO_IDS:
if cid == "base":
continue
recs = by_combo.get(cid) or []
if not recs:
continue
pnls = [_krw_int((r.get("recommended_stats") or {}).get("pnl")) for r in recs]
med = float(statistics.median(pnls)) if pnls else -10**15
if med > best_med_pnl:
best_med_pnl = med
best_cid = cid
combo_cons: Dict[str, Any] = {"ok": False, "combo_id": "", "label": "", "params": {}, "n": 0}
entry: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
exit_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
stop_c: Dict[str, Any] = {"ok": False, "params": {}, "n": 0}
ob_merged: Dict[str, Any] = {}
note = "합의=Top5(+mode) 8방 중 median PnL 최고 방 + median 파라미터. 실매 참고행 제외."
if best_cid:
recs = by_combo[best_cid]
params = _median_params([dict(r.get("params") or {}) for r in recs])
use_e, use_x, use_s = _COMBO_MASK[best_cid]
params["orderbook_filter_enabled"] = bool(use_e)
params["exit_ob_enabled"] = bool(use_x)
params["stop_ob_enabled"] = bool(use_s)
rec_st = _combo_median_stats(recs)
combo_cons = {
"ok": True,
"combo_id": best_cid,
"label": _COMBO_LABELS.get(best_cid, best_cid),
"mask": {"entry": bool(use_e), "exit": bool(use_x), "stop": bool(use_s)},
"params": params,
"recommended_stats": rec_st,
"n": len(recs),
}
ob_merged = dict(params)
entry = _axis_slice_from_combo(params, use=use_e, axis="entry", n=len(recs), rec_st=rec_st)
exit_c = _axis_slice_from_combo(params, use=use_x, axis="exit", n=len(recs), rec_st=rec_st)
stop_c = _axis_slice_from_combo(params, use=use_s, axis="stop", n=len(recs), rec_st=rec_st)
elif pool:
# 구 JSON(8방 없음): 축별 median 폴백 — apply 구스크립트 호환
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"),
)
ob_merged = dict(entry.get("params") or {})
ob_merged.update(exit_c.get("params") or {})
ob_merged.update(stop_c.get("params") or {})
note = "합의=구JSON 축분리 median(8방 없음). 후처리 재실행 권장."
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:
ws_cons = _median_params(ws_params_list)
if ws_cons:
ws_cons["whipsaw_filter_enabled"] = True
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/최빈). 실매 행 제외. 타점 적용과 별도 버튼.",
}
return {
"combo": combo_cons,
"entry": entry,
"exit": exit_c,
"stop": stop_c,
"orderbook": {
"ok": bool(ob_merged),
"params": ob_merged,
"combo_id": combo_cons.get("combo_id") or "",
"n": combo_cons.get("n") or entry.get("n") or 0,
},
"whipsaw": {"ok": bool(ws_cons), "params": ws_cons, "n": len(ws_params_list)},
"trail": trail_cons,
"strategy": strategy,
"note": note,
}
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 "") in ("gated", "mode")]
factors: List[Dict[str, Any]] = []
extra = 0.0
spreads = []
ratios = []
exit_ratios: List[float] = []
stop_ratios: List[float] = []
dips = []
arms = []
for a in pool:
ob = a.get("orderbook") or {}
combos = _anchor_combos_map(ob)
entry_p: Dict[str, Any] = {}
exit_p: Dict[str, Any] = {}
stop_p: Dict[str, Any] = {}
if combos:
for cid, keys, dest in (
("e", ("orderbook_max_spread_pct", "orderbook_min_bid_ask_ratio"), entry_p),
("x", ("exit_ob_ratio_min",), exit_p),
("s", ("stop_ob_ratio_min",), stop_p),
):
c = combos.get(cid) or {}
if c.get("ok"):
p = dict(c.get("params") or {})
for k in keys:
if p.get(k) is not None:
dest[k] = p[k]
if not entry_p:
op = ob.get("params") or {}
entry_p = dict((ob.get("entry") or {}).get("params") or op)
if not exit_p:
exit_p = dict((ob.get("exit") or {}).get("params") or {})
if not stop_p:
stop_p = dict((ob.get("stop") or {}).get("params") or {})
if ob.get("ok") or combos or ((ob.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 _reuse_ob_ws(anchors: List[Dict[str, Any]], trial: Any) -> Optional[Dict[str, Any]]:
if trial is None:
return None
try:
tn = int(trial)
except (TypeError, ValueError):
return None
for a in anchors:
try:
at = int(a.get("optuna_trial_number"))
except (TypeError, ValueError):
continue
if at != tn:
continue
ob = a.get("orderbook") or {}
if ob.get("ok") or (ob.get("entry") or {}).get("ok"):
return a
return None
def append_stable_postprocess_anchors(
data: Dict[str, Any],
anchors: List[Dict[str, Any]],
*,
strat: str,
strat_u: str,
top_n: int,
do_ob: bool,
evaluate_fn: Optional[EvalFn],
lg: logging.Logger,
ob_n_trials: int = 0,
) -> None:
"""results_stable TopN 을 후처리 표 앵커로 붙인다. 같은 trial 은 gated 호가 재사용."""
if not _include_stable():
return
if any(str(a.get("role") or "") == "stable" for a in anchors):
return
stable = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))]
for i, row in enumerate(stable, start=1):
if not isinstance(row, dict):
continue
combo = _combo_from_row(row)
trial = row.get("optuna_trial_number") or row.get("_trial_number")
reused = _reuse_ob_ws(anchors, trial)
fills: List[Dict[str, Any]] = []
pnl = row.get("total_pnl")
trail = _slim_trail(_trail_for_pnl(strat, pnl))
if do_ob:
from kis_trader.backtest import optuna_post_progress as opp
opp.next_unit(
f"stable#{i}",
"호가재사용" if reused is not None else "백테재실행·호가",
)
if reused is not None:
ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
if do_ob:
lg.info("📌 [후처리] stable#%d 호가 재사용 (gated와 동일 trial)", i)
else:
ob = {"ok": False, "reason": "light_skip"}
ws = {"ok": False, "reason": "light_skip"}
if do_ob and evaluate_fn is not None:
lg.info("📌 [후처리] stable#%d 백테 재실행 (호가/휩쏘 체결)", i)
fills = _replay_fills(evaluate_fn, combo, lg)
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"stable#{i}",
"role": "stable",
"rank": i,
"optuna_trial_number": trial,
"total_pnl": _krw_int(pnl),
"total_trades": int(row.get("total_trades") or 0),
"win_rate": row.get("win_rate"),
"pf": row.get("pf"),
"orderbook": ob,
"whipsaw": ws,
"trail": trail,
"note": "안정 후보" + (" · gated와 동일 trial 호가 재사용" if reused is not None else ""),
})
def ensure_stable_postprocess_on_payload(data: Dict[str, Any]) -> None:
"""구 JSON(gated만 있는 후처리)에도 안정 앵커를 붙여 웹 표가 나오게."""
topn = (data or {}).get("postprocess_topn")
if not isinstance(topn, dict):
return
anchors = list(topn.get("postprocess_by_anchor") or [])
if not anchors:
return
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"
before = len(anchors)
append_stable_postprocess_anchors(
data, anchors,
strat=strat, strat_u=strat_u, top_n=resolve_post_top_n(5),
do_ob=False, evaluate_fn=None, lg=logger, ob_n_trials=0,
)
if len(anchors) != before:
topn["postprocess_by_anchor"] = anchors
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]] = []
from kis_trader.backtest import optuna_post_progress as opp
stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else []
n_units = len(gated) + (len(stable_preview) if _include_stable() else 0)
if _include_mode():
n_units += 1
if _include_live():
n_units += 1
if do_ob:
opp.begin_job(lg, max(1, n_units))
for i, row in enumerate(gated, start=1):
combo = _combo_from_row(row)
fills: List[Dict[str, Any]] = []
if do_ob:
opp.next_unit(f"gated#{i}", "백테재실행·호가")
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),
"win_rate": row.get("win_rate"),
"pf": row.get("pf"),
"orderbook": ob,
"whipsaw": ws,
"trail": trail,
"note": "사후합격 후보",
})
append_stable_postprocess_anchors(
data, anchors,
strat=strat, strat_u=strat_u, top_n=top_n,
do_ob=do_ob, evaluate_fn=evaluate_fn, lg=lg, ob_n_trials=ob_n_trials,
)
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:
from kis_trader.backtest import optuna_post_progress as opp
opp.next_unit("mode", "mode_combo 호가")
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),
"win_rate": bt.get("win_rate"),
"pf": bt.get("pf"),
"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:
from kis_trader.backtest import optuna_post_progress as opp
opp.next_unit("live", "실매참고 호가")
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),
"win_rate": (live_ob.get("orig_stats") or live_ws.get("orig_stats") or {}).get("win_rate"),
"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 아님. "
"실매 참고행은 점수에 넣지 않음. 적용=호가 8방 중 하나(또는 휩쏘/트레일 별도)."
),
"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"):
cnote = consensus.get("note") or "TopN 합의(gated+mode). 실매 단독 아님."
cid = (consensus.get("combo") or {}).get("combo_id") or consensus.get("orderbook", {}).get("combo_id")
if cid:
cnote = f"{_COMBO_LABELS.get(str(cid), cid)} · {cnote}"
data["orderbook_recommend"] = {
"ok": True,
"strategy": strat_u,
"params": consensus["orderbook"]["params"],
"combo_id": cid or "",
"note": cnote,
}
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,
)
if do_ob:
from kis_trader.backtest import optuna_post_progress as opp
opp.finish_job(lg)
return data
# 구 UI 누적 upto → 방 id (하위호환). whipsaw=111방 + 휩쏘 ON.
_UPTO_TO_COMBO = {
"base": "base",
"entry": "e",
"exit": "ex",
"stop": "exs",
"whipsaw": "exs",
}
_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
role = "stable" if src == "stable" else "gated"
for a in anchors:
if str(a.get("role") or "") == role and int(a.get("rank") or 0) == rk:
return a
return None
def _normalize_strat_u(strategy: str, data: Optional[Dict[str, Any]] = None) -> str:
strat_u = str(strategy or (data or {}).get("strategy") or "").strip().upper()
if strat_u in ("TAIL", "SHORT"):
return "TAIL"
if strat_u in ("SCALPING", "SCALP"):
return "SCALP"
if strat_u in ("US_MOMENTUM",):
return "US_MOMENTUM"
if strat_u in ("MOMENTUM",):
return "MOMENTUM"
return strat_u
def _resolve_combo_id(raw: str) -> Tuple[str, bool]:
"""반환: (combo_id, include_whipsaw)."""
u = str(raw or "base").strip().lower()
if u == "trail":
return "base", False
if u == "whipsaw":
return "exs", True
if u in _COMBO_IDS:
return u, False
if u in _UPTO_TO_COMBO:
return _UPTO_TO_COMBO[u], False
# 비트 표기 허용
bit_map = {
"000": "base", "100": "e", "010": "x", "001": "s",
"110": "ex", "101": "es", "011": "xs", "111": "exs",
}
if u in bit_map:
return bit_map[u], False
raise ValueError(
f"combo/upto 는 {_COMBO_IDS}+whipsaw|trail|000~111 만 (got {raw})"
)
def build_combo_env_patch(
*,
data: Dict[str, Any],
source: str,
rank: int,
combo: str,
strategy: str,
include_whipsaw: Optional[bool] = None,
) -> Tuple[Dict[str, str], List[str]]:
"""
8방 중 하나 적용. 켠 축만 ON·숫자 반영, 끈 축은 ENABLED=false.
휩쏘는 8방 밖 — include_whipsaw=True 일 때만 붙임.
"""
from kis_trader.backtest.optuna_orderbook_recommend import (
_env_pfx,
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
cid, whip_default = _resolve_combo_id(combo)
do_whip = bool(whip_default if include_whipsaw is None else include_whipsaw)
notes: List[str] = []
strat_u = _normalize_strat_u(strategy, data)
epfx = _env_pfx(strat_u)
use_e, use_x, use_s = _COMBO_MASK[cid]
anchor = pick_postprocess_anchor(data, source, rank)
if not anchor:
notes.append("후처리 앵커 없음(구 JSON이면 재실행 필요)")
return {}, notes
ob = dict(anchor.get("orderbook") or {})
combos = ob.get("combos") if isinstance(ob.get("combos"), dict) else {}
c = combos.get(cid) if isinstance(combos.get(cid), dict) else None
# 구 JSON(combos 없음): entry/exit/stop 중첩으로 폴백
if not (c and c.get("ok")):
legacy_map = {
"e": ("entry",),
"x": ("exit",),
"s": ("stop",),
"ex": ("exit", "entry"),
"es": ("stop", "entry"),
"xs": ("stop", "exit"),
"exs": ("stop", "exit", "entry"),
}
if cid == "base":
c = {"ok": True, "params": {}}
else:
merged_p: Dict[str, Any] = {}
ok_any = False
for ax in legacy_map.get(cid, ()):
nested = ob.get(ax) if isinstance(ob.get(ax), dict) else {}
if nested.get("ok"):
ok_any = True
merged_p.update(dict(nested.get("params") or {}))
c = {"ok": ok_any, "params": merged_p} if ok_any else None
if cid != "base" and not (c and c.get("ok")):
notes.append(f"{cid} 추천 없음")
return {}, notes
params = dict((c or {}).get("params") or {})
# 마스크로 enabled 강제 (방 정의가 진실)
params["orderbook_filter_enabled"] = bool(use_e)
params["exit_ob_enabled"] = bool(use_x)
params["stop_ob_enabled"] = bool(use_s)
view = {"strategy": strat_u, "params": params, "entry": {"ok": True, "params": params},
"exit": {"ok": True, "params": params}, "stop": {"ok": True, "params": params}}
patch: Dict[str, str] = {}
if not epfx:
notes.append("전략 prefix 없음")
return {}, notes
if use_e:
ep = build_entry_ob_env_patch(view, strat_u)
if ep:
patch.update(ep)
else:
notes.append("진입 숫자 없음 → 진입 OFF")
patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
else:
patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
if epfx in ("MOMENTUM", "BREAKOUT"):
if use_x:
xp = build_exit_ob_env_patch(view, strat_u)
if xp:
patch.update(xp)
else:
notes.append("익절 숫자 없음 → 익절 OFF")
patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
else:
patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
if use_s:
sp = build_stop_ob_env_patch(view, strat_u)
if sp:
patch.update(sp)
else:
notes.append("손절 숫자 없음 → 손절 OFF")
patch[f"{epfx}_STOP_OB_ENABLED"] = "false"
else:
patch[f"{epfx}_STOP_OB_ENABLED"] = "false"
elif use_x or use_s:
notes.append("익절/손절호가 해당없음(전략)")
if do_whip:
ws = dict(anchor.get("whipsaw") or {})
ws["strategy"] = strat_u
wp = build_whipsaw_env_patch(ws)
if wp:
patch.update(wp)
elif strat_u in ("TAIL", "SHORT", "BREAKOUT"):
notes.append("휩쏘 DB 스킵(전략 특성)")
else:
notes.append("휩쏘 추천 없음")
elif epfx == "MOMENTUM":
patch[f"{epfx}_WHIPSAW_FILTER_ENABLED"] = "false"
notes.append(f"{cid} 적용 (진입={int(use_e)} 익절={int(use_x)} 손절={int(use_s)} 휩쏘={int(do_whip)})")
return patch, notes
def build_upto_env_patch(
*,
data: Dict[str, Any],
source: str,
rank: int,
upto: str,
strategy: str,
) -> Tuple[Dict[str, str], List[str]]:
"""하위호환: 구 누적 upto → 8방 combo 패치."""
u = str(upto or "base").strip().lower()
if u == "trail":
return {}, ["trail은 다단트레일 전용 경로"]
return build_combo_env_patch(
data=data, source=source, rank=rank, combo=u, strategy=strategy,
)
def _later_axes_off_patch(strat_u: str, upto: str) -> Dict[str, str]:
"""레거시 누적 off. 신규는 build_combo_env_patch 마스크 사용."""
from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
pfx = _env_pfx(strat_u)
if not pfx:
return {}
try:
cid, do_whip = _resolve_combo_id(upto)
except ValueError:
return {}
use_e, use_x, use_s = _COMBO_MASK[cid]
patch: Dict[str, str] = {}
if not use_e:
patch[f"{pfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
if not use_x and pfx in ("MOMENTUM", "BREAKOUT"):
patch[f"{pfx}_EXIT_OB_ENABLED"] = "false"
if not use_s and pfx in ("MOMENTUM", "BREAKOUT"):
patch[f"{pfx}_STOP_OB_ENABLED"] = "false"
if (not do_whip) and pfx == "MOMENTUM":
patch[f"{pfx}_WHIPSAW_FILTER_ENABLED"] = "false"
return patch