알겠습니다. **한 솥에 다 넣는 TPE가 아닙니다.** 말씀은 이렇게입니다.

기본(차트 타점)은 고정입니다. 그 위에 진입·익절·손절을 **켜기/끄기 8가지로 나눕니다.** 꺼진 축은 숫자 탐색도 안 하고 시뮬에도 안 넣습니다. 켜진 축만, **그 조합 안에서** 숫자를 TPE로 찾습니다. 시뮬은 항상 **켜진 것만 쌓아서** 한 성적입니다.

| 조합 | 무엇을 TPE하나 | 시뮬 |
|---|---|---|
| 000 | 없음 | 타점만 |
| 100 | 진입 숫자만 | 진입만 |
| 010 | 익절 숫자만 | 익절만 |
| 001 | 손절 숫자만 | 손절만 |
| 110 | 진입+익절 숫자 | 진입 다음 익절 |
| 101 | 진입+손절 | 진입 다음 손절 |
| 011 | 익절+손절 | 익절 다음 손절 |
| 111 | 세 축 숫자 | 진입→익절→손절 |

그래서 8가지 **경우**이고, 각 경우가 **자기 축만** TPE입니다. 익절이 꺼진 스터디에 손절 숫자를 섞지 않습니다.

지금과 차이: 지금은 100·010·001만 있고, 110·111처럼 **같이 켠 성적**이 없습니다.

비용: 꺼짐 1번은 공짜, 나머지 7번×500이면 지금 1500보다 깁니다. 표는 8칸(또는 켜진 축까지 누적)으로 가면 됩니다.

이 그림이 맞으면 그때 후처리만 이렇게 바꾸면 됩니다. 실매 청산 순서는 그대로입니다.


<timestamp>Sunday, Aug 16, 2026, 2:36 AM (UTC+9)</timestamp>
<user_query>
그래 이 경우의수가맞고 근데 또 tpe는 섞는게 제맛인데  니가 말한것도 일리가 잇는데 아 내가 이해를 못하는건가
</user_query>


이해를 못 하는 게 아닙니다. **섞기가 두 층**이라 겹쳐 보인 겁니다.

**TPE가 섞는 제맛** = 같은 시뮬 안에서 숫자를 같이 만지는 것.
예: 111번 방에서 스프레드랑 OR이랑 손절 OR을 **한 trial에** 넣고 성적을 봄. 진입을 세게 켜면 익절 숫자가 달라져야 하는 그 맛이 여기 있습니다.

**8가지를 방마다 나누는 것** = 무기를 **쓸지 말지**는 섞지 않는 것.
익절이 꺼진 방에 익절 숫자를 넣으면, 그건 섞는 게 아니라 쓰레기 차원입니다. TPE가 헷갈립니다.

그래서 둘이 싸울 일이 없습니다.

- 방(8) = 켜기/끄기, 표에 칸으로 남김
- 방 안 TPE = 켜진 축 숫자만 섞음

제가 예전에 말한 “한 스터디 500”은 **방 고르기까지 TPE에 맡기는** 다른 그림입니다. 그것도 섞는 맛이 있지만, 표에서 “익절까지 / 손절까지”를 경우의 수로 비교하려면 방이 안 보입니다. TPE가 111만 파고 011은 안 찍을 수 있습니다.

정리: **방 8개는 당신이 정하고, 방 안에서만 TPE가 섞는다.** 그게 표 목적이고, TPE 맛도 살립니다. 한 솥에 8방을 다 넣는 건 나중 옵션입니다.


<timestamp>Sunday, Aug 16, 2026, 2:38 AM (UTC+9)</timestamp>
<user_query>
오케이 이해햇어 저 경우의 수 안에서 tpe를 돌리는거군
</user_query>


맞습니다. **8개 방 안에서만** TPE가 숫자를 섞고, 꺼진 축은 그 방에 안 넣습니다.

다음에 후처리를 그 방식으로 바꾸라고 하시면 그때 손대면 됩니다. 실매 청산 순서는 그대로 두면 됩니다.


<timestamp>Sunday, Aug 16, 2026, 2:38 AM (UTC+9)</timestamp>
<user_query>
오케이 이렇게 처리하고 그럼 경우의수가 너무 많아지는데 몇번씩 돌리지
</user_query>


8방 안에서만 TPE를 돌리되, 횟수는 1500을 크게 넘기지 않게 잡겠습니다. 싱글·더블·트리플에 나눠 배정하고, 시뮬은 켜진 축만 쌓습니다.


If the available MCP tools do not fully support what the user asked you to do, complete the work you can with the current tool set. In your work summary, include what you were unable to do with MCP and why. Do not use browser automation to work around missing or unavailable MCP tools unless the user explicitly asks you to use the browser.


횟수는 축당 500×8이 아니라 방별로 나눕니다. 싱글 150·더블 200·트리플 250(합 약 1,300)으로 넣고, 시뮬은 켠 축만 스택으로 돌리겠습니다.

StrReplace

        "OPTUNA_OB_RECOMMEND_TRIALS": "500",
        "OPTUNA_OB_AXIS_TRIALS": "500",

        "OPTUNA_OB_RECOMMEND_TRIALS": "500",
        "OPTUNA_OB_AXIS_TRIALS": "500",
        "OPTUNA_OB_COMBO_TRIALS_SINGLE": "150",
        "OPTUNA_OB_COMBO_TRIALS_DOUBLE": "200",
        "OPTUNA_OB_COMBO_TRIALS_TRIPLE": "250",

def begin_ob_axis(axis_name: str, axis_i: int) -> None:
    c = _ctx()
    c["axis_i"] = max(0, int(axis_i))
    emit(
        stage=str(c.get("stage") or "ob"),
        detail=str(axis_name or ""),
        axis_done=0,
        axis_total=int(c.get("axis_trials") or 0),
    )

def begin_ob_axis(axis_name: str, axis_i: int, axis_trials: Optional[int] = None) -> None:
    c = _ctx()
    c["axis_i"] = max(0, int(axis_i))
    if axis_trials is not None:
        c["axis_trials"] = max(0, int(axis_trials))
    emit(
        stage=str(c.get("stage") or "ob"),
        detail=str(axis_name or ""),
        axis_done=0,
        axis_total=int(c.get("axis_trials") or 0),
    )

def _ob_axis_n_trials(n_trials: int) -> int:
    from kis_trader.utils.env import get_env_int
    axis = int(get_env_int("OPTUNA_OB_AXIS_TRIALS", 0) or 0)
    if axis > 0:
        return max(10, axis)
    return max(10, int(n_trials or _ob_n_trials(500)))

def _ob_axis_n_trials(n_trials: int) -> int:
    from kis_trader.utils.env import get_env_int
    axis = int(get_env_int("OPTUNA_OB_AXIS_TRIALS", 0) or 0)
    if axis > 0:
        return max(10, axis)
    return max(10, int(n_trials or _ob_n_trials(500)))

def _ob_combo_n_trials(n_on: int) -> int:
    """8방 중 켜진 축 개수별 trial. 축당 500×8 금지 — 합이 구 1,500 근방."""
    from kis_trader.utils.env import get_env_int

    n = int(n_on or 0)
    if n <= 0:
        return 0
    if n == 1:
        raw = int(get_env_int("OPTUNA_OB_COMBO_TRIALS_SINGLE", 150) or 0)
        return max(10, raw if raw > 0 else 150)
    if n == 2:
        raw = int(get_env_int("OPTUNA_OB_COMBO_TRIALS_DOUBLE", 200) or 0)
        return max(10, raw if raw > 0 else 200)
    raw = int(get_env_int("OPTUNA_OB_COMBO_TRIALS_TRIPLE", 250) or 0)
    return max(10, raw if raw > 0 else 250)


    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _run_axis_study(

    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _entry_sim_p(p: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "max_spread_pct": p.get("max_spread_pct", p.get("orderbook_max_spread_pct")),
        "min_bid_ask_ratio": p.get("min_bid_ask_ratio", p.get("orderbook_min_bid_ask_ratio")),
        "ask_max_mult": p.get("ask_max_mult", p.get("orderbook_entry_ask_max_mult")),
    }

def _flag_on(p: Dict[str, Any], raw_key: str, enabled_key: str) -> bool:
    if raw_key in p:
        return bool(p.get(raw_key))
    return bool(p.get(enabled_key))

def _sim_stacked(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
    """켜진 축만. 진입 탈락 → 보유 중 익절호가 → 손절호가 (실매 OB 순서)."""
    use_e = _flag_on(p, "entry_on", "orderbook_filter_enabled")
    use_x = _flag_on(p, "exit_on", "exit_ob_enabled")
    use_s = _flag_on(p, "stop_on", "stop_ob_enabled")
    if use_e:
        ep = _entry_sim_p(p)
        if ep.get("max_spread_pct") is None or ep.get("min_bid_ask_ratio") is None:
            return (0.0, 0.0, "ENTRY_REJECTED")
        r = _sim_entry(tr, ep)
        if r[2] == "ENTRY_REJECTED":
            return r
    if not (use_x or use_s):
        return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")

    from kis_trader.engine.momentum_hts_logic import _check_exit_ob_l3, _check_stop_ob

    exit_p = None
    stop_p = None
    if use_x:
        exit_p = {
            "exit_ob_enabled": True,
            "exit_ob_ratio_min": float(p.get("exit_ob_ratio_min") or p.get("ob_ratio_min") or 0.4),
            "exit_ob_ma_window": int(p.get("exit_ob_ma_window") or p.get("ma_window") or 5),
            "exit_ob_min_profit_pct": float(p.get("exit_ob_min_profit_pct") or p.get("min_profit_pct") or 0.005),
            "exit_ob_min_hold_bars": int(p.get("exit_ob_min_hold_bars") or p.get("min_hold_bars") or 3),
        }
    if use_s:
        stop_p = {
            "stop_ob_enabled": True,
            "stop_ob_ratio_min": float(p.get("stop_ob_ratio_min") or 0.4),
            "stop_ob_ma_window": int(p.get("stop_ob_ma_window") or 5),
            "stop_ob_min_loss_pct": float(p.get("stop_ob_min_loss_pct") or 0.003),
            "stop_ob_min_hold_bars": int(p.get("stop_ob_min_hold_bars") or 2),
        }
    history: List[Optional[float]] = []
    for s in tr.holding_snaps:
        cur_p, ratio = _snap_or_and_price(s)
        if cur_p <= 0:
            history.append(None)
            continue
        history.append(ratio)
        hold_bars = max(0, int((s.t - tr.buy_dt).total_seconds() / 60.0))
        if exit_p and _check_exit_ob_l3(exit_p, history, tr.buy_price, cur_p, hold_bars):
            realized = (cur_p - tr.buy_price) * tr.qty
            rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
            return (realized, rate, "OB_EXIT")
        if stop_p and _check_stop_ob(stop_p, history, tr.buy_price, cur_p, hold_bars):
            realized = (cur_p - tr.buy_price) * tr.qty
            rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
            return (realized, rate, "OB_STOP")
    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _finalize_combo_params(
    p: Dict[str, Any],
    *,
    use_e: bool,
    use_x: bool,
    use_s: bool,
) -> Dict[str, Any]:
    out: Dict[str, Any] = {}
    if use_e:
        out["orderbook_filter_enabled"] = True
        out["orderbook_max_spread_pct"] = p.get("max_spread_pct", p.get("orderbook_max_spread_pct"))
        out["orderbook_min_bid_ask_ratio"] = p.get("min_bid_ask_ratio", p.get("orderbook_min_bid_ask_ratio"))
        out["orderbook_entry_ask_max_mult"] = p.get("ask_max_mult", p.get("orderbook_entry_ask_max_mult"))
    else:
        out["orderbook_filter_enabled"] = False
    if use_x:
        out["exit_ob_enabled"] = True
        out["exit_ob_min_hold_bars"] = p.get("exit_ob_min_hold_bars")
        out["exit_ob_ratio_min"] = p.get("exit_ob_ratio_min")
        out["exit_ob_min_profit_pct"] = p.get("exit_ob_min_profit_pct")
        out["exit_ob_ma_window"] = p.get("exit_ob_ma_window")
    else:
        out["exit_ob_enabled"] = False
    if use_s:
        out["stop_ob_enabled"] = True
        out["stop_ob_min_hold_bars"] = p.get("stop_ob_min_hold_bars")
        out["stop_ob_ratio_min"] = p.get("stop_ob_ratio_min")
        out["stop_ob_min_loss_pct"] = p.get("stop_ob_min_loss_pct")
        out["stop_ob_ma_window"] = p.get("stop_ob_ma_window")
    else:
        out["stop_ob_enabled"] = False
    return out

def _suggest_combo(
    trial: Any,
    *,
    use_e: bool,
    use_x: bool,
    use_s: bool,
) -> Dict[str, Any]:
    from kis_trader.utils.env import get_env_float, get_env_int

    p: Dict[str, Any] = {"entry_on": bool(use_e), "exit_on": bool(use_x), "stop_on": bool(use_s)}
    if use_e:
        lo_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MIN", 0.1))
        hi_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MAX", 8.0))
        lo_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MIN", 0.05))
        hi_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MAX", 1.5))
        lo_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MIN", 1.0))
        hi_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MAX", 80.0))
        if hi_s < lo_s:
            lo_s, hi_s = hi_s, lo_s
        if hi_r < lo_r:
            lo_r, hi_r = hi_r, lo_r
        if hi_a < lo_a:
            lo_a, hi_a = hi_a, lo_a
        p["max_spread_pct"] = trial.suggest_float("max_spread_pct", lo_s, hi_s, step=0.1)
        p["min_bid_ask_ratio"] = trial.suggest_float("min_bid_ask_ratio", lo_r, hi_r, step=0.05)
        p["ask_max_mult"] = trial.suggest_float("ask_max_mult", lo_a, hi_a, step=1.0)
    if use_x:
        p["exit_ob_enabled"] = True
        p["exit_ob_min_hold_bars"] = trial.suggest_int(
            "exit_ob_min_hold_bars",
            int(get_env_int("OPTUNA_OB_EXIT_HOLD_MIN", 1)),
            int(get_env_int("OPTUNA_OB_EXIT_HOLD_MAX", 5)),
        )
        p["exit_ob_ratio_min"] = trial.suggest_float(
            "exit_ob_ratio_min",
            float(get_env_float("OPTUNA_OB_EXIT_RATIO_MIN", 0.2)),
            float(get_env_float("OPTUNA_OB_EXIT_RATIO_MAX", 0.8)),
            step=0.05,
        )
        p["exit_ob_min_profit_pct"] = trial.suggest_float(
            "exit_ob_min_profit_pct",
            float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MIN", 0.003)),
            float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MAX", 0.02)),
            step=0.001,
        )
        p["exit_ob_ma_window"] = trial.suggest_int(
            "exit_ob_ma_window",
            int(get_env_int("OPTUNA_OB_EXIT_MA_MIN", 3)),
            int(get_env_int("OPTUNA_OB_EXIT_MA_MAX", 10)),
        )
    if use_s:
        p["stop_ob_enabled"] = True
        p["stop_ob_min_hold_bars"] = trial.suggest_int(
            "stop_ob_min_hold_bars",
            int(get_env_int("OPTUNA_OB_STOP_HOLD_MIN", 1)),
            int(get_env_int("OPTUNA_OB_STOP_HOLD_MAX", 5)),
        )
        p["stop_ob_ratio_min"] = trial.suggest_float(
            "stop_ob_ratio_min",
            float(get_env_float("OPTUNA_OB_STOP_RATIO_MIN", 0.2)),
            float(get_env_float("OPTUNA_OB_STOP_RATIO_MAX", 0.8)),
            step=0.05,
        )
        p["stop_ob_min_loss_pct"] = trial.suggest_float(
            "stop_ob_min_loss_pct",
            float(get_env_float("OPTUNA_OB_STOP_LOSS_MIN", 0.001)),
            float(get_env_float("OPTUNA_OB_STOP_LOSS_MAX", 0.02)),
            step=0.001,
        )
        p["stop_ob_ma_window"] = trial.suggest_int(
            "stop_ob_ma_window",
            int(get_env_int("OPTUNA_OB_STOP_MA_MIN", 3)),
            int(get_env_int("OPTUNA_OB_STOP_MA_MAX", 10)),
        )
    return p

def _optimize_combo(
    trades: List[TradeInfo],
    *,
    orig_cnt: int,
    n_trials: int,
    lg: logging.Logger,
    use_e: bool,
    use_x: bool,
    use_s: bool,
    axis_name: str,
    combo_id: str,
) -> Dict[str, Any]:
    orig = _ob_orig_stats(trades)
    mask = {"entry": bool(use_e), "exit": bool(use_x), "stop": bool(use_s)}
    if int(n_trials or 0) <= 0:
        return {
            "ok": True,
            "reason": "base_no_tpe",
            "params": _finalize_combo_params({}, use_e=False, use_x=False, use_s=False),
            "recommended_stats": orig,
            "orig_stats": orig,
            "n_trials": 0,
            "combo_id": combo_id,
            "mask": mask,
        }

    def suggest(trial: optuna.Trial) -> Dict[str, Any]:
        return _suggest_combo(trial, use_e=use_e, use_x=use_x, use_s=use_s)

    rec = _run_axis_study(
        trades=trades,
        orig_cnt=orig_cnt,
        n_trials=n_trials,
        suggest_fn=suggest,
        sim_fn=_sim_stacked,
        skip_reject=bool(use_e),
        lg=lg,
        axis_name=axis_name,
    )
    rec["combo_id"] = combo_id
    rec["mask"] = mask
    if rec.get("ok"):
        rec["params"] = _finalize_combo_params(
            rec.get("params") or {}, use_e=use_e, use_x=use_x, use_s=use_s,
        )
        rows = [_sim_stacked(t, rec["params"]) for t in trades]
        rec_st = _suite_stats(rows, skip_reject=bool(use_e))
        rec_st["pnl_diff"] = _krw_int(int(rec_st["pnl"]) - int(orig["pnl"]))
        rec["recommended_stats"] = rec_st
        rec["orig_stats"] = orig
    return rec

def _run_axis_study(


    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _run_axis_study(

    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _entry_sim_p(p: Dict[str, Any]) -> Dict[str, Any]:
    return {
        "max_spread_pct": p.get("max_spread_pct", p.get("orderbook_max_spread_pct")),
        "min_bid_ask_ratio": p.get("min_bid_ask_ratio", p.get("orderbook_min_bid_ask_ratio")),
        "ask_max_mult": p.get("ask_max_mult", p.get("orderbook_entry_ask_max_mult")),
    }

def _flag_on(p: Dict[str, Any], raw_key: str, enabled_key: str) -> bool:
    if raw_key in p:
        return bool(p.get(raw_key))
    return bool(p.get(enabled_key))

def _sim_stacked(tr: TradeInfo, p: Dict[str, Any]) -> Tuple[float, float, str]:
    """켜진 축만. 진입 탈락 → 보유 중 익절호가 → 손절호가 (실매 OB 순서)."""
    use_e = _flag_on(p, "entry_on", "orderbook_filter_enabled")
    use_x = _flag_on(p, "exit_on", "exit_ob_enabled")
    use_s = _flag_on(p, "stop_on", "stop_ob_enabled")
    if use_e:
        ep = _entry_sim_p(p)
        if ep.get("max_spread_pct") is None or ep.get("min_bid_ask_ratio") is None:
            return (0.0, 0.0, "ENTRY_REJECTED")
        r = _sim_entry(tr, ep)
        if r[2] == "ENTRY_REJECTED":
            return r
    if not (use_x or use_s):
        return (tr.actual_pnl, tr.actual_profit_rate, "ORIGINAL")

    from kis_trader.engine.momentum_hts_logic import _check_exit_ob_l3, _check_stop_ob

    exit_p = None
    stop_p = None
    if use_x:
        exit_p = {
            "exit_ob_enabled": True,
            "exit_ob_ratio_min": float(p.get("exit_ob_ratio_min") or p.get("ob_ratio_min") or 0.4),
            "exit_ob_ma_window": int(p.get("exit_ob_ma_window") or p.get("ma_window") or 5),
            "exit_ob_min_profit_pct": float(p.get("exit_ob_min_profit_pct") or p.get("min_profit_pct") or 0.005),
            "exit_ob_min_hold_bars": int(p.get("exit_ob_min_hold_bars") or p.get("min_hold_bars") or 3),
        }
    if use_s:
        stop_p = {
            "stop_ob_enabled": True,
            "stop_ob_ratio_min": float(p.get("stop_ob_ratio_min") or 0.4),
            "stop_ob_ma_window": int(p.get("stop_ob_ma_window") or 5),
            "stop_ob_min_loss_pct": float(p.get("stop_ob_min_loss_pct") or 0.003),
            "stop_ob_min_hold_bars": int(p.get("stop_ob_min_hold_bars") or 2),
        }
    history: List[Optional[float]] = []
    for s in tr.holding_snaps:
        cur_p, ratio = _snap_or_and_price(s)
        if cur_p <= 0:
            history.append(None)
            continue
        history.append(ratio)
        hold_bars = max(0, int((s.t - tr.buy_dt).total_seconds() / 60.0))
        if exit_p and _check_exit_ob_l3(exit_p, history, tr.buy_price, cur_p, hold_bars):
            realized = (cur_p - tr.buy_price) * tr.qty
            rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
            return (realized, rate, "OB_EXIT")
        if stop_p and _check_stop_ob(stop_p, history, tr.buy_price, cur_p, hold_bars):
            realized = (cur_p - tr.buy_price) * tr.qty
            rate = ((cur_p - tr.buy_price) / tr.buy_price) * 100.0 if tr.buy_price else 0.0
            return (realized, rate, "OB_STOP")
    return (tr.actual_pnl, tr.actual_profit_rate, "HOLD_TO_ORIG")

def _finalize_combo_params(
    p: Dict[str, Any],
    *,
    use_e: bool,
    use_x: bool,
    use_s: bool,
) -> Dict[str, Any]:
    out: Dict[str, Any] = {}
    if use_e:
        out["orderbook_filter_enabled"] = True
        out["orderbook_max_spread_pct"] = p.get("max_spread_pct", p.get("orderbook_max_spread_pct"))
        out["orderbook_min_bid_ask_ratio"] = p.get("min_bid_ask_ratio", p.get("orderbook_min_bid_ask_ratio"))
        out["orderbook_entry_ask_max_mult"] = p.get("ask_max_mult", p.get("orderbook_entry_ask_max_mult"))
    else:
        out["orderbook_filter_enabled"] = False
    if use_x:
        out["exit_ob_enabled"] = True
        out["exit_ob_min_hold_bars"] = p.get("exit_ob_min_hold_bars")
        out["exit_ob_ratio_min"] = p.get("exit_ob_ratio_min")
        out["exit_ob_min_profit_pct"] = p.get("exit_ob_min_profit_pct")
        out["exit_ob_ma_window"] = p.get("exit_ob_ma_window")
    else:
        out["exit_ob_enabled"] = False
    if use_s:
        out["stop_ob_enabled"] = True
        out["stop_ob_min_hold_bars"] = p.get("stop_ob_min_hold_bars")
        out["stop_ob_ratio_min"] = p.get("stop_ob_ratio_min")
        out["stop_ob_min_loss_pct"] = p.get("stop_ob_min_loss_pct")
        out["stop_ob_ma_window"] = p.get("stop_ob_ma_window")
    else:
        out["stop_ob_enabled"] = False
    return out

def _suggest_combo(
    trial: Any,
    *,
    use_e: bool,
    use_x: bool,
    use_s: bool,
) -> Dict[str, Any]:
    from kis_trader.utils.env import get_env_float, get_env_int

    p: Dict[str, Any] = {"entry_on": bool(use_e), "exit_on": bool(use_x), "stop_on": bool(use_s)}
    if use_e:
        lo_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MIN", 0.1))
        hi_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MAX", 8.0))
        lo_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MIN", 0.05))
        hi_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MAX", 1.5))
        lo_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MIN", 1.0))
        hi_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MAX", 80.0))
        if hi_s < lo_s:
            lo_s, hi_s = hi_s, lo_s
        if hi_r < lo_r:
            lo_r, hi_r = hi_r, lo_r
        if hi_a < lo_a:
            lo_a, hi_a = hi_a, lo_a
        p["max_spread_pct"] = trial.suggest_float("max_spread_pct", lo_s, hi_s, step=0.1)
        p["min_bid_ask_ratio"] = trial.suggest_float("min_bid_ask_ratio", lo_r, hi_r, step=0.05)
        p["ask_max_mult"] = trial.suggest_float("ask_max_mult", lo_a, hi_a, step=1.0)
    if use_x:
        p["exit_ob_enabled"] = True
        p["exit_ob_min_hold_bars"] = trial.suggest_int(
            "exit_ob_min_hold_bars",
            int(get_env_int("OPTUNA_OB_EXIT_HOLD_MIN", 1)),
            int(get_env_int("OPTUNA_OB_EXIT_HOLD_MAX", 5)),
        )
        p["exit_ob_ratio_min"] = trial.suggest_float(
            "exit_ob_ratio_min",
            float(get_env_float("OPTUNA_OB_EXIT_RATIO_MIN", 0.2)),
            float(get_env_float("OPTUNA_OB_EXIT_RATIO_MAX", 0.8)),
            step=0.05,
        )
        p["exit_ob_min_profit_pct"] = trial.suggest_float(
            "exit_ob_min_profit_pct",
            float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MIN", 0.003)),
            float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MAX", 0.02)),
            step=0.001,
        )
        p["exit_ob_ma_window"] = trial.suggest_int(
            "exit_ob_ma_window",
            int(get_env_int("OPTUNA_OB_EXIT_MA_MIN", 3)),
            int(get_env_int("OPTUNA_OB_EXIT_MA_MAX", 10)),
        )
    if use_s:
        p["stop_ob_enabled"] = True
        p["stop_ob_min_hold_bars"] = trial.suggest_int(
            "stop_ob_min_hold_bars",
            int(get_env_int("OPTUNA_OB_STOP_HOLD_MIN", 1)),
            int(get_env_int("OPTUNA_OB_STOP_HOLD_MAX", 5)),
        )
        p["stop_ob_ratio_min"] = trial.suggest_float(
            "stop_ob_ratio_min",
            float(get_env_float("OPTUNA_OB_STOP_RATIO_MIN", 0.2)),
            float(get_env_float("OPTUNA_OB_STOP_RATIO_MAX", 0.8)),
            step=0.05,
        )
        p["stop_ob_min_loss_pct"] = trial.suggest_float(
            "stop_ob_min_loss_pct",
            float(get_env_float("OPTUNA_OB_STOP_LOSS_MIN", 0.001)),
            float(get_env_float("OPTUNA_OB_STOP_LOSS_MAX", 0.02)),
            step=0.001,
        )
        p["stop_ob_ma_window"] = trial.suggest_int(
            "stop_ob_ma_window",
            int(get_env_int("OPTUNA_OB_STOP_MA_MIN", 3)),
            int(get_env_int("OPTUNA_OB_STOP_MA_MAX", 10)),
        )
    return p

def _optimize_combo(
    trades: List[TradeInfo],
    *,
    orig_cnt: int,
    n_trials: int,
    lg: logging.Logger,
    use_e: bool,
    use_x: bool,
    use_s: bool,
    axis_name: str,
    combo_id: str,
) -> Dict[str, Any]:
    orig = _ob_orig_stats(trades)
    mask = {"entry": bool(use_e), "exit": bool(use_x), "stop": bool(use_s)}
    if int(n_trials or 0) <= 0:
        return {
            "ok": True,
            "reason": "base_no_tpe",
            "params": _finalize_combo_params({}, use_e=False, use_x=False, use_s=False),
            "recommended_stats": orig,
            "orig_stats": orig,
            "n_trials": 0,
            "combo_id": combo_id,
            "mask": mask,
        }

    def suggest(trial: optuna.Trial) -> Dict[str, Any]:
        return _suggest_combo(trial, use_e=use_e, use_x=use_x, use_s=use_s)

    rec = _run_axis_study(
        trades=trades,
        orig_cnt=orig_cnt,
        n_trials=n_trials,
        suggest_fn=suggest,
        sim_fn=_sim_stacked,
        skip_reject=bool(use_e),
        lg=lg,
        axis_name=axis_name,
    )
    rec["combo_id"] = combo_id
    rec["mask"] = mask
    if rec.get("ok"):
        rec["params"] = _finalize_combo_params(
            rec.get("params") or {}, use_e=use_e, use_x=use_x, use_s=use_s,
        )
        rows = [_sim_stacked(t, rec["params"]) for t in trades]
        rec_st = _suite_stats(rows, skip_reject=bool(use_e))
        rec_st["pnl_diff"] = _krw_int(int(rec_st["pnl"]) - int(orig["pnl"]))
        rec["recommended_stats"] = rec_st
        rec["orig_stats"] = orig
    return rec

def _run_axis_study(


def _optimize_entry_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    return _optimize_combo(
        trades, orig_cnt=orig_cnt, n_trials=n_trials, lg=lg,
        use_e=True, use_x=False, use_s=False, axis_name="100 진입", combo_id="e",
    )

def _optimize_exit_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    return _optimize_combo(
        trades, orig_cnt=orig_cnt, n_trials=n_trials, lg=lg,
        use_e=False, use_x=True, use_s=False, axis_name="010 익절", combo_id="x",
    )

def _optimize_stop_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    return _optimize_combo(
        trades, orig_cnt=orig_cnt, n_trials=n_trials, lg=lg,
        use_e=False, use_x=False, use_s=True, axis_name="001 손절", combo_id="s",
    )

def _optimize_entry_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    from kis_trader.utils.env import get_env_float

    lo_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MIN", 0.1))
    hi_s = float(get_env_float("OPTUNA_OB_ENTRY_SPREAD_MAX", 8.0))
    lo_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MIN", 0.05))
    hi_r = float(get_env_float("OPTUNA_OB_ENTRY_RATIO_MAX", 1.5))
    lo_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MIN", 1.0))
    hi_a = float(get_env_float("OPTUNA_OB_ENTRY_ASK_MULT_MAX", 80.0))
    if hi_s < lo_s:
        lo_s, hi_s = hi_s, lo_s
    if hi_r < lo_r:
        lo_r, hi_r = hi_r, lo_r
    if hi_a < lo_a:
        lo_a, hi_a = hi_a, lo_a

    def suggest(trial: optuna.Trial) -> Dict[str, Any]:
        return {
            "max_spread_pct": trial.suggest_float("max_spread_pct", lo_s, hi_s, step=0.1),
            "min_bid_ask_ratio": trial.suggest_float("min_bid_ask_ratio", lo_r, hi_r, step=0.05),
            "ask_max_mult": trial.suggest_float("ask_max_mult", lo_a, hi_a, step=1.0),
        }

    rec = _run_axis_study(
        trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
        suggest_fn=suggest, sim_fn=_sim_entry, skip_reject=True, lg=lg, axis_name="진입호가",
    )
    if rec.get("ok"):
        p = rec["params"]
        rec["params"] = {
            "orderbook_filter_enabled": True,
            "orderbook_max_spread_pct": p.get("max_spread_pct"),
            "orderbook_min_bid_ask_ratio": p.get("min_bid_ask_ratio"),
            "orderbook_entry_ask_max_mult": p.get("ask_max_mult"),
        }
    return rec

def _optimize_exit_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    from kis_trader.utils.env import get_env_float, get_env_int

    def suggest(trial: optuna.Trial) -> Dict[str, Any]:
        on = trial.suggest_categorical("exit_ob_enabled", [True, False])
        return {
            "exit_ob_enabled": on,
            "exit_ob_min_hold_bars": trial.suggest_int(
                "exit_ob_min_hold_bars",
                int(get_env_int("OPTUNA_OB_EXIT_HOLD_MIN", 1)),
                int(get_env_int("OPTUNA_OB_EXIT_HOLD_MAX", 5)),
            ),
            "exit_ob_ratio_min": trial.suggest_float(
                "exit_ob_ratio_min",
                float(get_env_float("OPTUNA_OB_EXIT_RATIO_MIN", 0.2)),
                float(get_env_float("OPTUNA_OB_EXIT_RATIO_MAX", 0.8)),
                step=0.05,
            ),
            "exit_ob_min_profit_pct": trial.suggest_float(
                "exit_ob_min_profit_pct",
                float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MIN", 0.003)),
                float(get_env_float("OPTUNA_OB_EXIT_PROFIT_MAX", 0.02)),
                step=0.001,
            ),
            "exit_ob_ma_window": trial.suggest_int(
                "exit_ob_ma_window",
                int(get_env_int("OPTUNA_OB_EXIT_MA_MIN", 3)),
                int(get_env_int("OPTUNA_OB_EXIT_MA_MAX", 10)),
            ),
        }

    return _run_axis_study(
        trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
        suggest_fn=suggest, sim_fn=_sim_exit_ob, skip_reject=False, lg=lg, axis_name="익절호가",
    )

def _optimize_stop_axis(trades: List[TradeInfo], *, orig_cnt: int, n_trials: int, lg: logging.Logger) -> Dict[str, Any]:
    from kis_trader.utils.env import get_env_float, get_env_int

    def suggest(trial: optuna.Trial) -> Dict[str, Any]:
        on = trial.suggest_categorical("stop_ob_enabled", [True, False])
        return {
            "stop_ob_enabled": on,
            "stop_ob_min_hold_bars": trial.suggest_int(
                "stop_ob_min_hold_bars",
                int(get_env_int("OPTUNA_OB_STOP_HOLD_MIN", 1)),
                int(get_env_int("OPTUNA_OB_STOP_HOLD_MAX", 5)),
            ),
            "stop_ob_ratio_min": trial.suggest_float(
                "stop_ob_ratio_min",
                float(get_env_float("OPTUNA_OB_STOP_RATIO_MIN", 0.2)),
                float(get_env_float("OPTUNA_OB_STOP_RATIO_MAX", 0.8)),
                step=0.05,
            ),
            "stop_ob_min_loss_pct": trial.suggest_float(
                "stop_ob_min_loss_pct",
                float(get_env_float("OPTUNA_OB_STOP_LOSS_MIN", 0.001)),
                float(get_env_float("OPTUNA_OB_STOP_LOSS_MAX", 0.02)),
                step=0.001,
            ),
            "stop_ob_ma_window": trial.suggest_int(
                "stop_ob_ma_window",
                int(get_env_int("OPTUNA_OB_STOP_MA_MIN", 3)),
                int(get_env_int("OPTUNA_OB_STOP_MA_MAX", 10)),
            ),
        }

    return _run_axis_study(
        trades=trades, orig_cnt=orig_cnt, n_trials=n_trials,
        suggest_fn=suggest, sim_fn=_sim_stop_ob, skip_reject=False, lg=lg, axis_name="손절호가",
    )

    orig_stats = _ob_orig_stats(trades)
    orig_cnt = int(orig_stats["count"])
    can_exit_stop = strat_upper in ("MOMENTUM", "BREAKOUT")
    combo_specs: List[Tuple[str, str, bool, bool, bool]] = [
        ("e", "100 진입", True, False, False),
    ]
    if can_exit_stop:
        combo_specs.extend(
            [
                ("x", "010 익절", False, True, False),
                ("s", "001 손절", False, False, True),
                ("ex", "110 진입+익절", True, True, False),
                ("es", "101 진입+손절", True, False, True),
                ("xs", "011 익절+손절", False, True, True),
                ("exs", "111 전부", True, True, True),
            ]
        )
    from kis_trader.backtest import optuna_post_progress as opp
    n_ax = len(combo_specs)
    opp.set_ob_axes(n_ax, 0)
    combos: Dict[str, Any] = {
        "base": {
            "ok": True,
            "reason": "base_no_tpe",
            "params": {},
            "recommended_stats": orig_stats,
            "orig_stats": orig_stats,
            "n_trials": 0,
            "combo_id": "base",
            "mask": {"entry": False, "exit": False, "stop": False},
        }
    }
    for i, (cid, label, use_e, use_x, use_s) in enumerate(combo_specs):
        n_on = int(use_e) + int(use_x) + int(use_s)
        n_tr = _ob_combo_n_trials(n_on)
        opp.begin_ob_axis(label, i, n_tr)
        combos[cid] = _optimize_combo(
            trades,
            orig_cnt=orig_cnt,
            n_trials=n_tr,
            lg=lg,
            use_e=use_e,
            use_x=use_x,
            use_s=use_s,
            axis_name=label,
            combo_id=cid,
        )

    def _pick(*ids: str) -> Dict[str, Any]:
        for i in ids:
            c = combos.get(i)
            if isinstance(c, dict) and c.get("ok"):
                return c
        return {"ok": False, "reason": "no_combo", "params": {}, "recommended_stats": orig_stats}

    # 적용 버튼: 진입=100 · 익절까지=110 스택 · 손절까지=111 스택
    entry_axis = _pick("e")
    exit_axis = _pick("ex", "x") if can_exit_stop else {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}
    stop_axis = _pick("exs", "es", "xs", "s") if can_exit_stop else {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}

    merged_params: Dict[str, Any] = {}
    for ax in (entry_axis, exit_axis, stop_axis):
        if ax.get("ok"):
            merged_params.update(ax.get("params") or {})

    rec_stats = dict(stop_axis.get("recommended_stats") or exit_axis.get("recommended_stats") or entry_axis.get("recommended_stats") or orig_stats)
    lg.info(
        "⚡ [호가 8방] 전략=%s 모수=%d e=%s ex=%s exs=%s",
        strat_upper,
        orig_cnt,
        "ok" if entry_axis.get("ok") else entry_axis.get("reason"),
        "ok" if exit_axis.get("ok") else exit_axis.get("reason"),
        "ok" if stop_axis.get("ok") else stop_axis.get("reason"),
    )
    return {
        "ok": any(bool((combos.get(k) or {}).get("ok")) for k in combo_specs[0:1] or ["e"]),
        "strategy": strat_upper,
        "ob_table": table,
        "n_trials": sum(int((combos.get(c[0]) or {}).get("n_trials") or 0) for c in combo_specs),
        "trade_count": orig_cnt,
        "orig_stats": orig_stats,
        "recommended_stats": rec_stats,
        "entry": entry_axis,
        "exit": exit_axis,
        "stop": stop_axis,
        "combos": combos,
        "params": merged_params,
    }

    orig_stats = _ob_orig_stats(trades)
    orig_cnt = int(orig_stats["count"])
    axis_n = _ob_axis_n_trials(int(n_trials))
    can_exit_stop = strat_upper in ("MOMENTUM", "BREAKOUT")
    from kis_trader.backtest import optuna_post_progress as opp
    n_ax = 3 if can_exit_stop else 1
    opp.set_ob_axes(n_ax, axis_n)
    opp.begin_ob_axis("진입호가", 0)
    entry_axis = _optimize_entry_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
    exit_axis: Dict[str, Any]
    stop_axis: Dict[str, Any]
    if can_exit_stop:
        opp.begin_ob_axis("익절호가", 1)
        exit_axis = _optimize_exit_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
        opp.begin_ob_axis("손절호가", 2)
        stop_axis = _optimize_stop_axis(trades, orig_cnt=orig_cnt, n_trials=axis_n, lg=lg)
    else:
        exit_axis = {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}
        stop_axis = {"ok": False, "reason": "n/a_strategy", "params": {}, "recommended_stats": {}}

    merged_params: Dict[str, Any] = {}
    if entry_axis.get("ok"):
        merged_params.update(entry_axis.get("params") or {})
    if exit_axis.get("ok"):
        merged_params.update(exit_axis.get("params") or {})
    if stop_axis.get("ok"):
        merged_params.update(stop_axis.get("params") or {})

    rec_stats = dict(entry_axis.get("recommended_stats") or orig_stats)
    lg.info(
        "⚡ [호가 축분리] 전략=%s 모수=%d entry=%s exit=%s stop=%s",
        strat_upper,
        orig_cnt,
        "ok" if entry_axis.get("ok") else entry_axis.get("reason"),
        "ok" if exit_axis.get("ok") else exit_axis.get("reason"),
        "ok" if stop_axis.get("ok") else stop_axis.get("reason"),
    )
    return {
        "ok": bool(entry_axis.get("ok") or exit_axis.get("ok") or stop_axis.get("ok")),
        "strategy": strat_upper,
        "ob_table": table,
        "n_trials": axis_n,
        "trade_count": orig_cnt,
        "orig_stats": orig_stats,
        "recommended_stats": rec_stats,
        "entry": entry_axis,
        "exit": exit_axis,
        "stop": stop_axis,
        "params": merged_params,
    }


Optuna 차트 캔들 최적화(Stage 1)가 완료된 후, 후처리(Stage 2)로 진입/익절/손절 켜기·끄기 8방(방 안 TPE)을
돌린다. 시뮬은 켠 축만 스택(진입→익절호가→손절호가). 실매 엔진은 수정하지 않는다.

Optuna 차트 캔들 최적화(Stage 1)가 완료된 후, 후처리(Stage 2)로 축당 500회 고속 호가 탐색을 수행하여
전략(모멘텀/돌파 등)별 최적의 진입 호가필터 & 수익구간 호가매도 합의 수치(Consensus)를 도출하고
Optuna out_data 및 Apply 패치에 자동으로 결합하는 핵심 모듈입니다.

"ok": any(bool((combos.get(cid) or {}).get("ok")) for cid, *_rest in combo_specs),

"ok": any(bool((combos.get(k) or {}).get("ok")) for k in combo_specs[0:1] or ["e"]),

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_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

    ob = dict(anchor.get("orderbook") or {})
    ob["strategy"] = strat_u
    combos = ob.get("combos") if isinstance(ob.get("combos"), dict) else {}

    def _combo_ok(*ids: str) -> Optional[Dict[str, Any]]:
        for i in ids:
            c = combos.get(i)
            if isinstance(c, dict) and c.get("ok"):
                return c
        return None

    def _ob_from_combo(c: Dict[str, Any]) -> Dict[str, Any]:
        p = dict(c.get("params") or {})
        view = dict(ob)
        view["params"] = p
        view["entry"] = {"ok": True, "params": p}
        view["exit"] = {"ok": True, "params": p}
        view["stop"] = {"ok": True, "params": p}
        return view

    if u == "entry":
        c = _combo_ok("e", "100")
        if c:
            ob = _ob_from_combo(c)
    elif u == "exit":
        c = _combo_ok("ex", "110", "x", "010")
        if c:
            ob = _ob_from_combo(c)
    elif u in ("stop", "whipsaw", "trail"):
        c = _combo_ok("exs", "111", "es", "xs", "s")
        if c and u == "stop":
            ob = _ob_from_combo(c)
        elif c and u in ("whipsaw", "trail"):
            ob = _ob_from_combo(c)
    ws = dict(anchor.get("whipsaw") or {})

    ob = dict(anchor.get("orderbook") or {})
    ob["strategy"] = strat_u
    ws = dict(anchor.get("whipsaw") or {})


  const recBitsP = [];
  if (p.orderbook_max_spread_pct != null) {
    recBitsP.push(`스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`);
  }
  if (p.exit_ob_enabled != null) {
    recBitsP.push((p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : ''));
  }
  if (p.stop_ob_enabled != null) {
    recBitsP.push((p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : ''));
  }
  if (p.whipsaw_dip_pct != null) {
    recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`);
  }
  const recLine = recBitsP.join(' · ');

  let recLine = '';
  if (p.orderbook_max_spread_pct != null) {
    recLine = `스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`;
  } else if (p.exit_ob_enabled != null) {
    recLine = (p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : '');
  } else if (p.stop_ob_enabled != null) {
    recLine = (p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : '');
  } else if (p.whipsaw_dip_pct != null) {
    recLine = `sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`;
  }

기본(타점)	진입까지(100)	익절까지(110)	손절까지(111)	휩쏘

기본(타점)	진입호가	익절호가	손절호가	휩쏘

        </table>
      </div>
      ${_optunaObComboTable(ob, canExitStop)}
    </div>`;

        </table>
      </div>
    </div>`;

    entry: '기본+진입호가까지(100)',
    exit: '기본+진입+익절호가까지(110 스택)',
    stop: '기본+진입+익절+손절호가까지(111 스택)',

    entry: '기본+진입호가까지',
    exit: '기본+진입+익절호가까지',
    stop: '기본+진입+익절+손절호가까지',


+ (origBits.length ? `<div class="text-muted">필터전 ${origBits.join(' · ')}</div>` : '');
}

function _optunaObComboTable(ob, canExitStop) {
  const combos = ob && ob.combos;
  if (!combos || typeof combos !== 'object' || !Object.keys(combos).length) return '';
  const rows = canExitStop
    ? [
      ['base', '000 타점만'],
      ['e', '100 진입만'],
      ['x', '010 익절만'],
      ['s', '001 손절만'],
      ['ex', '110 진입+익절'],
      ['es', '101 진입+손절'],
      ['xs', '011 익절+손절'],
      ['exs', '111 전부'],
    ]
    : [['base', '000 타점만'], ['e', '100 진입만']];
  const trs = rows.map(([id, lab]) => {
    const c = combos[id] || {};
    const nt = (c.n_trials != null) ? c.n_trials : '—';
    return `<tr><td>${lab}</td><td>${nt}</td><td>${_optunaAxisStatsHtml(c, ob)}</td></tr>`;
  }).join('');
  return `

방(켜진 축)	trial	성적

${trs}

적용은 스택 방만: 진입=100 · 익절까지=110 · 손절까지=111. 010/001 단독은 표 참고.

`;
}

function _optunaBaseCell(a) {

    + (origBits.length ? `<div class="text-muted">필터전 ${origBits.join(' · ')}</div>` : '');
}

function _optunaBaseCell(a) {

`OPTUNA_OB_COMBO_TRIALS_SINGLE`(기본 150) / `DOUBLE`(200) / `TRIPLE`(250) = 8방 중 켜진 축 개수별 trial.
방 000은 TPE 없음. 모멘텀·돌파 합 150×3+200×3+250=1,300. 꼬리·스캘프는 진입 on/off만(150).
구 `OPTUNA_OB_AXIS_TRIALS`(500)는 축 독립 탐색용 잔여 키 — 8방 경로에서는 안 씀.

`OPTUNA_OB_AXIS_TRIALS`(기본 500) = 진입·익절·손절 **각 축** trial 수.
0이면 `OPTUNA_OB_RECOMMEND_TRIALS`(기본 500)/축.

  축 trial(2026-08-16): OPTUNA_OB_AXIS_TRIALS=500 (레거시). 8방 스택(2026-08-16):
  OPTUNA_OB_COMBO_TRIALS_SINGLE=150 / DOUBLE=200 / TRIPLE=250. 시뮬=켠 축만 스택.

축 trial(2026-08-16): OPTUNA_OB_AXIS_TRIALS=500 (진입·익절·손절 각 500).

OB·WHIPSAW trial 수 · OPTUNA_OB_COMBO_TRIALS_* · OPTUNA_OB_AXIS_TRIALS · 진입/익절/STOP 탐색 범위 —

OB·WHIPSAW trial 수 · OPTUNA_OB_AXIS_TRIALS · 진입/익절/STOP 탐색 범위 —


Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_orderbook_recommend.py /home/hoon/kis_bot/kis_trader/backtest/optuna_postprocess_topn.py /home/hoon/kis_bot/kis_trader/backtest/optuna_post_progress.py /home/hoon/kis_bot/kis_trader/backtest/optuna_common.py && python3 - <<'PY'
from kis_trader.backtest.optuna_common import ensure_optuna_gate_env_defaults
ensure_optuna_gate_env_defaults()
from kis_trader.utils.env import get_env_int, invalidate_merged_env_cache
invalidate_merged_env_cache()
print('single', get_env_int('OPTUNA_OB_COMBO_TRIALS_SINGLE', 0))
print('double', get_env_int('OPTUNA_OB_COMBO_TRIALS_DOUBLE', 0))
print('triple', get_env_int('OPTUNA_OB_COMBO_TRIALS_TRIPLE', 0))
from kis_trader.backtest.optuna_orderbook_recommend import _ob_combo_n_trials
print('n1', _ob_combo_n_trials(1), 'n2', _ob_combo_n_trials(2), 'n3', _ob_combo_n_trials(3), 'n0', _ob_combo_n_trials(0))
print('mom', 150*3+200*3+250)
PY

Compile Python and persist combo trial env keys


StrReplace

사후합격 Top5 results_gated · 「상세」=아래 후처리 표(8방 호가)

사후합격 Top5 results_gated · 「상세」=아래 후처리 표(진입호가)

안정 Top5 results_stable · 「상세」=아래 후처리 표(8방 호가) · 손실일/최악일 게이트

안정 Top5 results_stable · 「상세」=아래 후처리 표(진입호가) · 손실일/최악일 게이트

Shell

sudo systemctl restart kis_backtest_web.service; sleep 2; systemctl is-active kis_backtest_web.service; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Restart backtest web service and health-check


CallMcpTool

List open browser tabs


Open backtest web with cache-bust URL


Lock browser for Optuna tab interaction


Click Optuna navigation tab


Grep

진입까지|8방 호가|상세


CallMcpTool

(() => {
  const texts = Array.from(document.querySelectorAll('a,button,h6,th,small')).map(el => (el.innerText||'').trim()).filter(Boolean);
  const hit = texts.filter(t => /Optuna|상세|8방|진입까지|후처리/.test(t)).slice(0,40);
  const fn = typeof _optunaObComboTable === 'function';
  const src = (document.querySelector('script[src*="backtest"]')||{}).src || '';
  return {fn, src, hit, hasCombo: String(window._optunaObComboTable || fn)};
})()

Check Optuna UI strings and combo helper


(() => {
  const btns = Array.from(document.querySelectorAll('button')).filter(b => (b.innerText||'').trim() === '상세');
  const info = btns.slice(0,8).map(b => ({dis:b.disabled, title:b.title||'', html:b.outerHTML.slice(0,180)}));
  const first = btns.find(b => !b.disabled);
  if (first) first.click();
  return {n: btns.length, enabled: btns.filter(b=>!b.disabled).length, clicked: !!first, info};
})()

Click first enabled Optuna 상세 button


(() => {
  const box = document.getElementById('opt_postprocess_cards');
  const t = (box && box.innerText) || '';
  return {
    has100: t.includes('진입까지(100)'),
    has110: t.includes('익절까지(110)'),
    has111: t.includes('손절까지(111)'),
    hasRoom: t.includes('방(켜진 축)'),
    hasApplyNote: t.includes('적용은 스택'),
    snippet: t.slice(0, 500)
  };
})()

Inspect postprocess card text after 상세

(() => {
  const logs = [];
  return {
    href: location.href,
    jsErr: window.__optunaJsErr || null,
  };
})()

Check page URL after 상세 click


JSON.stringify({comboFn: typeof _optunaObComboTable, apply: typeof optunaApplyUpto, axis: typeof _optunaAxisStatsHtml})

Confirm Optuna helper functions exist

Unlock browser after Optuna verification


**방마다 횟수를 나눕니다.** 축당 500×8은 돌리지 않습니다.

| 방 | 켜진 축 | trial |
|---|---|---|
| 000 | 타점만 | 0 |
| 100 / 010 / 001 | 1축 | **150** |
| 110 / 101 / 011 | 2축 | **200** |
| 111 | 3축 | **250** |

모멘텀·돌파 한 앵커 합 **1,300**(구 500×3=1,500 근방). 꼬리·스캘프는 진입 on/off만이라 **150**.
숫자는 `env_config_ext`의 `OPTUNA_OB_COMBO_TRIALS_SINGLE/DOUBLE/TRIPLE`입니다.

이미 코드에 넣었습니다. **실매 엔진은 그대로**입니다. 후처리만 8방 TPE + 켠 축 스택 시뮬(진입 → 익절호가 → 손절호가)입니다.

적용 버튼은 스택 방만 씁니다. **진입=100, 익절까지=110, 손절까지=111.** 010·001 단독 성적은 표 참고용입니다.

웹: `kis_backtest_web.service` **active**, curl **200**. 브라우저 `http://192.168.0.149:5050/?v=ob-combo-8` → Optuna → 「상세」. 열 이름 `진입까지(100)` / `익절까지(110)` / `손절까지(111)` 확인. **콘솔 오류 없음.**
지금 JSON은 구 후처리라 8방 표는 안 나옵니다. **다음 후처리(또는 「이 잡 후처리 재실행」)부터** 1,300회·8방 표가 채워집니다.


<timestamp>Monday, Aug 17, 2026, 8:51 AM (UTC+9)</timestamp>
<user_query>
히스토리 모아서 디비에 넣는거처림 옛날 초기빙식 중기빙식 레거시 코드가 더 있나 봐쭤 지금 전략이랑 성격 안맞는것들

후처리 경우의수는 8개인데 적용은 또 왜 예전방식이야 경우의수대로 나와야지 않겠나?

그리고 기본 진입 익설호가 손절호가 휩쏘
5개인가 8이 어떻게 나오는거지?
</user_query>


질문 세 가지로 나눠 확인합니다. (1) 초기·중기 빙식 레거시, (2) 적용이 왜 예전 방식인지, (3) 표 5열과 8방의 관계입니다.


Grep

초기방식|중기방식|초기빙|중기빙|히스토리 모아서|옛날.*방식|레거시.*호가|축분리|upto

초기|중기|구방식|옛|레거시.*후처리|축당|독립.*축

TodoWrite

5열 vs 8방 설명 + 적용 UI를 8방에 맞추기

초기/중기 레거시 후처리/호가 코드 스캐대


맞습니다. 5열 누적 적용은 예전 방식이고, 8방은 진입×익절×손절(2³)입니다. 적용을 방 단위로 바꾸고, 비슷한 레거시도 같이 스캔합니다.


적용을 8방 단위로 바꾸고, 표도 경우의수 기준으로 정리합니다.

StrReplace

_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 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 (
        _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

    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"
    epfx = _env_pfx(strat_u)

    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
    combos = ob.get("combos") if isinstance(ob.get("combos"), dict) else {}

    def _combo_ok(*ids: str) -> Optional[Dict[str, Any]]:
        for i in ids:
            c = combos.get(i)
            if isinstance(c, dict) and c.get("ok"):
                return c
        return None

    def _ob_from_combo(c: Dict[str, Any]) -> Dict[str, Any]:
        p = dict(c.get("params") or {})
        view = dict(ob)
        view["params"] = p
        view["entry"] = {"ok": True, "params": p}
        view["exit"] = {"ok": True, "params": p}
        view["stop"] = {"ok": True, "params": p}
        return view

    if u == "entry":
        c = _combo_ok("e", "100")
        if c:
            ob = _ob_from_combo(c)
    elif u == "exit":
        c = _combo_ok("ex", "110", "x", "010")
        if c:
            ob = _ob_from_combo(c)
    elif u in ("stop", "whipsaw", "trail"):
        c = _combo_ok("exs", "111", "es", "xs", "s")
        if c and u == "stop":
            ob = _ob_from_combo(c)
        elif c and u in ("whipsaw", "trail"):
            ob = _ob_from_combo(c)
    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("진입호가 추천 없음 → 진입 필터 OFF")
            if epfx:
                patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
    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("익절호가 추천 없음 → 익절호가 OFF")
            if epfx:
                patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
    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("손절호가 추천 없음 → 손절호가 OFF")
            if epfx:
                patch[f"{epfx}_STOP_OB_ENABLED"] = "false"
    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

# 호가 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),
}
# 구 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


    upto_s = str(upto or "base").strip().lower() or "base"
    _allowed = (
        "base", "entry", "exit", "stop", "whipsaw", "trail",
        "e", "x", "s", "ex", "es", "xs", "exs",
        "000", "100", "010", "001", "110", "101", "011", "111",
    )
    if upto_s not in _allowed:
        raise ValueError(
            "upto/combo 는 base|e|x|s|ex|es|xs|exs|whipsaw|trail|000~111 "
            "(구 entry/exit/stop 별칭 포함) 만"
        )

    upto_s = str(upto or "base").strip().lower() or "base"
    if upto_s not in ("base", "entry", "exit", "stop", "whipsaw", "trail"):
        raise ValueError("upto 는 base|entry|exit|stop|whipsaw|trail 만")

function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive) {
  const combos = ob && ob.combos;
  const rows = canExitStop
    ? [
      ['base', '000 타점만(호가OFF)'],
      ['e', '100 진입만'],
      ['x', '010 익절만'],
      ['s', '001 손절만'],
      ['ex', '110 진입+익절'],
      ['es', '101 진입+손절'],
      ['xs', '011 익절+손절'],
      ['exs', '111 전부'],
    ]
    : [['base', '000 타점만(호가OFF)'], ['e', '100 진입만']];
  const hasCombos = combos && typeof combos === 'object' && Object.keys(combos).length;
  const btn = (cid, lab) => {
    if (isLive || !rowSrc) return '참고';
    const c = hasCombos ? (combos[cid] || {}) : null;
    const disabled = (cid !== 'base' && hasCombos && !(c && c.ok));
    if (disabled) return `<button class="btn btn-sm btn-outline-secondary py-0" disabled>${lab}</button>`;
    return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'${cid}')">${lab}</button>`;
  };
  const trs = rows.map(([id, lab]) => {
    const c = hasCombos ? (combos[id] || {}) : null;
    const nt = (c && c.n_trials != null) ? c.n_trials : (hasCombos ? '—' : '구JSON');
    const stats = hasCombos
      ? _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob)
      : '후처리 재실행 필요';
    return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td><td>${btn(id, '이 방 적용')}</td></tr>`;
  }).join('');
  return `

호가 8방 (진입×익절×손절)	trial	성적	적용

${trs}

8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.

`;
}

function _optunaObComboTable(ob, canExitStop) {
  const combos = ob && ob.combos;
  if (!combos || typeof combos !== 'object' || !Object.keys(combos).length) return '';
  const rows = canExitStop
    ? [
      ['base', '000 타점만'],
      ['e', '100 진입만'],
      ['x', '010 익절만'],
      ['s', '001 손절만'],
      ['ex', '110 진입+익절'],
      ['es', '101 진입+손절'],
      ['xs', '011 익절+손절'],
      ['exs', '111 전부'],
    ]
    : [['base', '000 타점만'], ['e', '100 진입만']];
  const trs = rows.map(([id, lab]) => {
    const c = combos[id] || {};
    const nt = (c.n_trials != null) ? c.n_trials : '—';
    return `<tr><td>${lab}</td><td>${nt}</td><td>${_optunaAxisStatsHtml(c, ob)}</td></tr>`;
  }).join('');
  return `

방(켜진 축)	trial	성적

${trs}

적용은 스택 방만: 진입=100 · 익절까지=110 · 손절까지=111. 010/001 단독은 표 참고.

`;
}


const renderOne = (row, opts) => {
    const isLive = !!(opts && opts.live);
    const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : 'gated'));
    const rowRk = row.rank || 1;
    const ob = row.orderbook || {};
    const whipBtn = () => {
      if (isLive || !rowSrc) return '참고';
      if (whipSkip) return '해당없음';
      return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'whipsaw')">111+휩쏘 적용</button>`;
    };
    const baseBtn = () => {
      if (isLive || !rowSrc) return '참고';
      return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'base')">타점만(호가OFF)</button>`;
    };
    const wr = row.win_rate != null ? row.win_rate
      : (isLive
        ? (((row.orderbook || {}).orig_stats || {}).win_rate)
        : (ofRow && ofRow.win_rate));
    const rowForBase = Object.assign({}, row, { win_rate: wr });
    const title = isLive
      ? `${row.id} · 실매 참고 (적용 없음)`
      : `${row.id} · trial ${row.optuna_trial_number != null ? '#' + row.optuna_trial_number : '—'}`
        + ` · 과적합 가능도 ${ofPct != null ? ofPct : '—'}% · ${verd || ''}`;
    return `

${title}

과적합%=추정. ${row.note || ''} · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖

차트 타점: ${_optunaBaseCell(rowForBase)} · ${baseBtn()}

      ${_optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive)}

8방 밖	성적	적용

              휩쏘
              ${_optunaAxisStatsHtml(row.whipsaw, row.whipsaw)}
              ${whipBtn()}

`;
  };

const renderOne = (row, opts) => {
    const isLive = !!(opts && opts.live);
    const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : 'gated'));
    const rowRk = row.rank || 1;
    const ob = row.orderbook || {};
    const entry = ob.entry || (ob.ok ? { ok: true, params: ob.params, recommended_stats: ob.recommended_stats } : ob);
    const exitAx = ob.exit || { ok: false, reason: canExitStop ? (ob.reason || '없음') : 'n/a_strategy' };
    const stopAx = ob.stop || { ok: false, reason: canExitStop ? (ob.reason || '없음') : 'n/a_strategy' };
    const btn = (upto, label, disabled) => {
      if (isLive || !rowSrc) return '참고';
      if (disabled) return `<button class="btn btn-sm btn-outline-secondary py-0" disabled>${label}</button>`;
      return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'${upto}')">${label}</button>`;
    };
    const wr = row.win_rate != null ? row.win_rate
      : (isLive
        ? (((row.orderbook || {}).orig_stats || {}).win_rate)
        : (ofRow && ofRow.win_rate));
    const rowForBase = Object.assign({}, row, { win_rate: wr });
    const title = isLive
      ? `${row.id} · 실매 참고 (적용 없음)`
      : `${row.id} · trial ${row.optuna_trial_number != null ? '#' + row.optuna_trial_number : '—'}`
        + ` · 과적합 가능도 ${ofPct != null ? ofPct : '—'}% · ${verd || ''}`;
    return `

${title}

과적합%=이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정). AI 아님. ${row.note || ''}

            	기본(타점)	진입까지(100)	익절까지(110)	손절까지(111)	휩쏘

              추천값
              ${_optunaBaseCell(rowForBase)}
              ${_optunaAxisStatsHtml(entry, ob)}
              ${canExitStop ? _optunaAxisStatsHtml(exitAx, ob) : '해당없음'}
              ${canExitStop ? _optunaAxisStatsHtml(stopAx, ob) : '해당없음'}
              ${_optunaAxisStatsHtml(row.whipsaw, row.whipsaw)}

              후처리 성적
              Optuna 본결과
              ${_optunaAxisStatsHtml(entry, ob)}
              ${canExitStop ? _optunaAxisStatsHtml(exitAx, ob) : '해당없음'}
              ${canExitStop ? _optunaAxisStatsHtml(stopAx, ob) : '해당없음'}
              ${_optunaAxisStatsHtml(row.whipsaw, row.whipsaw)}

              적용
              ${btn('base', '기본만', false)}
              ${btn('entry', '진입까지', false)}
              ${btn('exit', '익절까지', !canExitStop)}
              ${btn('stop', '손절까지', !canExitStop)}
              ${btn('whipsaw', '휩쏘까지', whipSkip)}

      ${_optunaObComboTable(ob, canExitStop)}

`;
  };

  const labels = {
    base: '000 타점만(호가OFF)',
    e: '100 진입만',
    x: '010 익절만',
    s: '001 손절만',
    ex: '110 진입+익절',
    es: '101 진입+손절',
    xs: '011 익절+손절',
    exs: '111 호가전부',
    entry: '100 진입만(구별칭)',
    exit: '110 진입+익절(구별칭)',
    stop: '111 호가전부(구별칭)',
    whipsaw: '111+휩쏘',
    trail: '합의 다단트레일만(타점 미변경)',
    '000': '000 타점만', '100': '100 진입만', '010': '010 익절만', '001': '001 손절만',
    '110': '110 진입+익절', '101': '101 진입+손절', '011': '011 익절+손절', '111': '111 전부',
  };
  let extra = '';
  try {
    const sum = window._optunaLastSummary || null;
    const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
    const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk));
    const ofPct = ofRow && ofRow.overfit_risk_pct != null
      ? ofRow.overfit_risk_pct
      : (sum && (sum.apply_overfit_pct != null
        ? sum.apply_overfit_pct
        : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct)));
    if (ofPct != null && ofPct !== '') {
      extra += `\n\n이 후보 과적합 ${Number(ofPct)}% (0~100 · 높을수록 위험 · 추정).`;
    }
    if (u !== 'trail') {
      extra += '\n\n선택한 방의 켠 축만 ON, 나머지 호가축은 ENABLED=false (숫자는 DB에 남김).';
    }
    if (u === 'trail') {
      const rec = sum && (sum.daily_trail_recommend
        || (sum.postprocess_topn && sum.postprocess_topn.postprocess_consensus
          && sum.postprocess_topn.postprocess_consensus.trail));
      if (rec && rec.ok) {
        extra += `\n\n${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\nARM=${Number(rec.arm_krw || 0).toLocaleString()}원`;
      }
    }
  } catch (e) { /* ignore */ }

  const labels = {
    base: '기본(타점)만',
    entry: '기본+진입호가까지(100)',
    exit: '기본+진입+익절호가까지(110 스택)',
    stop: '기본+진입+익절+손절호가까지(111 스택)',
    whipsaw: '기본+진입+익절+손절+휩쏘까지',
    trail: '합의 다단트레일만(타점 미변경)',
  };
  let extra = '';
  try {
    const sum = window._optunaLastSummary || null;
    const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
    const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk));
    const ofPct = ofRow && ofRow.overfit_risk_pct != null
      ? ofRow.overfit_risk_pct
      : (sum && (sum.apply_overfit_pct != null
        ? sum.apply_overfit_pct
        : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct)));
    if (ofPct != null && ofPct !== '') {
      extra += `\n\n이 후보 과적합 ${Number(ofPct)}% (0~100 · 높을수록 위험 · 추정).`;
    }
    if (u !== 'trail') {
      extra += '\n\n선택한 열까지 켜고, 그 뒤 축은 ENABLED=false 입니다(숫자는 DB에 남김).';
    }
    if (u === 'trail') {
      const rec = sum && (sum.daily_trail_recommend
        || (sum.postprocess_topn && sum.postprocess_topn.postprocess_consensus
          && sum.postprocess_topn.postprocess_consensus.trail));
      if (rec && rec.ok) {
        extra += `\n\n${rec.prefix}_DAILY_PROFIT_TRAIL_TIERS=${rec.tiers}\nARM=${Number(rec.arm_krw || 0).toLocaleString()}원`;
      }
    }
  } catch (e) { /* ignore */ }


## 4. 실매 DB 적용 분리

| 구분 | 차트 캔들 | 호가 후처리 |
|------|-----------|-------------|
| `--apply-best` (기본 미사용) | 차트 축 | 호가 자동 각인 아님 |
| 웹 Optuna 「이 방 적용」 | 차트 + **8방 중 하나** (`build_combo_env_patch`) | 켠 축만 ON, 끈 축 ENABLED=false |
| `whipsaw` | 차트 + **111방** + 휩쏘 | 휩쏘는 8방 밖 |
| 구 upto `entry/exit/stop` | 별칭 → `e` / `ex` / `exs` | 하위호환만 |

**8방 ≠ 표 5열.** 8 = 진입×익절×손절(2³). 예전 UI의 「기본·진입·익절·손절·휩쏘」5열 누적은 레거시.
후처리 **재실행만** 하면 JSON만 갱신. DB는 안 바뀜.

## 4. 실매 DB 적용 분리

| 구분 | 차트 캔들 | 호가 후처리 숫자 |
|------|-----------|------------------|
| `--apply-best` (기본 미사용) | 차트 축 | 호가 합의 **자동 각인 아님** |
| 웹 Optuna 「적용」 `upto` | 차트 + 선택 시 entry/exit/stop (`build_upto_env_patch`) | `upto=entry` 이상이면 호가 패치가 **들어갈 수 있음** |
| 전용 스크립트 | — | `scripts/apply_optuna_ob_consensus.py --strategy MOMENTUM` |

후처리 **재실행만** 하면 JSON만 갱신. DB는 안 바뀜. 1일 best는 과적합 가능.

  8방 스택(2026-08-16/17): OPTUNA_OB_COMBO_TRIALS_SINGLE=150 / DOUBLE=200 / TRIPLE=250.
  적용=방 단위(build_combo_env_patch). 구 누적 upto 5열은 레거시 별칭만.
  OPTUNA_OB_AXIS_TRIALS=500 은 축독립 잔여키(8방 경로 미사용).

  축 trial(2026-08-16): OPTUNA_OB_AXIS_TRIALS=500 (레거시). 8방 스택(2026-08-16):
  OPTUNA_OB_COMBO_TRIALS_SINGLE=150 / DOUBLE=200 / TRIPLE=250. 시뮬=켠 축만 스택.

Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_postprocess_topn.py /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py && python3 - <<'PY'
from kis_trader.backtest.optuna_postprocess_topn import _resolve_combo_id, _COMBO_MASK, build_upto_env_patch
for u in ['base','e','x','ex','exs','whipsaw','entry','exit','stop','110','111']:
    print(u, '->', _resolve_combo_id(u))
print('masks', len(_COMBO_MASK))
PY
sudo systemctl restart kis_backtest_web.service; sleep 2; systemctl is-active kis_backtest_web.service; curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Compile, smoke combo resolve, restart web


CallMcpTool

Open backtest web for UI verify


Lock browser for Optuna check


StrReplace

    """
    Optuna JSON 후보 → 전략별 apply_params_to_db.
    source: gated | stable | learn | mode
    rank: gated/stable/learn 1-based
    upto/combo: base|e|x|s|ex|es|xs|exs|whipsaw|trail (+ 구 entry/exit/stop·000~111)
      8방=진입×익절×손절. whipsaw=111+휩쏘. trail=합의 트레일만(타점 미적용).
    symbol: us_momentum 종목 cfg 적용 시 (없으면 job/JSON 메타 또는 전역)
    """

    """
    Optuna JSON 후보 → 전략별 apply_params_to_db.
    source: gated | stable | learn | mode
    rank: gated/stable/learn 1-based
    upto: base|entry|exit|stop|whipsaw|trail
      base=타점만. entry…whipsaw=누적 후처리. trail=합의 트레일만(타점 미적용).
    symbol: us_momentum 종목 cfg 적용 시 (없으면 job/JSON 메타 또는 전역)
    """

"실매 참고행은 점수에 넣지 않음. 적용=호가 8방 중 하나(또는 휩쏘/트레일 별도)."

"실매 참고행은 점수에 넣지 않음. 열 아래 적용=그 순위 누적(까지). 트레일은 별도."

CallMcpTool

Click Optuna tab


(() => { const b=Array.from(document.querySelectorAll('button')).find(x=>x.innerText.trim()==='상세'&&!x.disabled); if(b)b.click(); return !!b; })()

Click first 상세 button via CDP


(() => { const t=(document.getElementById('opt_postprocess_cards')||{}).innerText||''; return {has8:t.includes('호가 8방'), hasApply:t.includes('이 방 적용'), hasWhip:t.includes('111+휩쏘'), noOld5:!t.includes('진입까지(100)'), snip:t.slice(0,400)}; })()

Verify 8-room apply UI text


Unlock browser after verification


맞습니다. **5열 누적 적용은 예전 방식**이었고, 방금 **8방마다 「이 방 적용」**으로 고쳤습니다.

### 5개 vs 8개 — 왜 헷갈렸나
| | 무엇 |
|---|---|
| **8방** | 진입 × 익절호가 × 손절호가 = **2³ = 8** (켜기/끄기 조합) |
| **예전 5열** | 기본 · 진입 · 익절 · 손절 · 휩쏘 = **누적 “까지” UI** (성격이 다름) |
| **휩쏘** | 8방 **밖**. 틱 휩쏘 필터라 호가 on/off 조합에 안 들어감 |

그래서 표에 5칸이 있으면 “8이 어디?”가 됩니다. 지금은 **8방 표 + 적용 버튼**, 휩쏘는 아래 **별도**입니다.

### 적용 — 고친 내용
- 각 방(`000/100/010/…/111`)에 **「이 방 적용」**
- 켠 축만 ON, 끈 축은 `ENABLED=false`
- `whipsaw` = **111 + 휩쏘**
- 구 `entry/exit/stop` 버튼은 별칭만 (`e` / `ex` / `exs`)

웹 재시작·브라우저(`http://192.168.0.149:5050/?v=ob-combo-apply-8`)에서 8방 표·「이 방 적용」 확인. **구 JSON**이면 방 성적은 “재실행 필요” — **후처리 재실행** 후 숫자가 찹니다.

---

### 초기·중기 “방식” 레거시로 더 남은 것 (성격 안 맞는 후보)

후처리/적용 쪽:
1. **누적 upto 5열** ← 방금 제거(별칭만 남김)
2. **`OPTUNA_OB_AXIS_TRIALS` / `_ob_axis_n_trials`** — 축 독립 500회 잔여키, 8방 경로 미사용
3. **합의(`_consensus_from_anchors`)** — 여전히 entry/exit/stop을 **따로 섞음** → 8방 세상과 안 맞음 (다음 손볼 후보)
4. **`entry`/`exit`/`stop` 중첩 필드** — 하위호환용 껍데기

전략 그리드/UI (후처리와 별개):
5. **`pattern_breakout` / `pattern_pullback`**, **`mom_max_from_open_pct`** — 모멘텀 레거시 패턴·시가컷
6. **`docs/layered_exit_design.md`** — 옛 청산 설계서
7. **`remove/legacy_standalone_bots/`** — 이미 비활성

원하시면 다음으로 **합의(consensus)를 8방 best로** 맞추거나, 모멘텀 그리드 레거시 축(`pattern_*` / 시가컷) 정리 중 어떤 것부터 할지 고르면 됩니다.


<timestamp>Monday, Aug 17, 2026, 1:05 PM (UTC+9)</timestamp>
<user_query>
📈 KIS Quant Dashboard
돌파(fixed) 완료 250/250
100% · best#210 WR 75.0% · PnL 65,863원 · PF 8.79 · MDD — · 4건
모멘텀 백테 완료 2026-08-12~2026-08-14
100%
업데이트: 2026. 8. 17. 오후 1:02:08
매매
모의 · active
시세:키움(실) | 호가:키움(실) | DB:후보
📊 실거래 분석
📈 오늘 운영
⚙ 운영 설정
💼 보유·매도
📉 스캘핑 백테스트
🎣 꼬리잡기 백테스트
🚀 돌파매매 백테스트
🌊 모멘텀 백테스트
🇺🇸 해외 모멘텀
🔬 Optuna
📈 홀딩 전략
📦 박스엔진
📡 영구구독 관리
📢 DART 수주
🔬 Optuna TPE 탐색
서버 subprocess · apply-best 없음 · 해외모멘텀=전역만(종목 cfg는 🇺🇸탭)
전략 (여러 개면 순차 · 해외는 따로 체크)
모멘텀
해외모멘텀(전역)
꼬리 진입
align 다음봉시가
limit_atr 지정가
돌파 손절
fixed 고정%
atr ATR배수
스캘핑
시작
2026-08-14
종료
2026-08-17
trials
200
mode
tpe
이력소스
키움 (target_candidates)
캔들소스
기본
틱소스
기본
호가소스
기본

탐색 게이트 WR/PF=0 · 사후=results_gated · 안정=results_stable · DB 자동적용 안 함 · 2개 이상=순차(병렬 아님) · 이력소스=저장 후보 테이블 · 꼬리: align/limit_atr 중 켠 것만 · 돌파: fixed/atr 중 켠 것만(둘 다=스터디 2개 순차). 별도 「꼬리」「돌파」체크는 없음
진행
학습 trial
후처리 (호가·휩쏘 · 끝나면 「상세」)
후처리 끝 · 「상세」 가능
상태 done
잡 import_optuna_breakout_tpe_20260816_041300
study breakout_fixed_tpe_20260812_20260814_20260816_040254
trial 250 / 250 (100%) · best#210 WR 75.0% · PnL 65,863원 · PF 8.79 · MDD — · 4건
로그 —
결과 JSON /home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260816_041300.json
상위(gated): trial #210 · 거래 4 · WR 75.0% · PF 8.79 · PnL 65,863원 · gated 188/210 · stable 188
 과적합 가능도 26% · 상대적으로 낮음  과적합 가능도 26% · 상대적으로 낮음  — 완료 후에만 · TIME_* 미변경 · 위 버튼=타점만 · 트레일/호가는 아래 표 · confirm 필수 · mode_combo=TopN 최빈값 조합(표에서 뺌)
스터디 과적합 요약 (휴리스틱 · 교차검증 아님 · 적용은 아래 각 행 과적합% 보세요)
과적합 26% · 적용가능 74% · 상대적으로낮음
휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
요인	가점	설명
표본 장일	+16.0	거래일≈3일 — 다일 재검증 권장(≥5일)
거래 표본	+10.0	상위 후보 거래 4건 — 해석 시 주의
승률·PF 이상치	+0.0	이상치 없음
게이트 커버	+0.0	사후합격 188건 · stable 188건
PnL 고원	+0.0	고원 비율 1% (1건)
임계값 분포 (참고 · 적용 판단용 아님)
과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다.
다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)
BREAKOUT · ENABLED=true · MODE=trailing
ARM 35,000원 · TIERS 35000:40,70000:30,140000:20
앵커 61,766원 (mode/median/best×frac) · 타점 적용과 별도 · 이후 운영탭에서 끄기/수정 가능
학습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#210	4	75.0%	8.79	65,863	0	14,434	14434.00	26% · 상대적으로 낮음
2	#149	4	75.0%	8.39	62,481	0	14,884	14884.00	26% · 상대적으로 낮음
3	#215	4	75.0%	7.78	61,051	0	15,872	15872.00	26% · 상대적으로 낮음
4	#172	4	75.0%	8.20	60,850	0	14,434	14434.00	26% · 상대적으로 낮음
5	#248	4	75.0%	8.16	60,542	0	14,434	14434.00	26% · 상대적으로 낮음
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(8방 호가)
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#210	4	75.0%	8.79	65,863	0	14,434	14434.00	26% · 상대적으로 낮음
2	#149	4	75.0%	8.39	62,481	0	14,884	14884.00	26% · 상대적으로 낮음
3	#215	4	75.0%	7.78	61,051	0	15,872	15872.00	26% · 상대적으로 낮음
4	#172	4	75.0%	8.20	60,850	0	14,434	14434.00	26% · 상대적으로 낮음
5	#248	4	75.0%	8.16	60,542	0	14,434	14434.00	26% · 상대적으로 낮음
안정 Top5 results_stable · 「상세」=아래 후처리 표(8방 호가) · 손실일/최악일 게이트
안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. 게이트: 손실일≤1 · 최악일≥-30,000 · 활성일≥2 · λ=1
#	trial	거래	승률	PF	PnL	손실일	최악일	안정점수 ↑	과적합% ↓
1	#202	7	57.1%	3.77	48,980	0	23,662	23662.00	26% · 상대적으로 낮음
2	#179	4	75.0%	8.54	59,528	0	23,429	23429.00	26% · 상대적으로 낮음
3	#158	5	60.0%	8.30	55,484	0	23,216	23216.00	26% · 상대적으로 낮음
4	#213	7	42.9%	3.24	45,727	0	22,606	22606.00	26% · 상대적으로 낮음
5	#146	4	75.0%	9.21	55,571	0	22,148	22148.00	26% · 상대적으로 낮음
후처리 비교표 (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)
  후처리 끝 · 「상세」 가능
합의(gated+mode, 실매 제외)
진입: 없음 · 익절: 있음 · 손절: 있음 · 휩쏘: 있음 · 트레일: ARM 35,000
stable#5 · trial #146 · 과적합 가능도 26% · 상대적으로 낮음
과적합%=추정. 안정 후보 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial #146
4건 · 승률 75.0% · 55,571원
·
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	0	추천있음
필터후 4건 · 승률 75.0% · 18,035원
필터전 승률 75.0%
100 진입만	—	no_valid_trials
010 익절만	150	EXIT ON · OR≥0.22 · 보유1 · STOP OFF
필터후 4건 · 승률 100.0% · 34,036원
필터전 승률 75.0% · 18,035원
001 손절만	150	EXIT OFF · STOP ON · 손실0.016 · OR≥0.59
필터후 4건 · 승률 50.0% · 10,519원
필터전 승률 75.0% · 18,035원
110 진입+익절	—	no_valid_trials
101 진입+손절	—	no_valid_trials
011 익절+손절	200	EXIT ON · OR≥0.2 · 보유1 · STOP ON · 손실0.015 · OR≥0.65
필터후 4건 · 승률 100.0% · 33,476원
필터전 승률 75.0% · 18,035원
111 전부	—	no_valid_trials
8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.
8방 밖	성적	적용
휩쏘	sub=28 · lb=105 · dip=0.0042
필터후 4건 · 승률 75.0% · 18,035원
필터전 승률 75.0%
해당없음
live · 실매 참고 (적용 없음)
과적합%=추정. 실매 trade_history 참고 — Optuna 칸과 섞지 않음 · 과적합% 제외 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial —
4건 · 승률 50.0% · -4,851원
· 참고
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	0	추천있음
필터후 4건 · 승률 50.0% · -4,851원
필터전 승률 50.0%
참고
100 진입만	—	no_valid_trials	참고
010 익절만	150	EXIT ON · OR≥0.36 · 보유3 · STOP OFF
필터후 4건 · 승률 50.0% · 3,528원
필터전 승률 50.0% · -4,851원
참고
001 손절만	150	EXIT OFF · STOP ON · 손실0.001 · OR≥0.53
필터후 4건 · 승률 50.0% · -702원
필터전 승률 50.0% · -4,851원
참고
110 진입+익절	—	no_valid_trials	참고
101 진입+손절	—	no_valid_trials	참고
011 익절+손절	200	EXIT ON · OR≥0.25 · 보유2 · STOP ON · 손실0.001 · OR≥0.4
필터후 4건 · 승률 50.0% · 9,078원
필터전 승률 50.0% · -4,851원
참고
111 전부	—	no_valid_trials	참고
8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.
8방 밖	성적	적용
휩쏘	sub=42 · lb=84 · dip=0.0072
필터후 4건 · 승률 50.0% · -4,851원
필터전 승률 50.0%
참고
선택 후보 상세 Top5「보기」선택
아직 선택 없음
—
브리핑 미리보기
# Optuna 브리핑 — 돌파 (tpe)
기간: 2026-08-12 ~ 2026-08-14 (거래일≈3일) · trials 250/250
탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1
- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다.
학습용 통과 250건 · 사후합격(results_gated) 188건

## 과적합·적용 가능도 (휴리스틱)
- **과적합 위험 26.0%** · **적용 가능도 74.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
  - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일)
  - 거래 표본: +10.0p — 상위 후보 거래 4건 — 해석 시 주의
  - 승률·PF 이상치: +0.0p — 이상치 없음
  - 게이트 커버: +0.0p — 사후합격 188건 · stable 188건
  - PnL 고원: +0.0p — 고원 비율 1% (1건)
- 임계값 분포 표본: results_gated 상위 30건
| 파라미터 | median | p25~p75 | mode(비율) |
|---|---:|---|---|
| `max_daily_chg` | 35.0 | 29.25 ~ 44.75 | 31.0 (7%) |
| `vol_mult` | 4.2 | 3.1 ~ 4.8 | 5.0 (17%) |
| `vol_window` | 11.0 | 10.0 ~ 13.0 | 10 (27%) |
| `min_turnover_1m_pct` | 0.35 | 0.3 ~ 0.4 | 0.35 (30%) |
| `prev_chg_min` | 0.9 | 0.9 ~ 1.0 | 0.9 (50%) |
| `prev_chg_max` | 22.75 | 20.125 ~ 26.875 | 9.0 (10%) |
| `min_price` | 2000.0 | 1500.0 ~ 2875.0 | 2000 (27%) |
| `tp_pct` | 19.75 | 17.5 ~ 23.0 | 17.5 (17%) |
| `sl_mode` | — | — | fixed (100%) |
| `trail_pct` | 5.1 | 4.55 ~ 5.675 | 5.7 (17%) |
| `trail_arm_pct` | 3.85 | 1.85 ~ 4.95 | 2.4 (7%) |
| `shoulder_min_high_pct` | 4.3 | 2.525 ~ 5.2 | 1.1 (7%) |
- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._

## 이전 장에서는
- 실매 동기간 청산 4건 · 승률 50% · 합산 PnL -4,852원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이).
- 사후합격 1위: trial #210 · 거래 4건 · 승률 75.0% · PF 8.79 · PnL 65,863원

## 앞으로 장에서는
- **1~2일 Optuna best를 바로 실매에 넣지 마세요.** 다일(≥5거래일) 재검증·웹백테 1회 후 검토.
- 적용 시에는 `results_gated` 후보만. 탐색용 best(objective)와 다를 수 있습니다.
- 장이 좁아지거나 변동성이 커지면: 손절·트레일·시간창이 먼저 깨집니다. 승률 자랑보다 **손절이 살아 있는지**를 보세요.
- 사후합격이 있어도 ‘내일도 같은 장’이 아닙니다. 페이퍼/소액·모의로 1~2일 관찰 후 확대하세요.

_생성: 2026-08-16 04:18:26 (rule)_

## AI 코멘트

_(미생성: api_call_failed. 구글 키는 GEMINI_API_KEY 를 DB/환경에 넣으세요 — kis_trader/backtest/optuna_briefing.py 모듈 docstring 참고)_
최근 잡
정렬

시작↓
job_id	전략	기간	상태	trial	시작	종료
import_optuna_scalp_tpe_20260816_044543	스캘핑	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
import_optuna_breakout_tpe_20260816_042827	돌파(atr)	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
import_optuna_breakout_tpe_20260816_041300	돌파(fixed)	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
import_optuna_tail_tpe_20260816_040252	꼬리(limit_atr)	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
import_optuna_tail_tpe_20260816_035024	꼬리(align)	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
import_optuna_momentum_tpe_20260816_031742	모멘텀	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46
opt_20260816_025631_seq	순차(모멘텀+꼬리(align+limit_atr)+돌파(fixed+atr)+스캘핑)	2026-08-12~2026-08-14	done	250/250	2026-08-16T02:56:31	2026-08-16T04:47:46
import_optuna_tail_tpe_20260816_025236	꼬리(align)	2026-08-12~2026-08-14	done	50/50	2026-08-16T02:56:04	2026-08-16T02:56:04
opt_20260816_024742_tail	꼬리(align)	2026-08-12~2026-08-14	done	50/50	2026-08-16T02:47:42	2026-08-16T02:53:52
import_optuna_momentum_tpe_20260816_022501	모멘텀	2026-08-12~2026-08-14	done	50/50	2026-08-16T02:46:02	2026-08-16T02:46:02
opt_20260816_022027_mome	모멘텀	2026-08-12~2026-08-14	done	50/50	2026-08-16T02:20:27	2026-08-16T02:31:15
import_optuna_breakout_tpe_20260816_013913	돌파(fixed)	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:39:57	2026-08-16T01:39:57
opt_20260816_013629_brea	돌파(fixed)	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:36:29	2026-08-16T01:54:45
import_optuna_breakout_tpe_20260816_012812	돌파(atr)	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:36:30	2026-08-16T01:36:30
opt_20260816_012544_brea	돌파(atr)	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:25:44	2026-08-16T01:35:42
opt_20260816_012534_seq	순차(모멘텀+돌파(atr))	2026-08-12~2026-08-14	error	250/50	2026-08-16T01:25:34	2026-08-16T01:25:39
import_optuna_breakout_tpe_20260816_011525	돌파(atr)	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:23:26	2026-08-16T01:23:26
import_optuna_momentum_tpe_20260816_010709	모멘텀	2026-08-12~2026-08-14	done	50/50	2026-08-16T01:09:42	2026-08-16T01:09:42
opt_20260816_010242_seq	순차(모멘텀+돌파(atr))	2026-08-12~2026-08-14	done	250/50	2026-08-16T01:02:42	2026-08-16T01:23:26
import_optuna_scalp_tpe_20260816_005458	스캘핑	2026-08-12~2026-08-14	done	50/50	2026-08-16T00:55:44	2026-08-16T00:55:44

필터후 4건 · 승률 50.0% · 10,519원 이건 왜 노란색이야?
</user_query>


노란색 조건이 `_optunaAxisStatsHtml`에 있는지 확인합니다.


**승률이 필터 전보다 떨어졌을 때** 노란(`text-warning`)으로 표시합니다.

조건은 이겁니다.

```7974:7975:static/js/backtest.js
  const wrCls = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr)
    ? 'text-warning' : 'text-muted';
```

말씀하신 **001 손절만** 칸을 보면:

| | 승률 | PnL |
|---|---|---|
| 필터전(타점만) | **75.0%** | 18,035원 |
| 필터후(손절호가 ON) | **50.0%** | 10,519원 |

50% < 75% 이라서 노란색입니다.
의미는 “PnL이 플러스여도 **승률이 깎였다**”는 주의 표시입니다. 손절호가가 회복될 수도 있던 건을 손실로 확정하면 이런 일이 납니다.

반대로 **010 익절만**처럼 필터후 승률 100% > 필터전 75% 이면 회색(`text-muted`)으로 나옵니다.

버그가 아니라 **의도된 경고색**입니다.


<timestamp>Monday, Aug 17, 2026, 1:06 PM (UTC+9)</timestamp>
<user_query>
초기·중기 “방식” 레거시로 더 남은 것 (성격 안 맞는 후보)
후처리/적용 쪽:

누적 upto 5열 ← 방금 제거(별칭만 남김)
OPTUNA_OB_AXIS_TRIALS / _ob_axis_n_trials — 축 독립 500회 잔여키, 8방 경로 미사용
합의(_consensus_from_anchors) — 여전히 entry/exit/stop을 따로 섞음 → 8방 세상과 안 맞음 (다음 손볼 후보)
entry/exit/stop 중첩 필드 — 하위호환용 껍데기
전략 그리드/UI (후처리와 별개): 5. pattern_breakout / pattern_pullback, mom_max_from_open_pct — 모멘텀 레거시 패턴·시가컷 6. docs/layered_exit_design.md — 옛 청산 설계서 7. remove/legacy_standalone_bots/ — 이미 비활성

원하시면 다음으로 합의(consensus)를 8방 best로 맞추거나, 모멘텀 그리드 레거시 축(pattern_* / 시가컷) 정리 중 어떤 것부터 할지 고르면 됩니다.

뭐 죄다 영어로 해서 어디가 어딘지 모르겟네 어디서 사용하는건지 다시 설명
</user_query>


영문 심볼 말고, **화면·역할** 기준으로만 다시 풀겠습니다.

---

### 후처리 / 적용 쪽

**1. 예전 「누적 5열」적용**
- **뭐였나:** 표에 `기본 → 진입 → 익절 → 손절 → 휩쏘`를 **한 줄로 쌓아서** “여기까지 켜기” 하던 방식.
- **어디:** Optuna 탭 → Top5 「상세」 아래 후처리 표.
- **지금:** 그 방식은 빼고, **8방마다 「이 방 적용」**으로 바꿈. 예전 이름(`entry` 등)은 코드 안에만 별칭으로 남음.

**2. `OPTUNA_OB_AXIS_TRIALS` / 축당 500회**
- **뭐였나:** 예전에 진입·익절·손절을 **각각 따로 500번** 돌리던 설정값.
- **어디:** DB `env_config_ext`에 키로 남아 있을 수 있음. **화면에는 안 나옴.**
- **지금:** 실제로는 **방별로 150/200/250** 씀. 이 500 키는 **안 쓰는 잔여**.

**3. 합의(consensus)**
- **뭐였나:** Top5 여러 후보의 진입/익절/손절 숫자를 **각각 따로 섞어서** “합의값” 하나 만드는 것.
- **어디:** Optuna 상세 위쪽 문구
  `합의(gated+mode…) 진입:없음 · 익절:있음 · …`
- **문제:** 지금은 **8방 세트**로 돌리는데, 합의는 아직 **예전처럼 축을 따로 섞음** → 표의 8방과 성격이 안 맞음.
- **손보면:** “합의도 **제일 잘 나온 방(예: 011)** 하나”로 맞추는 쪽.

**4. `entry` / `exit` / `stop` 중첩 필드**
- **뭐였나:** JSON 안에 `orderbook.entry`, `.exit`, `.stop`처럼 **칸 3개로 나눠 저장**하던 껍데기.
- **어디:** 결과 JSON / 구 코드. 화면의 **8방 표**가 본체고, 이건 **옛 형식 호환용**.
- **사용자 체감:** 거의 없음. 개발·구잡 호환용.

---

### 전략 그리드 / 문서 (후처리랑 별개)

**5. 모멘텀 레거시 패턴·시가컷**
- **이름 풀이:**
  - `pattern_breakout` / `pattern_pullback` → 예전에 쓰던 **분봉 돌파·눌림 패턴** 스위치
  - `mom_max_from_open_pct` → **당일 시가 대비 과열%** 상한 (웹에 “시가+과열(legacy)”로 보임)
- **어디:** 모멘텀(·해외) 백테 탭 파라미터, Optuna/그리드에도 섞여 있을 수 있음.
- **지금 전략:** HTS 후보 + TRIGGER 타점·청산이 본체. 위 축은 **옛 성격**이라 “정리할지” 후보.

**6. `docs/layered_exit_design.md`**
- **뭐였나:** 예전에 쓰던 **계단식 청산 설계 메모**.
- **어디:** 문서 폴더만. 실매 코드 경로 아님.
- **현행:** `docs/호가.md` 쪽이 진실에 가깝다고 표기된 옛 문서.

**7. `remove/legacy_standalone_bots/`**
- **뭐였나:** 아주 옛날 **단독 봇 스크립트** 보관함.
- **어디:** `remove/` 폴더. **실행·웹과 연결 안 됨.** 이미 치워 둔 것.

---

### 한 줄로

| 번호 | 쉬운 말 | 지금 상태 |
|---|---|---|
| 1 | 상세 표 「쌓아서 적용」 | 거의 정리됨 |
| 2 | 축마다 500번 돌리기 설정 | 안 쓰는데 DB/코드에 잔여 |
| 3 | 상세 위 「합의」한 줄 | **8방이랑 안 맞음 · 손볼 1순위** |
| 4 | JSON 옛 칸 3개 | 호환용 껍데기 |
| 5 | 모멘텀 옛 패턴·시가컷 | 탭/그리드에 남을 수 있음 |
| 6 | 옛 청산 설계 MD | 문서만 |
| 7 | 옛날 단독 봇 | 이미 비활성 |

원하시면 다음으로 **③ 합의 문구를 8방 best에 맞추기**부터 하면, 화면에서 “진입 없음 · 익절 있음”처럼 **8방이랑 어긋나 보이는 부분**이 바로 정리됩니다.