<timestamp>Saturday, Aug 15, 2026, 10:53 PM (UTC+9)</timestamp>
<user_query>
지금 왜 웹에서 옵투나 돌렸는데 진행상황이 안나오냐
</user_query>


웹에서 Optuna를 돌렸는데 진행상황이 안 나오는 원인을 코드·API·프론트 경로부터 확인하겠습니다.


저장된 잡 상태를 보면 가상 `seq_*` 스터디와 `[tail/align] START` 로그 형식이 진행률 경로와 안 맞습니다. 가설을 로그로 확인한 뒤 고치겠습니다.


StrReplace

_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")

_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")

# #region agent log
def _agent_dbg(hid: str, loc: str, msg: str, data: Optional[Dict[str, Any]] = None) -> None:
    try:
        payload = {
            "sessionId": "4f9616",
            "hypothesisId": hid,
            "location": loc,
            "message": msg,
            "data": data or {},
            "timestamp": int(time.time() * 1000),
        }
        with open("/home/hoon/kis_bot/.cursor/debug-4f9616.log", "a", encoding="utf-8") as _df:
            _df.write(json.dumps(payload, ensure_ascii=False) + "\n")
    except Exception:
        pass
# #endregion

        out["study_ok"] = True
    except Exception as exc:
        out["error"] = str(exc)[:200]
    return out

        out["study_ok"] = True
    except Exception as exc:
        out["error"] = str(exc)[:200]
    # #region agent log
    _agent_dbg(
        "A",
        "optuna_web_jobs.py:_study_progress",
        "study_progress",
        {
            "study_name": study_name,
            "study_ok": out.get("study_ok"),
            "trials_done": out.get("trials_done"),
            "error": out.get("error"),
        },
    )
    # #endregion
    return out

        mm = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)\] START[^\n]*",
            master_blob,
        )
        if mm:

        mm = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)\] START[^\n]*",
            master_blob,
        )
        mm_slash = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/[^\]]+)?\] START[^\n]*",
            master_blob,
        )
        # #region agent log
        _agent_dbg(
            "A",
            "optuna_web_jobs.py:refresh_job_status:regex",
            "seq_start_regex",
            {
                "job_id": m.get("job_id"),
                "meta_study": m.get("study_name"),
                "mm_n": len(mm),
                "mm_last": (mm[-1] if mm else None),
                "mm_slash_n": len(mm_slash),
                "mm_slash_last": (mm_slash[-1] if mm_slash else None),
                "sidecar_tail_exists": (ROOT / "logs" / "optuna_tail_tpe_latest.study").is_file(),
            },
        )
        # #endregion
        if mm:

        if rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
        ):
            alive = False
        if alive:

        if rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
        ):
            # #region agent log
            _agent_dbg(
                "B",
                "optuna_web_jobs.py:refresh_job_status:force_dead",
                "result_json_forces_not_alive",
                {
                    "job_id": m.get("job_id"),
                    "kind": m.get("kind"),
                    "pid": pid,
                    "pid_was_alive": alive,
                    "rj": str(rj)[-80:],
                    "status_before": m.get("status"),
                },
            )
            # #endregion
            alive = False
        if alive:

    optunaRenderJob(j.job);
    if (j.job.status === 'done' || j.job.status === 'error') {

    optunaRenderJob(j.job);
    // #region agent log
    fetch('http://localhost:7416/ingest/8fd35f12-4bbb-4430-ad36-8d3f87f2fbaa',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'4f9616'},body:JSON.stringify({sessionId:'4f9616',hypothesisId:'C',location:'backtest.js:optunaPollOnce',message:'status_poll',data:{job_id:j.job&&j.job.job_id,status:j.job&&j.job.status,kind:j.job&&j.job.kind,study:j.job&&j.job.study_name,active_study:j.job&&j.job.active_study_name,current_strategy:j.job&&j.job.current_strategy,study_ok:j.job&&j.job.progress&&j.job.progress.study_ok,trials_done:j.job&&j.job.progress&&j.job.progress.trials_done,pct:j.job&&j.job.progress&&j.job.progress.pct,prog_err:j.job&&j.job.progress&&j.job.progress.error},timestamp:Date.now()})}).catch(()=>{});
    // #endregion
    if (j.job.status === 'done' || j.job.status === 'error') {


    m["progress"] = prog
    m["pid_alive"] = alive

    m["progress"] = prog
    m["pid_alive"] = alive
    # #region agent log
    _agent_dbg(
        "A",
        "optuna_web_jobs.py:refresh_job_status:out",
        "refresh_out",
        {
            "job_id": m.get("job_id"),
            "kind": m.get("kind"),
            "status": m.get("status"),
            "pid_alive": alive,
            "current_strategy": m.get("current_strategy"),
            "active_study": m.get("active_study_name"),
            "meta_study": m.get("study_name"),
            "study_ok": prog.get("study_ok"),
            "trials_done": prog.get("trials_done"),
            "pct": prog.get("pct"),
            "prog_err": prog.get("error"),
        },
    )
    # #endregion

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 and health-check


StrReplace

    m["progress"] = prog
    m["pid_alive"] = alive
    # seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)

    m["progress"] = prog
    m["pid_alive"] = alive
    # #region agent log
    _agent_dbg(
        "A",
        "optuna_web_jobs.py:refresh_job_status:out",
        "refresh_out",
        {
            "job_id": m.get("job_id"),
            "kind": m.get("kind"),
            "status": m.get("status"),
            "pid_alive": alive,
            "current_strategy": m.get("current_strategy"),
            "active_study": m.get("active_study_name"),
            "meta_study": m.get("study_name"),
            "study_ok": prog.get("study_ok"),
            "trials_done": prog.get("trials_done"),
            "pct": prog.get("pct"),
            "prog_err": prog.get("error"),
        },
    )
    # #endregion
    # seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)


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 web after remaining debug log


웹에서 Optuna를 돌렸는데 진행률이 안 보인 건, **숫자가 안 올라온 것**과 **실행 중인데 완료처럼 보인 것**이 겹쳤을 가능성이 큽니다. 지금은 가설만 잡고, 탭을 한 번 열어 로그로 확인한 뒤에 고치겠습니다.

방금 돌린 잡 `opt_20260815_224827_seq`는 꼬리 **align + limit_atr 순차 2스터디**였습니다. 잡 파일에는 이미 이런 흔적이 있습니다.

- 진행률용 스터디 이름이 실제 Optuna DB에 없는 `seq_20260812_...` 이고, `study_ok: false` / `Record does not exist`
- 로그는 `[tail/align] START` 형식인데, 웹은 `[tail] START` 만 찾아서 실제 스터디 이름을 못 붙임
- `finished_at`이 22:53:16(align 끝난 직후)인데, `limit_atr`는 22:55:05까지 돌아감

확인 중인 가설은 네 가지입니다.

1. **A.** 순차/이중진입 잡의 가상 `seq_*` 스터디를 조회해서 진행률이 항상 0·에러
2. **B.** 첫 스터디가 `OPTUNA_RESULT_JSON`을 찍으면 PID가 살아 있어도 **완료**로 처리
3. **C.** 프론트가 폴링은 하지만 `study_ok=false`라 바가 비어 보임
4. **D.** Optuna storage 자체 조회 실패 (권한/URL)

웹 서비스는 재시작했고 HTTP 200입니다. Optuna 탭을 열면 status 폴링이 가설 A/C를 가립니다.

<reproduction_steps>
1. 브라우저에서 http://192.168.0.149:5050/ 을 강력 새로고침한 뒤 Optuna 탭을 연다.
2. 최근 잡 목록에서 `opt_20260815_224827_seq` 의 「보기」를 누른다.
3. 상단 진행바·trial 숫자·상태가 어떻게 보이는지 확인한다 (0/? , 100% 완료, best 지표 없음 등).
4. Press Proceed/Mark as fixed when done.
</reproduction_steps>


<timestamp>Saturday, Aug 15, 2026, 10:59 PM (UTC+9)</timestamp>
<user_query>
job_id	전략	기간	상태	trial	시작	종료
import_optuna_tail_tpe_20260815_225502	tail	2026-08-12~2026-08-14	done	30/30	2026-08-15T22:55:28	2026-08-15T22:55:28
import_optuna_tail_tpe_20260815_225139	tail	2026-08-12~2026-08-14	done	30/30	2026-08-15T22:55:28	2026-08-15T22:55:28
opt_20260815_224827_seq	순차(꼬리(align+limit_atr))	2026-08-12~2026-08-14	done	30/30	2026-08-15T22:48:27	2026-08-15T22:53:16

🔬 Optuna TPE 탐색
서버 subprocess · apply-best 없음 · 해외모멘텀=전역만(종목 cfg는 🇺🇸탭)
전략 (여러 개면 순차 · 해외는 따로 체크)
모멘텀
해외모멘텀(전역)
꼬리
align
limit_atr
돌파
스캘핑

꼬리를  align모드 limit_atr 모드 이렇게 나눳는데 꼬리가 왜 또 있어? 이 꼬리는 진입모드가 뭔데?
그리고 왜 다 완료 후 표시야 밑에서 보기를 눌러도 아무 변화가 없어

</user_query>


꼬리 체크박스와 진입모드가 겹쳐 보이는 이유, 그리고 「보기」를 눌러도 결과가 안 바뀌는 경로를 UI·잡 JSON부터 확인하겠습니다.


Check Optuna result JSON file sizes


원인은 두 가지입니다. ‘꼬리’는 전략이고 align/limit_atr는 진입모드인데 UI가 같은 줄에 나란히 있어 세 번째 모드처럼 보였고, 순차 잡은 마지막(후보 0건) JSON만 붙여 「보기」해도 표가 비어 있었습니다. 그걸 고치겠습니다.

StrReplace

_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")

# #region agent log
def _agent_dbg(hid: str, loc: str, msg: str, data: Optional[Dict[str, Any]] = None) -> None:
    try:
        payload = {
            "sessionId": "4f9616",
            "hypothesisId": hid,
            "location": loc,
            "message": msg,
            "data": data or {},
            "timestamp": int(time.time() * 1000),
        }
        with open("/home/hoon/kis_bot/.cursor/debug-4f9616.log", "a", encoding="utf-8") as _df:
            _df.write(json.dumps(payload, ensure_ascii=False) + "\n")
    except Exception:
        pass
# #endregion

_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")

        out["study_ok"] = True
    except Exception as exc:
        out["error"] = str(exc)[:200]
    # #region agent log
    _agent_dbg(
        "A",
        "optuna_web_jobs.py:_study_progress",
        "study_progress",
        {
            "study_name": study_name,
            "study_ok": out.get("study_ok"),
            "trials_done": out.get("trials_done"),
            "error": out.get("error"),
        },
    )
    # #endregion
    return out

        out["study_ok"] = True
    except Exception as exc:
        out["error"] = str(exc)[:200]
    return out

    for m in re.finditer(r"OPTUNA_RESULT_JSON=(.+)", tail):
        rj_list.append(m.group(1).strip())
    for m in re.finditer(r"OPTUNA_BRIEFING_MD=(.+)", tail):
        bm_list.append(m.group(1).strip())

    return {
        "result_json": rj_list[-1] if rj_list else None,
        "result_jsons": rj_list,
        "briefing_md": bm_list[-1] if bm_list else None,
        "briefing_mds": bm_list,
    }

    for m in re.finditer(r"OPTUNA_RESULT_JSON=(.+)", tail):
        rj_list.append(m.group(1).strip())
    for m in re.finditer(r"OPTUNA_BRIEFING_MD=(.+)", tail):
        bm_list.append(m.group(1).strip())
    rj_list = _uniq_keep(rj_list)
    bm_list = _uniq_keep(bm_list)
    best = _pick_result_json_with_rows(rj_list) or (rj_list[-1] if rj_list else None)
    return {
        "result_json": best,
        "result_jsons": rj_list,
        "briefing_md": bm_list[-1] if bm_list else None,
        "briefing_mds": bm_list,
    }

def _uniq_keep(seq: List[str]) -> List[str]:
    seen = set()
    out: List[str] = []
    for x in seq:
        s = str(x or "").strip()
        if not s or s in seen:
            continue
        seen.add(s)
        out.append(s)
    return out

def _json_n_results(path: str) -> int:
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
    except Exception:
        return -1
    n = data.get("n_results_all")
    if n is not None:
        try:
            return int(n)
        except (TypeError, ValueError):
            pass
    return len(list(data.get("results_all") or data.get("results") or []))

def _pick_result_json_with_rows(paths: List[str]) -> Optional[str]:
    """순차 2스터디: 마지막 JSON이 0건이면 후보가 있는 쪽을 미리보기로 고른다."""
    scored = []
    for p in paths:
        if p and Path(p).is_file():
            scored.append((_json_n_results(p), p))
    if not scored:
        return None
    scored.sort(key=lambda x: x[0])
    return scored[-1][1]


        mm = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)\] START[^\n]*",
            master_blob,
        )
        mm_slash = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/[^\]]+)?\] START[^\n]*",
            master_blob,
        )
        # #region agent log
        _agent_dbg(
            "A",
            "optuna_web_jobs.py:refresh_job_status:regex",
            "seq_start_regex",
            {
                "job_id": m.get("job_id"),
                "meta_study": m.get("study_name"),
                "mm_n": len(mm),
                "mm_last": (mm[-1] if mm else None),
                "mm_slash_n": len(mm_slash),
                "mm_slash_last": (mm_slash[-1] if mm_slash else None),
                "sidecar_tail_exists": (ROOT / "logs" / "optuna_tail_tpe_latest.study").is_file(),
            },
        )
        # #endregion
        if mm:
            last = mm[-1]
            m_cs = re.match(
                r"\[?(momentum|us_momentum|tail|breakout|scalp)\]?",
                last,
            )
            if m_cs:
                m["current_strategy"] = m_cs.group(1)
            # START 줄에 study=... 있으면 그 study로 진행률 (seq_* 가상명은 DB에 없음)
            sm = re.search(r"study=([^\s]+)", last)
            if sm:
                m["active_study_name"] = sm.group(1).strip()
                prog = _study_progress(m["active_study_name"], int(m.get("trials") or 0))

        # 로그: [tail] START 또는 [tail/align] START study=...
        hits = re.findall(
            r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/([a-z0-9_]+))?\] START(?:[^\n]*study=([^\s]+))?",
            master_blob,
        )
        if hits:
            strat_h, em_h, study_h = hits[-1]
            m["current_strategy"] = strat_h
            if em_h:
                m["current_entry_mode"] = em_h
            if study_h:
                m["active_study_name"] = study_h.strip()
                prog = _study_progress(
                    m["active_study_name"], int(m.get("trials") or 0)
                )

        rj = m.get("result_json")
        if rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
        ):
            # #region agent log
            _agent_dbg(
                "B",
                "optuna_web_jobs.py:refresh_job_status:force_dead",
                "result_json_forces_not_alive",
                {
                    "job_id": m.get("job_id"),
                    "kind": m.get("kind"),
                    "pid": pid,
                    "pid_was_alive": alive,
                    "rj": str(rj)[-80:],
                    "status_before": m.get("status"),
                },
            )
            # #endregion
            alive = False
        if alive:

        rj = m.get("result_json")
        # 순차(꼬리 align+limit_atr 등): 첫 스터디 JSON이 찍혀도 다음 스터디가 남음
        if m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
        ):
            alive = False
        if alive:

    m["progress"] = prog
    m["pid_alive"] = alive
    # #region agent log
    _agent_dbg(
        "A",
        "optuna_web_jobs.py:refresh_job_status:out",
        "refresh_out",
        {
            "job_id": m.get("job_id"),
            "kind": m.get("kind"),
            "status": m.get("status"),
            "pid_alive": alive,
            "current_strategy": m.get("current_strategy"),
            "active_study": m.get("active_study_name"),
            "meta_study": m.get("study_name"),
            "study_ok": prog.get("study_ok"),
            "trials_done": prog.get("trials_done"),
            "pct": prog.get("pct"),
            "prog_err": prog.get("error"),
        },
    )
    # #endregion
    # seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)

    m["progress"] = prog
    m["pid_alive"] = alive
    # seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)

    study = str(data.get("optuna_study_name") or data.get("study_name") or "")
    start = str(data.get("start") or "")
    end = str(data.get("end") or "")
    mode = str(data.get("mode") or "tpe")
    # 안정 job_id: 파일 stem
    job_id = f"import_{path.stem}"

    existing = load_job(job_id)
    meta: Dict[str, Any] = existing or {}
    meta.update({
        "job_id": job_id,
        "kind": "import",
        "source": source_label,
        "strategy": strat,
        "mode": mode,

    study = str(data.get("optuna_study_name") or data.get("study_name") or "")
    start = str(data.get("start") or "")
    end = str(data.get("end") or "")
    mode = str(data.get("mode") or "tpe")
    label = strat
    if strat == "tail":
        em = ""
        if "limit_atr" in study:
            em = "limit_atr"
        elif "align" in study:
            em = "align"
        else:
            rows = list(data.get("results_all") or data.get("results") or [])
            p0 = (rows[0] or {}).get("params") or {} if rows else {}
            em = str(p0.get("entry_mode") or "").strip()
        label = f"꼬리({em})" if em else "꼬리"
    _labels_imp = {
        "momentum": "모멘텀",
        "us_momentum": "해외모멘텀",
        "breakout": "돌파",
        "scalp": "스캘핑",
    }
    if strat != "tail":
        label = _labels_imp.get(strat, strat)
    # 안정 job_id: 파일 stem
    job_id = f"import_{path.stem}"

    existing = load_job(job_id)
    meta: Dict[str, Any] = existing or {}
    meta.update({
        "job_id": job_id,
        "kind": "import",
        "source": source_label,
        "label": label,
        "strategy": strat,
        "mode": mode,


            <input class="form-check-input opt-strat-cb" type="checkbox" value="momentum" checked> 모멘텀

            <input class="form-check-input opt-strat-cb" type="checkbox" value="us_momentum"> 해외모멘텀(전역)

              꼬리 진입

                <input class="form-check-input opt-tail-em" type="checkbox" id="opt_tail_em_align" value="align"> align
                다음봉시가

                <input class="form-check-input opt-tail-em" type="checkbox" id="opt_tail_em_limit" value="limit_atr"> limit_atr
                지정가

            <input class="form-check-input opt-strat-cb" type="checkbox" value="breakout"> 돌파
            <input class="form-check-input opt-strat-cb" type="checkbox" value="scalp"> 스캘핑

            <input class="form-check-input opt-strat-cb" type="checkbox" value="momentum" checked> 모멘텀

            <input class="form-check-input opt-strat-cb" type="checkbox" value="us_momentum"> 해외모멘텀(전역)

            <input class="form-check-input opt-strat-cb" type="checkbox" value="tail"> 꼬리

              <input class="form-check-input" type="checkbox" id="opt_tail_em_align" checked> align

              <input class="form-check-input" type="checkbox" id="opt_tail_em_limit"> limit_atr

            <input class="form-check-input opt-strat-cb" type="checkbox" value="breakout"> 돌파
            <input class="form-check-input opt-strat-cb" type="checkbox" value="scalp"> 스캘핑

<small class="text-muted d-block mt-2">탐색 게이트 WR/PF=0 · 사후=results_gated · 안정=results_stable · DB 자동적용 안 함 · 2개 이상=순차(병렬 아님) · 이력소스=저장 후보 테이블 · 꼬리: align/limit_atr 중 켠 것만 실행(둘 다=스터디 2개 순차). 별도 「꼬리」체크는 없음</small>

<small class="text-muted d-block mt-2">탐색 게이트 WR/PF=0 · 사후=results_gated · 안정=results_stable · DB 자동적용 안 함 · 2개 이상=순차(병렬 아님) · 이력소스=저장 후보 테이블 · 꼬리 진입 align/limit_atr 둘 다=스터디 2개 순차(한 TPE에 섞지 않음)</small>


    if paths.get("result_json"):
        m["result_json"] = paths["result_json"]
        m["result_jsons"] = paths.get("result_jsons", [])
        notes = []
        for p in m["result_jsons"]:
            notes.append({"path": p, "n_all": _json_n_results(p)})
        m["result_json_notes"] = notes

    if paths.get("result_json"):
        m["result_json"] = paths["result_json"]
        m["result_jsons"] = paths.get("result_jsons", [])

function usmomGoOptuna() {
  /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = (cb.value === 'us_momentum');
  });
  document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; });
  const el = document.querySelector('[data-tab="optuna"]');
  if (el) el.click();
}

function usmomGoOptuna() {
  /* Optuna 탭 = 전역 US_MOMENTUM_* 만 (종목 cfg는 이 탭 종목 Optuna) */
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = (cb.value === 'us_momentum');
  });
  const el = document.querySelector('[data-tab="optuna"]');
  if (el) el.click();
}

const rows = (sum && sum.compare_rows) || [];
  if (!rows.length) {
    const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
    tb.innerHTML = done
      ? '
완료 · 학습/게이트 통과 0건 (표가 비는 것이 정상)
'
      : '
완료 후 표시
';

const rows = (sum && sum.compare_rows) || [];
  if (!rows.length) {
    tb.innerHTML = '
완료 후 표시
';

  if (!d || d.overfit_risk_pct == null) {
    const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
    const empty = done ? '완료 · 진단 없음(후보 0건)' : '완료 후 표시';


    const empty = done ? '완료 · 진단 없음(후보 0건)' : '완료 후 표시';
    if (scoreEl) scoreEl.textContent = empty;
    if (facTb) facTb.innerHTML = `<tr><td colspan="3" class="text-muted">${empty}</td></tr>`;
    if (distTb) distTb.innerHTML = `<tr><td colspan="6" class="text-muted">${empty}</td></tr>`;

const empty = done ? '완료 · 진단 없음(후보 0건)' : '완료 후 표시';
    if (scoreEl) scoreEl.textContent = '완료 후 표시';
    if (facTb) facTb.innerHTML = '
완료 후 표시
';
    if (distTb) distTb.innerHTML = '
완료 후 표시
';

  if ($('opt_st_study')) {
    $('opt_st_study').textContent = job.active_study_name || job.study_name || '—';
  }
  if ($('opt_st_trials')) {
    const met = optunaBestMetricsText(prog, job);
    const nowBits = [
      job.current_strategy,
      job.current_entry_mode,
    ].filter(Boolean).join('/');
    $('opt_st_trials').textContent =
      `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
      (nowBits ? ` · now=${nowBits}` : '') +
      (met ? ` · ${met}` : '');
  }
  if ($('opt_st_log')) $('opt_st_log').textContent = job.display_log_path || job.log_path || '—';
  if ($('opt_st_json')) {
    const notes = job.result_json_notes || [];
    if (notes.length > 1) {
      $('opt_st_json').textContent = notes.map(n => {
        const name = String(n.path || '').split('/').pop();
        return `${name}(n=${n.n_all})`;
      }).join(' · ');
    } else {
      $('opt_st_json').textContent = job.result_json || '—';
    }
  }
  const sum = job.result_summary;
  window._optunaLastSummary = sum || null;
  window._optunaLastJob = job;
  if ($('opt_st_top')) {
    if (sum && sum.top) {
      const t = sum.top;
      $('opt_st_top').innerHTML =
        `상위(${t.source}): trial #${t.optuna_trial_number} · ` +
        `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
        `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
        ` · gated ${sum.n_gated}/${sum.n_all}` +
        (sum.n_stable != null ? ` · stable ${sum.n_stable}` : '');
    } else if (job.status === 'done') {
      $('opt_st_top').textContent = job.error
        || '완료 · 학습/게이트 통과 0건 (이 스터디는 표가 비는 것이 정상)';
    } else {
      $('opt_st_top').textContent = job.error || '결과 요약 없음';
    }
  }

  if ($('opt_st_study')) $('opt_st_study').textContent = job.study_name || '—';
  if ($('opt_st_trials')) {
    const met = optunaBestMetricsText(prog, job);
    $('opt_st_trials').textContent =
      `${prog.trials_done ?? '—'} / ${prog.trials_total ?? job.trials ?? '—'} (${pct}%)` +
      (job.current_strategy ? ` · now=${job.current_strategy}` : '') +
      (met ? ` · ${met}` : '');
  }
  if ($('opt_st_log')) $('opt_st_log').textContent = job.display_log_path || job.log_path || '—';
  if ($('opt_st_json')) $('opt_st_json').textContent = job.result_json || '—';
  const sum = job.result_summary;
  if ($('opt_st_top')) {
    if (sum && sum.top) {
      const t = sum.top;
      $('opt_st_top').innerHTML =
        `상위(${t.source}): trial #${t.optuna_trial_number} · ` +
        `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
        `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
        ` · gated ${sum.n_gated}/${sum.n_all}` +
        (sum.n_stable != null ? ` · stable ${sum.n_stable}` : '');
    } else {
      $('opt_st_top').textContent = job.error || '결과 요약 없음';
    }
  }

  optunaRenderCompare(sum);
  optunaRenderTop5(sum);
  if (!window._optunaPostSel) window._optunaPostSel = { source: 'gated', rank: 1 };
  optunaRenderPostprocess(sum);
  optunaSetNav(job);
}

  optunaRenderCompare(sum);
  optunaRenderTop5(sum);
  window._optunaLastSummary = sum || null;
  window._optunaLastJob = job;
  if (!window._optunaPostSel) window._optunaPostSel = { source: 'gated', rank: 1 };
  optunaRenderPostprocess(sum);
  optunaSetNav(job);
}

    optunaRenderJob(j.job);
    if (j.job.status === 'done' || j.job.status === 'error') {

    optunaRenderJob(j.job);
    // #region agent log
    fetch('http://localhost:7416/ingest/8fd35f12-4bbb-4430-ad36-8d3f87f2fbaa',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'4f9616'},body:JSON.stringify({sessionId:'4f9616',hypothesisId:'C',location:'backtest.js:optunaPollOnce',message:'status_poll',data:{job_id:j.job&&j.job.job_id,status:j.job&&j.job.status,kind:j.job&&j.job.kind,study:j.job&&j.job.study_name,active_study:j.job&&j.job.active_study_name,current_strategy:j.job&&j.job.current_strategy,study_ok:j.job&&j.job.progress&&j.job.progress.study_ok,trials_done:j.job&&j.job.progress&&j.job.progress.trials_done,pct:j.job&&j.job.progress&&j.job.progress.pct,prog_err:j.job&&j.job.progress&&j.job.progress.error},timestamp:Date.now()})}).catch(()=>{});
    // #endregion
    if (j.job.status === 'done' || j.job.status === 'error') {

function optunaSelectedStrategies() {
  const order = ['momentum', 'us_momentum', 'tail', 'breakout', 'scalp'];
  const checked = new Set(
    Array.from(document.querySelectorAll('.opt-strat-cb:checked')).map(cb => cb.value)
  );
  if (optunaTailEntryModes().length) checked.add('tail');
  return order.filter(s => checked.has(s));
}

function optunaPickKr4() {
  const kr = new Set(['momentum', 'breakout', 'scalp']);
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = kr.has(cb.value);
  });
  if ($('opt_tail_em_align')) $('opt_tail_em_align').checked = true;
  if ($('opt_tail_em_limit')) $('opt_tail_em_limit').checked = false;
}

function optunaPickNone() {
  document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; });
  document.querySelectorAll('.opt-tail-em').forEach(cb => { cb.checked = false; });
}

function optunaTailEntryModes() {
  const out = [];
  if ($('opt_tail_em_align')?.checked) out.push('align');
  if ($('opt_tail_em_limit')?.checked) out.push('limit_atr');
  return out;
}

function optunaSelectedStrategies() {
  const order = ['momentum', 'us_momentum', 'tail', 'breakout', 'scalp'];
  const checked = new Set(
    Array.from(document.querySelectorAll('.opt-strat-cb:checked')).map(cb => cb.value)
  );
  return order.filter(s => checked.has(s));
}

function optunaPickKr4() {
  const kr = new Set(['momentum', 'tail', 'breakout', 'scalp']);
  document.querySelectorAll('.opt-strat-cb').forEach(cb => {
    cb.checked = kr.has(cb.value);
  });
}

function optunaPickNone() {
  document.querySelectorAll('.opt-strat-cb').forEach(cb => { cb.checked = false; });
}

function optunaTailEntryModes() {
  const out = [];
  if ($('opt_tail_em_align')?.checked) out.push('align');
  if ($('opt_tail_em_limit')?.checked) out.push('limit_atr');
  return out.length ? out : ['align'];
}


tb.innerHTML = j.jobs.map(job => {
      const prog = job.progress || {};
      const jid = job.job_id;
      const label = job.label || job.strategy || '';
      const sel = jid === _optunaJobId ? 'outline:1px solid var(--accent)' : '';
      return `

        ${jid}
        ${label}
        ${job.start || ''}~${job.end || ''}
        ${job.status || ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}
        ${job.started_at || ''}
        ${job.finished_at || '—'}
        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button>

`;
    }).join('');

tb.innerHTML = j.jobs.map(job => {
      const prog = job.progress || {};
      const jid = job.job_id;
      const label = job.label || job.strategy || '';
      return `

        ${jid}
        ${label}
        ${job.start || ''}~${job.end || ''}
        ${job.status || ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}
        ${job.started_at || ''}
        ${job.finished_at || '—'}
        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button>

`;
    }).join('');

function optunaWatch(jobId) {
  _optunaJobId = jobId;
  localStorage.setItem(OPTUNA_LS_KEY, jobId);
  if ($('opt_st_job')) $('opt_st_job').textContent = jobId;
  const card = $('opt_status_card');
  if (card) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
  document.querySelectorAll('#opt_jobs_tbody tr').forEach(tr => {
    tr.style.outline = tr.getAttribute('data-job-id') === jobId ? '1px solid var(--accent)' : '';
  });
  optunaPollOnce();
  optunaStartPolling();
}

function optunaWatch(jobId) {
  _optunaJobId = jobId;
  localStorage.setItem(OPTUNA_LS_KEY, jobId);
  optunaPollOnce();
  optunaStartPolling();
}

Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py && echo OK

Syntax-check optuna_web_jobs.py


cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.backtest import optuna_web_jobs as owj
m = owj.load_job('opt_20260815_224827_seq')
r = owj.refresh_job_status(m)
s = r.get('result_summary') or {}
print('status', r.get('status'))
print('active_study', r.get('active_study_name'))
print('current', r.get('current_strategy'), r.get('current_entry_mode'))
print('study_ok', (r.get('progress') or {}).get('study_ok'), (r.get('progress') or {}).get('error'))
print('trials', (r.get('progress') or {}).get('trials_done'), (r.get('progress') or {}).get('best_pnl'))
print('result_json', str(r.get('result_json') or '')[-50:])
print('notes', r.get('result_json_notes'))
print('n_all', s.get('n_all'), 'n_gated', s.get('n_gated'), 'top', bool(s.get('top')), 'compare', len(s.get('compare_rows') or []))
imp = owj.load_job('import_optuna_tail_tpe_20260815_225139')
if imp:
    r2 = owj.refresh_job_status(imp)
    s2 = r2.get('result_summary') or {}
    print('import225139 label', r2.get('label'), 'n_gated', s2.get('n_gated'), 'compare', len(s2.get('compare_rows') or []))
imp0 = owj.load_job('import_optuna_tail_tpe_20260815_225502')
if imp0:
    r3 = owj.refresh_job_status(imp0)
    s3 = r3.get('result_summary') or {}
    print('import225502 label', r3.get('label'), 'n_gated', s3.get('n_gated'), 'n_all', s3.get('n_all'))
PY

Refresh seq/import jobs and print summary keys


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

Restart backtest web service and health-check


CallMcpTool

List open browser tabs


Open backtest web at LAN URL


Retry navigate to backtest web


Lock browser for Optuna tab interaction


Click Optuna tab


Read Optuna tab key UI texts

Grep

꼬리 진입|align|보기|학습1위


CallMcpTool

Evaluate Optuna tab UI text


Wait and read Optuna jobs table text


(async()=>{optunaWatch('opt_20260815_224827_seq'); await new Promise(r=>setTimeout(r,1500)); return JSON.stringify({job:opt_st_job.textContent, study:opt_st_study.textContent, json:(opt_st_json.textContent||'').slice(0,180), top:(opt_st_top.textContent||'').slice(0,220), compare:(opt_compare_tbody.innerText||'').slice(0,220), gated:(opt_top5_tbody.innerText||'').slice(0,120)});})()

Watch seq job and read compare/top UI


(async()=>{optunaWatch('import_optuna_tail_tpe_20260815_225502'); await new Promise(r=>setTimeout(r,1200)); const a=opt_st_top.textContent; optunaWatch('import_optuna_tail_tpe_20260815_225139'); await new Promise(r=>setTimeout(r,1200)); return JSON.stringify({limit_top:a.slice(0,120), align_top:(opt_st_top.textContent||'').slice(0,120), align_compare:(opt_compare_tbody.innerText||'').split('\n')[0]});})()

Toggle 보기 between empty limit_atr and align jobs

Check if any stored window errors


StrReplace

  try {
    const jid = _optunaJobId;
    const r = await fetch('/api/optuna/status/' + encodeURIComponent(jid));
    const j = await r.json();
    if (jid !== _optunaJobId) return;
    if (!j.ok) {

  try {
    const r = await fetch('/api/optuna/status/' + encodeURIComponent(_optunaJobId));
    const j = await r.json();
    if (!j.ok) {

CallMcpTool

window.addEventListener('error', e=>{ window.__errs=(window.__errs||[]).concat(String(e.message||e)); }); 'ok'

Install console error collector


Hard-reload backtest page for new JS


Click Optuna tab after reload


(async()=>{await new Promise(r=>setTimeout(r,2500)); optunaWatch('opt_20260815_224827_seq'); await new Promise(r=>setTimeout(r,2000)); const seq={job:opt_st_job.textContent, top:(opt_st_top.textContent||'').slice(0,100), compareFirst:(opt_compare_tbody.innerText||'').split('\n')[0]}; optunaWatch('import_optuna_tail_tpe_20260815_225502'); await new Promise(r=>setTimeout(r,2500)); const lim={job:opt_st_job.textContent, top:(opt_st_top.textContent||'').slice(0,140), compare:(opt_compare_tbody.innerText||'').slice(0,80)}; return JSON.stringify({seq, lim, labels:(document.getElementById('opt_strat_checks').innerText||'').replace(/\s+/g,' ').slice(0,120)});})()

Watch seq then empty limit_atr job after load


Confirm backtest.js loaded from page

Unlock browser after Optuna UI check


**꼬리 옆에 있던 그 칸은 세 번째 진입모드가 아니었습니다.** 전략(꼬리)과 진입모드(align / limit_atr)를 같은 줄에 나란히 둬서, align·limit_atr로 나눈 뒤에 꼬리가 하나 더 있는 것처럼 보였습니다.

지금은 **「꼬리 진입」 상자만** 있습니다.

- **align** = 다음 봉 시가 진입
- **limit_atr** = 지정가(ATR) 진입
- 둘 중 하나라도 켜야 꼬리가 돌아갑니다. 둘 다 끄면 꼬리 미실행입니다.
- 둘 다 켜면 예전처럼 **스터디 2개 순차**입니다.

**「보기」를 눌러도 표가 안 바뀌던 이유**는 UI가 죽은 게 아니라, 순차 잡이 **마지막 JSON만** 붙이고 있었기 때문입니다. `limit_atr` 30트라이얼은 전부 실패(후보 0건)라 표가 비었고, 문구가 「완료 후 표시」라서 아직 안 끝난 것처럼 보였습니다. `align` JSON에는 후보 23건이 있었습니다.

고친 내용:

- 순차 잡은 후보가 있는 JSON을 보여 줌 (`225139` n=23 + `225502` n=0 둘 다 표시)
- 「보기」가 이전 폴링 결과에 덮이지 않게 함
- 후보 0건이면 「완료 · 통과 0건」이라고 명시

브라우저에서 Optuna 탭 → 순차 잡 보기: trial #17 · gated 20 · 비교표 채워짐. `꼬리(limit_atr)` 보기: 통과 0건 문구. 콘솔 오류 없음.

재시작: `kis_backtest_web.service` **active**, HTTP 200. URL: `http://192.168.0.149:5050/`

**실매 영향 없음.** 웹 Optuna 진행/결과 표시만 해당합니다. 강력 새로고침 후 목록에서 `꼬리(align)` 또는 순차 잡 「보기」를 누르면 표가 바뀝니다.


[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-1ab06559-0724-4ddb-aa8c-89abf105bfb4.png

These images can be copied for use in other locations.
</image_files>
<timestamp>Saturday, Aug 15, 2026, 11:19 PM (UTC+9)</timestamp>
<user_query>
아니 이게 뭐야 체크 두개로 돌리면 보기 눌렀을때 하나밖에 안나오고 적용 누리니까 이딴 에러 나오고 순차로 돌리면 잡이 따로따로 하나씩이어야 보기를 누를때 그 전략이 나오지 않겠나?
</user_query>


순차로 여러 전략을 돌리면 잡이 하나로 묶여서 「보기」는 한 결과만 나오고, 「적용」은 `momentum,tail`을 전략명으로 받습니다. 적용 경로와 잡 분할부터 확인하겠습니다.


순차 잡은 `strategy`가 `momentum,tail`로 묶여 적용이 깨지고, 결과도 한 JSON만 보여줍니다. 적용은 JSON의 단일 전략을 쓰고, 전략(·꼬리 진입모드)마다 자식 잡을 만들겠습니다.

StrReplace

        "briefing_md": briefing,
    }

def _strategy_from_filename(name: str) -> str:
    n = str(name or "").lower()
    for s in ("us_momentum", "momentum", "tail", "breakout", "scalp"):
        if n.startswith(f"optuna_{s}_"):
            return s
    return ""

def _resolve_single_strategy(
    meta: Optional[Dict[str, Any]],
    data: Optional[Dict[str, Any]],
    path: Optional[str],
) -> str:
    """잡.strategy 가 'momentum,tail' 묶음이면 JSON/파일명이 진실."""
    js = str((data or {}).get("strategy") or "").strip().lower()
    if js in _STRATS:
        return js
    fn = _strategy_from_filename(Path(str(path or "")).name)
    if fn:
        return fn
    raw = str((meta or {}).get("strategy") or "").strip().lower()
    if raw in _STRATS:
        return raw
    return ""

"strategy": _resolve_single_strategy(meta, data, path) or data.get("strategy"),

"strategy": (meta or {}).get("strategy") or data.get("strategy"),

    data = json.loads(Path(path).read_text(encoding="utf-8"))
    strat = _resolve_single_strategy(meta, data, path)
    if strat not in _STRATS:
        raise ValueError(f"전략 불명: {strat or (meta or {}).get('strategy')}")

    data = json.loads(Path(path).read_text(encoding="utf-8"))
    strat = str(
        (meta or {}).get("strategy")
        or data.get("strategy")
        or "",
    ).strip().lower()
    if strat in ("", "all"):
        # JSON 파일명에서 추론
        name = Path(path).name
        for s in _STRATS:
            if f"_{s}_" in f"_{name}_" or name.startswith(f"optuna_{s}_"):
                strat = s
                break
    if strat not in _STRATS:
        raise ValueError(f"전략 불명: {strat}")


        m["result_json_notes"] = notes
        if m.get("kind") in ("seq", "seq4"):
            m["child_jobs"] = _child_jobs_from_jsons(m["result_jsons"])

    return out

def _child_jobs_from_jsons(paths: List[str]) -> List[Dict[str, Any]]:
    """순차 묶음의 전략별 JSON → 잡 목록에 따로 등록 (보기/적용은 여기)."""
    out: List[Dict[str, Any]] = []
    for p in paths:
        if not p or not Path(str(p)).is_file():
            continue
        try:
            ch = register_result_json_as_job(str(p), source_label="seq")
        except Exception:
            continue
        sm = ch.get("result_summary") or {}
        out.append({
            "job_id": ch.get("job_id"),
            "label": ch.get("label") or ch.get("strategy"),
            "strategy": ch.get("strategy"),
            "n_all": sm.get("n_all") if sm.get("n_all") is not None else _json_n_results(str(p)),
            "n_gated": sm.get("n_gated"),
        })
    return out


            except Exception:
                continue
    return out

def _child_jobs_from_jsons(paths: List[str]) -> List[Dict[str, Any]]:
    """순차 묶음의 전략별 JSON → 잡 목록에 따로 등록 (보기/적용은 여기)."""
    out: List[Dict[str, Any]] = []
    for p in paths:
        if not p or not Path(str(p)).is_file():
            continue
        try:
            ch = register_result_json_as_job(str(p), source_label="seq")
        except Exception:
            continue
        sm = ch.get("result_summary") or {}
        out.append({
            "job_id": ch.get("job_id"),
            "label": ch.get("label") or ch.get("strategy"),
            "strategy": ch.get("strategy"),
            "n_all": sm.get("n_all") if sm.get("n_all") is not None else _json_n_results(str(p)),
            "n_gated": sm.get("n_gated"),
        })
    return out

def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:

            except Exception:
                continue
    return out

def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:


const isSeq = job.kind === 'seq' || job.kind === 'seq4';
  const kids = job.child_jobs || [];
  const sum = (isSeq && kids.length) ? null : (job.result_summary || null);
  window._optunaLastSummary = sum || null;
  window._optunaLastJob = job;
  if ($('opt_st_top')) {
    if (isSeq && kids.length) {
      $('opt_st_top').innerHTML =
        '
순차 묶음 — 적용은 전략별 잡에서. 버튼을 누르면 그 전략 Top5가 나옵니다.
' +
        kids.map(k => {
          const jid = String(k.job_id || '').replace(/'/g, '');
          const lab = k.label || k.strategy || jid;
          return `<button type="button" class="btn btn-sm btn-outline-primary py-0 me-1 mb-1" onclick="optunaWatch('${jid}')">${lab} · gated ${k.n_gated ?? '—'} / ${k.n_all ?? '—'}</button>`;
        }).join('');
    } else if (sum && sum.top) {
      const t = sum.top;
      $('opt_st_top').innerHTML =
        `상위(${t.source}): trial #${t.optuna_trial_number} · ` +
        `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
        `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
        ` · gated ${sum.n_gated}/${sum.n_all}` +
        (sum.n_stable != null ? ` · stable ${sum.n_stable}` : '');
    } else if (job.status === 'done') {
      $('opt_st_top').textContent = job.error
        || '완료 · 학습/게이트 통과 0건 (이 스터디는 표가 비는 것이 정상)';
    } else {
      $('opt_st_top').textContent = job.error || '결과 요약 없음';
    }
  }

  const sum = job.result_summary;
  window._optunaLastSummary = sum || null;
  window._optunaLastJob = job;
  if ($('opt_st_top')) {
    if (sum && sum.top) {
      const t = sum.top;
      $('opt_st_top').innerHTML =
        `상위(${t.source}): trial #${t.optuna_trial_number} · ` +
        `거래 ${t.total_trades} · WR ${Number(t.win_rate || 0).toFixed(1)}% · ` +
        `PF ${Number(t.pf || 0).toFixed(2)} · PnL ${Number(t.total_pnl || 0).toLocaleString()}원` +
        ` · gated ${sum.n_gated}/${sum.n_all}` +
        (sum.n_stable != null ? ` · stable ${sum.n_stable}` : '');
    } else if (job.status === 'done') {
      $('opt_st_top').textContent = job.error
        || '완료 · 학습/게이트 통과 0건 (이 스터디는 표가 비는 것이 정상)';
    } else {
      $('opt_st_top').textContent = job.error || '결과 요약 없음';
    }
  }

  const applyBox = $('opt_apply_box');
  if (applyBox) {
    applyBox.classList.toggle('d-none', !done || (isSeq && kids.length));
    const canGate = !!(sum && sum.apply_ready);
    const canStable = !!(sum && sum.apply_stable_ready);
    if ($('opt_btn_apply_gated')) $('opt_btn_apply_gated').disabled = !canGate;
    if ($('opt_btn_apply_stable')) $('opt_btn_apply_stable').disabled = !canStable;
    if ($('opt_btn_apply_mode')) {
      const hasMode = !!(sum && (sum.compare_rows || []).some(r => r.source === 'mode'));
      $('opt_btn_apply_mode').disabled = !done || !hasMode;
    }
  }

  const applyBox = $('opt_apply_box');
  if (applyBox) {
    applyBox.classList.toggle('d-none', !done);
    const canGate = !!(sum && sum.apply_ready);
    const canStable = !!(sum && sum.apply_stable_ready);
    if ($('opt_btn_apply_gated')) $('opt_btn_apply_gated').disabled = !canGate;
    if ($('opt_btn_apply_stable')) $('opt_btn_apply_stable').disabled = !canStable;
    if ($('opt_btn_apply_mode')) {
      const hasMode = !!(sum && (sum.compare_rows || []).some(r => r.source === 'mode'));
      $('opt_btn_apply_mode').disabled = !done || !hasMode;
    }
  }

  if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; }
  const curJob = window._optunaLastJob;
  if (curJob && (curJob.kind === 'seq' || curJob.kind === 'seq4') && (curJob.child_jobs || []).length) {
    const lines = (curJob.child_jobs || []).map(k => `· ${k.label || k.strategy}`).join('\n');
    alert('순차 묶음 잡입니다.\n전략별 잡에서「보기」한 뒤 적용하세요.\n' + lines);
    return;
  }

if (!_optunaJobId) { alert('선택된 job 없음 — 최근 잡에서 보기 먼저'); return; }


Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py && cd /home/hoon/kis_bot && python3 - <<'PY'
from kis_trader.backtest import optuna_web_jobs as owj
# strategy resolve
assert owj._resolve_single_strategy({"strategy":"momentum,tail"}, {"strategy":"momentum"}, "x.json") == "momentum"
assert owj._resolve_single_strategy({"strategy":"momentum,tail"}, {}, "optuna_tail_tpe_1.json") == "tail"
m = owj.load_job("opt_20260815_224827_seq")
r = owj.refresh_job_status(m)
print("children", r.get("child_jobs"))
print("apply would be", owj._resolve_single_strategy(r, None, r.get("result_json")))
PY

Compile and test strategy resolve + child jobs


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 seq-job split


CallMcpTool

Open backtest web with cache-bust URL


Lock browser for Optuna verification


Open Optuna tab


(async()=>{await new Promise(r=>setTimeout(r,2800)); const jobsTxt=(opt_jobs_tbody.innerText||'').slice(0,600); optunaWatch('opt_20260815_224827_seq'); await new Promise(r=>setTimeout(r,2000)); const seqTop=(opt_st_top.innerText||'').slice(0,280); const seqCompare=(opt_compare_tbody.innerText||'').slice(0,80); optunaWatch('import_optuna_tail_tpe_20260815_225139'); await new Promise(r=>setTimeout(r,2000)); const alTop=(opt_st_top.innerText||'').slice(0,160); const alCmp=(opt_compare_tbody.innerText||'').split('\n')[0]; return JSON.stringify({jobsTxt, seqTop, seqCompare, alTop, alCmp, job:opt_st_job.textContent});})()

Watch seq parent then align child job


Unlock browser after verification


맞습니다. 체크 두 개로 순차 돌리면 **실행은 한 프로세스**지만, **보기/적용은 전략마다 따로**여야 합니다. 지금까지는 잡 하나를 `momentum,tail`로 묶어서 「보기」는 한 결과만 나오고, 「적용」은 그 문자열을 전략명으로 써서 `전략 불명`이 났습니다.

지금은 이렇게 동작합니다.

- **순차 묶음 잡** (`opt_…_seq`): 진행/로그만. Top5를 한 전략처럼 보여 주지 않습니다.
- **전략별 잡**이 목록에 따로 생깁니다. 꼬리면 `꼬리(align)`, `꼬리(limit_atr)`처럼 진입모드도 분리됩니다.
- 묶음 잡을 보면 노란 안내와 **전략 버튼**이 나옵니다. 그걸 누르면 그 전략 Top5가 열립니다.
- **적용은 전략별 잡에서만.** 묶음에서 적용을 누르면 안내만 뜹니다.
- 서버도 JSON 안의 단일 전략을 쓰므로 `momentum,tail` 에러는 나지 않습니다.

강력 새로고침(`http://192.168.0.149:5050/`) 후 Optuna 탭에서 순차 잡 「보기」→ `꼬리(align)` 또는 `모멘텀` 버튼을 누르면 됩니다. 목록의 `꼬리(align)` / `모멘텀` 행 「보기」도 같습니다.

**실매 코드는 안 바꿨습니다.** 웹 Optuna 잡 목록·적용만입니다. 웹 재시작 **active**, HTTP 200, 콘솔 오류 없음.


<timestamp>Saturday, Aug 15, 2026, 11:35 PM (UTC+9)</timestamp>
<user_query>
안정 탑5에는 왜 진입 호가 저 표가 없어?
</user_query>


안정 Top5에 진입호가 표가 없는 이유를 국장 게이트 쪽 UI와 대조해서 확인하겠습니다.


안정 Top5는 후처리 앵커를 안 만들고, 진입호가 표는 사후합격 아래에만 붙어 있습니다. 안정에도 같은 「상세」·표를 붙이겠습니다.

StrReplace

def _include_live() -> bool:
    return bool(get_env_bool("OPTUNA_POST_INCLUDE_LIVE", True))

def _include_stable() -> bool:
    return bool(get_env_bool("OPTUNA_POST_INCLUDE_STABLE", True))

def _include_live() -> bool:
    return bool(get_env_bool("OPTUNA_POST_INCLUDE_LIVE", True))

    """gated+mode 만. live·stable 제외(안정은 표만, 합의 축에 안 섞음)."""
    pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]

    """gated+mode 만. live 제외. 축=entry/exit/stop/whipsaw/trail."""
    pool = [a for a in anchors if str(a.get("role") or "") != "live"]

pool = [a for a in anchors if str(a.get("role") or "") in ("gated", "mode")]

pool = [a for a in anchors if str(a.get("role") or "") != "live"]


def _reuse_ob_ws(anchors: List[Dict[str, Any]], trial: Any) -> Optional[Dict[str, Any]]:
    if trial is None:
        return None
    try:
        tn = int(trial)
    except (TypeError, ValueError):
        return None
    for a in anchors:
        try:
            at = int(a.get("optuna_trial_number"))
        except (TypeError, ValueError):
            continue
        if at != tn:
            continue
        ob = a.get("orderbook") or {}
        if ob.get("ok") or (ob.get("entry") or {}).get("ok"):
            return a
    return None

def append_stable_postprocess_anchors(
    data: Dict[str, Any],
    anchors: List[Dict[str, Any]],
    *,
    strat: str,
    strat_u: str,
    top_n: int,
    do_ob: bool,
    evaluate_fn: Optional[EvalFn],
    lg: logging.Logger,
    ob_n_trials: int = 0,
) -> None:
    """results_stable TopN 을 후처리 표 앵커로 붙인다. 같은 trial 은 gated 호가 재사용."""
    if not _include_stable():
        return
    if any(str(a.get("role") or "") == "stable" for a in anchors):
        return
    stable = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))]
    for i, row in enumerate(stable, start=1):
        if not isinstance(row, dict):
            continue
        combo = _combo_from_row(row)
        trial = row.get("optuna_trial_number") or row.get("_trial_number")
        reused = _reuse_ob_ws(anchors, trial)
        fills: List[Dict[str, Any]] = []
        pnl = row.get("total_pnl")
        trail = _slim_trail(_trail_for_pnl(strat, pnl))
        if reused is not None:
            ob = reused.get("orderbook") or {"ok": False, "reason": "light_skip"}
            ws = reused.get("whipsaw") or {"ok": False, "reason": "light_skip"}
        else:
            ob = {"ok": False, "reason": "light_skip"}
            ws = {"ok": False, "reason": "light_skip"}
            if do_ob and evaluate_fn is not None:
                lg.info("📌 [후처리] stable#%d 백테 재실행 (호가/휩쏘 체결)", i)
                fills = _replay_fills(evaluate_fn, combo, lg)
            if do_ob:
                if fills:
                    ob = _ob_for_anchor(
                        strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg, n_trials=ob_n_trials,
                    )
                    ws = _ws_for_anchor(strategy=strat_u, out_data=data, fills=fills, live=False, lg=lg)
                elif evaluate_fn is None:
                    ob = {"ok": False, "reason": "no_replay_fills"}
                    ws = {"ok": False, "reason": "no_replay_fills"}
                else:
                    ob = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
                    ws = {"ok": False, "reason": "not_enough_trades", "trade_count": 0}
        anchors.append({
            "id": f"stable#{i}",
            "role": "stable",
            "rank": i,
            "optuna_trial_number": trial,
            "total_pnl": _krw_int(pnl),
            "total_trades": int(row.get("total_trades") or 0),
            "orderbook": ob,
            "whipsaw": ws,
            "trail": trail,
            "note": "안정 후보" + (" · gated와 동일 trial 호가 재사용" if reused is not None else ""),
        })

def ensure_stable_postprocess_on_payload(data: Dict[str, Any]) -> None:
    """구 JSON(gated만 있는 후처리)에도 안정 앵커를 붙여 웹 표가 나오게."""
    topn = (data or {}).get("postprocess_topn")
    if not isinstance(topn, dict):
        return
    anchors = list(topn.get("postprocess_by_anchor") or [])
    if not anchors:
        return
    strat = str(data.get("strategy") or "momentum").strip().lower()
    strat_u = strat.upper()
    if strat_u in ("TAIL", "SHORT"):
        strat_u = "TAIL"
    elif strat_u in ("SCALPING", "SCALP"):
        strat_u = "SCALP"
    before = len(anchors)
    append_stable_postprocess_anchors(
        data, anchors,
        strat=strat, strat_u=strat_u, top_n=resolve_post_top_n(5),
        do_ob=False, evaluate_fn=None, lg=logger, ob_n_trials=0,
    )
    if len(anchors) != before:
        topn["postprocess_by_anchor"] = anchors

            "note": "사후합격 후보",
        })

    append_stable_postprocess_anchors(
        data, anchors,
        strat=strat, strat_u=strat_u, top_n=top_n,
        do_ob=do_ob, evaluate_fn=evaluate_fn, lg=lg, ob_n_trials=ob_n_trials,
    )

    if _include_mode():

            "note": "사후합격 후보",
        })

    if _include_mode():

    if src == "mode":
        for a in anchors:
            if str(a.get("role") or "") == "mode":
                return a
        return None
    if src == "live":
        return None
    role = "stable" if src == "stable" else "gated"
    for a in anchors:
        if str(a.get("role") or "") == role and int(a.get("rank") or 0) == rk:
            return a
    return None

    if src == "mode":
        for a in anchors:
            if str(a.get("role") or "") == "mode":
                return a
        return None
    if src == "live":
        return None
    for a in anchors:
        if str(a.get("role") or "") == "gated" and int(a.get("rank") or 0) == rk:
            return a
    return None

        "OPTUNA_POST_INCLUDE_MODE": "true",
        "OPTUNA_POST_INCLUDE_LIVE": "true",
        "OPTUNA_POST_INCLUDE_STABLE": "true",

        "OPTUNA_POST_INCLUDE_MODE": "true",
        "OPTUNA_POST_INCLUDE_LIVE": "true",


    if len(anchors) != before:
        topn["postprocess_by_anchor"] = anchors

def attach_topn_postprocess(
    out_data: Dict[str, Any],
    *,
    evaluate_fn: Optional[EvalFn] = None,
    mode_fills: Optional[List[Dict[str, Any]]] = None,
    log: Optional[logging.Logger] = None,
    run_ob_whipsaw: Optional[bool] = None,
    ob_n_trials: int = 0,
) -> Dict[str, Any]:

    if len(anchors) != before:
        topn["postprocess_by_anchor"] = anchors
    out_data: Dict[str, Any],
    *,
    evaluate_fn: Optional[EvalFn] = None,
    mode_fills: Optional[List[Dict[str, Any]]] = None,
    log: Optional[logging.Logger] = None,
    run_ob_whipsaw: Optional[bool] = None,
    ob_n_trials: int = 0,
) -> Dict[str, Any]:


Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_postprocess_topn.py && echo OK

Syntax-check postprocess_topn after restore


StrReplace

            post_topn = data.get("postprocess_topn")
        except Exception:
            post_topn = None
    if isinstance(post_topn, dict) and post_topn.get("postprocess_by_anchor"):
        try:
            from kis_trader.backtest.optuna_postprocess_topn import (
                ensure_stable_postprocess_on_payload,
            )
            ensure_stable_postprocess_on_payload(data)
            post_topn = data.get("postprocess_topn") or post_topn
        except Exception:
            pass

            post_topn = data.get("postprocess_topn")
        except Exception:
            post_topn = None

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

              #	trial	거래
              승률	PF	PnL

완료 후 표시

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

              #	trial	거래
              승률	PnL
              손실일	최악일	안정점수

완료 후 표시 (구 JSON은 재실행 필요)

후처리 비교표
          (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)

          <button class="btn btn-sm btn-outline-secondary py-0" id="opt_btn_pp_rerun" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
          <button class="btn btn-sm btn-outline-secondary py-0 ms-1" onclick="optunaSelectPostprocess('mode',1)">mode 상세</button>
          구 JSON은 재실행해야 호가/휩쏘 숫자가 채워집니다.

합의: 완료 후 표시

사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.

사후합격 Top5 results_gated · 「상세」=아래 후처리 표

              #	trial	거래
              승률	PF	PnL

완료 후 표시

후처리 비교표
          (Top5 「상세」로 순위 전환 · 열까지 적용=뒤쪽 축은 OFF · 숫자는 유지 · live 참고만)

          <button class="btn btn-sm btn-outline-secondary py-0" id="opt_btn_pp_rerun" onclick="optunaRerunPostprocess()">이 잡 후처리 재실행</button>
          <button class="btn btn-sm btn-outline-secondary py-0 ms-1" onclick="optunaSelectPostprocess('mode',1)">mode 상세</button>
          구 JSON은 재실행해야 호가/휩쏘 숫자가 채워집니다.

합의: 완료 후 표시

Top5에서 「상세」를 누르면 이 표가 바뀝니다.

안정 Top5 results_stable · 손실일/최악일 게이트 · score=일평균−λ×표준편차

              #	trial	거래
              승률	PnL
              손실일	최악일	안정점수

완료 후 표시 (구 JSON은 재실행 필요)


cards.innerHTML = '
사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
';

cards.innerHTML = '
Top5에서 「상세」를 누르면 이 표가 바뀝니다.
';

  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else if (src === 'stable') {
    a = anchors.find((x) => x.role === 'stable' && Number(x.rank) === Number(rk)) || null;
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }

  let a = null;
  if (src === 'mode') {
    a = anchors.find((x) => x.role === 'mode') || null;
  } else {
    a = anchors.find((x) => x.role === 'gated' && Number(x.rank) === Number(rk)) || null;
  }

const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : (row.role === 'stable' ? 'stable' : 'gated'));

const rowSrc = isLive ? '' : (row.role === 'mode' ? 'mode' : 'gated');

  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });
  document.querySelectorAll('#opt_top5_stable_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'stable' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });

  document.querySelectorAll('#opt_top5_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'gated' && i + 1 === Number(rk)) ? '1px solid var(--accent)' : '';
  });

        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('stable',${rank})">보기</button>
        <button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('stable',${rank})">상세</button>
        <button class="btn btn-sm btn-outline-info py-0" onclick="optunaApply('stable',${rank})">적용</button>

        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('stable',${rank})">보기</button>
        <button class="btn btn-sm btn-outline-info py-0" onclick="optunaApply('stable',${rank})">적용</button>


  모듈: kis_trader/backtest/optuna_postprocess_topn.py · optuna_orderbook_recommend.py
  · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」)
  후처리 앵커: gated TopN + stable TopN(표/적용) + mode + live(참고). 합의·과적합 가점은 gated+mode만.
  OPTUNA_POST_INCLUDE_STABLE 기본 true.

  모듈: kis_trader/backtest/optuna_postprocess_topn.py · optuna_orderbook_recommend.py
  · optuna_rerun_postprocess.py (구 JSON 「이 잡 후처리 재실행」)

Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_postprocess_topn.py /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py /home/hoon/kis_bot/kis_trader/backtest/optuna_common.py && cd /home/hoon/kis_bot && python3 - <<'PY'
import json
from pathlib import Path
from kis_trader.backtest.optuna_postprocess_topn import ensure_stable_postprocess_on_payload
p = Path('kis_trader/backtest/results/optuna_tail_tpe_20260815_225139.json')
data = json.loads(p.read_text())
topn = data.get('postprocess_topn') or {}
roles_before = [a.get('role') for a in (topn.get('postprocess_by_anchor') or [])]
ensure_stable_postprocess_on_payload(data)
roles_after = [a.get('role') for a in ((data.get('postprocess_topn') or {}).get('postprocess_by_anchor') or [])]
st = [a for a in (data.get('postprocess_topn') or {}).get('postprocess_by_anchor') or [] if a.get('role')=='stable']
print('before', roles_before)
print('after', roles_after)
print('stable_n', len(st), 'first', (st[0] if st else None) and {k: st[0].get(k) for k in ('id','rank','optuna_trial_number','note')})
print('entry_ok', bool(((st[0].get('orderbook') or {}).get('entry') or st[0].get('orderbook') or {}).get('ok')) if st else None)
PY

Compile and test stable postprocess anchors on tail JSON


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 for stable postprocess UI


CallMcpTool

Open backtest page for stable OB table check


Lock browser


Open Optuna tab


(async()=>{await new Promise(r=>setTimeout(r,2500)); optunaWatch('import_optuna_tail_tpe_20260815_225139'); await new Promise(r=>setTimeout(r,3500)); const stableBtns=(opt_top5_stable_tbody.innerText||'').slice(0,180); optunaSelectPostprocess('stable',1); await new Promise(r=>setTimeout(r,400)); const cards=(opt_postprocess_cards.innerText||'').slice(0,350); const thead=opt_postprocess_cards.querySelector('thead')&&opt_postprocess_cards.querySelector('thead').innerText; return JSON.stringify({stableBtns, thead, cards});})()

Watch align job and open stable#1 postprocess table


Unlock browser


**안정 Top5에 진입 호가 표가 없던 이유**는 UI가 비어서가 아니라, **후처리(진입호가·휩쏘)를 사후합격 Top5에만 붙이도록 되어 있었기 때문**입니다.

- 후처리 앵커는 `gated`(사후합격) + `mode` + `live`만 만들고, **`stable`(안정 Top5)은 대상이 아님**
- 안정 행에는 **「상세」 버튼이 없음**
- 비교표 DOM도 **사후합격 카드 아래**에만 있어서, 안정 Top5를 보면 표가 없는 것처럼 보임

지금은 그렇게 맞춰 두었습니다.

- 안정 후보에도 후처리 앵커를 붙임 (같은 trial이면 **사후합격에서 계산한 호가/휩쏘를 재사용**, 불필요한 재백테 없음)
- 안정 Top5에도 **「상세」**
- 비교표를 **안정 Top5 카드 안**으로 옮김

브라우저에서 `http://192.168.0.149:5050/` → Optuna → `꼬리(align)` 보기 → 안정 1위 **상세** 확인했습니다. `stable#1 · trial #18`에 **기본(타점) / 진입호가 / 익절호가 / 손절호가 / 휩쏘** 표가 나오고, 진입호가는 `스프레드≤4.4% · 잔량≥0.5` 로 채워져 있습니다. 웹 재시작 `active`, HTTP 200, 콘솔 Uncaught는 해당 조작에서 없었습니다.

구 JSON은 보기 한 번에 안정 앵커를 채웁니다. 같은 trial이 사후합격에 없으면 호가 칸이 비어 있을 수 있고, 그때는 「이 잡 후처리 재실행」이 필요합니다. 실매 엔진은 건드리지 않았습니다.


<timestamp>Saturday, Aug 15, 2026, 11:45 PM (UTC+9)</timestamp>
<user_query>
가장 중요한게 빠졌어 과적합 정도가 빠졌어 이게 지금 임계값 분포 이거랑 안정점수 같은데 안정점수가 높을수록 좋은건지 낮을수록 좋은건지 몇점 만점인지도 모르겠고 분포도는 뭐 그리 중요하지 않아 순위권안에 잇는 각각의 과적합정도가 중요하지 뭘 적용해야될지를 알아야하기 때문에 안정점수 옆에 표시를 해주는게 어떻겠냐?
사후합격 Top5 results_gated · 「상세」=아래 후처리 표(진입호가)
#	trial	거래	승률	PF	PnL
1	#14	3	100.0%	9999.00	12,688
2	#36	3	100.0%	9999.00	12,478
3	#20	3	100.0%	9999.00	11,849
4	#28	3	100.0%	9999.00	11,849
5	#37	3	100.0%	9999.00	11,849
안정 Top5 results_stable · 「상세」=아래 후처리 표(진입호가) · 손실일/최악일 게이트
#	trial	거래	승률	PnL	손실일	최악일	안정점수
1	#45	4	75.0%	10,668	1	-1,600	-1600.00
2	#29	3	66.7%	475	1	-5,502	-5502.00
3	#27	4	75.0%	728	1	-11,750	-11750.00

여기 학습1위 모드도 넣어주고 맨 뒤에 안정점수 과접합정도가 있어야겟어

비교표 학습1위 · gated1위 · stable1위 · mode_combo
구분	trial	거래	승률%	PF	PnL	손실일	최악일	안정점수
학습1위(results)	#14	3	100.0	9999.00	12,688	0	12,688	12688.00
사후합격1위(gated)	#14	3	100.0	9999.00	12,688	0	12,688	12688.00
안정1위(stable)	#45	4	75.0	7.67	10,668	1	-1,600	-1600.00
mode_combo 실측	—	4	75.0	1.04	518	—	—	—
이건 왜 있는거야? 전혀 쓸모가 없어보이는데
</user_query>


과적합·안정점수 표시와 비교표 역할을 코드에서 확인한 뒤, 표에 넣을 값과 학습1위 위치를 맞춰 수정하겠습니다.


비교표는 1위만 모아 두고 행별 과적합이 없어 적용 판단에 쓰이지 않습니다. 학습 Top5를 넣고, 각 행 맨 뒤에 안정점수(높을수록 좋음·만점 없음)와 과적합%를 붙이겠습니다.


StrReplace

        "n_results_stable": len(stable),
    }

def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]:

        "n_results_stable": len(stable),
    }

def _optuna_overfit_sample_days(data: Dict[str, Any]) -> int:
    try:
        days = int(data.get("backtest_days") or 0)
    except (TypeError, ValueError):
        days = 0
    if days > 0:
        return days
    start = str(data.get("start") or "")
    end = str(data.get("end") or "")
    try:
        from datetime import datetime as _dt

        return max(
            1,
            (_dt.strptime(end, "%Y-%m-%d") - _dt.strptime(start, "%Y-%m-%d")).days + 1,
        )
    except Exception:
        return 1

def overfit_risk_pct_for_row(
    data: Dict[str, Any],
    row: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
    """
    후보 한 줄의 과적합 가능도% (0~100, 높을수록 위험 · 만점=100).

    스터디 공통(표본 장일) + 이 trial의 거래수·승률·PF 이상치.
    교차검증이 아님. DB apply 게이트와 별개.
    """
    def _f(x: Any, default: float = 0.0) -> float:
        try:
            return float(x)
        except (TypeError, ValueError):
            return default

    def _i(x: Any, default: int = 0) -> int:
        try:
            return int(x)
        except (TypeError, ValueError):
            return default

    days = _optuna_overfit_sample_days(data or {})
    risk = 0.0
    if days <= 1:
        risk += 40.0
    elif days == 2:
        risk += 28.0
    elif days <= 4:
        risk += 16.0

    if not row:
        risk = max(0.0, min(100.0, round(risk + 25.0, 1)))
        return {
            "overfit_risk_pct": risk,
            "verdict": "비권장",
            "verdict_ui": "위험 · 비권장",
        }

    nt = _i(row.get("total_trades"))
    wr = _f(row.get("win_rate"))
    pf = _f(row.get("pf"))
    pnl = _f(row.get("total_pnl"))

    if nt <= 1:
        risk += 25.0
    elif nt <= 3:
        risk += 18.0
    elif nt <= 9:
        risk += 10.0

    if wr >= 90.0 and nt < 10:
        risk += 15.0
    elif wr >= 80.0 and nt < 5:
        risk += 10.0
    if pf >= 50.0 and nt < 10:
        risk += 10.0
    elif pf >= 10.0 and nt < 5:
        risk += 6.0

    gated = list((data or {}).get("results_gated") or [])
    learn = list((data or {}).get("results") or (data or {}).get("results_all") or [])
    pool = gated if gated else learn
    if pool and nt > 0:
        best_pnl = round(pnl, 0)
        same = [r for r in pool if abs(_f(r.get("total_pnl")) - best_pnl) < 1.0]
        share = len(same) / max(1, len(pool))
        if share >= 0.4 and len(same) >= 5:
            risk += 12.0
        elif share >= 0.25 and len(same) >= 3:
            risk += 6.0

    risk = max(0.0, min(100.0, round(risk, 1)))
    if risk >= 70.0:
        verdict, verd_ui = "비권장", "위험 · 비권장"
    elif risk >= 40.0:
        verdict, verd_ui = "주의", "주의"
    else:
        verdict, verd_ui = "상대적으로낮음", "상대적으로 낮음"
    return {
        "overfit_risk_pct": risk,
        "verdict": verdict,
        "verdict_ui": verd_ui,
    }

def build_optuna_overfit_diagnostics(data: Dict[str, Any]) -> Dict[str, Any]:

def _row_metrics(row: Optional[Dict[str, Any]], *, label: str, source: str) -> Optional[Dict[str, Any]]:
    if not row:
        return None
    out = {
        "label": label,
        "source": source,
        "optuna_trial_number": row.get("optuna_trial_number"),
        "total_pnl": row.get("total_pnl"),
        "total_trades": row.get("total_trades"),
        "win_rate": row.get("win_rate"),
        "pf": row.get("pf"),
        "score": row.get("score"),
    }
    # 일별 안정성 (신규 Optuna JSON)
    for k in (
        "stability_score", "n_losing_days", "n_active_days",
        "worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std",
        "daily_pnl",
    ):
        if row.get(k) is not None:
            out[k] = row.get(k)
    return out

def _row_metrics(
    row: Optional[Dict[str, Any]],
    *,
    label: str,
    source: str,
    data: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
    if not row:
        return None
    out = {
        "label": label,
        "source": source,
        "optuna_trial_number": row.get("optuna_trial_number"),
        "total_pnl": row.get("total_pnl"),
        "total_trades": row.get("total_trades"),
        "win_rate": row.get("win_rate"),
        "pf": row.get("pf"),
        "score": row.get("score"),
    }
    # 일별 안정성 (신규 Optuna JSON)
    for k in (
        "stability_score", "n_losing_days", "n_active_days",
        "worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std",
        "daily_pnl",
    ):
        if row.get(k) is not None:
            out[k] = row.get(k)
    if data is not None:
        try:
            from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
            of = overfit_risk_pct_for_row(data, row)
            out["overfit_risk_pct"] = of.get("overfit_risk_pct")
            out["overfit_verdict"] = of.get("verdict")
            out["overfit_verdict_ui"] = of.get("verdict_ui")
        except Exception:
            pass
    return out

"""완료 JSON → Top5 gated/stable · 비교표(학습1위/gated1위/stable1위/mode) · vs_best."""

"""완료 JSON → Top5 learn/gated/stable · 행별 과적합% · vs_best."""

    r_learn = _row_metrics(learn, label="학습1위(results)", source="learn")
    if r_learn:
        compare_rows.append(r_learn)
    r_gate = _row_metrics(gate0, label="사후합격1위(gated)", source="gated")
    if r_gate:
        compare_rows.append(r_gate)
    r_stab = _row_metrics(stab0, label="안정1위(stable)", source="stable")
    if r_stab:
        compare_rows.append(r_stab)

    r_learn = _row_metrics(learn, label="학습1위(results)", source="learn", data=data)
    if r_learn:
        compare_rows.append(r_learn)
    r_gate = _row_metrics(gate0, label="사후합격1위(gated)", source="gated", data=data)
    if r_gate:
        compare_rows.append(r_gate)
    r_stab = _row_metrics(stab0, label="안정1위(stable)", source="stable", data=data)
    if r_stab:
        compare_rows.append(r_stab)

m = _row_metrics(row, label=f"gated #{i}", source="gated")

m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data)

m = _row_metrics(row, label=f"learn #{i}", source="learn")

m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data)

m = _row_metrics(row, label=f"stable #{i}", source="stable")

m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data)

        "top": _row_metrics(
            top,
            label="적용후보(gated우선)",
            source="gated" if gate0 else "learn",
        ),

        "top": _row_metrics(
            top,
            label="적용후보(gated우선)",
            source="gated" if gate0 else "learn",
            data=data,
        ),


            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
            "daily_pnl": item.get("daily_pnl"),
        }

            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
            "daily_pnl": item.get("daily_pnl"),
        }
        try:
            from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
            of = overfit_risk_pct_for_row(data, item)
            metrics["overfit_risk_pct"] = of.get("overfit_risk_pct")
            metrics["overfit_verdict_ui"] = of.get("verdict_ui")
        except Exception:
            pass

비교표 학습1위 · gated1위 · stable1위 · mode_combo

              구분	trial	거래
              승률%	PF	PnL
              손실일	최악일	안정점수

완료 후 표시

—

—

과적합·적용 가능도 (휴리스틱 · 교차검증 아님)

완료 후 표시

                요인	가점	설명

완료 후 표시

임계값 분포

                파라미터
                median
                p25
                p75
                mode
                mode%

완료 후 표시

—

다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)

완료 후 표시

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

              #	trial	거래
              승률	PF	PnL

완료 후 표시

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

              #	trial	거래
              승률	PnL
              손실일	최악일	안정점수

완료 후 표시 (구 JSON은 재실행 필요)

스터디 과적합 요약 (휴리스틱 · 교차검증 아님 · 적용은 아래 각 행 과적합% 보세요)

완료 후 표시

                요인	가점	설명

완료 후 표시

          임계값 분포 (참고 · 적용 판단용 아님)

                  파라미터
                  median
                  p25
                  p75
                  mode
                  mode%

완료 후 표시

—

다단트레일 추천 (탐색 축 아님 · DB 적용 시에만 반영)

완료 후 표시

—

학습 Top5 results · Optuna 목적함수 1위 풀 · 진행바 best=여기 1위

              #	trial	거래
              승률	PF	PnL
              손실일	최악일
              안정점수 ↑
              과적합% ↓

완료 후 표시

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

              #	trial	거래
              승률	PF	PnL
              손실일	최악일
              안정점수 ↑
              과적합% ↓

완료 후 표시

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

—

              #	trial	거래
              승률	PF	PnL
              손실일	최악일
              안정점수 ↑
              과적합% ↓

완료 후 표시 (구 JSON은 재실행 필요)


            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
            "daily_pnl": item.get("daily_pnl"),
        }

            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
            "daily_pnl": item.get("daily_pnl"),
        }
        try:
            from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
            of = overfit_risk_pct_for_row(data, item)
            metrics["overfit_risk_pct"] = of.get("overfit_risk_pct")
            metrics["overfit_verdict_ui"] = of.get("verdict_ui")
        except Exception:
            pass

function optunaRenderCompare(sum) {
  const tb = $('opt_compare_tbody');
  const vsEl = $('opt_vs_best');
  const gatesEl = $('opt_stable_gates');
  if (!tb) return;
  const rows = (sum && sum.compare_rows) || [];
  if (!rows.length) {
    const done = window._optunaLastJob && window._optunaLastJob.status === 'done';
    tb.innerHTML = done
      ? '
완료 · 학습/게이트 통과 0건 (표가 비는 것이 정상)
'
      : '
완료 후 표시
';
    if (vsEl) vsEl.textContent = '—';
    if (gatesEl) gatesEl.textContent = '—';
    optunaRenderTrailRec(null);
    optunaRenderPostprocess(null);
    optunaRenderOverfitBadges(null);
    optunaRenderOverfit(null);
    return;
  }
  tb.innerHTML = rows.map(r => `

    ${r.label || r.source}
    ${r.optuna_trial_number != null ? '#' + r.optuna_trial_number : '—'}
    ${optunaFmtNum(r.total_trades, 0)}
    ${optunaFmtNum(r.win_rate, 1)}
    ${optunaFmtNum(r.pf, 2)}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}
    ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
    ${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'}

`).join('');
  const vs = sum && sum.vs_best;
  if (vsEl) {
    if (vs) {
      vsEl.innerHTML =
        `mode vs 학습1위: Δpnl <b class="${Number(vs.delta_pnl) >= 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(vs.delta_pnl)}</b>` +
        ` · Δwr ${optunaFmtNum(vs.delta_wr, 1)}%p · Δpf ${optunaFmtNum(vs.delta_pf, 2)}` +
        ` <span class="text-muted">(best ${optunaFmtNum(vs.best_pnl)} / mode ${optunaFmtNum(vs.mode_pnl)})</span>`;
    } else {
      vsEl.textContent = 'vs_best 없음 (mode 실측 전/실패 가능)';
    }
  }
  if (gatesEl) {
    const g = sum && sum.stable_gates;
    if (g) {
      gatesEl.textContent =
        `stable 게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` +
        `활성일≥${g.min_active_days} · λ=${g.stability_lambda}` +
        (g.score_note ? ` · ${g.score_note}` : '');
    } else {
      gatesEl.textContent = 'stable 게이트: 구 JSON이거나 미기록 (Optuna 재실행 시 생성)';
    }
  }
  optunaRenderTrailRec(sum);
  optunaRenderPostprocess(sum);
  optunaRenderOverfitBadges(sum);
  optunaRenderOverfit(sum);
}

function optunaRenderCompare(sum) {
  const gatesEl = $('opt_stable_gates');
  if (gatesEl) {
    const g = sum && sum.stable_gates;
    if (g) {
      gatesEl.textContent =
        `안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. ` +
        `게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` +
        `활성일≥${g.min_active_days} · λ=${g.stability_lambda}`;
    } else if (sum) {
      gatesEl.textContent = '안정점수 = 일평균PnL − λ×일표준편차(원). 높을수록 좋음 · 만점 없음. (구 JSON은 게이트 미기록)';
    } else {
      gatesEl.textContent = '—';
    }
  }
  optunaRenderTrailRec(sum);
  optunaRenderPostprocess(sum);
  optunaRenderOverfitBadges(sum);
  optunaRenderOverfit(sum);
}


function optunaOverfitCell(r) {
  const p = r && r.overfit_risk_pct;
  if (p == null || Number.isNaN(Number(p))) return '—';
  const n = Number(p);
  let cls = 'text-pnl-pos';
  if (n >= 70) cls = 'text-pnl-neg';
  else if (n >= 40) cls = 'text-warning';
  const v = r.overfit_verdict_ui || r.overfit_verdict || '';
  return `<span class="${cls}" title="과적합%=높을수록 위험(0~100). 이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정).">${n}%${v ? ' · ' + v : ''}</span>`;
}

function optunaStableScoreCell(r) {
  if (!r || r.stability_score == null) return '—';
  const n = Number(r.stability_score);
  const cls = n >= 0 ? 'text-pnl-pos' : 'text-pnl-neg';
  return `<span class="${cls}" title="높을수록 좋음. 만점 없음. 일평균PnL − λ×일표준편차(원).">${optunaFmtNum(r.stability_score, 2)}</span>`;
}

function optunaRankTableRow(r, source) {
  const rank = r.rank || 1;
  const src = source;
  const detailBtn = (src === 'learn')
    ? ''
    : `<button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('${src}',${rank})">상세</button>`;
  const applyCls = src === 'stable' ? 'btn-outline-info' : 'btn-outline-success';
  return `

    ${rank}
    #${r.optuna_trial_number ?? '—'}
    ${optunaFmtNum(r.total_trades, 0)}
    ${optunaFmtNum(r.win_rate, 1)}%
    ${optunaFmtNum(r.pf, 2)}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}
    ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
    = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
    ${optunaStableScoreCell(r)}
    ${optunaOverfitCell(r)}

      <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('${src}',${rank})">보기</button>
      ${detailBtn}
      <button class="btn btn-sm ${applyCls} py-0" onclick="optunaApply('${src}',${rank})">적용</button>

`;
}

function optunaFillRankTbody(tbodyId, rows, source, emptyMsg) {
  const tb = $(tbodyId);
  if (!tb) return;
  if (!rows || !rows.length) {
    tb.innerHTML = `<tr><td colspan="11" class="text-muted">${emptyMsg}</td></tr>`;
    return;
  }
  tb.innerHTML = rows.map((r) => optunaRankTableRow(r, source)).join('');
}

/** Optuna 후처리 표 — 행=gated# / mode / live, 열=호가·휩쏘·트레일 (원 정수). */

/** Optuna 후처리 표 — 행=gated# / mode / live, 열=호가·휩쏘·트레일 (원 정수). */

  const ofRow = (() => {
    const key = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
    const rows = (sum && sum[key]) || [];
    return rows.find((x) => Number(x.rank) === Number(rk)) || null;
  })();
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';

  const ofPct = (topn && topn.apply_overfit_pct != null)
    ? topn.apply_overfit_pct
    : sum.apply_overfit_pct;
  const verd = (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';

function optunaRenderTop5(sum) {
  optunaFillRankTbody(
    'opt_top5_learn_tbody',
    (sum && sum.top5_learn) || [],
    'learn',
    '학습 후보 없음 / 미완료',
  );
  optunaFillRankTbody(
    'opt_top5_tbody',
    (sum && sum.top5_gated) || [],
    'gated',
    'gated 후보 없음 / 미완료',
  );
  optunaRenderTop5Stable(sum);
}

function optunaRenderTop5Stable(sum) {
  optunaFillRankTbody(
    'opt_top5_stable_tbody',
    (sum && sum.top5_stable) || [],
    'stable',
    'stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)',
  );
}

function optunaRenderTop5(sum) {
  const tb = $('opt_top5_tbody');
  if (!tb) return;
  const rows = (sum && sum.top5_gated) || [];
  if (!rows.length) {
    tb.innerHTML = '
gated 후보 없음 / 미완료
';
  } else {
    tb.innerHTML = rows.map(r => {
      const rank = r.rank || 1;
      return `

      ${rank}
      #${r.optuna_trial_number ?? '—'}
      ${optunaFmtNum(r.total_trades, 0)}
      ${optunaFmtNum(r.win_rate, 1)}%
      ${optunaFmtNum(r.pf, 2)}
      = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}

        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaHighlightGated(${rank})">보기</button>
        <button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('gated',${rank})">상세</button>
        <button class="btn btn-sm btn-outline-success py-0" onclick="optunaApply('gated',${rank})">적용</button>

`;
    }).join('');
  }
  optunaRenderTop5Stable(sum);
}

function optunaRenderTop5Stable(sum) {
  const tb = $('opt_top5_stable_tbody');
  if (!tb) return;
  const rows = (sum && sum.top5_stable) || [];
  if (!rows.length) {
    tb.innerHTML = '
stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)
';
    return;
  }
  tb.innerHTML = rows.map(r => {
    const rank = r.rank || 1;
    return `

      ${rank}
      #${r.optuna_trial_number ?? '—'}
      ${optunaFmtNum(r.total_trades, 0)}
      ${optunaFmtNum(r.win_rate, 1)}%
      = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${optunaFmtNum(r.total_pnl)}
      ${r.n_losing_days != null ? optunaFmtNum(r.n_losing_days, 0) : '—'}
      = 0 ? 'text-pnl-pos' : 'text-pnl-neg'}">${r.worst_day_pnl != null ? optunaFmtNum(r.worst_day_pnl) : '—'}
      ${r.stability_score != null ? optunaFmtNum(r.stability_score, 2) : '—'}

        <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaShowCandidate('stable',${rank})">보기</button>
        <button class="btn btn-sm btn-outline-primary py-0" onclick="optunaSelectPostprocess('stable',${rank})">상세</button>
        <button class="btn btn-sm btn-outline-info py-0" onclick="optunaApply('stable',${rank})">적용</button>

`;
  }).join('');
}


  const topn = sum.postprocess_topn;
  const anchors = (topn && topn.postprocess_by_anchor) || [];
  const cons = (topn && topn.postprocess_consensus) || {};

  const topn = sum.postprocess_topn;
  const anchors = (topn && topn.postprocess_by_anchor) || [];
  const cons = (topn && topn.postprocess_consensus) || {};
  const ofRow = (() => {
    const key = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
    const rows = (sum && sum[key]) || [];
    return rows.find((x) => Number(x.rank) === Number(rk)) || null;
  })();
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';

  const live = anchors.find((x) => x.role === 'live') || null;
  const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
  const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk)) || null;
  const ofPct = (ofRow && ofRow.overfit_risk_pct != null)
    ? ofRow.overfit_risk_pct
    : ((topn && topn.apply_overfit_pct != null) ? topn.apply_overfit_pct : sum.apply_overfit_pct);
  const verd = (ofRow && (ofRow.overfit_verdict_ui || ofRow.overfit_verdict))
    || (topn && (topn.apply_overfit_verdict_ui || topn.apply_overfit_verdict)) || '';
  if (!a) {

  const live = anchors.find((x) => x.role === 'live') || null;
  if (!a) {

        (m.stability_score != null
          ? ` · 안정 <b>${optunaFmtNum(m.stability_score, 2)}</b> (높을수록 좋음·만점없음 · 손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})`
          : '') +
        (m.overfit_risk_pct != null
          ? ` · 과적합 <b>${optunaFmtNum(m.overfit_risk_pct, 1)}%</b> (높을수록 위험·0~100${m.overfit_verdict_ui ? ' · ' + m.overfit_verdict_ui : ''})`
          : '') +

        (m.stability_score != null
          ? ` · 안정 <b>${optunaFmtNum(m.stability_score, 2)}</b> (손실일 ${optunaFmtNum(m.n_losing_days, 0)} · 최악 ${optunaFmtNum(m.worst_day_pnl)})`
          : '') +


    const ofKey = src === 'stable' ? 'top5_stable' : (src === 'learn' ? 'top5_learn' : 'top5_gated');
    const ofRow = ((sum && sum[ofKey]) || []).find((x) => Number(x.rank) === Number(rk));
    const ofPct = ofRow && ofRow.overfit_risk_pct != null
      ? ofRow.overfit_risk_pct
      : (sum && (sum.apply_overfit_pct != null
        ? sum.apply_overfit_pct
        : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct)));
    if (ofPct != null && ofPct !== '') {
      extra += `\n\n이 후보 과적합 ${Number(ofPct)}% (0~100 · 높을수록 위험 · 추정).`;
    }

    const ofPct = sum && (sum.apply_overfit_pct != null
      ? sum.apply_overfit_pct
      : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct));
    if (ofPct != null && ofPct !== '') {
      extra += `\n\n과적합 가능도 ${Number(ofPct)}% (추정 · AI 아님). 높으면 적용 전 한 번 더 생각하세요.`;
    }

const who = (src === 'mode' ? 'mode_combo' : (src === 'stable' ? `안정 #${rk}` : (src === 'learn' ? `학습 #${rk}` : `사후합격 #${rk}`)));

const who = (src === 'mode' ? 'mode_combo' : (src === 'stable' ? `안정 #${rk}` : `사후합격 #${rk}`));


function optunaRenderOverfitBadges(sum) {
  const pick = (key) => ((sum && sum[key]) || [])[0] || null;
  const set = (id, row) => {
    const el = $(id);
    if (!el) return;
    el.innerHTML = optunaOverfitBadgeHtml(
      row && row.overfit_risk_pct,
      row && (row.overfit_verdict_ui || row.overfit_verdict),
    );
  };
  set('opt_overfit_badge_gated', pick('top5_gated'));
  set('opt_overfit_badge_stable', pick('top5_stable'));
  const modeRow = ((sum && sum.compare_rows) || []).find((r) => r.source === 'mode');
  set('opt_overfit_badge_mode', modeRow);
}

function optunaRenderOverfitBadges(sum) {
  const pct = sum && (sum.apply_overfit_pct != null
    ? sum.apply_overfit_pct
    : (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_pct));
  const verd = (sum && (sum.apply_overfit_verdict_ui || sum.apply_overfit_verdict
    || (sum.postprocess_topn && sum.postprocess_topn.apply_overfit_verdict_ui))) || '';
  const html = optunaOverfitBadgeHtml(pct, verd);
  ['opt_overfit_badge_gated', 'opt_overfit_badge_stable', 'opt_overfit_badge_mode'].forEach((id) => {
    const el = $(id);
    if (el) el.innerHTML = html;
  });
}

mode_combo DB 적용
        </button>
        —
        완료 후에만 · TIME_* 미변경 · 위 버튼=타점만 · 트레일/호가는 아래 표 · confirm 필수 · mode_combo=TopN 최빈값 조합(표에서 뺌)

mode_combo DB 적용
        </button>
        —
        완료 후에만 · TIME_* 미변경 · 위 버튼=타점만 · 트레일/호가는 아래 표 · confirm 필수

Shell

python3 -m py_compile /home/hoon/kis_bot/kis_trader/backtest/optuna_common.py /home/hoon/kis_bot/kis_trader/backtest/optuna_web_jobs.py

Syntax-check modified Python files


python3 - <<'PY'
from pathlib import Path
import json
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
p = Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_tail_tpe_20260815_225139.json')
data = json.loads(p.read_text())
for name, key in [('learn', 'results'), ('gated', 'results_gated'), ('stable', 'results_stable')]:
    rows = list(data.get(key) or [])[:3]
    print('==', name)
    for i, r in enumerate(rows, 1):
        of = overfit_risk_pct_for_row(data, r)
        print(i, 'trial', r.get('optuna_trial_number'), 'nt', r.get('total_trades'), 'wr', r.get('win_rate'), 'pf', r.get('pf'), 'stab', r.get('stability_score'), of)
PY

Compute per-row overfit on sample Optuna JSON


StrReplace

웹 Optuna: 학습/gated/stable Top5 행에 안정점수(↑·만점없음·원) + 과적합%(↓·0~100). 1위 비교표·mode_combo 실측행은 표시 안 함.

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 and health-check


CallMcpTool

Open backtest dashboard for Optuna table check


Lock browser for Optuna UI clicks


Open Optuna tab


(async()=>{const logs=[]; const orig=console.error; console.error=function(){logs.push([...arguments].map(String).join(' ')); return orig.apply(console,arguments);}; optunaWatch('import_optuna_tail_tpe_20260815_225139'); await new Promise(r=>setTimeout(r,4000)); const learn=(opt_top5_learn_tbody&&opt_top5_learn_tbody.innerText||'').slice(0,400); const gated=(opt_top5_tbody&&opt_top5_tbody.innerText||'').slice(0,400); const stable=(opt_top5_stable_tbody&&opt_top5_stable_tbody.innerText||'').slice(0,400); const cmp=document.querySelector('h6'); const h6s=[...document.querySelectorAll('#tab-optuna h6, [data-tab-panel="optuna"] h6, #optuna h6')].map(h=>h.innerText).slice(0,12); const allH6=[...document.querySelectorAll('h6')].map(h=>h.innerText).filter(t=>/비교표|학습 Top5|사후합격|안정 Top5/.test(t)); return JSON.stringify({learn,gated,stable,allH6,logs,hasCompare:!!document.querySelector('#opt_compare_tbody')&&opt_compare_tbody.classList.contains('d-none')});})()

Load Optuna job and read Top5 table texts


StrReplace

선택 후보 상세 Top5「보기」선택

선택 후보 상세 Top5「보기」또는 비교표 행 선택

CallMcpTool

Unlock browser after Optuna table check


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 web after HTML title tweak


**비교표는 적용 판단용 표가 아니라서 뺐습니다.** 학습1위·사후합격1위·안정1위·`mode_combo`만 한 줄씩 모아 둔 요약이었고, **행별 과적합이 없고** 1위 숫자만 반복해서 쓸모가 없었습니다.

`mode_combo`는 Optuna trial이 아니라 **TopN에서 축별 최빈값을 모아 한 번 다시 백테한 가상 조합**입니다. 실제 trial이 없어서 호가 표·과적합 행과 안 맞습니다. 상단 **mode_combo DB 적용** 버튼만 남겨 두었습니다.

**안정점수**는 0~100 만점이 아닙니다.
`일평균 PnL − λ × 일표준편차` (**원**). **높을수록 좋음**(들쭉날쭉·손실일이 적으면 커짐). 표 헤더는 `안정점수 ↑`.

**과적합%**는 **0~100, 높을수록 위험**. 교차검증이 아니라 표본 장일·거래수·승률/PF 이상치 휴리스틱입니다. 이제 **학습 / 사후합격 / 안정 Top5 각 행 맨 뒤**에 붙었습니다. 임계값 분포는 접어 두었습니다.

브라우저에서 `http://192.168.0.149:5050/` → Optuna → `꼬리(align)` 확인했습니다. 예: 학습1위 `#17` 과적합 **59% · 주의**, `#26` **26% · 상대적으로 낮음**. 웹 `active` · HTTP 200 · 콘솔 오류 없음.

실매 엔진은 그대로입니다. 해외 모멘텀 Optuna 표는 종목핀 2열 레이아웃이라 이번엔 국장 Optuna 탭만 맞췄습니다.


<timestamp>Saturday, Aug 15, 2026, 11:54 PM (UTC+9)</timestamp>
<user_query>
근데 학습 탑5랑 사후합격5랑 똑같은데?? 다른경우도 나오나?
</user_query>


학습 Top5와 사후합격 Top5가 같아 보이는 조건을 코드에서 확인하겠습니다.


**지금 그 잡은 같아 보이는 게 정상입니다.** 다른 경우도 나옵니다.

두 표는 같은 숫자를 두 번 그린 게 아니라 **필터만 다릅니다.**

| 표 | 무엇 |
|---|---|
| **학습 Top5** (`results`) | 플러스 PnL trial을 **PnL 큰 순** |
| **사후합격 Top5** (`results_gated`) | 그중에서 **승률 ≥ 40% · PF ≥ 1.0 · 최소 거래수** 통과한 것만, 역시 PnL 순 |

방금 본 꼬리(align)는 학습 상위가 이미 승률 60~100%, PF 4.68~9999라서 **게이트를 전부 통과**합니다. 그래서 Top5 멤버·순서가 같습니다.

**달라지는 때**는 학습 상위에 “수익은 큰데 게이트 탈락”이 끼일 때입니다. 예:

- PnL은 1위인데 **승률 35%** → 학습에는 있고 사후합격에서는 빠짐
- 거래 몇 건만 크게 먹어서 **PF < 1** → 학습만
- 사후합격이 비거나, 5위 안에 못 들어오는 자리가 생김 → 아래 순위가 올라와 **다른 trial**이 보임

**안정 Top5**는 여기서 한 번 더 갈립니다. 사후합격 ∩ 손실일/최악일 게이트이고, 정렬이 PnL이 아니라 **안정점수**라서 지금처럼 `#18`이 1위가 됩니다.

적용은 **사후합격·안정**을 보는 편이 맞습니다. 학습은 “Optuna가 뭘 1등으로 쳤는지” 참고용입니다. 플러스 PnL 상위가 전부 WR/PF 게이트를 넘기면 두 표는 계속 똑같이 나옵니다.


<timestamp>Saturday, Aug 15, 2026, 11:56 PM (UTC+9)</timestamp>
<user_query>
전략별롤 atr rsi ema 값 사용한느 전략이 있나?
</user_query>


전략별로 ATR·RSI·EMA가 실제로 쓰이는지 코드에서 확인하겠습니다.


**있습니다.** 전략마다 쓰임이 다릅니다. 키만 있고 엔진이 안 쓰는 것도 있습니다.

| 전략 | ATR | RSI | EMA |
|------|-----|-----|-----|
| **꼬리** | **씀** — 손절/익절 배수(`TAIL_STOP_ATR_MULT` 등) + % 캡. 지정가 진입(`limit_atr`)도 ATR | **씀** — `TAIL_USE_RSI_FILTER` 기본 ON, 과열 컷(`TAIL_RSI_THRESHOLD`) | **안 씀** — EMA가 아니라 **MA20(단순평균)** 옵션 (`TAIL_USE_MA20_FILTER` 기본 OFF) |
| **모멘텀 / 해외모멘텀** | **안 씀** (로그 `atr_entry=0` 자리만) | **씀** — 하한/상한 필터. `USE_RSI_FILTER` 기본 **OFF** | **씀** — 빠른/느린 EMA 추세 필터 (`USE_EMA_FILTER`) |
| **돌파** | **씀** — ATR 손절 모드 (`atr_sl_mult` + min/max %) | **안 씀** | **씀** — 옵션 (`BREAKOUT_USE_EMA_FILTER` 기본 OFF) |
| **스캘핑** | ** practically 안 씀** — `ATR_DOWN_MULT`는 로드만 하고 트리거에 안 탐 | **씀** — 과매도/과매수 (`SCALP_RSI_*`) | **직접 필터는 아님** — MACD 켤 때만 내부에서 EMA(12/26)로 MACD 계산 |
| **레인지/박스** | 자리만 (`atr_entry=0`) | 없음 | 없음 |

정리하면:

- **ATR을 진짜 쓰는 것:** 꼬리(청산·지정가), 돌파(손절)
- **RSI를 진짜 쓰는 것:** 꼬리, 스캘핑, 모멘텀(필터 ON일 때)
- **EMA 추세필터:** 모멘텀·해외모멘텀, 돌파(옵션)

Optuna 그리드에 `atr_*` / `rsi_*` / `ema_*`가 있어도, 그 전략 엔진이 안 읽으면 학습만 되고 실매에는 안 먹습니다.


<timestamp>Sunday, Aug 16, 2026, 12:02 AM (UTC+9)</timestamp>
<user_query>
합의(gated+mode, 실매 제외)
진입: 없음 · 익절: 없음 · 손절: 없음 · 휩쏘: 없음 · 트레일: ARM 20,000
stable#1 · trial #20 · 과적합 가능도 26% · 상대적으로 낮음
과적합%=이 숫자만 믿으면 내일 틀릴 수 있는 정도(추정). AI 아님. 안정 후보
기본(타점)	진입호가	익절호가	손절호가	휩쏘
추천값	trial #20
9건 · 36,942원
구JSON · 재실행 필요	구JSON · 재실행 필요	구JSON · 재실행 필요	구JSON · 재실행 필요
후처리 성적	Optuna 본결과	구JSON · 재실행 필요	구JSON · 재실행 필요	구JSON · 재실행 필요	구JSON · 재실행 필요
적용
live · 실매 참고 (적용 없음)
방금 돌파 돌렸는데 왜 구 json이래 방금 돌렸는데 어이가 없구만 다른전략도 다 이런거 아냐?
분명 로그엔 잇느거 같은데 그리고 진입은 통과가 없는데 호가익절은 통과라면 진입은 꺼지고 호가익절만 켜지나?
</user_query>


방금 돌린 돌파가 ‘구JSON’으로 뜨는 이유와, 진입 없음·익절 있음일 때 적용이 어떻게 켜지는지 코드와 로그를 대조하겠습니다.


python3 - <<'PY'
import json
from pathlib import Path
p = Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json')
data = json.loads(p.read_text())
pt = data.get('postprocess_topn') or {}
print('keys post', sorted(pt.keys())[:20])
print('run_ob', pt.get('run_ob_whipsaw'))
print('n_anchors', len(pt.get('postprocess_by_anchor') or []))
cons = pt.get('postprocess_consensus') or {}
print('consensus', {k: {kk: cons.get(k,{}).get(kk) for kk in ('ok','reason')} for k in ('entry','exit','stop','whipsaw','trail','orderbook')})
for a in (pt.get('postprocess_by_anchor') or [])[:8]:
    ob = a.get('orderbook') or {}
    en = ob.get('entry') or {}
    ex = ob.get('exit') or {}
    st = ob.get('stop') or {}
    ws = a.get('whipsaw') or {}
    print(a.get('id'), 'trial', a.get('optuna_trial_number'),
          'ob', ob.get('ok'), ob.get('reason'),
          'entry', en.get('ok'), en.get('reason'),
          'exit', ex.get('ok'), ex.get('reason'),
          'ws', ws.get('ok'), ws.get('reason'))
print('orderbook_recommend', (data.get('orderbook_recommend') or {}).get('ok'), (data.get('orderbook_recommend') or {}).get('reason'))
print('strategy', data.get('strategy'), 'n_gated', data.get('n_results_gated'), len(data.get('results_gated') or []))
PY

Inspect breakout Optuna JSON postprocess anchors


wc -l /home/hoon/kis_bot/logs/optuna_web_breakout_20260815_235740.log; python3 - <<'PY'
import json, os
from pathlib import Path
p=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_breakout_tpe_20260815_235924.json')
print('mtime', p.stat().st_mtime)
print('size', p.stat().st_size)
data=json.loads(p.read_text())
print('postprocess', bool(data.get('postprocess_topn')), 'mode_combo keys', list((data.get('mode_combo') or {}).keys())[:12])
print('has backtest', bool((data.get('mode_combo') or {}).get('backtest')))
PY
ps aux | grep -E 'param_search_optuna|breakout' | grep -v grep | head

Check if breakout Optuna still running and JSON state


원인은 후처리가 아직 돌아가는 중에 중간 JSON을 ‘완료’로 본 것입니다. PID가 살아 있으면 완료로 치지 않고, 진입 추천이 없으면 진입 호가는 끄도록 적용 로직을 맞추겠습니다.


StrReplace

    alive = _pid_alive(pid)
    # 중간저장 JSON(OPTUNA_RESULT_JSON)만 보고 완료 처리하면 후처리(호가) 중에
    # 「구JSON」이 뜬다. PID 살아 있으면 무조건 진행 중.
    log_path = str(m.get("log_path") or "")

    alive = _pid_alive(pid)
    log_path = str(m.get("log_path") or "")

    if m.get("status") not in ("done", "error") or alive:
        # 로그에 결과 JSON 이 이미 찍혔어도, 프로세스가 살아 있으면 후처리 중일 수 있음.
        # 「최종 JSON」이 찍히고 PID 가 죽은 뒤에만 완료.
        rj = m.get("result_json")
        log_tail = _tail_text(log_path, 80) or ""
        final_json = ("최종 JSON" in log_tail) or ("[후처리 TopN]" in log_tail)
        if m.get("kind") not in ("seq", "seq4") and alive:
            m["status"] = "running"
            m["finished_at"] = None
        elif m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in log_tail
        ) and final_json:
            alive = False
        elif m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in log_tail
        ) and not alive:
            pass  # 프로세스 종료 + JSON 있음 → 아래 done
        if alive:
            m["status"] = "running"
            m["finished_at"] = None
        else:

    if m.get("status") not in ("done", "error"):
        # 로그에 결과 JSON 이 이미 찍혔으면 좀비/고아 PID 여도 완료로 확정
        rj = m.get("result_json")
        # 순차(꼬리 align+limit_atr 등): 첫 스터디 JSON이 찍혀도 다음 스터디가 남음
        if m.get("kind") not in ("seq", "seq4") and rj and Path(str(rj)).is_file() and (
            "OPTUNA_RESULT_JSON=" in (_tail_text(log_path, 40) or "")
        ):
            alive = False
        if alive:
            m["status"] = "running"
        else:

    post_topn = data.get("postprocess_topn")
    # 웹 새로고침에서 가짜 light_skip 앵커를 만들지 않음.
    # (중간 JSON + run_ob=False attach → 「구JSON」오인)
    if not isinstance(post_topn, dict) or not post_topn.get("postprocess_by_anchor"):
        post_topn = None
    if isinstance(post_topn, dict) and post_topn.get("postprocess_by_anchor"):

    post_topn = data.get("postprocess_topn")
    if not isinstance(post_topn, dict) or not post_topn.get("postprocess_by_anchor"):
        try:
            from kis_trader.backtest.optuna_postprocess_topn import attach_topn_postprocess
            # 웹 새로고침: 호가 1000회 재탐색 금지(DB/CPU). 트레일·과적합%만.
            attach_topn_postprocess(
                data, evaluate_fn=None, log=None, run_ob_whipsaw=False,
            )
            post_topn = data.get("postprocess_topn")
        except Exception:
            post_topn = None
    if isinstance(post_topn, dict) and post_topn.get("postprocess_by_anchor"):

    if idx >= _UPTO_ORDER.index("entry"):
        ep = build_entry_ob_env_patch(ob, strat_u)
        if ep:
            patch.update(ep)
        else:
            notes.append("진입호가 추천 없음 → 진입 필터 OFF")
            pfx = _later_axes_off_patch.__doc__  # noqa: keep pfx via helper
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_ORDERBOOK_FILTER_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("exit"):
        xp = build_exit_ob_env_patch(ob, strat_u)
        if xp:
            patch.update(xp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("익절호가 해당없음")
        else:
            notes.append("익절호가 추천 없음 → 익절호가 OFF")
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_EXIT_OB_ENABLED"] = "false"
    if idx >= _UPTO_ORDER.index("stop"):
        sp = build_stop_ob_env_patch(ob, strat_u)
        if sp:
            patch.update(sp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("손절호가 해당없음")
        else:
            notes.append("손절호가 추천 없음 → 손절호가 OFF")
            from kis_trader.backtest.optuna_orderbook_recommend import _env_pfx
            epfx = _env_pfx(strat_u)
            if epfx:
                patch[f"{epfx}_STOP_OB_ENABLED"] = "false"

    if idx >= _UPTO_ORDER.index("entry"):
        ep = build_entry_ob_env_patch(ob, strat_u)
        if ep:
            patch.update(ep)
        else:
            notes.append("진입호가 추천 없음")
    if idx >= _UPTO_ORDER.index("exit"):
        xp = build_exit_ob_env_patch(ob, strat_u)
        if xp:
            patch.update(xp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("익절호가 해당없음")
        else:
            notes.append("익절호가 추천 없음")
    if idx >= _UPTO_ORDER.index("stop"):
        sp = build_stop_ob_env_patch(ob, strat_u)
        if sp:
            patch.update(sp)
        elif strat_u not in ("MOMENTUM", "BREAKOUT"):
            notes.append("손절호가 해당없음")
        else:
            notes.append("손절호가 추천 없음")