    logger.info(
        "🔬 Optuna SCALP | study=%s | mode=%s | trials=%d | sort=%s | universe=%s | ticks=%s",
        study_name, ctx.mode, n_trials, sort_by, ctx.universe_source,
        f"{ctx.tick_rows:,}",
    )
    t0 = time.time()
    try:
        if n_trials <= 0:
            logger.info("📌 추가 trial 없음 — 기존 study 결과만 정리")
        else:
            study.optimize(
                objective,
                n_trials=n_trials,
                n_jobs=n_jobs,
                show_progress_bar=show_progress,
                callbacks=[make_study_goal_stop_callback(logger)],
            )
        elapsed = time.time() - t0

        passing: List[Dict[str, Any]] = []
        for trial in study.trials:
            if trial.state != optuna.trial.TrialState.COMPLETE:
                continue
            if not trial.user_attrs.get("gates_ok"):
                continue
            merged_raw = trial.user_attrs.get("merged_json") or "{}"
            try:
                merged = json.loads(merged_raw)

    logger.info(
        "🔬 Optuna SCALP | study=%s | mode=%s | trials=%d | sort=%s | universe=%s | ticks=%s",
        study_name, ctx.mode, n_trials, sort_by, ctx.universe_source,
        f"{ctx.tick_rows:,}",
    )
    t0 = time.time()
    try:
        study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=show_progress)
        elapsed = time.time() - t0

        passing: List[Dict[str, Any]] = []
        for trial in study.trials:
            if trial.state != optuna.trial.TrialState.COMPLETE:
                continue
            if not trial.user_attrs.get("gates_ok"):
                continue
            merged_raw = trial.user_attrs.get("merged_json") or "{}"
            try:
                merged = json.loads(merged_raw)

    logger.info(
        "🔬 Optuna 시작 | study=%s | trials=%d | sampler=%s | storage=%s | n_jobs=%d",
        study_name, n_trials, sampler_name, storage_url, n_jobs,
    )
    t0 = time.time()
    try:
        if n_trials <= 0:
            logger.info("📌 추가 trial 없음 — 기존 study 결과만 정리")
        else:
            study.optimize(
                objective,
                n_trials=n_trials,
                n_jobs=n_jobs,
                show_progress_bar=show_progress,
                callbacks=[make_study_goal_stop_callback(logger)],
            )
        elapsed = time.time() - t0
        logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials))

        # JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전)
        passing: List[Dict[str, Any]] = []
        for trial in study.trials:
            if trial.state != optuna.trial.TrialState.COMPLETE:

    logger.info(
        "🔬 Optuna 시작 | study=%s | trials=%d | sampler=%s | storage=%s | n_jobs=%d",
        study_name, n_trials, sampler_name, storage_url, n_jobs,
    )
    t0 = time.time()
    try:
        study.optimize(
            objective,
            n_trials=n_trials,
            n_jobs=n_jobs,
            show_progress_bar=show_progress,
        )
        elapsed = time.time() - t0
        logger.info("✅ Optuna 완료 | %.1f초 | 완료 trial %d", elapsed, len(study.trials))

        # JSON export — study.user_attrs 기준 (n_jobs>1 에도 안전)
        passing: List[Dict[str, Any]] = []
        for trial in study.trials:
            if trial.state != optuna.trial.TrialState.COMPLETE:

def _join_argv_for_study(
    meta: Dict[str, Any],
    *,
    strategy: str,
    study: str,
    extra: str = "",
    py_bin: str = "python3",
) -> List[str]:
    """다른 PC용 — 상대경로. --trials 는 추가분(leftover). py_bin=python3|python|venv경로."""
    strat = str(strategy or "").strip().lower()
    mode = str(meta.get("mode") or "tpe").strip() or "tpe"
    trials = str(int(meta.get("trials") or 200))
    start = str(meta.get("start") or "")
    end = str(meta.get("end") or "")
    hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom"
    sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl"
    argv = [
        py_bin, "-u", "kis_trader/backtest/param_search_optuna.py",
        "--strategy", strat,
        "--mode", mode,
        "--start", start,
        "--end", end,
        "--trials", trials,
        "--min_trades", "1",
        "--min_win_rate", "0",
        "--min_pf", "0",
        "--orderbook-filter", "off",
        "--no-progress",
        "--study-name", study,
        "--sort-by", sort_by,
        "--universe-history-source", hist,
    ]
    st_goal = 0
    leftover = 0
    # 항상 DB 플래그로 leftover 재계산 (meta 캐시가 오래된 200을 붙이지 않게)
    try:
        from kis_trader.backtest.optuna_study_store import flags_for_web
        fl = flags_for_web(study)
        st_goal = int(fl.get("study_trials") or 0)
        leftover = int(fl.get("leftover_trials") or 0)
    except Exception:
        try:
            st_goal = int(meta.get("study_trials") or 0)
        except (TypeError, ValueError):
            st_goal = 0
        try:
            leftover = int(meta.get("leftover_trials") or 0)
        except (TypeError, ValueError):
            leftover = 0
    if st_goal > 0:
        argv.extend(["--study-trials", str(st_goal)])
        # 목표 도달이면 trials=0 (명령 복사해도 추가 연타 금지)
        try:
            idx = argv.index("--trials")
            argv[idx + 1] = str(max(0, leftover))
        except (ValueError, IndexError):
            argv.extend(["--trials", str(max(0, leftover))])
    extra = str(extra or "").strip()
    if strat == "tail":
        ems = list(meta.get("tail_entry_modes") or ["align"])
        argv.extend(["--entry-mode", extra or str(ems[0] if ems else "align")])
    if strat == "breakout":
        sms = list(meta.get("breakout_sl_modes") or ["fixed"])
        argv.extend(["--sl-mode", extra or str(sms[0] if sms else "fixed")])
    for flag, key in (
        ("--candle-source", "candle_source"),
        ("--tick-source", "tick_source"),
        ("--ob-source", "ob_source"),
    ):
        val = str(meta.get(key) or "").strip()
        if val:
            argv.extend([flag, val])
    if strat == "us_momentum":
        sym = str(meta.get("symbol") or "").strip().upper()
        if sym:
            argv.extend(["--symbol", sym])
    return argv

def _ps_join_script(argv: List[str]) -> str:
    """Win11 PowerShell 한 덩어리. 레포 루트에서 실행 · .venv python 고정."""
    from kis_trader.backtest.optuna_common import mariadb_creds

    host = str(mariadb_creds().get("host") or "192.168.0.141")
    body_argv = list(argv or [])
    if body_argv and str(body_argv[0]).strip().lower() in ("python", "python3"):
        body_argv[0] = r".\.venv\Scripts\python.exe"
    body = _quote_ps(body_argv)
    return (
        "# 레포 루트로 이동한 뒤 붙여넣기 (git 커밋 = 웹 VM 과 동일)\n"
        "# --trials = 남은 횟수(leftover). study-trials 목표 도달이면 0.\n"
        "$env:PYTHONUNBUFFERED = '1'\n"
        "$env:PYTHONPATH = (Get-Location).Path\n"
        f"$env:DB_HOST = '{host}'\n"
        f"{body}\n"
    )

def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
    """웹 실행 명령 + 다른 PC에서 같은 study 에 붙는 python 명령."""
    m = meta or {}
    kind = str(m.get("kind") or "")
    is_seq = kind in ("seq", "seq4")
    strat = str(m.get("current_strategy") or "").strip().lower()
    if not strat:
        raw = str(m.get("strategy") or "")
        if "," in raw or raw in ("all", "seq"):
            picked = list(m.get("strategies") or [])
            strat = str(picked[0] or "").strip().lower() if picked else ""
        else:
            strat = raw.strip().lower()
    extra = str(
        m.get("current_sl_mode") or m.get("current_entry_mode") or ""
    ).strip()
    study = str(m.get("active_study_name") or "").strip()
    if not is_seq:
        study = study or str(m.get("study_name") or "").strip()
    if study.startswith("seq_"):
        study = str(m.get("active_study_name") or "").strip()
    web_argv = m.get("cmd_argv")
    if isinstance(web_argv, list) and web_argv:
        web_cmd = _quote_cmd([str(x) for x in web_argv])
    else:
        web_cmd = str(m.get("cmd") or "").strip()
    hints = [
        "레포 루트 · 웹과 같은 git 커밋 · MariaDB 141/kis_optuna.",
        "PowerShell: .\\.venv\\Scripts\\python.exe 사용. --trials=이 PC 추가분(남은 횟수), --study-trials=스터디 총 목표.",
        "목표가 이미 찼으면 --trials 0 (추가 연타 없음). 자잘한 VM 다수보다 Win PC 1대가 현실적.",
    ]
    if is_seq:
        hints.append(
            "순차 웹 bash 를 그대로 돌리면 새 study. 아래 python 만 복사."
        )
    join_cmd = ""
    join_cmd_ps = ""
    if strat and study:
        argv_sh = _join_argv_for_study(
            m, strategy=strat, study=study, extra=extra,
            py_bin=".venv/bin/python",
        )
        argv_ps = _join_argv_for_study(
            m, strategy=strat, study=study, extra=extra, py_bin="python"
        )
        join_cmd = _quote_cmd(argv_sh)
        join_cmd_ps = _ps_join_script(argv_ps)
    elif is_seq:
        hints.append("현재 study 가 없으면 첫 전략 START 후 다시 여세요.")
    join_all: List[Dict[str, str]] = []
    log_path = str(m.get("log_path") or "")
    if is_seq and log_path:
        try:
            data = Path(log_path).read_bytes()
            if len(data) > 400_000:
                data = data[-400_000:]
            text = data.decode("utf-8", errors="replace")
            seen = set()
            for hit in _SEQ_START_RE.finditer(text):
                st = str(hit.group("strat") or "").strip().lower()
                ex = str(hit.group("extra") or "").strip()
                sy = str(hit.group("study") or "").strip()
                if not st or not sy or sy in seen:
                    continue
                seen.add(sy)
                a_sh = _join_argv_for_study(
                    m, strategy=st, study=sy, extra=ex,
                    py_bin=".venv/bin/python",
                )
                a_ps = _join_argv_for_study(
                    m, strategy=st, study=sy, extra=ex, py_bin="python"
                )
                join_all.append({
                    "strategy": st,
                    "extra": ex,
                    "study": sy,
                    "cmd": _quote_cmd(a_sh),
                    "cmd_ps": _ps_join_script(a_ps),
                })
        except Exception:
            join_all = []
    return {
        "web_cmd": web_cmd,
        "join_cmd": join_cmd,
        "join_cmd_ps": join_cmd_ps,
        "join_hint": "\n".join(hints),

def _join_argv_for_study(
    meta: Dict[str, Any],
    *,
    strategy: str,
    study: str,
    extra: str = "",
    py_bin: str = "python3",
) -> List[str]:
    """다른 PC용 — 상대경로. --trials 는 추가분. py_bin=python3|python."""
    strat = str(strategy or "").strip().lower()
    mode = str(meta.get("mode") or "tpe").strip() or "tpe"
    trials = str(int(meta.get("trials") or 200))
    start = str(meta.get("start") or "")
    end = str(meta.get("end") or "")
    hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom"
    sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl"
    argv = [
        py_bin, "-u", "kis_trader/backtest/param_search_optuna.py",
        "--strategy", strat,
        "--mode", mode,
        "--start", start,
        "--end", end,
        "--trials", trials,
        "--min_trades", "1",
        "--min_win_rate", "0",
        "--min_pf", "0",
        "--orderbook-filter", "off",
        "--no-progress",
        "--study-name", study,
        "--sort-by", sort_by,
        "--universe-history-source", hist,
    ]
    st_goal = 0
    try:
        st_goal = int(meta.get("study_trials") or 0)
    except (TypeError, ValueError):
        st_goal = 0
    leftover = 0
    try:
        leftover = int(meta.get("leftover_trials") or 0)
    except (TypeError, ValueError):
        leftover = 0
    if st_goal <= 0:
        try:
            from kis_trader.backtest.optuna_study_store import flags_for_web
            fl = flags_for_web(study)
            st_goal = int(fl.get("study_trials") or 0)
            leftover = int(fl.get("leftover_trials") or leftover)
        except Exception:
            pass
    if st_goal > 0:
        argv.extend(["--study-trials", str(st_goal)])
        if leftover > 0:
            # --trials 는 이 프로세스 추가분 = 남은 횟수
            try:
                idx = argv.index("--trials")
                argv[idx + 1] = str(leftover)
            except (ValueError, IndexError):
                argv.extend(["--trials", str(leftover)])
    extra = str(extra or "").strip()
    if strat == "tail":
        ems = list(meta.get("tail_entry_modes") or ["align"])
        argv.extend(["--entry-mode", extra or str(ems[0] if ems else "align")])
    if strat == "breakout":
        sms = list(meta.get("breakout_sl_modes") or ["fixed"])
        argv.extend(["--sl-mode", extra or str(sms[0] if sms else "fixed")])
    for flag, key in (
        ("--candle-source", "candle_source"),
        ("--tick-source", "tick_source"),
        ("--ob-source", "ob_source"),
    ):
        val = str(meta.get(key) or "").strip()
        if val:
            argv.extend([flag, val])
    if strat == "us_momentum":
        sym = str(meta.get("symbol") or "").strip().upper()
        if sym:
            argv.extend(["--symbol", sym])
    return argv

def _ps_join_script(argv: List[str]) -> str:
    """Win11 PowerShell 한 덩어리. 레포 루트에서 실행."""
    from kis_trader.backtest.optuna_common import mariadb_creds

    host = str(mariadb_creds().get("host") or "192.168.0.141")
    body = _quote_ps(argv)
    return (
        "# 레포 루트로 이동한 뒤 붙여넣기 (git 커밋 = 웹 VM 과 동일)\n"
        "$env:PYTHONUNBUFFERED = '1'\n"
        "$env:PYTHONPATH = (Get-Location).Path\n"
        f"$env:DB_HOST = '{host}'\n"
        f"{body}\n"
    )

def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
    """웹 실행 명령 + 다른 PC에서 같은 study 에 붙는 python 명령."""
    m = meta or {}
    kind = str(m.get("kind") or "")
    is_seq = kind in ("seq", "seq4")
    strat = str(m.get("current_strategy") or "").strip().lower()
    if not strat:
        raw = str(m.get("strategy") or "")
        if "," in raw or raw in ("all", "seq"):
            picked = list(m.get("strategies") or [])
            strat = str(picked[0] or "").strip().lower() if picked else ""
        else:
            strat = raw.strip().lower()
    extra = str(
        m.get("current_sl_mode") or m.get("current_entry_mode") or ""
    ).strip()
    study = str(m.get("active_study_name") or "").strip()
    if not is_seq:
        study = study or str(m.get("study_name") or "").strip()
    if study.startswith("seq_"):
        study = str(m.get("active_study_name") or "").strip()
    web_argv = m.get("cmd_argv")
    if isinstance(web_argv, list) and web_argv:
        web_cmd = _quote_cmd([str(x) for x in web_argv])
    else:
        web_cmd = str(m.get("cmd") or "").strip()
    hints = [
        "레포 루트 · 웹과 같은 git 커밋 · MariaDB 141/kis_optuna.",
        "PowerShell 은 python (python3 아님). --trials 는 이 PC 추가분, --study-trials 는 스터디 총 완료 목표.",
        "자잘한 VM 10대는 틱 RAM 이 10번 복제됩니다. Win PC 1대 + 기존 VM 이 현실적입니다.",
    ]
    if is_seq:
        hints.append(
            "순차 웹 bash 를 그대로 돌리면 새 study. 아래 python 만 복사."
        )
    join_cmd = ""
    join_cmd_ps = ""
    if strat and study:
        argv_sh = _join_argv_for_study(
            m, strategy=strat, study=study, extra=extra, py_bin="python3"
        )
        argv_ps = _join_argv_for_study(
            m, strategy=strat, study=study, extra=extra, py_bin="python"
        )
        join_cmd = _quote_cmd(argv_sh)
        join_cmd_ps = _ps_join_script(argv_ps)
    elif is_seq:
        hints.append("현재 study 가 없으면 첫 전략 START 후 다시 여세요.")
    join_all: List[Dict[str, str]] = []
    log_path = str(m.get("log_path") or "")
    if is_seq and log_path:
        try:
            data = Path(log_path).read_bytes()
            if len(data) > 400_000:
                data = data[-400_000:]
            text = data.decode("utf-8", errors="replace")
            seen = set()
            for hit in _SEQ_START_RE.finditer(text):
                st = str(hit.group("strat") or "").strip().lower()
                ex = str(hit.group("extra") or "").strip()
                sy = str(hit.group("study") or "").strip()
                if not st or not sy or sy in seen:
                    continue
                seen.add(sy)
                a_sh = _join_argv_for_study(
                    m, strategy=st, study=sy, extra=ex, py_bin="python3"
                )
                a_ps = _join_argv_for_study(
                    m, strategy=st, study=sy, extra=ex, py_bin="python"
                )
                join_all.append({
                    "strategy": st,
                    "extra": ex,
                    "study": sy,
                    "cmd": _quote_cmd(a_sh),
                    "cmd_ps": _ps_join_script(a_ps),
                })
        except Exception:
            join_all = []
    return {
        "web_cmd": web_cmd,
        "join_cmd": join_cmd,
        "join_cmd_ps": join_cmd_ps,
        "join_hint": "\n".join(hints),


REST 4단 증량과 DB 캐시 테이블을 구현합니다.

StrReplace

# 백테 REST 전일봉 웜업: 1차→실패시만 사다리 증량(최대 4단) · DB 캐시 공유
    "MOMENTUM_BACKTEST_REST_WARMUP",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS_3",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS_4",
    "MOMENTUM_BACKTEST_REST_WARMUP_DB_CACHE",
    "MOMENTUM_BACKTEST_REST_MAX_CODES",
    "MOMENTUM_BACKTEST_REST_SLEEP_SEC",
    "MOMENTUM_BACKTEST_REST_KIWOOM_MOCK",
    "MOMENTUM_PREV_DAY_OPEN_HM_MAX",

# 백테 REST 전일봉 웜업: 1차 봉수 · 전일 장시작 미확보 시만 재시도 봉수
    "MOMENTUM_BACKTEST_REST_WARMUP",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS",
    "MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY",
    "MOMENTUM_BACKTEST_REST_MAX_CODES",
    "MOMENTUM_BACKTEST_REST_SLEEP_SEC",
    "MOMENTUM_BACKTEST_REST_KIWOOM_MOCK",
    "MOMENTUM_PREV_DAY_OPEN_HM_MAX",

Write

# -*- coding: utf-8 -*-
"""백테 REST 전일봉 웜업 DB 캐시 — 다른 PC/동일 study 재사용.

성공분만 저장. 실패는 저장하지 않아 다른 머신·코드 개선 후 재시도 가능.
"""
from __future__ import annotations

import json
import logging
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger("kis_trader.bt_rest_warmup_cache")

_TABLE = "bt_rest_warmup_cache"
_ENSURED = False

_DDL = """
CREATE TABLE IF NOT EXISTS bt_rest_warmup_cache (
  code         VARCHAR(16)  NOT NULL,
  period_day   CHAR(8)      NOT NULL COMMENT '백테 구간 시작일 YYYYMMDD',
  market       VARCHAR(8)   NOT NULL DEFAULT 'KR',
  n_bars       INT          NOT NULL DEFAULT 0 COMMENT '확보에 쓴 REST 봉수',
  ok           TINYINT      NOT NULL DEFAULT 0,
  prefix_json  LONGTEXT     NULL COMMENT '[{datetime,open,high,low,close,volume},...]',
  updated_at   DATETIME     NOT NULL,
  PRIMARY KEY (code, period_day, market),
  KEY idx_bt_rest_warm_day (period_day, market)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='모멘텀 백테 REST 전일봉 웜업 공유 캐시(성공만)'
"""

def ensure_bt_rest_warmup_cache_table() -> None:
    global _ENSURED
    if _ENSURED:
        return
    try:
        from database import TradeDB

        db = TradeDB()
        db.conn.execute(_DDL)
        _ENSURED = True
    except Exception as exc:
        logger.warning("⚠ bt_rest_warmup_cache 테이블 생성 실패: %s", exc)

def _day_key(start_key: str) -> str:
    s = str(start_key or "").strip().replace("-", "")[:8]
    return s if len(s) == 8 and s.isdigit() else ""

def load_rest_warmup_prefix(
    code: str,
    *,
    period_day: str,
    market: str = "KR",
) -> Optional[Tuple[List[Dict[str, Any]], int]]:
    """성공 캐시면 (prefix_rows, n_bars), 없으면 None."""
    c = str(code or "").strip()
    day = _day_key(period_day)
    mkt = str(market or "KR").strip().upper() or "KR"
    if not c or not day:
        return None
    try:
        ensure_bt_rest_warmup_cache_table()
        from database import TradeDB

        cur = TradeDB().conn.execute(
            "SELECT ok, n_bars, prefix_json FROM bt_rest_warmup_cache "
            "WHERE code=%s AND period_day=%s AND market=%s LIMIT 1",
            (c, day, mkt),
        )
        row = cur.fetchone()
        if not row:
            return None
        if int(row.get("ok") or 0) != 1:
            return None
        raw = row.get("prefix_json") or "[]"
        rows = json.loads(raw) if isinstance(raw, str) else (raw or [])
        if not isinstance(rows, list) or not rows:
            return None
        return rows, int(row.get("n_bars") or 0)
    except Exception as exc:
        logger.debug("bt_rest_warmup load miss %s: %s", c, exc)
        return None

def save_rest_warmup_prefix(
    code: str,
    *,
    period_day: str,
    market: str,
    n_bars: int,
    prefix_rows: List[Dict[str, Any]],
) -> bool:
    """성공 접두만 upsert. 실패는 저장하지 않음."""
    c = str(code or "").strip()
    day = _day_key(period_day)
    mkt = str(market or "KR").strip().upper() or "KR"
    if not c or not day or not prefix_rows:
        return False
    try:
        ensure_bt_rest_warmup_cache_table()
        from database import TradeDB

        payload = json.dumps(prefix_rows, ensure_ascii=False, separators=(",", ":"))
        now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        TradeDB().conn.execute(
            """
            INSERT INTO bt_rest_warmup_cache
              (code, period_day, market, n_bars, ok, prefix_json, updated_at)
            VALUES (%s, %s, %s, %s, 1, %s, %s)
            ON DUPLICATE KEY UPDATE
              n_bars=VALUES(n_bars),
              ok=1,
              prefix_json=VALUES(prefix_json),
              updated_at=VALUES(updated_at)
            """,
            (c, day, mkt, int(n_bars or 0), payload, now),
        )
        return True
    except Exception as exc:
        logger.warning("⚠ bt_rest_warmup 저장 실패 %s: %s", c, exc)
        return False


StrReplace

def inject_momentum_rest_warmup_memory(
    codes_candles: Dict[str, List[Dict[str, Any]]],
    *,
    start_key: str,
    end_key: str = "",
) -> Dict[str, Any]:
    """
    전일 장시작 시가 부족 종목만 키움 REST(ka10080)로 앞쪽 봉을 **메모리에만** 붙인다.

    - DB 캔들 테이블에는 쓰지 않음 (기존 유지).
    - **성공분만** `bt_rest_warmup_cache` 에 저장 → 다른 PC/동일 기간 재사용.
    - 1차 짧은 봉수 → 전일 종가 미확보 시만 2·3·4단 증량 (전체 상시 증량 금지).
    """
    stats: Dict[str, Any] = {
        "enabled": False,
        "need": 0,
        "ok": 0,
        "fail": 0,
        "cache_hit": 0,
        "retry": 0,
        "bars": 0,
        "skipped_cap": 0,
    }
    if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True):
        return stats
    sk = str(start_key or "").strip()
    if len(sk) < 8 or not codes_candles:
        return stats

    from kis_trader.utils.env import get_env_float, get_env_int

    n1 = max(60, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)))
    n2 = max(n1, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY", 1500)))
    n3 = max(n2, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_3", 2500)))
    n4 = max(n3, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_4", 4000)))
    # 최대 4단 사다리 (실패 종목만 다음 단)
    ladder = [n1, n2, n3, n4]
    # 중복 제거·오름차순 유지
    seen_n: set = set()
    ladder_u: List[int] = []
    for n in ladder:
        if n not in seen_n:
            seen_n.add(n)
            ladder_u.append(n)
    ladder = ladder_u[:4]
    max_codes = max(0, int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0)))
    sleep_sec = max(0.0, float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.35)))
    use_db_cache = get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP_DB_CACHE", True)

    need_codes: List[str] = []
    for code, rows in (codes_candles or {}).items():
        c = str(code or "").strip()
        if not c or not rows:
            continue
        if _has_prev_session_open_in_rows(rows, sk):
            continue
        need_codes.append(c)
    need_codes = sorted(set(need_codes))
    stats["need"] = len(need_codes)
    if not need_codes:
        return stats

    if max_codes > 0 and len(need_codes) > max_codes:
        stats["skipped_cap"] = len(need_codes) - max_codes
        need_codes = need_codes[:max_codes]

    stats["enabled"] = True
    logger.info(
        "📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · 사다리 n=%s "
        "(실패 시만 다음 단, DB캐시=%s)",
        len(need_codes), ladder, "ON" if use_db_cache else "OFF",
    )

    from kis_trader.utils.kiwoom_token_manager import get_kiwoom_token_manager
    from kis_trader.websocket.kis_ws_helpers import fetch_kiwoom_chart_1m_gapfill

    period_day = sk[:8].replace("-", "")[:8]
    market = "KR"

    try:
        token = get_kiwoom_token_manager().get_token()
    except Exception as exc:
        logger.warning("⚠ REST 웜업 토큰 실패: %s", exc)
        stats["fail"] = len(need_codes)
        return stats
    if not token:
        stats["fail"] = len(need_codes)
        return stats

    mock = get_env_bool("MOMENTUM_BACKTEST_REST_KIWOOM_MOCK", False)
    ok = fail = cache_hit = retry_n = bars_sum = 0

    for i, code in enumerate(need_codes):
        existing = list(codes_candles.get(code) or [])
        if use_db_cache:
            try:
                from kis_trader.backtest.bt_rest_warmup_cache import load_rest_warmup_prefix
                hit = load_rest_warmup_prefix(code, period_day=period_day, market=market)
            except Exception:
                hit = None
            if hit:
                prefix_rows, n_used = hit
                merged = _merge_prefix_rows(existing, prefix_rows)
                if _has_prev_session_open_in_rows(merged, sk):
                    codes_candles[code] = merged
                    ok += 1
                    cache_hit += 1
                    bars_sum += len(prefix_rows)
                    continue

        # 실패 종목만 사다리 증량 (최대 4단)
        done = False
        for tier_i, n_try in enumerate(ladder):
            if tier_i > 0:
                retry_n += 1
                logger.info(
                    "📡 REST 웜업 재시도 %s: n=%s → n=%s (전일 종가 미확보 · %d/%d단)",
                    code, ladder[tier_i - 1], n_try, tier_i + 1, len(ladder),
                )
            try:
                raw = fetch_kiwoom_chart_1m_gapfill(
                    code, token, n=int(n_try), mock=mock, logger=logger,
                ) or []
            except Exception as exc:
                logger.warning("⚠ REST 웜업 실패 %s n=%s: %s", code, n_try, exc)
                raw = []
            if sleep_sec > 0:
                time.sleep(sleep_sec)
            if not raw:
                continue
            prefix = _rows_from_kiwoom_raw(raw)
            if not prefix:
                continue
            merged = _merge_prefix_rows(existing, prefix)
            if _has_prev_session_open_in_rows(merged, sk):
                codes_candles[code] = merged
                ok += 1
                bars_sum += len(prefix)
                if use_db_cache:
                    try:
                        from kis_trader.backtest.bt_rest_warmup_cache import save_rest_warmup_prefix
                        save_rest_warmup_prefix(
                            code,
                            period_day=period_day,
                            market=market,
                            n_bars=int(n_try),
                            prefix_rows=prefix,
                        )
                    except Exception:
                        pass
                done = True
                break
        if not done:
            fail += 1

        if (i + 1) % 20 == 0:
            logger.info(
                "📡 REST 웜업 진행 %d/%d ok=%d fail=%d cache=%d",
                i + 1, len(need_codes), ok, fail, cache_hit,
            )

    stats["ok"] = ok
    stats["fail"] = fail
    stats["cache_hit"] = cache_hit
    stats["retry"] = retry_n
    stats["bars"] = bars_sum
    logger.info(
        "✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d retry=%d bars=%d",
        ok, fail, cache_hit, retry_n, bars_sum,
    )
    return stats

def inject_momentum_rest_warmup_memory(
    codes_candles: Dict[str, List[Dict[str, Any]]],
    *,
    start_key: str,
    end_key: str = "",
) -> Dict[str, Any]:
    """
    전일 장시작 시가 부족 종목만 키움 REST(ka10080)로 앞쪽 봉을 **메모리에만** 붙인다.

    - DB 캔들 테이블에는 쓰지 않음 (기존 유지).
    - 평소: 짧은 1차(기본 700봉). 전일 장시작이 안 잡힐 때만 긴 2차(기본 1500) 1회 재시도.
    - Optuna trial마다 재호출하지 않도록, prepare 단계에서 1회만 호출.
    """
    stats: Dict[str, Any] = {
        "enabled": False,
        "need": 0,
        "ok": 0,
        "fail": 0,
        "cache_hit": 0,
        "retry": 0,
        "bars": 0,
        "skipped_cap": 0,
    }
    if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True):
        return stats
    sk = str(start_key or "").strip()
    if len(sk) < 8 or not codes_candles:
        return stats

    from kis_trader.utils.env import get_env_float, get_env_int

    n1 = max(60, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)))
    n2 = max(n1, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY", 1500)))
    max_codes = max(0, int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0)))
    sleep_sec = max(0.0, float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.35)))

    need_codes: List[str] = []
    for code, rows in (codes_candles or {}).items():
        c = str(code or "").strip()
        if not c or not rows:
            continue
        if _has_prev_session_open_in_rows(rows, sk):
            continue
        need_codes.append(c)
    need_codes = sorted(set(need_codes))
    stats["need"] = len(need_codes)
    if not need_codes:
        return stats

    if max_codes > 0 and len(need_codes) > max_codes:
        stats["skipped_cap"] = len(need_codes) - max_codes
        need_codes = need_codes[:max_codes]

    stats["enabled"] = True
    logger.info(
        "📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · ka10080 n=%d "
        "(실패 시 n=%d 1회 재시도, DB 미기록)",
        len(need_codes), n1, n2,
    )

    from kis_trader.utils.kiwoom_token_manager import get_kiwoom_token_manager
    from kis_trader.websocket.kis_ws_helpers import fetch_kiwoom_chart_1m_gapfill

    try:
        token = get_kiwoom_token_manager().get_token()
    except Exception as exc:
        logger.warning("⚠ REST 웜업 토큰 실패: %s", exc)
        stats["fail"] = len(need_codes)
        return stats
    if not token:
        stats["fail"] = len(need_codes)
        return stats

    mock = get_env_bool("MOMENTUM_BACKTEST_REST_KIWOOM_MOCK", False)
    ok = fail = retry_n = bars_sum = 0

    for i, code in enumerate(need_codes):
        existing = list(codes_candles.get(code) or [])
        n_try = n1
        raw: List[Any] = []
        try:
            raw = fetch_kiwoom_chart_1m_gapfill(
                code, token, n=int(n_try), mock=mock, logger=logger,
            ) or []
        except Exception as exc:
            logger.warning("⚠ REST 웜업 실패 %s n=%s: %s", code, n_try, exc)
            raw = []
        if sleep_sec > 0:
            time.sleep(sleep_sec)

        prefix = _rows_from_kiwoom_raw(raw) if raw else []
        merged = _merge_prefix_rows(existing, prefix) if prefix else existing
        if prefix and _has_prev_session_open_in_rows(merged, sk):
            codes_candles[code] = merged
            ok += 1
            bars_sum += len(prefix)
        else:
            # 전일 장시작 미확보 → 긴 봉수 1회만 재시도 (해당 종목)
            if n2 > n1:
                retry_n += 1
                logger.info(
                    "📡 REST 웜업 재시도 %s: n=%s → n=%s (전일 종가 미확보)",
                    code, n1, n2,
                )
                try:
                    raw2 = fetch_kiwoom_chart_1m_gapfill(
                        code, token, n=int(n2), mock=mock, logger=logger,
                    ) or []
                except Exception as exc:
                    logger.warning("⚠ REST 웜업 재시도 실패 %s: %s", code, exc)
                    raw2 = []
                if sleep_sec > 0:
                    time.sleep(sleep_sec)
                prefix2 = _rows_from_kiwoom_raw(raw2) if raw2 else []
                merged2 = _merge_prefix_rows(existing, prefix2) if prefix2 else existing
                if prefix2 and _has_prev_session_open_in_rows(merged2, sk):
                    codes_candles[code] = merged2
                    ok += 1
                    bars_sum += len(prefix2)
                else:
                    fail += 1
            else:
                fail += 1

        if (i + 1) % 20 == 0:
            logger.info(
                "📡 REST 웜업 진행 %d/%d ok=%d fail=%d",
                i + 1, len(need_codes), ok, fail,
            )

    stats["ok"] = ok
    stats["fail"] = fail
    stats["retry"] = retry_n
    stats["bars"] = bars_sum
    logger.info(
        "✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d retry=%d bars=%d",
        ok, fail, 0, retry_n, bars_sum,
    )
    return stats


def inject_momentum_rest_warmup_memory(
    candles_by_code: Dict[str, List[Dict]],
    period_start_key: str,
    *,
    universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> Dict[str, int]:
    """
    DB 전일봉이 없을 때 키움 ka10080 REST를 종목당 호출해 **메모리에만** prepend.

    - 1차: MOMENTUM_BACKTEST_REST_WARMUP_BARS (기본 700)
    - 전일 종가 미확보 시에만 2차: MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY (기본 1500)
    - DB INSERT 없음. 성공 prefix·영구실패는 프로세스 캐시 (Optuna trial 재조회·로그 스팸 방지).
    """
    from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
    from kis_trader.utils.logger import get_logger

    log = get_logger("kis_trader.momentum_backtest")
    stats = {
        "need": 0, "ok": 0, "fail": 0, "cache_hit": 0,
        "bars": 0, "skipped": 0, "retry": 0,
    }
    if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True):
        stats["skipped"] = 1
        return stats
    ps = str(period_start_key or "")[:12]
    if len(ps) < 8 or not candles_by_code:
        return stats
    period_day = ps[:8]

    target: Set[str]
    if universe_by_slot:
        target = set()
        for codes in universe_by_slot.values():
            for c in codes or []:
                if c:
                    target.add(str(c).strip())
        target &= set(candles_by_code.keys())
    else:
        target = set(candles_by_code.keys())

    need_codes = [
        c for c in sorted(target)
        if (c, period_day) not in _REST_WARMUP_PERM_FAIL
        and not _momentum_rows_have_prev_day(candles_by_code.get(c) or [], period_day)
    ]
    stats["need"] = len(need_codes)
    if not need_codes:
        return stats

    max_codes = int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0))
    if max_codes > 0:
        need_codes = need_codes[:max_codes]

    n_bars = max(
        50,
        int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)),
    )
    # 전일(직전 세션) 종가가 1차에 안 잡힐 때만 — 평소엔 700만
    n_retry = max(
        n_bars,
        int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY", 1500)),
    )
    sleep_sec = float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.25))
    kw_key, kw_secret, is_mock = _kiwoom_gap_credentials()
    if not kw_key or not kw_secret:
        log.warning("⚠ 모멘텀 REST 웜업 스킵 — 키움 앱키/시크릿 없음")
        stats["fail"] = len(need_codes)
        return stats

    from kis_trader.ws.kis_ws import get_kiwoom_candles_df

    # 전부 캐시 hit면 Optuna trial 경로에서 INFO 스팸 금지
    will_fetch = any(
        (c, period_day) not in _REST_WARMUP_PREFIX_CACHE for c in need_codes
    )
    if will_fetch:
        log.info(
            "📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · ka10080 n=%d"
            " (실패 시 n=%d 1회 재시도, DB 미기록)",
            len(need_codes), n_bars, n_retry,
        )

    for i, code in enumerate(need_codes):
        rows = candles_by_code.get(code) or []
        if not rows:
            _REST_WARMUP_PERM_FAIL.add((code, period_day))
            stats["fail"] += 1
            continue
        cache_key = (code, period_day)
        cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key)
        did_network = False
        if cached is not None:
            stats["cache_hit"] += 1
            prefix = [dict(r) for r in cached]
        else:
            did_network = True
            try:
                df = get_kiwoom_candles_df(
                    code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_bars,
                )
            except Exception as e:
                log.warning("⚠ REST 웜업 실패 %s: %s", code, e)
                _REST_WARMUP_PERM_FAIL.add(cache_key)
                stats["fail"] += 1
                continue
            if df is None or getattr(df, "empty", True):
                _REST_WARMUP_PERM_FAIL.add(cache_key)
                stats["fail"] += 1
                continue
            try:
                prefix = _rest_df_to_prefix(df, rows, ps)
            except Exception as e:
                log.warning("⚠ REST 웜업 파싱 실패 %s: %s", code, e)
                _REST_WARMUP_PERM_FAIL.add(cache_key)
                stats["fail"] += 1
                continue

            # 1차로 전일 종가 미확보 → 봉 수 늘려 1회만 재시도 (중간 거래일 0봉 등)
            if (not prefix or not _momentum_rows_have_prev_day(prefix, period_day)) and n_retry > n_bars:
                stats["retry"] += 1
                log.info(
                    "📡 REST 웜업 재시도 %s: n=%d → n=%d (전일 종가 미확보)",
                    code, n_bars, n_retry,
                )
                try:
                    df2 = get_kiwoom_candles_df(
                        code, 1, kw_key, kw_secret, is_mock=is_mock, n=n_retry,
                    )
                except Exception as e:
                    log.warning("⚠ REST 웜업 재시도 실패 %s: %s", code, e)
                    df2 = None
                if df2 is not None and not getattr(df2, "empty", True):
                    try:
                        prefix = _rest_df_to_prefix(df2, rows, ps)
                    except Exception as e:
                        log.warning("⚠ REST 웜업 재시도 파싱 실패 %s: %s", code, e)
                        prefix = []

            _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
            if sleep_sec > 0 and did_network and i + 1 < len(need_codes):
                time.sleep(sleep_sec)

        if not prefix or not _momentum_rows_have_prev_day(prefix, period_day):
            _REST_WARMUP_PERM_FAIL.add(cache_key)
            stats["fail"] += 1
            continue
        candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows]
        stats["ok"] += 1
        stats["bars"] += len(prefix)

    if will_fetch:
        log.info(
            "✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d retry=%d bars=%d",
            stats["ok"], stats["fail"], stats["cache_hit"], stats["retry"], stats["bars"],
        )
    return stats

def inject_momentum_rest_warmup_memory(
    candles_by_code: Dict[str, List[Dict]],
    period_start_key: str,
    *,
    universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> Dict[str, int]:
    """
    DB 전일봉이 없을 때 키움 ka10080 REST를 종목당 호출해 **메모리에만** prepend.

    - 사다리 최대 4단(실패 종목만 다음 단): BARS → RETRY → BARS_3 → BARS_4
    - 성공 prefix: 프로세스 RAM + `bt_rest_warmup_cache` (다른 PC 공유)
    - 실패는 DB 미저장(재시도 가능). 프로세스 영구실패는 동일 런 내 재호출 방지.
    """
    from kis_trader.utils.env import get_env_bool, get_env_float, get_env_int
    from kis_trader.utils.logger import get_logger

    log = get_logger("kis_trader.momentum_backtest")
    stats = {
        "need": 0, "ok": 0, "fail": 0, "cache_hit": 0,
        "bars": 0, "skipped": 0, "retry": 0,
    }
    if not get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP", True):
        stats["skipped"] = 1
        return stats
    ps = str(period_start_key or "")[:12]
    if len(ps) < 8 or not candles_by_code:
        return stats
    period_day = ps[:8]
    use_db_cache = get_env_bool("MOMENTUM_BACKTEST_REST_WARMUP_DB_CACHE", True)

    target: Set[str]
    if universe_by_slot:
        target = set()
        for codes in universe_by_slot.values():
            for c in codes or []:
                if c:
                    target.add(str(c).strip())
        target &= set(candles_by_code.keys())
    else:
        target = set(candles_by_code.keys())

    need_codes = [
        c for c in sorted(target)
        if (c, period_day) not in _REST_WARMUP_PERM_FAIL
        and not _momentum_rows_have_prev_day(candles_by_code.get(c) or [], period_day)
    ]
    stats["need"] = len(need_codes)
    if not need_codes:
        return stats

    max_codes = int(get_env_int("MOMENTUM_BACKTEST_REST_MAX_CODES", 0))
    if max_codes > 0:
        need_codes = need_codes[:max_codes]

    n1 = max(50, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS", 700)))
    n2 = max(n1, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_RETRY", 1500)))
    n3 = max(n2, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_3", 2500)))
    n4 = max(n3, int(get_env_int("MOMENTUM_BACKTEST_REST_WARMUP_BARS_4", 4000)))
    ladder: List[int] = []
    seen_n: Set[int] = set()
    for n in (n1, n2, n3, n4):
        if n not in seen_n:
            seen_n.add(n)
            ladder.append(n)
    ladder = ladder[:4]
    sleep_sec = float(get_env_float("MOMENTUM_BACKTEST_REST_SLEEP_SEC", 0.25))
    kw_key, kw_secret, is_mock = _kiwoom_gap_credentials()
    if not kw_key or not kw_secret:
        log.warning("⚠ 모멘텀 REST 웜업 스킵 — 키움 앱키/시크릿 없음")
        stats["fail"] = len(need_codes)
        return stats

    from kis_trader.ws.kis_ws import get_kiwoom_candles_df

    will_fetch = any(
        (c, period_day) not in _REST_WARMUP_PREFIX_CACHE for c in need_codes
    )
    if will_fetch:
        log.info(
            "📡 모멘텀 REST 웜업(메모리): 전일봉 부족 %d종목 · 사다리 n=%s"
            " (실패 시만 다음 단, DB캐시=%s)",
            len(need_codes), ladder, "ON" if use_db_cache else "OFF",
        )

    for i, code in enumerate(need_codes):
        rows = candles_by_code.get(code) or []
        if not rows:
            _REST_WARMUP_PERM_FAIL.add((code, period_day))
            stats["fail"] += 1
            continue
        cache_key = (code, period_day)
        prefix: List[Dict[str, Any]] = []
        did_network = False

        cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key)
        if cached is not None:
            stats["cache_hit"] += 1
            prefix = [dict(r) for r in cached]
        elif use_db_cache:
            try:
                from kis_trader.backtest.bt_rest_warmup_cache import load_rest_warmup_prefix
                hit = load_rest_warmup_prefix(code, period_day=period_day, market="KR")
            except Exception:
                hit = None
            if hit:
                db_rows, _n_used = hit
                prefix = [dict(r) for r in db_rows]
                _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
                stats["cache_hit"] += 1

        if not prefix:
            did_network = True
            for tier_i, n_try in enumerate(ladder):
                if tier_i > 0:
                    stats["retry"] += 1
                    log.info(
                        "📡 REST 웜업 재시도 %s: n=%d → n=%d (전일 종가 미확보 · %d/%d단)",
                        code, ladder[tier_i - 1], n_try, tier_i + 1, len(ladder),
                    )
                try:
                    df = get_kiwoom_candles_df(
                        code, 1, kw_key, kw_secret, is_mock=is_mock, n=int(n_try),
                    )
                except Exception as e:
                    log.warning("⚠ REST 웜업 실패 %s n=%s: %s", code, n_try, e)
                    df = None
                if sleep_sec > 0 and i + 1 < len(need_codes):
                    time.sleep(sleep_sec)
                if df is None or getattr(df, "empty", True):
                    continue
                try:
                    prefix = _rest_df_to_prefix(df, rows, ps)
                except Exception as e:
                    log.warning("⚠ REST 웜업 파싱 실패 %s: %s", code, e)
                    prefix = []
                if prefix and _momentum_rows_have_prev_day(prefix, period_day):
                    _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
                    if use_db_cache:
                        try:
                            from kis_trader.backtest.bt_rest_warmup_cache import (
                                save_rest_warmup_prefix,
                            )
                            save_rest_warmup_prefix(
                                code,
                                period_day=period_day,
                                market="KR",
                                n_bars=int(n_try),
                                prefix_rows=prefix,
                            )
                        except Exception:
                            pass
                    break
            else:
                # 사다리 전부 실패
                _REST_WARMUP_PERM_FAIL.add(cache_key)
                stats["fail"] += 1
                continue

        if not prefix or not _momentum_rows_have_prev_day(prefix, period_day):
            _REST_WARMUP_PERM_FAIL.add(cache_key)
            stats["fail"] += 1
            continue
        candles_by_code[code] = [dict(r) for r in prefix] + [dict(r) for r in rows]
        stats["ok"] += 1
        stats["bars"] += len(prefix)

    if will_fetch or stats["cache_hit"] > 0:
        log.info(
            "✅ 모멘텀 REST 웜업 완료: ok=%d fail=%d cache=%d retry=%d bars=%d",
            stats["ok"], stats["fail"], stats["cache_hit"], stats["retry"], stats["bars"],
        )
    return stats


Shell

python3 -m py_compile kis_trader/backtest/optuna_study_store.py kis_trader/backtest/optuna_momentum.py kis_trader/backtest/optuna_breakout.py kis_trader/backtest/optuna_scalping.py kis_trader/backtest/param_search_optuna.py kis_trader/backtest/optuna_web_jobs.py kis_trader/backtest/momentum_backtest_common.py kis_trader/backtest/bt_rest_warmup_cache.py && python3 - <<'PY'
from kis_trader.backtest.bt_rest_warmup_cache import ensure_bt_rest_warmup_cache_table
ensure_bt_rest_warmup_cache_table()
from database import TradeDB
cols = [r['Field'] for r in TradeDB().conn.execute('SHOW COLUMNS FROM bt_rest_warmup_cache').fetchall()]
print('OK cols=', cols)
PY

Compile changed modules; ensure cache table


StrReplace

        cache_key = (code, period_day)
        prefix: List[Dict[str, Any]] = []
        did_network = False

        cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key)
        if cached is not None:
            stats["cache_hit"] += 1
            prefix = [dict(r) for r in cached]
        elif use_db_cache:
            try:
                from kis_trader.backtest.bt_rest_warmup_cache import load_rest_warmup_prefix
                hit = load_rest_warmup_prefix(code, period_day=period_day, market="KR")
            except Exception:
                hit = None
            if hit:
                db_rows, _n_used = hit
                prefix = [dict(r) for r in db_rows]
                _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
                stats["cache_hit"] += 1

        if not prefix:
            did_network = True
            for tier_i, n_try in enumerate(ladder):

        cache_key = (code, period_day)
        prefix: List[Dict[str, Any]] = []

        cached = _REST_WARMUP_PREFIX_CACHE.get(cache_key)
        if cached is not None:
            stats["cache_hit"] += 1
            prefix = [dict(r) for r in cached]
        elif use_db_cache:
            try:
                from kis_trader.backtest.bt_rest_warmup_cache import load_rest_warmup_prefix
                hit = load_rest_warmup_prefix(code, period_day=period_day, market="KR")
            except Exception:
                hit = None
            if hit:
                db_rows, _n_used = hit
                prefix = [dict(r) for r in db_rows]
                _REST_WARMUP_PREFIX_CACHE[cache_key] = [dict(r) for r in prefix]
                stats["cache_hit"] += 1

        if not prefix:
            for tier_i, n_try in enumerate(ladder):

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; verify active+HTTP


**결론:** 명령이 “총 200회”가 아니라 **이미 있는 study에 200회를 더** 돌린 것이고, 웹 「명령」도 leftover=0인데 `--trials 200`을 그대로 붙이던 버그가 있었습니다. 지금은 leftover·venv·목표 도달 중지로 고쳤고, REST는 4단+DB 캐시까지 넣었습니다.

### 왜 Trial 267이 넘어가나
- Optuna `study.optimize(n_trials=200)` = **이번에 추가로 200회** (누적 상한 아님).
- 로그에 `Using an existing study …145331` → 웹에서 이미 ~200회 끝난 study에 붙음.
- 그래서 Trial **200, 201, …** 이 정상 번호이고, `--study-trials 200`은 후처리/남은횟수 표시용이라 **optimize를 끊지 않았음**.
- 웹 복붙이 `--trials 200 --study-trials 200`이면 → 목표 찬 뒤에도 **또 200회** 연타.

지금 Windows에서 돌리는 건 **Ctrl+C로 끊으세요.** (이미 목표 초과 중)

### 고친 것
1. **`--trials`를 leftover로 자르기** + study-trials 도달 시 `study.stop()`
2. **웹 명령**: DB leftover 재조회, 목표 도달 시 `--trials 0`
   - Linux: `.venv/bin/python`
   - Win PS: `.\.venv\Scripts\python.exe`
3. **REST 웜업**: 최대 4단 `700→1500→2500→4000` (실패 종목만)
   - 성공분만 테이블 `bt_rest_warmup_cache` 저장 → 다른 PC 재사용
   - env: `MOMENTUM_BACKTEST_REST_WARMUP_BARS_3/4`, `…_DB_CACHE` (기본 ON)

백테 웹은 재시작해 두었습니다 (`active`, HTTP 200).

### Windows에서 다시 돌릴 때
1. 서버 코드 pull (또는 동일 커밋)
2. 웹에서 해당 job **「명령」** 다시 복사 (venv 경로 포함)
3. 목표가 이미 찼으면 `--trials 0` → 추가 trial 없이 끝

예시 형태:
```powershell
.\.venv\Scripts\python.exe -u kis_trader/backtest/param_search_optuna.py ... --trials <남은횟수> --study-trials 200 ...
```


<timestamp>Friday, Aug 21, 2026, 8:15 PM (UTC+9)</timestamp>
<user_query>
최근 잡 (8개)
정렬

시작↓
job_id	전략	기간	상태	trial	시작	종료
import_optuna_momentum_tpe_20260821_194013	모멘텀	2026-08-18~2026-08-21	done ·남은 100회 (시도 100 / 목표 200 · COMPLETE 100)	—/100 ·목표 100/200	2026-08-21T19:40:14	2026-08-21T19:40:14
opt_20260821_193005_mome	모멘텀	2026-08-18~2026-08-21	done ·남은 100회 (시도 100 / 목표 200 · COMPLETE 100)	100/100 ·목표 100/200	2026-08-21T19:30:05	2026-08-21T19:40:14
import_optuna_momentum_tpe_20260821_151059	모멘텀	2026-08-18~2026-08-20	done ·목표 도달 (시도 200 / 목표 200 · COMPLETE 185)	200/200 ·목표 200/200	2026-08-21T15:11:01	2026-08-21T15:11:01
이건 왜 그러지? 웹에서 끝났고 윈도우에서도 지금 400회나 더 돈거같은데
</user_query>


웹에 보이는 ‘남은 100회’와 Windows에서 돌린 study가 같은지, Optuna DB의 실제 trial 수를 확인합니다.

python3 - <<'PY'
from kis_trader.backtest.optuna_study_store import flags_for_web, count_study_states, load_row
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
import optuna

storage = resolve_optuna_storage_url(None)
print('storage ok')

# job meta from web job files if any
from pathlib import Path
import json
jobs_dir = Path('kis_trader/backtest/optuna_jobs')
if not jobs_dir.exists():
    jobs_dir = Path('logs/optuna_jobs')
# find job metas
for p in sorted(Path('.').rglob('opt_20260821_193005*'))[:20]:
    print('found', p)
for p in sorted(Path('kis_trader/backtest').rglob('*193005*'))[:30]:
    print('bt', p)

# known study names from conversation
names = [
    'momentum_tpe_20260818_20260820_20260821_145331',
    # try discover from optuna_study_result
]
from database import TradeDB
db = TradeDB()
# schema first
cols = [r['Field'] for r in db.conn.execute('SHOW COLUMNS FROM optuna_study_result').fetchall()]
print('optuna_study_result cols', cols)
rows = db.conn.execute(
    "SELECT study_name, study_trials, n_complete, leftover_note, updated_at FROM optuna_study_result "
    "WHERE study_name LIKE %s OR study_name LIKE %s OR study_name LIKE %s ORDER BY updated_at DESC LIMIT 20",
    ('%20260821%', '%145331%', '%193005%'),
).fetchall()
for r in rows:
    print(dict(r))

# also list recent studies matching momentum
try:
    rows2 = db.conn.execute(
        "SELECT study_name, study_trials, n_complete, leftover_note FROM optuna_study_result "
        "WHERE study_name LIKE %s ORDER BY updated_at DESC LIMIT 15",
        ('momentum%',),
    ).fetchall()
    print('--- momentum studies ---')
    for r in rows2:
        print(dict(r))
except Exception as e:
    print('err', e)
PY

Query optuna_study_result for leftover state


python3 - <<'PY'
import optuna
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
from kis_trader.backtest.optuna_study_store import count_study_states, flags_for_web, leftover_trials

storage = resolve_optuna_storage_url(None)
for name in (
    'momentum_tpe_20260818_20260820_20260821_145331',
    'momentum_tpe_20260818_20260821_20260821_193005',
):
    st = optuna.load_study(study_name=name, storage=storage)
    n_c, n_r, n_f = count_study_states(st)
    goal = (st.user_attrs or {}).get('kis_study_trials')
    print('===', name)
    print('  total trials list', len(st.trials))
    print('  COMPLETE/RUNNING/finished', n_c, n_r, n_f)
    print('  kis_study_trials attr', goal)
    print('  flags_for_web', flags_for_web(name))
    # max trial number
    nums = [t.number for t in st.trials]
    print('  trial# min/max', min(nums) if nums else None, max(nums) if nums else None)
PY

Count live Optuna trials vs web leftover


**서로 다른 study 두 개**라서 그렇게 보입니다. Windows 400회는 「남은 100회」 잡에 붙지 않았습니다.

### 1) `남은 100회` (8/18~8/21) — 정상
| job | study |
|-----|--------|
| `opt_…193005_mome` | `…20260821_193005` |
| `import_…194013` | 위와 **동일 study** (결과 import 행) |

- 웹 설정: **이번 `--trials 100`**, **목표 `--study-trials 200`**
- Optuna 실측: **끝난 시도 100 / 목표 200** → leftover **100** 맞음
- Windows는 이 study를 **안 돌림** (여전히 trial 0~99만 있음)

`done · 남은 100회` = 잡 프로세스는 끝났고, **스터디 목표는 아직 절반**이라는 뜻입니다. 「이어돌리기」로 남은 100을 채우면 됩니다.

### 2) Windows ~400회 — **다른 study** (8/18~8/20)
study: `momentum_tpe_…20260820_…145331`
(= 화면의 `import_…151059` / 목표 도달로 보이던 그쪽)

| 시점 | 내용 |
|------|------|
| 웹 종료 시 | 목표 200 도달 → `남은 0` / COMPLETE 185 |
| Windows | 같은 이름에 `--trials 200` 추가 → **또 200회** |
| 지금 Optuna | **총 400회** (번호 0~399), finished=400 |
| 웹 leftover 표시 | 목표는 여전히 200이라 **「목표 도달」** 유지 (초과분 400을 “남은 횟수”로 안 셈) |

즉 **400회 연타 = 145331 study 버그(추가 n_trials)** 이고, 위쪽 **남은 100회 잡(193005)과는 무관**합니다.

### 한 줄
- **남은 100회** → 8/21 포함 study, 아직 100 남음 (Windows 미실행)
- **Windows 400** → 8/20까지 study에 목표가 찬 뒤 **200을 더** 돌린 것

지금은 145331은 더 돌리지 말고, 8/21 study는 「이어돌리기」만 leftover만큼 돌리면 됩니다.


<timestamp>Friday, Aug 21, 2026, 8:16 PM (UTC+9)</timestamp>
<user_query>
이제 다음부터 이런 오류 없다는거?
</user_query>


**같은 패턴(목표가 찼는데 `--trials 200`으로 또 연타)은 막히게 고쳐 둔 상태입니다.** 다만 “영원히 어떤 실수도 없다”까지는 아닙니다.

### 이제 안 나는 것
- 기존 study에 `--trials 200`을 붙여도 → **남은 횟수(leftover)로 잘림** (목표 도달이면 **0**)
- trial 돌리다 목표 도달하면 → **`study.stop()`**
- 웹 「명령」 → leftover 반영 + **venv python** 경로

### 아직 조심할 것
- **예전 코드**로 돌리면 예전처럼 연타 가능 → Windows도 **서버와 같은 커밋**이어야 함
- **study 이름을 잘못** 붙이면 다른 잡에 붙음 (이번처럼 145331 vs 193005)
- **목표를 의도적으로 크게** 올리면 그 목표까지는 정상적으로 더 돎

정리하면, pull 한 뒤·웹 명령으로 돌리면 **이번 오류는 재발하지 않게** 되어 있습니다.


<timestamp>Friday, Aug 21, 2026, 8:22 PM (UTC+9)</timestamp>
<user_query>
웹에서 다 로드 햇는데 윈도우에서 또 로드하냐 이거 왜
 --mode tpe --start 2026-08-18 --end 2026-08-21 --trials 100 --min_trades 1 --min_win_rate 0 --min_pf 0 --orderbook-filter off --no-progress --study-name scalp_tpe_20260818_20260821_20260821_201902 --sort-by score --universe-history-source kiwoom
[20:20:37] [param_search_optuna] 📌 mode=tpe — 연속(float/int) 탐색 (Grid categorical 미사용, TPE 가 구간 축소)
[20:20:37] [param_search_optuna] 📌 호가필터: OFF (스킵 — 코어 파라미터 순수 탐색 (실매 ORDERBOOK도 OFF 권장 정합))
[20:20:38] [param_search_optuna] 💼 포트폴리오: 1회 300,000원 | 동시 8종 | 총한도 1,200,000원 | 매매 0900-1530
[20:20:38] [kis_trader.optuna_feed_trace] 🔎 [Optuna-SCALP 피드체인] 틱1차=kis(체인 kis→kiwoom→ls) | 호가1차=kiwoom(체인 kiwoom→kis→ls)
[20:20:56] [bt_candle_source] 📥 틱 bulk 쓰레기검사용: KR=1760335 US=622598 종목=640
[20:21:13] [bt_candle_source] 📂 캔들 bulk: 봉 595339행 · 틱 2382933행 · 종목 644 · 35.68s (tf=1 market=ALL)
[20:21:13] [bt_candle_source] 📊 봉 pick 비율(1차→2차→REST): 슬롯 566906 · pick 555676 · hole 11230(2.0%) · 쓰레기스킵 27475(슬롯대비 4.8%) | raw[kiwoom/rest=554357 kis/ws=32179 kiwoom/ws=8803] | pick[kiwoom/rest=544123(98%) kis/ws=11529(2%) kiwoom/ws=24(0%)] | garbage[kis/ws=20650 kiwoom/ws=6825]
[20:21:33] [param_search_optuna] ✅ 데이터 로드: 628종목 (history=kiwoom)
[20:21:34] [kis_trader.breakout_tick_loader] 📥 ws_ticks 일별 로드 시작 | days=4 | read_timeout=180s | codes=628
[20:21:37] [kis_trader.breakout_tick_loader] ✅ ws_ticks day=20260818 rows=199568 (누적=199568)
[20:21:45] [kis_trader.breakout_tick_loader] ✅ ws_ticks day=20260819 rows=451961 (누적=651529)
</user_query>


**맞습니다. 웹에서 이미 로드했어도 Windows는 또 로드합니다.** 버그가 아니라, 지금 구조가 그렇게 되어 있습니다.

### 무엇이 공유되고 / 안 되나

| 항목 | 공유? | 설명 |
|------|--------|------|
| Optuna study (trial·점수) | ✅ MariaDB | 같은 `--study-name`으로 trial만 이어받음 |
| 봉·틱 RAM | ❌ | 각 PC 프로세스 안 메모리. 웹 로드 ≠ Windows RAM |
| `ws_ticks` / 캔들 DB | ✅ 같은 DB | 다만 **SELECT해서 자기 RAM에 다시 올림** |
| REST 전일봉 웜업 | △ | 성공분만 `bt_rest_warmup_cache`로 재사용 (방금 넣은 것). 틱 bulk는 해당 없음 |

웹이 끝난 뒤에도 서버에 “이미 로드된 봉·틱 공유 메모리”를 열어 두고 Windows가 붙는 구조가 **아닙니다.**
Windows도 `param_search_optuna.py`를 **새 프로세스**로 띄우면, trial 돌리기 전에 **자기 쪽에서 봉·틱을 다시 읽습니다.**

로그의
`캔들 bulk …` / `ws_ticks 일별 로드`
= DB에서 읽는 단계이지, WS로 다시 수집하는 게 아닙니다. 그래도 **디스크/네트워크 I/O + RAM 적재**는 PC마다 한 번씩 듭니다.

### 왜 이렇게 두었나
- Optuna 분산 = **결과(study)만 DB로 공유**, 시세 엔진은 프로세스 로컬이 기본
- 수 GB 틱을 머신 간 공유메모리로 넘기려면 별도 설계(공유 파일·Redis·서버가 워커만 돌리기 등)가 필요

### 체감 줄이는 현실적 방법
1. **시세 로드는 웹(서버)만**, Windows는 같은 study에 **이어돌리기만** 할 때도 로드는  unavoidably 한 번 함
2. 로드 부담이 싫으면 Windows 대신 **서버에서 leftover만** 돌리기
3. (원하면 나중에) 봉·틱을 pickle/공유 캐시로 넘기는 설계 — **지금은 없음**, 만들려면 선보고·승인 후

한 줄: **study는 이어받지만, 시세 RAM은 PC마다 각자 다시 채운다.**


<timestamp>Friday, Aug 21, 2026, 8:24 PM (UTC+9)</timestamp>
<user_query>
그거까지 다 공유하면 얼마나 빨라지고 작업량은 어떻게 되고 복잡도는 얼마나 되냐?
</user_query>


**체감:** Windows에서 “첫 trial 전까지” 기다리는 시간만 줄어듭니다. trial 자체 속도는 거의 안 바뀝니다.

### 지금 준비 구간이 먹는 시간 (로그 기준 대략)
| 단계 | 대략 | 공유하면 |
|------|------|----------|
| 캔들 bulk | 30~40초 | 대부분 절약 |
| 틱 `ws_ticks` 일별 로드 | 40초~2분+ | 대부분 절약 |
| REST 웜업 | 0~수분 (종목별) | 이미 DB 캐시로 일부 절약 |
| **합** | **대개 1~3분+/PC** | **첫 기동만** 크게 감소 |

trial 1회가 수 초면, leftover 100회면 trial 쪽이 훨씬 깁니다.
**이득 = “PC마다 1~3분 준비”를 1번만 내거나 수 초로 줄이는 것**이지, 전체 Optuna가 2배로 빨라지진 않습니다.

### 공유하면 얼마나 빨라지나 (현실 숫자)
- **2대(웹+Win) 동시:** 지금은 준비 2번 → 공유 시 **준비 1번 + 전송/마운트**
  - 잘 되면 Win 기동: **1~3분 → 5~20초** 수준
  - 이득 상한 ≈ **PC당 준비시간 × (머신 수 − 1)**
- **trial 병렬:** 데이터 공유와 무관. study 락·CPU가 병목.
- **같은 PC에서 웹만 leftover:** 공유 구현 없이도 준비 중복 0.

### 작업량 / 복잡도

| 방식 | 작업량 | 복잡도 | 비고 |
|------|--------|--------|------|
| A. 안 함 (서버만 / leftover만 웹) | 0 | 0 | 운영으로 해결 |
| B. 기간키 pickle을 SMB/NFS에 dump → Win load | **1~2일** | 중 | 가장 현실적. 버전·경로·용량·깨진 캐시 처리 필요 |
| C. Redis / Arrow / 전용 피드 서버 | **수일~1주+** | 높음 | 인프라·직렬화·TTL·멀티전략 |
| D. 진짜 OS 공유메모리 크로스 PC | — | 사실상 불가 | 머신이 다르면 SHM 불가 |

**B 기준 대략**
- 코드: dump 키(`start/end/tf/codes hash/스키마버전`), load 검증, miss 시 기존 DB 로드 폴백
- 용량: 틱 100만행급이면 **수백 MB~수 GB**/기간. SSD·SMB 대역 봐야 함
- 리스크: 웹이 dump 끝나기 전 Win이 읽기, 커밋 다른데 캐시 재사용 → **정합 사고**
- 유지: 스키마·틱 필터·벤더 체인 바뀔 때마다 캐시 무효화 규칙

### 추천
- **이득 대비:** leftover 100·trial 위주면 **우선순위 낮음**. PC 2대 준비 중복이 진짜 거슬릴 때만 B.
- **가성비 최고:** Windows로 시세 로드하지 말고, **서버에서 leftover만** 돌리기 (작업 0).
- **꼭 공유:** B(피클/아카이브 + 폴백)만. C는 오버엔지니어링.

원하시면 B만 **설계 초안(키·경로·무효화·폴백)** 짧게 적어 드리고, 승인 후에 손대는 게 맞습니다.


[Image]
<image_files>
The following images were provided by the user and saved to the workspace for future use:
1. /home/hoon/.cursor/projects/home-hoon-kis-bot/assets/c__Users_hjhvk_AppData_Roaming_Cursor_User_workspaceStorage_75c3a8d8080694f63a47886970c318a7_images_image-c23f2884-a463-4e9b-a0aa-6752f419c445.png

These images can be copied for use in other locations.
</image_files>
<timestamp>Friday, Aug 21, 2026, 8:26 PM (UTC+9)</timestamp>
<user_query>
3분줄이려고 이지랄 하는거네?

이건 또 왜 안끝나 200회인데
</user_query>


UI가 `126/100`인데 목표 200에서 안 끝나는 원인을 study·잡 설정부터 확인합니다.

python3 - <<'PY'
import optuna, json
from pathlib import Path
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
from kis_trader.backtest.optuna_study_store import count_study_states, flags_for_web

storage = resolve_optuna_storage_url(None)
# find scalp studies from today
from database import TradeDB
db = TradeDB()
rows = db.conn.execute(
    "SELECT study_name, study_trials, n_complete, leftover_note, updated_at FROM optuna_study_result "
    "WHERE study_name LIKE %s ORDER BY updated_at DESC LIMIT 8",
    ('scalp%',),
).fetchall()
print('=== optuna_study_result ===')
for r in rows:
    print(dict(r))

# job file
for p in sorted(Path('logs/optuna_web_jobs').glob('*201902*')):
    m = json.loads(p.read_text(encoding='utf-8'))
    print('=== job', p.name, '===')
    print({k: m.get(k) for k in ('job_id','status','trials','study_trials','study_name','started_at','finished_at')})
    prog = m.get('progress') or {}
    print('progress', {k: prog.get(k) for k in ('trials_done','trials_total','pct','best_trial')})

name = 'scalp_tpe_20260818_20260821_20260821_201902'
st = optuna.load_study(study_name=name, storage=storage)
n_c, n_r, n_f = count_study_states(st)
print('=== live study', name, '===')
print('len', len(st.trials), 'C/R/F', n_c, n_r, n_f)
print('kis_study_trials', (st.user_attrs or {}).get('kis_study_trials'))
print('flags', flags_for_web(name))
nums = [t.number for t in st.trials]
print('max trial#', max(nums) if nums else None)
PY

Check scalp study trial counts vs goal


**3분 줄이려고 공유 메모리 이지랄 → 안 하는 게 맞습니다.** 그거 하지 마세요. 이득이 그 정도입니다.

---

### 왜 `126/100`인데 안 끝나 보이냐

지금은 **200에서 멈춘 게 아니라, 아직 200 전**입니다.

| 구분 | 값 |
|------|-----|
| 스터디 목표 | **200** (`study_trials`) |
| 웹 이번 배치 | **100** (`--trials 100`) → 웹 job은 **이미 done** |
| 화면 `126/100` | 분자=스터디 **누적** trial, 분모=**이번 잡 100** → UI가 헷갈리게 잡힘 |
| 방금 DB | 누적 **~158 / 목표 200** → **남은 ~40회대** |

즉:
- **200회 “끝났어야” 하는 시점이 아직 아님** (126·158은 절반~3/4)
- 웹은 100 하고 끝났고, **Windows가 leftover로 더 돌리는 중**이라 터미널이 계속 감
- `100%`는 “이번 웹 잡 100회분 기준”이라 누적이 100을 넘어도 **100%에 고정**되는 표시 버그/혼선

목표 200에 닿으면(클램프 있는 코드 기준) 그즈음 멈춥니다.
지금 돌리는 Windows가 **예전 코드**이거나 `--study-trials` 없이 붙었으면, leftover 무시하고 배치 100을 끝까지 채울 수는 있습니다. **서버와 같은 커밋 + 웹 「명령」 복붙**이면 200에서 컷됩니다.

**한 줄:** 3분 캐시는 포기하고, 이건 “200 안 끝남”이 아니라 **아직 200 전 + 화면 분모가 100이라 이상해 보이는 것**입니다.


<timestamp>Friday, Aug 21, 2026, 8:50 PM (UTC+9)</timestamp>
<user_query>
.\.venv\Scripts\python.exe -u kis_trader/backtest/param_search_optuna.py --strategy scalp --mode tpe --start 2026-08-18 --end 2026-08-21 --trials 0 --min_trades 1 --min_win_rate 0 --min_pf 0 --orderbook-filter off --no-progress --study-name scalp_tpe_20260818_20260821_20260821_201902 --sort-by score --universe-history-source kiwoom --study-trials 200

웹 명령에 떠 있는 거 이대로 돌렸고 내가 이미지 준게 웹에도 124 윈도우 125가 찍혀 있잖아 웹 100으로 돌리고 총 200 돌리는거였는데 왜 둘 다 120이 넘어가냐 이말이야 그리고 지금 보면 완료도 안되서 이어돌리기라고 떠 잇어 시바꺼 그리고 000 타점만은 뭐야
진입 호가익절손절 휩쏘 이렇게 아니었나 타점만이 뭐지 그리고 휍쏘에 111+휩쏜느 뭐야 8방엔 000 100 이렇게 두개밖에 안보이는데 이거 나머지는 왜 안보이는거야 통과된게 없으면 없다 이 전략에는 이걸 안쓴다 나와야지 휩쏘에는 111 휩쏘라고 되어잇는데 정작 111이 안보여 그리고 스캘핑은 휩쏘를 걸면 승률 pnl이 잘나오는 전략이야?
</user_query>


스캘핑 Optuna 후처리 UI(타점만·111+휩쏘·8방)와 해당 study의 실제 trial/leftover 상태를 확인합니다.

python3 - <<'PY'
import optuna
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
from kis_trader.backtest.optuna_study_store import count_study_states, flags_for_web

name='scalp_tpe_20260818_20260821_20260821_201902'
st=optuna.load_study(study_name=name, storage=resolve_optuna_storage_url(None))
n_c,n_r,n_f=count_study_states(st)
print('trials',len(st.trials),'C/R/F',n_c,n_r,n_f,'attr',(st.user_attrs or {}).get('kis_study_trials'))
print('flags',flags_for_web(name))
print('max#',max(t.number for t in st.trials) if st.trials else None)
PY

Scalp study finished count vs leftover


원인: 웹·Windows가 같은 study의 누적 번호를 같이 보고, 스캘핑은 호가 8방 중 진입만 쓰도록 숨겨져 있었습니다. UI에 ‘미사용’ 표기와 진행률 분모를 고칩니다.


StrReplace

function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row) {
  const st = optunaOb8wayState(ob);
  // 8방 정의는 항상 8행. 전략이 익절/손절 호가축을 안 쓰면 행은 보이되 「미사용」.
  const allRows = [
    ['base', '000 타점만(호가OFF)', true],
    ['e', '100 진입만', true],
    ['x', '010 익절만', !!canExitStop],
    ['s', '001 손절만', !!canExitStop],
    ['ex', '110 진입+익절', !!canExitStop],
    ['es', '101 진입+손절', !!canExitStop],
    ['xs', '011 익절+손절', !!canExitStop],
    ['exs', '111 전부', !!canExitStop],
  ];
  const hasCombos = st.kind === 'ok';
  const btn = (cid, lab, used) => {
    if (isLive || !rowSrc) return '참고';
    if (!used) {
      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>`;
    if (cid !== 'base' && !hasCombos) {
      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 combos = st.combos || {};
  const trs = allRows.map(([id, lab, used]) => {
    if (!used) {
      return `<tr class="text-muted"><td>${lab}</td><td>—</td>`
        + `<td><span class="text-muted">이 전략에는 이 호가축 없음 (진입 호가만)</span></td>`
        + `<td><span class="text-muted">이 전략 미사용</span></td></tr>`;
    }
    const c = hasCombos ? (combos[id] || {}) : null;
    let nt;
    let stats;
    if (hasCombos) {
      nt = (c && c.n_trials != null) ? c.n_trials : '—';
      stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob);
    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }
    return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td><td>${btn(id, '이 방 적용', used)}</td></tr>`;
  }).join('');
  const foot = !canExitStop
    ? '스캘핑 등: 호가 후처리는 000·100(진입)만. 익절/손절 호가축은 엔진에 없음 → 「이 전략 미사용」. 휩쏘는 아래 별도.'
    : (st.kind === 'ok'
      ? '8=2³(진입×익절×손절). 휩쏘는 아래 별도.'
      : (st.kind === 'legacy'
        ? '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요.'
        : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`));
  const rerunBtn = (st.kind !== 'ok' && !isLive)
    ? '
<button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
'
    : '';
  return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row) {
  const st = optunaOb8wayState(ob);
  const combos = st.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 = st.kind === 'ok';
  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>`;
    if (cid !== 'base' && !hasCombos) {
      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;
    let nt;
    let stats;
    if (hasCombos) {
      nt = (c && c.n_trials != null) ? c.n_trials : '—';
      stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob);
    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }
    return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td><td>${btn(id, '이 방 적용')}</td></tr>`;
  }).join('');
  const foot = st.kind === 'ok'
    ? '8=2³. 휩쏘는 아래 별도.'
    : (st.kind === 'legacy'
      ? '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요.'
      : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`);
  const rerunBtn = (st.kind !== 'ok' && !isLive)
    ? '
<button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
'
    : '';
  return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}


function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row) {
  const st = optunaOb8wayState(ob);
  const combos = st.combos || {};
  // 8방 정의는 항상 8행. 전략이 익절/손절 호가축을 안 쓰면 행은 보이되 「미사용」.
  const allRows = [
    ['base', '000 타점만(호가OFF)', true],
    ['e', '100 진입만', true],
    ['x', '010 익절만', !!canExitStop],
    ['s', '001 손절만', !!canExitStop],
    ['ex', '110 진입+익절', !!canExitStop],
    ['es', '101 진입+손절', !!canExitStop],
    ['xs', '011 익절+손절', !!canExitStop],
    ['exs', '111 전부', !!canExitStop],
  ];
  const hasCombos = st.kind === 'ok';
  const btn = (cid, lab, used) => {
    if (isLive || !rowSrc) return '참고';
    if (!used) {
      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>`;
    if (cid !== 'base' && !hasCombos) {
      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 = allRows.map(([id, lab, used]) => {
    if (!used) {
      return `<tr class="text-muted"><td>${lab}</td><td>—</td>`
        + `<td><span class="text-muted">이 전략에는 이 호가축 없음 (진입 호가만)</span></td>`
        + `<td><span class="text-muted">이 전략 미사용</span></td></tr>`;
    }
    const c = hasCombos ? (combos[id] || {}) : null;
    let nt;
    let stats;
    if (hasCombos) {
      nt = (c && c.n_trials != null) ? c.n_trials : '—';
      stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob);
    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }
    return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td><td>${btn(id, '이 방 적용', used)}</td></tr>`;
  }).join('');
  const foot = !canExitStop
    ? '스캘핑 등: 호가 후처리는 000·100(진입)만. 익절/손절 호가축은 엔진에 없음 → 「이 전략 미사용」. 휩쏘는 아래 별도.'
    : (st.kind === 'ok'
      ? '8=2³(진입×익절×손절). 휩쏘는 아래 별도.'
      : (st.kind === 'legacy'
        ? '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요.'
        : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`));
  const rerunBtn = (st.kind !== 'ok' && !isLive)
    ? '
<button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
'
    : '';
  return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

function _optunaObComboTable(ob, canExitStop, rowSrc, rowRk, isLive, row) {
  const st = optunaOb8wayState(ob);
  // 8방 정의는 항상 8행. 전략이 익절/손절 호가축을 안 쓰면 행은 보이되 「미사용」.
  const allRows = [
    ['base', '000 타점만(호가OFF)', true],
    ['e', '100 진입만', true],
    ['x', '010 익절만', !!canExitStop],
    ['s', '001 손절만', !!canExitStop],
    ['ex', '110 진입+익절', !!canExitStop],
    ['es', '101 진입+손절', !!canExitStop],
    ['xs', '011 익절+손절', !!canExitStop],
    ['exs', '111 전부', !!canExitStop],
  ];
  const hasCombos = st.kind === 'ok';
  const btn = (cid, lab, used) => {
    if (isLive || !rowSrc) return '참고';
    if (!used) {
      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>`;
    if (cid !== 'base' && !hasCombos) {
      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 combos = st.combos || {};
  const trs = allRows.map(([id, lab, used]) => {
    if (!used) {
      return `<tr class="text-muted"><td>${lab}</td><td>—</td>`
        + `<td><span class="text-muted">이 전략에는 이 호가축 없음 (진입 호가만)</span></td>`
        + `<td><span class="text-muted">이 전략 미사용</span></td></tr>`;
    }
    const c = hasCombos ? (combos[id] || {}) : null;
    let nt;
    let stats;
    if (hasCombos) {
      nt = (c && c.n_trials != null) ? c.n_trials : '—';
      stats = _optunaAxisStatsHtml(c || { ok: false, reason: '없음' }, ob);
    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }
    return `<tr><td>${lab}</td><td>${nt}</td><td>${stats}</td><td>${btn(id, '이 방 적용', used)}</td></tr>`;
  }).join('');
  const foot = !canExitStop
    ? '스캘핑 등: 호가 후처리는 000·100(진입)만. 익절/손절 호가축은 엔진에 없음 → 「이 전략 미사용」. 휩쏘는 아래 별도.'
    : (st.kind === 'ok'
      ? '8=2³(진입×익절×손절). 휩쏘는 아래 별도.'
      : (st.kind === 'legacy'
        ? '구JSON · 「이 잡 후처리 재실행」으로 8방을 채우세요.'
        : `${optunaObReasonKo(st)} · 재실행해도 호가스냅이 안 늘면 동일.`));
  const rerunBtn = (st.kind !== 'ok' && !isLive)
    ? '
<button type="button" class="btn btn-sm btn-warning py-0" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
'
    : '';
  return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

const whipBtn = () => {
      if (isLive || !rowSrc) return '참고';
      if (whipSkip) return '해당없음';
      // 스캘핑 등 익절/손절 호가축 없음 → 「111+」표기 금지 (111 방이 표에 없음)
      const lab = canExitStop ? '111+휩쏘 적용' : '휩쏘 ON 적용';
      return `<button class="btn btn-sm btn-outline-success py-0" onclick="optunaApplyUpto('${rowSrc}',${rowRk},'whipsaw')">${lab}</button>`;
    };

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>`;
    };

과적합%=추정. ${row.note || ''} · 호가=진입×익절×손절(전략별 미사용 축은 표에 표시) · 휩쏘는 8방 밖

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

    whipsaw: '휩쏘 ON(호가 111은 전략별)',
    trail: '합의 다단트레일만(타점 미변경)',

    whipsaw: '111+휩쏘',
    trail: '합의 다단트레일만(타점 미변경)',

    $('opt_st_trials').textContent =
      (() => {
        const done = prog.trials_done ?? '—';
        const batch = prog.trials_total ?? job.trials ?? '—';
        const goal = job.study_trials || 0;
        const nDone = job.n_complete != null ? job.n_complete : done;
        // 분모=이번 배치(100)면 126/100 처럼 보임 → 목표가 있으면 목표 기준으로 표시
        if (goal > 0) {
          const p2 = Math.min(100, Math.round((Number(nDone || 0) / goal) * 1000) / 10);
          return `누적 ${nDone}/${goal} (${p2}%) · 이번배치 ${batch}`;
        }
        return `${done} / ${batch} (${pct}%)`;
      })() +
      (seqBits ? ` · ${seqBits}` : '') +
      (nowBits ? ` · now=${nowBits}` : '') +
      (met ? ` · ${met}` : '') +
      ((job.status === 'running' && Number(prog.trials_done || 0) === 0)
        ? ' · 준비(캔들·틱 로드) — Top5는 trial 시작 후'
        : '');

    $('opt_st_trials').textContent =
      `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
      (seqBits ? ` · ${seqBits}` : '') +
      (nowBits ? ` · now=${nowBits}` : '') +
      (met ? ` · ${met}` : '') +
      ((job.status === 'running' && Number(prog.trials_done || 0) === 0)
        ? ' · 준비(캔들·틱 로드) — Top5는 trial 시작 후'
        : '');

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821scalpOb8"></script>

<script src="{{ url_for('static', filename='js/backtest.js') }}?v=20260821stableMode"></script>


${(() => {
          const done = prog.trials_done ?? '—';
          const batch = prog.trials_total ?? job.trials ?? '—';
          const goal = job.study_trials;
          const nDone = job.n_complete != null ? job.n_complete : done;
          if (goal) return `${nDone}/${goal} ·배치${batch}`;
          return `${done}/${batch}`;
        })()}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}

${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.study_trials ? ' ·목표 ' + (job.n_complete ?? '—') + '/' + job.study_trials : ''}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}

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 after UI fix


**둘 다 120을 넘긴 것처럼 보인 건, 웹·Windows가 각자 100을 더 돌린 게 아니라 같은 study의 누적 번호를 같이 본 겁니다.**

### 1) 왜 웹·윈도우에 124 / 125가 같이 찍히나
- Optuna trial 번호는 **스터디 전역** (0,1,…199).
- 웹: `--trials 100` → 그 프로세스만 100회 추가 후 **끝**.
- Windows: 같은 study에 붙으면 **101부터** 이어서 돌림.
- 웹 UI 진행률은 “이번 잡 100”이 아니라 **study에 쌓인 총 trial**을 보여서 `126/100`처럼 보임.
- 지금 DB: **200/200 도달**, leftover 0.
  지금 웹 명령 `--trials 0` = “더 돌리지 마”라서 **정상**.

「이어돌리기」는 **200 채우기 전**에 leftover>0일 때 뜹니다. 지금은 목표 도달이면 안 떠야 함 → 새로고침. 예전이면 Windows가 아직 덜 채운 시점.

### 2) `000 타점만` / `111+휩쏘` / 8방에 2줄만
| 표기 | 의미 |
|------|------|
| **000 타점만** | 호가 필터 **전부 OFF**. Optuna가 찾은 **진입·청산 파라미터만** |
| **100 진입만** | **진입 호가**만 ON |
| **010/001/…/111** | 익절·손절 호가 조합 (모멘텀·돌파용) |

스캘핑은 엔진에 **익절/손절 호가축이 없어서** 예전 UI가 000·100만 보여 줬습니다.
「통과 없음」이 아니라 **이 전략 미사용**인데 숨겨서 헷갈린 것.

**111+휩쏘**: 모멘텀/돌파용 이름(호가 111 + 휩쏘). 스캘핑에는 111 방이 없는데 버튼만 있어서 더 헷갈림.
→ 지금은 8방 **8줄 다 보이되** 나머지 6줄은 「이 전략 미사용」, 휩쏘 버튼은 **「휩쏘 ON 적용」**으로 바꿈. 진행률도 `누적 n/200 · 이번배치 100` 형태.

하드 새로고침: `http://192.168.0.149:5050/` (`?v=20260821scalpOb8`)

### 3) 스캘핑 + 휩쏘가 “잘 나오는” 전략이냐
**항상 그렇다 단정은 못 함.**
휩쏘는 가짜 V·급반등 진입을 거르는 필터라 **거래 수↓ · 쓰레기 진입↓** 쪽. 이 study 후처리 휩쏘 성적(WR/PnL)을 000·100과 비교해서 판단하는 게 맞습니다.


<timestamp>Friday, Aug 21, 2026, 8:56 PM (UTC+9)</timestamp>
<user_query>
휩쏘는 그럼 휩쏘만 따로 돌리는거야 8방 경우의수에 안붙이고? 그럼 뭐 어떻게 되는거지 별도로 on 하면 또 결과가 천차만별 아닌가
</user_query>


호가 8방과 휩쏘가 후처리에서 어떻게 붙는지 코드 기준으로 확인합니다.


**맞습니다. 휩쏘는 8방(호가 경우의수) 안에 안 넣고, 옆에서 따로 돌립니다.**

### 구조 (의도)
| 축 | 무엇 | 관계 |
|----|------|------|
| **타점** | Optuna trial (진입·익절·손절 % 등) | 본체 |
| **호가 8방** | 진입×익절×손절 호가필터 ON/OFF | 타점 **위에** 얹음 |
| **휩쏘** | 가짜 V 진입 거르기 | 호가와 **직교** — 8방에 안 섞음 |

후처리도 그렇게 돌아갑니다.
- 호가: `_ob_for_anchor` → 000~111 성적
- 휩쏘: `_ws_for_anchor` → **같은 앵커(같은 trial 타점)** 체결로 따로 추천

코드 주석도 그대로입니다: *「휩쏘는 8방 밖 — include_whipsaw=True 일 때만 붙임」*

### 「휩쏘 ON」누르면 뭐가 되나
- **스캘핑:** 타점 유지 + 휩쏘 파라미터 ON (호가 111은 원래 없음)
- **모멘텀/돌파의 옛 「111+휩쏘」:** 호가 111 + 휩쏘 ON (레거시 한 방 버튼)

천차만별이 **될 수는** 있습니다. 호가 OFF/ON이랑 마찬가지로, 필터를 켜면 거래 수·WR·PnL이 바뀝니다.
다만 “랜덤으로 새로 돌린다”가 아니라:

1. **타점은 고정** (그 trial / Top 후보)
2. 그 체결에 휩쏘만 시뮬해서 **추천값 + 성적**을 표에 보여 줌
3. 적용 = 그 타점(+선택 호가방) + 휩쏘 env

그래서 **표에 나온 휩쏘 WR/PnL을 000·100이랑 비교**한 뒤 켤지 말지 정하면 됩니다. 암흑 적용이 아닙니다.

### 왜 8×휩쏘(16방)로 안 붙이나
경우의수가 배로 늘고, 호가 TPE·휩쏘 TPE가 서로 다른 축이라 **의도적으로 분리**한 겁니다.
「호가 방 고르고 → 휩쏘는 성적 보고 ON/OFF」가 운영 순서입니다.

**한 줄:** 휩쏘는 8방 조합이 아니라 **같은 타점 위의 추가 스위치**. 결과가 달라지는 건 맞고, 달라진 숫자가 후처리 표에 나와서 그걸 보고 켜는 구조입니다.


<timestamp>Friday, Aug 21, 2026, 8:58 PM (UTC+9)</timestamp>
<user_query>
호가방에 따라 휩쏘 결과가 다를건데?
</user_query>


**맞습니다. 지금 구조는 그걸 반영하지 않습니다.**

### 실제로 뭐가 돌아가나
1. 앵커(그 trial 타점)로 **호가 OFF** 백테 → 체결 리스트 `fills` 한 번
2. **호가 8방** = 그 `fills` 위에서 “이 호가 조건이면 남/죽음”
3. **휩쏘** = **같은** `fills` 전체로 또 따로 추천

즉 휩쏘 숫자는 **「타점만(000) 체결」 기준**이지,
「100 진입호가 통과한 체결만」 또는 「111 통과분」 기준이 **아닙니다.**

그래서 실매/백테에 **호가 100 + 휩쏘**를 같이 켜면, 표에 있던 휩쏘 WR/PnL과 **어긋날 수 있습니다.** 호가방마다 남는 진입이 달라지니까요.

### 왜 이렇게 짜 둔 건지
- 8방 × 휩쏘 = 경우의수·시간이 크게 늘어남
- 근사: 타점 고정 → 호가 / 휩쏘를 **각각** 보고, 켜는 조합은 운영자가 고름
- **결합 성적은 표에 없음** (한계)

### 정리
| 질문 | 답 |
|------|-----|
| 호가방 따라 휩쏘 결과 다르지 않냐? | **이론·실측 둘 다 다를 수 있음** |
| 지금 UI가 그걸 보여주냐? | **아니요. 휩쏘는 000 체결 기준 1회** |
| 같이 ON 하면? | 근사 추천 조합일 뿐, **결합 검증은 별도** |

제대로 맞추려면 「호가방마다 통과 `fills`로 휩쏘 재추천」 또는 「선택한 방+휩쏘 한 번 재백테」가 필요합니다. 원하시면 그 설계만 짧게 잡고 승인 후 손대면 됩니다.


<timestamp>Friday, Aug 21, 2026, 9:01 PM (UTC+9)</timestamp>
<user_query>
당연히 8방에 붙여야되지 않나 이거 tpe 옵투나로 찾아야될 것 같은데 스탭 넣어서 각 방마다 돌리는게 좋겠어 8방 이래도 빡쎄냐?
</user_query>


현재 후처리 부하(호가 TPE·휩쏘 trial 수)를 기준으로, 8방에 붙였을 때 체감 시간과 현실적인 스텝 설계를 짧게 잡겠습니다.


**결론: 방마다 붙이는 게 맞고, 그냥 500×8로 때리면 빡세고, 스텝+방별 trial 줄이면 현실적입니다.**

### 지금 한 앵커(예: gated#1) 대략
| 단계 | 기본 | 비고 |
|------|------|------|
| 호가 8방 TPE | 방당 ~150~250회 | 이미 있음 |
| 휩쏘 TPE | **500회 × 1** (000 체결) | 호가와 무관 |

### naive 「8방 × 휩쏘 500」
- 휩쏘만 **500×8 = 4,000**/앵커
- Top5면 **~2만** + mode/learn…
- → **빡셈** (후처리가 코어 Optuna만큼 길어질 수 있음)

### 추천 설계 (스텝)
1. 타점 백테 → `fills` 1회 (호가 OFF)
2. 호가 방마다: 그 방 통과 `fills'` 만 남김 → 건수 < min이면 **스킵**
3. 통과분만 **휩쏘 TPE (방별, trial 줄임)**
4. UI: 각 방 행에 `호가성적 | 휩쏘성적 | 이 방+휩쏘 적용`

**방별 trial 예 (env)**
`OPTUNA_WHIPSAW_PER_COMBO_TRIALS` 기본 **80~120** (전체 500 대신)

| 전략 | 돌릴 방 | 휩쏘 부하 (앵커 1개) |
|------|---------|----------------------|
| 스캘핑 | 000·100만 (2) | ~160~240 → **지금 500과 비슷하거나 덜함** |
| 모멘텀/돌파 | 유효 방만 (최대 8) | ~640~960 → **지금 대비 ~1.5~2배**, 감당 가능 쪽 |

「8방 전부 무조건 500」만 아니면 **굳이 지옥은 아님.**
스텝으로 돌리면 진행률도 `gated#1 · 방 110 · 휩쏘 40/100`처럼 찍을 수 있음.

### 작업량·복잡도
- **중~중상** (1.5~3일 급): 방별 fills 필터 → 휩쏘 입력, UI 8행 확장, apply=`combo+whipsaw`, env 키, 진행률
- 코어 매매 엔진 변경은 거의 없고 **후처리·적용 패치** 위주

### 제안
- 8×500 풀스윕 **하지 않음**
- **유효 방만 + 방당 80~120 TPE + 스텝 로그**
- 스캘핑은 2방만

이 방향으로 구현할까요? 승인하면 그때 코드 들어갑니다.


<timestamp>Friday, Aug 21, 2026, 9:03 PM (UTC+9)</timestamp>
<user_query>
오케이 진행해 꼬리는 몇방이야?
</user_query>


꼬리 호가방 수를 확인한 뒤, 승인하신 ‘방별 휩쏘 TPE’ 구현을 진행합니다.

TodoWrite

Confirm tail OB room count + whipSkip

Whipsaw TPE per OB combo + env + progress

UI 8방 행에 방별 휩쏘 + apply


Shell

python3 - <<'PY'
# find classify for OPTUNA
from kis_trader.utils import env_key_split as e
# or
import database as d
print('has classify', hasattr(d, 'classify_config_key'))
from kis_trader.config.env_key_split import classify_config_key
for k in ['OPTUNA_WHIPSAW_PER_COMBO_TRIALS','OPTUNA_OB_COMBO_TRIALS_SINGLE']:
    print(k, classify_config_key(k))
PY

Check classify_config_key for OPTUNA_*


TodoWrite

Confirm tail OB room count + whipSkip


StrReplace

    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"),
    )
    out = {
        "ok": any(bool((combos.get(cid) or {}).get("ok")) for cid, *_rest in combo_specs),
        "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,
    }
    _attach_whipsaw_per_combo(out, trades=trades, strat_upper=strat_upper, lg=lg)
    return out

def _whipsaw_per_combo_enabled() -> bool:
    from kis_trader.utils.env import get_env_bool
    return bool(get_env_bool("OPTUNA_WHIPSAW_PER_COMBO", True))

def _whipsaw_per_combo_n_trials() -> int:
    from kis_trader.utils.env import get_env_int
    return max(10, int(get_env_int("OPTUNA_WHIPSAW_PER_COMBO_TRIALS", 100)))

def _whipsaw_per_combo_min_trades() -> int:
    from kis_trader.utils.env import get_env_int
    return max(1, int(get_env_int("OPTUNA_WHIPSAW_PER_COMBO_MIN_TRADES", 3)))

def _strategy_runs_whipsaw(strat_upper: str) -> bool:
    """꼬리·돌파는 UI/특성상 휩쏘 후처리 스킵 (기존 whipSkip 과 동일)."""
    u = str(strat_upper or "").strip().upper()
    if u in ("TAIL", "SHORT", "BREAKOUT"):
        return False
    return u in ("MOMENTUM", "SCALP", "SCALPING", "US_MOMENTUM")

def _trade_to_raw_fill(tr: TradeInfo, pnl: float, rate: float) -> Dict[str, Any]:
    return {
        "code": tr.code,
        "name": tr.name,
        "buy_date": tr.buy_dt.strftime("%Y-%m-%d %H:%M:%S") if tr.buy_dt else "",
        "buy_time": tr.buy_dt.strftime("%Y%m%d%H%M%S") if tr.buy_dt else "",
        "buy_price": float(tr.buy_price or 0),
        "actual_pnl": float(pnl),
        "pnl": float(pnl),
        "realized_pnl": float(pnl),
        "profit_rate": float(rate),
        "actual_profit_rate": float(rate),
    }

def _combo_stack_params(combo: Dict[str, Any]) -> Dict[str, Any]:
    """방 params + mask → _sim_stacked 용."""
    p = dict(combo.get("params") or {})
    mask = combo.get("mask") if isinstance(combo.get("mask"), dict) else {}
    use_e = bool(mask.get("entry")) if mask else bool(p.get("orderbook_filter_enabled"))
    use_x = bool(mask.get("exit")) if mask else bool(p.get("exit_ob_enabled"))
    use_s = bool(mask.get("stop")) if mask else bool(p.get("stop_ob_enabled"))
    p["entry_on"] = use_e
    p["exit_on"] = use_x
    p["stop_on"] = use_s
    p["orderbook_filter_enabled"] = use_e
    p["exit_ob_enabled"] = use_x
    p["stop_ob_enabled"] = use_s
    return p

def _slim_ws_local(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    if not isinstance(rec, dict):
        return {"ok": False, "reason": "none"}
    return {
        "ok": bool(rec.get("ok")),
        "reason": rec.get("reason") or "",
        "n_trials": rec.get("n_trials"),
        "trade_count": int(rec.get("trade_count") or 0),
        "params": dict(rec.get("params") or {}),
        "orig_stats": dict(rec.get("orig_stats") or {}),
        "recommended_stats": dict(rec.get("recommended_stats") or {}),
    }

def _attach_whipsaw_per_combo(
    ob_out: Dict[str, Any],
    *,
    trades: List[TradeInfo],
    strat_upper: str,
    lg: logging.Logger,
) -> None:
    """
    호가 방마다 통과 체결만으로 휩쏘 TPE.
    - base/진입OFF 방: 전체 체결
    - 진입ON 방: ENTRY_REJECTED 제외, PnL은 스택 시뮬 결과
    """
    combos = ob_out.get("combos") if isinstance(ob_out.get("combos"), dict) else {}
    if not combos:
        return
    if not _whipsaw_per_combo_enabled():
        for c in combos.values():
            if isinstance(c, dict) and "whipsaw" not in c:
                c["whipsaw"] = {"ok": False, "reason": "per_combo_off"}
        return
    if not _strategy_runs_whipsaw(strat_upper):
        for c in combos.values():
            if isinstance(c, dict):
                c["whipsaw"] = {"ok": False, "reason": "n/a_strategy"}
        return

    from kis_trader.backtest.optuna_whipsaw_recommend import recommend_whipsaw_parameters

    n_tr = _whipsaw_per_combo_n_trials()
    min_tr = _whipsaw_per_combo_min_trades()
    mk = "US" if "US" in strat_upper else "KR"
    order = ("base", "e", "x", "s", "ex", "es", "xs", "exs")
    ids = [cid for cid in order if cid in combos] + [c for c in combos if c not in order]

    lg.info(
        "📡 [휩쏘×호가방] 전략=%s · 방=%d · 방당 trial=%d · min_trades=%d",
        strat_upper, len(ids), n_tr, min_tr,
    )
    try:
        from kis_trader.backtest import optuna_post_progress as opp
        opp.set_ob_axes(len(ids), 0)  # 재사용: 축 진행 표시
    except Exception:
        opp = None

    for i, cid in enumerate(ids):
        c = combos.get(cid)
        if not isinstance(c, dict):
            continue
        label = f"휩쏘@{cid}"
        if opp:
            try:
                opp.begin_ob_axis(label, i, n_tr)
            except Exception:
                pass
        if not c.get("ok") and cid != "base":
            c["whipsaw"] = {"ok": False, "reason": "combo_not_ok"}
            continue
        stack_p = _combo_stack_params(c)
        raw_fills: List[Dict[str, Any]] = []
        for tr in trades:
            pnl, rate, reason = _sim_stacked(tr, stack_p)
            if reason == "ENTRY_REJECTED":
                continue
            raw_fills.append(_trade_to_raw_fill(tr, pnl, rate))
        if len(raw_fills) < min_tr:
            c["whipsaw"] = {
                "ok": False,
                "reason": "not_enough_trades",
                "trade_count": len(raw_fills),
            }
            lg.info("📡 [휩쏘×호가방] %s 스킵 — 통과 %d<%d", cid, len(raw_fills), min_tr)
            continue
        try:
            rec = recommend_whipsaw_parameters(
                strategy=strat_upper,
                n_trials=n_tr,
                log=lg,
                raw_fills=raw_fills,
                market=mk,
            )
        except Exception as exc:
            lg.warning("⚠ [휩쏘×호가방] %s 실패: %s", cid, exc)
            c["whipsaw"] = {"ok": False, "reason": str(exc)}
            continue
        c["whipsaw"] = _slim_ws_local(rec)
        if rec.get("ok"):
            rs = rec.get("recommended_stats") or {}
            lg.info(
                "📡 [휩쏘×호가방] %s ok · fills=%d · WR=%s PnL=%s",
                cid, len(raw_fills), rs.get("win_rate"), rs.get("pnl"),
            )

    # 앵커 top-level 호환: base 방 휩쏘
    base_ws = (combos.get("base") or {}).get("whipsaw")
    if isinstance(base_ws, dict):
        ob_out["whipsaw_base"] = base_ws

def attach_orderbook_recommend(

    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(cid) or {}).get("ok")) for cid, *_rest in combo_specs),
        "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,
    }

def attach_orderbook_recommend(


Grep

def attach_orderbook_recommend|def _attach_whipsaw|def _env_pfx


StrReplace

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"))
    ws_raw = ax.get("whipsaw")
    ws = None
    if isinstance(ws_raw, dict):
        ws = {
            "ok": bool(ws_raw.get("ok")),
            "reason": ws_raw.get("reason") or "",
            "n_trials": ws_raw.get("n_trials"),
            "trade_count": int(ws_raw.get("trade_count") or 0),
            "params": dict(ws_raw.get("params") or {}),
            "orig_stats": _slim_stats(ws_raw.get("orig_stats")),
            "recommended_stats": _slim_stats(ws_raw.get("recommended_stats")),
        }
    out = {
        "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 {}),
    }
    if ws is not None:
        out["whipsaw"] = ws
    return out

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),
        "fill_count": int(rec.get("fill_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,
    }
    # base 방 휩쏘를 앵커 top-level 에도 (구 UI 호환)
    base_ws = (combos_out.get("base") or {}).get("whipsaw")
    if isinstance(base_ws, dict):
        out["whipsaw_base"] = base_ws
    return out

def _slim_ws(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    if not isinstance(rec, dict):
        return {"ok": False, "reason": "none"}
    # 호가 rec 가 아니라 휩쏘 단독 rec
    if "combos" in rec or "entry" in rec:
        return _slim_ob(rec)
    return {
        "ok": bool(rec.get("ok")),
        "reason": rec.get("reason") or "",
        "n_trials": rec.get("n_trials"),
        "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")),
    }

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),
        "fill_count": int(rec.get("fill_count") or 0),
        "params": dict(rec.get("params") or {}),
        "orig_stats": _slim_stats(rec.get("orig_stats")),
        "recommended_stats": _slim_stats(rec.get("recommended_stats")),
        "entry": _slim_axis(rec.get("entry")),
        "exit": _slim_axis(rec.get("exit")),
        "stop": _slim_axis(rec.get("stop")),
        "combos": combos_out,
    }
    return out

def _slim_ws(rec: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    return _slim_ob(rec)

            if fills:
                ob = _ob_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials)
                # 방별 휩쏘는 호가 추천 안에서 채움 → base 방 우선
                ws = dict((ob.get("combos") or {}).get("base", {}).get("whipsaw") or {})
                if not ws:
                    ws = dict(ob.get("whipsaw_base") or {})
                if not ws:
                    ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)

            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)


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 _ws_from_ob_or_anchor(
    ob: Dict[str, Any],
    *,
    strategy: str,
    out_data: Dict[str, Any],
    fills: Optional[List[Dict[str, Any]]],
    live: bool,
    lg: logging.Logger,
) -> Dict[str, Any]:
    """호가 방별 휩쏘(base) 우선. 없거나 n/a 면 레거시 단독 휩쏘."""
    ws = dict((ob.get("combos") or {}).get("base", {}).get("whipsaw") or {})
    if not ws:
        ws = dict(ob.get("whipsaw_base") or {})
    if ws.get("ok") or ws.get("reason") in ("n/a_strategy", "per_combo_off", "not_enough_trades"):
        return ws if ws else {"ok": False, "reason": "none"}
    return _ws_for_anchor(
        strategy=strategy, out_data=out_data, fills=fills, live=live, lg=lg,
    )

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)