Files
kis_bot/kis_trader/backtest/optuna_postprocess_topn.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

749 lines
28 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 _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