아니요. **전부는 아닙니다.**

사후합격이 비었을 때만, **학습 상위 N개**(기본 **Top5**)에만 호가 후처리를 돌립니다.
마이너스 10만 개가 있어도 **상위 5개만**입니다.

- 사후합격 있음 → 예전처럼 **합격 Top5만**
- 사후합격 0건 → **학습 Top5만** (폴백)
- mode / live는 원래처럼 각각 추가로 1번씩

10만 trial 전부에 호가 8방을 돌리는 구조가 아닙니다.


<timestamp>Friday, Aug 21, 2026, 6:22 PM (UTC+9)</timestamp>
<user_query>
탑5는 너무 후보가 적은데 탑 10은 되어야겠는데 근데 안정이 의미가 있나 그리고 combo랑
</user_query>


TopN·안정·mode_combo 의미를 코드에서 확인한 뒤, Top10으로 올릴지 정리하겠습니다.


TopN 기본을 10으로 올리고, 안정·mode_combo가 뭔지 한글로 정리합니다.

StrReplace

def resolve_post_top_n(default: int = 10) -> int:
    """후처리·웹 Top 표 행 수. 기본 10 (사후/학습/안정 공통)."""
    return max(1, int(get_env_int("OPTUNA_POST_TOP_N", int(default))))

def resolve_post_top_n(default: int = 5) -> int:
    return max(1, int(get_env_int("OPTUNA_POST_TOP_N", int(default))))


def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
    """완료 JSON → TopN learn/gated/stable · 행별 과적합% · vs_best."""
    if not path or not Path(path).is_file():
        return None
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
    except Exception:
        return None
    return _summarize_result_data(data, path=path)

def _summarize_result_data(
    data: Optional[Dict[str, Any]],
    path: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
    if not isinstance(data, dict):
        return None
    from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n

    top_n = resolve_post_top_n(10)
    gated = list(data.get("results_gated") or [])
    stable = list(data.get("results_stable") or [])
    allr = list(data.get("results") or data.get("results_all") or [])
    learn = allr[0] if allr else None
    gate0 = gated[0] if gated else None
    stab0 = stable[0] if stable else None
    top = gate0 or learn

    mc = data.get("mode_combo") or {}
    mc_bt = mc.get("backtest") or {}
    vs = mc.get("vs_best") or {}

def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
    """완료 JSON → Top5 learn/gated/stable · 행별 과적합% · vs_best."""
    if not path or not Path(path).is_file():
        return None
    try:
        data = json.loads(Path(path).read_text(encoding="utf-8"))
    except Exception:
        return None
    return _summarize_result_data(data, path=path)

def _summarize_result_data(
    data: Optional[Dict[str, Any]],
    path: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
    if not isinstance(data, dict):
        return None
    gated = list(data.get("results_gated") or [])
    stable = list(data.get("results_stable") or [])
    allr = list(data.get("results") or data.get("results_all") or [])
    learn = allr[0] if allr else None
    gate0 = gated[0] if gated else None
    stab0 = stable[0] if stable else None
    top = gate0 or learn

    mc = data.get("mode_combo") or {}
    mc_bt = mc.get("backtest") or {}
    vs = mc.get("vs_best") or {}

    top5 = []
    for i, row in enumerate(gated[:top_n], start=1):
        m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data)
        if m:
            m["rank"] = i
            top5.append(m)

    top5_learn: List[Dict[str, Any]] = []
    for i, row in enumerate(allr[:top_n], start=1):
        m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data)
        if m:
            m["rank"] = i
            top5_learn.append(m)

    top5_stable: List[Dict[str, Any]] = []
    for i, row in enumerate(stable[:top_n], start=1):
        m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data)
        if m:
            m["rank"] = i
            top5_stable.append(m)

    top5 = []
    for i, row in enumerate(gated[:5], start=1):
        m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data)
        if m:
            m["rank"] = i
            top5.append(m)

    top5_learn: List[Dict[str, Any]] = []
    for i, row in enumerate(allr[:5], start=1):
        m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data)
        if m:
            m["rank"] = i
            top5_learn.append(m)

    top5_stable: List[Dict[str, Any]] = []
    for i, row in enumerate(stable[:5], start=1):
        m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data)
        if m:
            m["rank"] = i
            top5_stable.append(m)

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

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

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

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

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

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

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

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

Shell

cd /home/hoon/kis_bot && python3 - <<'PY'
from database import TradeDB
from datetime import datetime
from kis_trader.utils.env import invalidate_merged_env_cache, get_env_from_db

db = TradeDB()
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 사용자 요청: 후처리 TopN 기본 10 — 기존 5가 있으면 덮어씀
n = db._persist_env_config_overflow({"OPTUNA_POST_TOP_N": "10"}, now)
invalidate_merged_env_cache()
v = get_env_from_db("OPTUNA_POST_TOP_N")
print("persist", n, "read", v)
db.close()

from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
print("resolve", resolve_post_top_n())
PY
sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/

Set OPTUNA_POST_TOP_N=10 in DB and restart web


**Top10으로 올렸습니다.** (후처리·화면 표 공통, `OPTUNA_POST_TOP_N=10`)

---

### 안정(stable) — 의미 있나?
**있습니다.** 다만 “합계 PnL이 좋은 놈”이 아니라 **날마다 덜 들쭉날쭉한 놈**입니다.

사후합격 중에서 또 거름:
- 손실 난 날 ≤ 1일
- 하루 최악 손실 ≥ −3만 원
- 거래한 날 ≥ 2일

이번처럼 **3일 중 2일이 마이너스**면 안정 표가 비는 게 정상입니다.
“사후합격은 됐는데 일별로 너무 출렁이면 빼자”용입니다. 합계만 보면 안 보이는 구멍.

---

### mode_combo(콤보) — 뭐냐?
**한 trial이 아닙니다.**

1. 학습 결과 중 PnL 상위 대략 **20개** 보고
2. 파라미터 **축마다 가장 자주 나온 값**을 이어 붙임 (최빈값 조합)
3. 그 조합으로 백테 **1번** 다시 돌림

→ “1등 한 방”이 아니라 “상위권이 자주 고른 설정 묶음” 참고용.
후처리 호가 표의 **mode** 행이 그겁니다.

---

### 정리
| 이름 | 쉽게 |
|------|------|
| 학습 Top10 | 목적함수 순위 상위 10 |
| 사후합격 Top10 | 그중 승률·PF 통과 상위 10 |
| 안정 Top10 | 사후합격 중 일별이 덜 출렁인 상위 10 |
| mode_combo | 상위권 최빈값 조합 1세트 |

이미 끝난 잡은 **「이 잡 후처리 재실행」** 해야 Top10 폴백·호가가 다시 돕니다.


<timestamp>Friday, Aug 21, 2026, 6:26 PM (UTC+9)</timestamp>
<user_query>
호가8방 구역을 스크롤내리면 자꾸 올라가는데 이건 왜이래 3초마다 프로그레스바 받아와서 그런가? 좀 늘려줘라
</user_query>


호가 8방 구역이 스크롤될 때마다 위로 튀는 원인을 찾고, 폴링 간격을 늘리겠습니다.


맞습니다. 3초마다 `optunaRenderJob`이 호가 8방 DOM을 통째로 다시 그려 스크롤이 튀었습니다. 폴링을 늘리고, 내용 같으면 다시 그리지 않게 고칩니다.

StrReplace

const OPTUNA_LS_KEY = 'kis_optuna_job_id';
let _optunaPollTimer = null;
let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || '';
let _optunaPollStatus = '';
let _optunaJobsRefreshing = false;
/** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */
let _optunaPostFp = '';
/** 러닝/후처리 중 폴링(ms) · 완료·대기 중은 더 김 */
const OPTUNA_POLL_MS_BUSY = 8000;
const OPTUNA_POLL_MS_IDLE = 20000;

const OPTUNA_LS_KEY = 'kis_optuna_job_id';
let _optunaPollTimer = null;
let _optunaJobId = localStorage.getItem(OPTUNA_LS_KEY) || '';
let _optunaPollStatus = '';
let _optunaJobsRefreshing = false;

function optunaSelectPostprocess(source, rank) {
  window._optunaPostSel = { source: source || 'gated', rank: rank || 1 };
  _optunaPostFp = ''; // 선택 바뀌면 표 다시 그림
  optunaRenderPostprocess(window._optunaLastSummary || null);
  const box = $('opt_postprocess_rec');
  if (box) box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

function optunaPostprocessFingerprint(sum) {
  const topn = (sum && sum.postprocess_topn) || {};
  const anchors = topn.postprocess_by_anchor || [];
  const cons = topn.postprocess_consensus || {};
  const cc = cons.combo || {};
  const sel = window._optunaPostSel || {};
  return [
    sel.source || '',
    sel.rank || '',
    anchors.map((a) => `${a.id}:${(a.orderbook && a.orderbook.ok) ? 1 : 0}`).join(','),
    cc.combo_id || '',
    cc.ok ? 1 : 0,
    topn.apply_overfit_pct != null ? topn.apply_overfit_pct : '',
    topn.run_ob_whipsaw ? 1 : 0,
  ].join('|');
}

function optunaRenderPostprocess(sum) {
  const cards = $('opt_postprocess_cards');
  const consEl = $('opt_postprocess_consensus');
  const job = window._optunaLastJob || null;
  const finish = () => optunaSyncRerunButton(sum, job);
  if (!cards) { finish(); return; }
  if (!sum) {
    cards.innerHTML = '
사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
';
    if (consEl) consEl.textContent = '합의: 완료 후 표시';
    _optunaPostFp = '';
    finish();
    return;
  }
  // 폴링 재진입: 내용·선택이 같으면 DOM 유지 (스크롤 점프 방지)
  const fp = optunaPostprocessFingerprint(sum);
  if (fp && fp === _optunaPostFp && cards.innerHTML && cards.innerHTML.length > 80) {
    finish();
    return;
  }
  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const yBox = box ? box.scrollTop : 0;
  _optunaPostFp = fp;

function optunaSelectPostprocess(source, rank) {
  window._optunaPostSel = { source: source || 'gated', rank: rank || 1 };
  optunaRenderPostprocess(window._optunaLastSummary || null);
  const box = $('opt_postprocess_rec');
  if (box) box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}

function optunaRenderPostprocess(sum) {
  const cards = $('opt_postprocess_cards');
  const consEl = $('opt_postprocess_consensus');
  const job = window._optunaLastJob || null;
  const finish = () => optunaSyncRerunButton(sum, job);
  if (!cards) { finish(); return; }
  if (!sum) {
    cards.innerHTML = '
사후합격 또는 안정 Top5에서 「상세」를 누르면 이 표가 바뀝니다.
';
    if (consEl) consEl.textContent = '합의: 완료 후 표시';
    finish();
    return;
  }


  cards.innerHTML =
    (fallbackNote || '')
    + renderOne(a, { live: false })
    + (live ? renderOne(live, { live: true }) : '');
  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_learn_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'learn' && 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)' : '';
  });
  // 폴링 재렌더 후 스크롤 복원 (호가8방 읽다 위로 튀는 것 방지)
  try {
    if (box) box.scrollTop = yBox;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  finish();
}

  cards.innerHTML =
    (fallbackNote || '')
    + renderOne(a, { live: false })
    + (live ? renderOne(live, { live: true }) : '');
  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_learn_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'learn' && 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)' : '';
  });
  finish();
}

function optunaPollDelayMs() {
  const job = window._optunaLastJob;
  if (!job) return OPTUNA_POLL_MS_IDLE;
  if (job.status === 'running' || optunaPostBusy(job)) return OPTUNA_POLL_MS_BUSY;
  return OPTUNA_POLL_MS_IDLE;
}

function optunaStartPolling() {
  if (_optunaPollTimer) {
    clearTimeout(_optunaPollTimer);
    clearInterval(_optunaPollTimer);
    _optunaPollTimer = null;
  }
  const tick = async () => {
    try {
      await optunaPollOnce();
    } catch (e) { /* ignore */ }
    _optunaPollTimer = setTimeout(tick, optunaPollDelayMs());
  };
  tick();
}

function optunaStartPolling() {
  if (_optunaPollTimer) clearInterval(_optunaPollTimer);
  optunaPollOnce();
  _optunaPollTimer = setInterval(optunaPollOnce, 3000);
}

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

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


  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const yBox = box ? box.scrollTop : 0;

  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const yBox = box ? box.scrollTop : 0;
  _optunaPostFp = fp;

  // 폴링 재렌더 후 스크롤 복원 (호가8방 읽다 위로 튀는 것 방지)
  try {
    if (box) box.scrollTop = yBox;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  finish();
}

  // 폴링 재렌더 후 스크롤 복원 (호가8방 읽다 위로 튀는 것 방지)
  try {
    if (box) box.scrollTop = yBox;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  finish();
}

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/static/js/backtest.js?v=20260821poll' | head -1; curl -s 'http://127.0.0.1:5050/static/js/backtest.js?v=20260821poll' | rg -n 'OPTUNA_POLL_MS|optunaPostprocessFingerprint|optunaStartPolling' | head -10

Restart web and verify new poll JS served


맞아요. **3초마다 상태 받아서 호가 8방 HTML을 통째로 다시 그려서** 스크롤이 위로 튀었습니다.

고친 내용:
1. **폴링 간격** — 실행/후처리 중 **8초**, 완료·대기 **20초** (기존 3초)
2. **내용 같으면 표 다시 안 그림** — 스크롤 유지
3. 부득이 다시 그릴 때도 **스크롤 위치 복원**

하드 새로고침(`?v=20260821poll`) 한 번 하면 됩니다.


<timestamp>Friday, Aug 21, 2026, 6:31 PM (UTC+9)</timestamp>
<user_query>
아니 구역을 늘려달라고 3초를 늘리라는게 아니고 다시 받아와도 안올라가게 2초로 줄여 프로그래스바가 좀 잘 보이면 좋겠는데  20초면 바가 계쏙 그대로인거처럼 보이잖나 로그라도 올라가면 몰라도 로그창이 있긴 하지만서도 20초는 좀
</user_query>


오해했습니다. 폴링은 2초로 되돌리고, 스크롤 점프만 막으며 호가 8방 구역 높이를 키우겠습니다.


StrReplace

/** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */
let _optunaPostFp = '';
/** 프로그레스 갱신 체감용 2초 · 표는 fingerprint로 안 다시 그림 */
const OPTUNA_POLL_MS_BUSY = 2000;
const OPTUNA_POLL_MS_IDLE = 2000;

/** 호가8방 표 재렌더 방지용 — 폴링마다 innerHTML 갈아끼우면 스크롤이 위로 튐 */
let _optunaPostFp = '';
/** 러닝/후처리 중 폴링(ms) · 완료·대기 중은 더 김 */
const OPTUNA_POLL_MS_BUSY = 8000;
const OPTUNA_POLL_MS_IDLE = 20000;

  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const zone = $('opt_postprocess_cards');
  const yBox = box ? box.scrollTop : 0;
  const yZone = zone ? zone.scrollTop : 0;

  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const yBox = box ? box.scrollTop : 0;

  // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
  try {
    if (box) box.scrollTop = yBox;
    if (zone) zone.scrollTop = yZone;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  finish();
}

  // 폴링 재렌더 후 스크롤 복원 (호가8방 읽다 위로 튀는 것 방지)
  try {
    if (box) box.scrollTop = yBox;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  finish();
}


return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

합의: 완료 후 표시

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

합의: 완료 후 표시

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

  // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
  try {
    if (box) box.scrollTop = yBox;
    if (zone) zone.scrollTop = yZone;
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8 && window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  // 다음 폴링용 — 구역 안 스크롤 기억 (fingerprint skip 시에도 유지)
  try {
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8) {
      ob8.onscroll = function () { window._optunaOb8Scroll = ob8.scrollTop; };
    }
  } catch (e2) { /* ignore */ }
  finish();
}

  // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
  try {
    if (box) box.scrollTop = yBox;
    if (zone) zone.scrollTop = yZone;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  finish();
}


  cards.innerHTML =
    (fallbackNote || '')
    + renderOne(a, { live: false })
    + (live ? renderOne(live, { live: true }) : '');
  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_learn_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'learn' && 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)' : '';
  });
  // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
  try {
    if (box) box.scrollTop = yBox;
    if (zone) zone.scrollTop = yZone;
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8 && window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  // 다음 폴링용 — 구역 안 스크롤 기억 (fingerprint skip 시에도 유지)
  try {
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8) {
      if (window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
      ob8.onscroll = function () { window._optunaOb8Scroll = ob8.scrollTop; };
    }
  } catch (e2) { /* ignore */ }
  finish();
}

  cards.innerHTML =
    (fallbackNote || '')
    + renderOne(a, { live: false })
    + (live ? renderOne(live, { live: true }) : '');
  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_learn_tbody tr').forEach((tr, i) => {
    tr.style.outline = (src === 'learn' && 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)' : '';
  });
  // 폴링 재렌더 후 스크롤 복원 (호가8방 구역·창)
  try {
    if (box) box.scrollTop = yBox;
    if (zone) zone.scrollTop = yZone;
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8 && window._optunaOb8Scroll != null) ob8.scrollTop = window._optunaOb8Scroll;
    window.scrollTo(0, yWin);
  } catch (e) { /* ignore */ }
  _optunaPostFp = optunaPostprocessFingerprint(sum);
  // 다음 폴링용 — 구역 안 스크롤 기억 (fingerprint skip 시에도 유지)
  try {
    const ob8 = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8) {
      ob8.onscroll = function () { window._optunaOb8Scroll = ob8.scrollTop; };
    }
  } catch (e2) { /* ignore */ }
  finish();
}

  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const zone = $('opt_postprocess_cards');
  const yBox = box ? box.scrollTop : 0;
  const yZone = zone ? zone.scrollTop : 0;
  try {
    const ob8prev = zone && zone.querySelector('.opt-ob8-zone');
    if (ob8prev) window._optunaOb8Scroll = ob8prev.scrollTop;
  } catch (e0) { /* ignore */ }

  const yWin = window.scrollY || window.pageYOffset || 0;
  const box = $('opt_postprocess_rec');
  const zone = $('opt_postprocess_cards');
  const yBox = box ? box.scrollTop : 0;
  const yZone = zone ? zone.scrollTop : 0;

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

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

Shell

sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s 'http://127.0.0.1:5050/static/js/backtest.js?v=20260821ob8z' | rg -n 'OPTUNA_POLL_MS|opt-ob8-zone|72vh' | head -10

Restart web and verify poll 2s + zone CSS


Verify HTML cache bust and zone height


CallMcpTool

({pollBusy: typeof OPTUNA_POLL_MS_BUSY!=='undefined'?OPTUNA_POLL_MS_BUSY:null, pollIdle: typeof OPTUNA_POLL_MS_IDLE!=='undefined'?OPTUNA_POLL_MS_IDLE:null, cardsMax: (document.getElementById('opt_postprocess_cards')||{}).style?.maxHeight||'', script: [...document.scripts].map(s=>s.src).filter(s=>s.includes('backtest.js'))[0]||''})


맞습니다. 제가 폴링만 늘린 게 잘못이었습니다.

반영한 내용:
- **폴링 2초** (busy/idle 둘 다) — 프로그레스바가 자주 갱신됨
- **호가 8방 구역 키움**: 카드 `~72vh`, 표 `~62vh` 안에서 스크롤
- **스크롤 유지**: 재조회해도 fingerprint로 표 안 다시 그리거나, 그려도 구역 스크롤 복원

웹 재시작됨. `http://192.168.0.149:5050/#optuna` 하드새로고침(`?v=20260821ob8z`) 한 번만 하면 됩니다.


<timestamp>Friday, Aug 21, 2026, 6:34 PM (UTC+9)</timestamp>
<user_query>
그리고 호가 8방엔 왜 pnl 숫자가 색상이 없냐 글씨도 쪼매나서 -가 붙었는지 보이지도 않아 승률도 40퍼이상만 녹색 미만은 빨강해주고 pnl에도 색깔 넣어줘
</user_query>


호가 8방 성적 셀에 승률·PnL 색상과 가독성을 넣겠습니다. 렌더 함수부터 확인합니다.


`_optunaAxisStatsHtml`와 base 행에 승률(≥40% 녹색)·PnL 색상·부호를 넣고, 글씨도 조금 키우겠습니다.


StrReplace

function _optunaAxisStatsHtml(ax, recHint) {
  if (!ax || !ax.ok) {
    const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음';
    const ko = {
      light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',
      no_replay_fills: '체결 재실행 없음',
      not_enough_trades: '체결 부족',
      'n/a_strategy': '해당없음',
      none: '없음',
    };
    return `<span class="text-muted">${ko[r] || r}</span>`;
  }
  const p = ax.params || {};
  const rs = ax.recommended_stats || {};
  const os = ax.orig_stats || {};
  const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null;
  const cnt = rs.count;
  const wr = rs.win_rate != null ? Number(rs.win_rate) : NaN;
  const origWr = os.win_rate != null ? Number(os.win_rate) : NaN;
  const origPnl = os.pnl != null ? Math.trunc(Number(os.pnl)) : null;
  const recBitsP = [];
  if (p.orderbook_max_spread_pct != null) {
    recBitsP.push(`스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`);
  }
  if (p.exit_ob_enabled != null) {
    recBitsP.push((p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : ''));
  }
  if (p.stop_ob_enabled != null) {
    recBitsP.push((p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : ''));
  }
  if (p.whipsaw_dip_pct != null) {
    recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`);
  }
  const recLine = recBitsP.join(' · ');
  const wrCls = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr)
    ? 'text-warning' : 'text-muted';
  const recBits = [];
  if (cnt != null) recBits.push(cnt + '건');
  if (Number.isFinite(wr)) recBits.push('승률 ' + wr.toFixed(1) + '%');
  if (pnl != null) recBits.push(Number(pnl).toLocaleString() + '원');
  const origBits = [];
  if (os.count != null && os.count !== cnt) origBits.push(os.count + '건');
  if (Number.isFinite(origWr)) origBits.push('승률 ' + origWr.toFixed(1) + '%');
  if (origPnl != null && origPnl !== pnl) origBits.push(Number(origPnl).toLocaleString() + '원');
  return `${recLine || '추천있음'}`
    + (recBits.length ? `<div class="${wrCls}">필터후 ${recBits.join(' · ')}</div>` : '')
    + (origBits.length ? `<div class="text-muted">필터전 ${origBits.join(' · ')}</div>` : '');
}

/** 호가8방 UI: 승률 색 기준(사후게이트 WR와 맞춤). PnL은 부호 색. */
const OPTUNA_OB8_WR_GOOD_PCT = 40;

function _optunaOb8WrSpan(wr) {
  if (!Number.isFinite(wr)) return '';
  const cls = wr >= OPTUNA_OB8_WR_GOOD_PCT ? 'text-pnl-pos' : 'text-pnl-neg';
  return `<span class="${cls}" style="font-weight:600">승률 ${wr.toFixed(1)}%</span>`;
}

function _optunaOb8PnlSpan(pnl) {
  if (pnl == null || !Number.isFinite(Number(pnl))) return '';
  const n = Math.trunc(Number(pnl));
  const cls = n > 0 ? 'text-pnl-pos' : (n < 0 ? 'text-pnl-neg' : '');
  const sign = n > 0 ? '+' : '';
  return `<span class="${cls}" style="font-weight:700">${sign}${n.toLocaleString()}원</span>`;
}

function _optunaAxisStatsHtml(ax, recHint) {
  if (!ax || !ax.ok) {
    const r = (ax && ax.reason) || (recHint && recHint.reason) || '없음';
    const ko = {
      light_skip: '후처리 미저장 · 진행 중이면 완료 후 새로고침',
      no_replay_fills: '체결 재실행 없음',
      not_enough_trades: '체결 부족',
      'n/a_strategy': '해당없음',
      none: '없음',
    };
    return `<span class="text-muted">${ko[r] || r}</span>`;
  }
  const p = ax.params || {};
  const rs = ax.recommended_stats || {};
  const os = ax.orig_stats || {};
  const pnl = rs.pnl != null ? Math.trunc(Number(rs.pnl)) : null;
  const cnt = rs.count;
  const wr = rs.win_rate != null ? Number(rs.win_rate) : NaN;
  const origWr = os.win_rate != null ? Number(os.win_rate) : NaN;
  const origPnl = os.pnl != null ? Math.trunc(Number(os.pnl)) : null;
  const recBitsP = [];
  if (p.orderbook_max_spread_pct != null) {
    recBitsP.push(`스프레드≤${p.orderbook_max_spread_pct}% · 잔량≥${p.orderbook_min_bid_ask_ratio}`);
  }
  if (p.exit_ob_enabled != null) {
    recBitsP.push((p.exit_ob_enabled ? 'EXIT ON' : 'EXIT OFF')
      + (p.exit_ob_ratio_min != null ? ` · OR≥${p.exit_ob_ratio_min}` : '')
      + (p.exit_ob_min_hold_bars != null ? ` · 보유${p.exit_ob_min_hold_bars}` : ''));
  }
  if (p.stop_ob_enabled != null) {
    recBitsP.push((p.stop_ob_enabled ? 'STOP ON' : 'STOP OFF')
      + (p.stop_ob_min_loss_pct != null ? ` · 손실${p.stop_ob_min_loss_pct}` : '')
      + (p.stop_ob_ratio_min != null ? ` · OR≥${p.stop_ob_ratio_min}` : ''));
  }
  if (p.whipsaw_dip_pct != null) {
    recBitsP.push(`sub=${p.whipsaw_subbar_sec} · lb=${p.whipsaw_lookback_sec} · dip=${p.whipsaw_dip_pct}`);
  }
  const recLine = recBitsP.join(' · ');
  const wrDrop = (Number.isFinite(wr) && Number.isFinite(origWr) && wr + 0.05 < origWr);
  const recBits = [];
  if (cnt != null) recBits.push(cnt + '건');
  if (Number.isFinite(wr)) recBits.push(_optunaOb8WrSpan(wr));
  if (pnl != null) recBits.push(_optunaOb8PnlSpan(pnl));
  const origBits = [];
  if (os.count != null && os.count !== cnt) origBits.push(os.count + '건');
  if (Number.isFinite(origWr)) origBits.push(_optunaOb8WrSpan(origWr));
  if (origPnl != null && origPnl !== pnl) origBits.push(_optunaOb8PnlSpan(origPnl));
  const dropHint = wrDrop ? ' (필터후 승률↓)' : '';
  return `<div style="font-size:12px;line-height:1.45">${recLine || '추천있음'}`
    + (recBits.length ? `<div>필터후 ${recBits.join(' · ')}${dropHint}</div>` : '')
    + (origBits.length ? `<div class="text-muted" style="font-size:11px">필터전 ${origBits.join(' · ')}</div>` : '')
    + `</div>`;
}


    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted" style="font-size:11px">${why}</div>` : '');
    } else {
      nt = '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || '—'}</span>`;
    }

    } else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push('승률 ' + Number(row.win_rate).toFixed(1) + '%');
      if (row.total_pnl != null) bits.push(Number(row.total_pnl).toLocaleString() + '원');
      const why = optunaObReasonKo(st);
      stats = (bits.length ? bits.join(' · ') : '')
        + (why ? `<div class="text-muted" style="font-size:10px">${why}</div>` : '');
    } else {
      nt = '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || '—'}</span>`;
    }

function _optunaBaseCell(a) {
  const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null;
  const wr = a.win_rate != null ? Number(a.win_rate) : NaN;
  const bits = [];
  if (a.total_trades != null) bits.push(a.total_trades + '건');
  if (Number.isFinite(wr)) bits.push(_optunaOb8WrSpan(wr));
  if (pnl != null) bits.push(_optunaOb8PnlSpan(pnl));
  return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}`
    + (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '');
}

function _optunaBaseCell(a) {
  const pnl = a.total_pnl != null ? Math.trunc(Number(a.total_pnl)) : null;
  const wr = a.win_rate != null ? Number(a.win_rate) : NaN;
  const bits = [];
  if (a.total_trades != null) bits.push(a.total_trades + '건');
  if (Number.isFinite(wr)) bits.push('승률 ' + wr.toFixed(1) + '%');
  if (pnl != null) bits.push(Number(pnl).toLocaleString() + '원');
  return `trial ${a.optuna_trial_number != null ? '#' + a.optuna_trial_number : '—'}`
    + (bits.length ? `<div class="text-muted">${bits.join(' · ')}</div>` : '');
}

return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

return `

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

${trs}

${foot}

    ${rerunBtn}

`;
}

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

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

Shell

sudo systemctl restart kis_backtest_web.service && sleep 2 && systemctl is-active kis_backtest_web.service && curl -s 'http://127.0.0.1:5050/static/js/backtest.js?v=20260821ob8clr' | rg -n 'OPTUNA_OB8_WR_GOOD|_optunaOb8PnlSpan|_optunaOb8WrSpan' | head -8

Restart web; verify PnL/WR color helpers


StrReplace

} else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push(_optunaOb8WrSpan(Number(row.win_rate)));
      if (row.total_pnl != null) bits.push(_optunaOb8PnlSpan(row.total_pnl));
      const why = optunaObReasonKo(st);
      stats = (bits.length ? `<div style="font-size:12px;line-height:1.45">${bits.join(' · ')}</div>` : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }

} else if (id === 'base' && row) {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      const bits = [];
      if (row.total_trades != null) bits.push(row.total_trades + '건');
      if (row.win_rate != null) bits.push('승률 ' + Number(row.win_rate).toFixed(1) + '%');
      if (row.total_pnl != null) bits.push(Number(row.total_pnl).toLocaleString() + '원');
      const why = optunaObReasonKo(st);
      stats = (bits.length ? bits.join(' · ') : '')
        + (why ? `<div class="text-muted">${why}</div>` : '8방 미산출');
    } else {
      nt = st.kind === 'legacy' ? '구JSON' : '—';
      stats = `<span class="text-muted">${optunaObReasonKo(st) || (st.kind === 'legacy' ? '후처리 재실행 필요' : '8방 미산출')}</span>`;
    }

Shell

sudo systemctl restart kis_backtest_web.service && sleep 1 && systemctl is-active kis_backtest_web.service && curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:5050/ && curl -s 'http://127.0.0.1:5050/' | rg -n '20260821ob8clr' | head -3

Restart web; confirm cache-bust version


<timestamp>Friday, Aug 21, 2026, 6:39 PM (UTC+9)</timestamp>
<user_query>
안정 탑 10이 비는것도 로직상 말이 안되는데 마이너스여도 안정적인 수치로다가 나온 값이 있을거 아냐 그리고 mode는 또 어디갔을까 옵투나는 0부터 좁혀가면서 왜 여긴 안나올까
</user_query>


안정 Top·mode 앵커가 비는 원인을 코드·후처리 로직에서 확인하겠습니다.


python3 - <<'PY'
import json
from pathlib import Path
p=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json')
d=json.loads(p.read_text())
mc=d.get('mode_combo') or {}
print('mode keys', sorted(mc.keys()))
bt=mc.get('backtest') or {}
print('backtest ok', bt.get('ok'), 'pnl', bt.get('total_pnl'), 'wr', bt.get('win_rate'), 'trades', bt.get('total_trades'))
print('note', (mc.get('note') or '')[:200])
vs=mc.get('vs_best') or {}
print('vs_best', {k:vs.get(k) for k in list(vs)[:8]})
anchors=(d.get('postprocess_topn') or {}).get('postprocess_by_anchor') or []
print('anchor roles', [(a.get('id'), a.get('role')) for a in anchors])
# how many learn rows have stability fields and would pass stable gates if not gated
from kis_trader.backtest.optuna_common import row_passes_stable_gates, optuna_stable_gate_defaults
print('stable_gates', optuna_stable_gate_defaults())
allr=list(d.get('results') or d.get('results_all') or [])
n_stab_fields=sum(1 for r in allr if r.get('n_active_days') is not None or r.get('daily_pnl'))
n_pass_stable=sum(1 for r in allr if row_passes_stable_gates(r))
n_pass_stable_pos=sum(1 for r in allr if row_passes_stable_gates(r) and float(r.get('total_pnl') or 0)>0)
# sample top by stability among all
scored=[r for r in allr if r.get('stability_score') is not None]
scored.sort(key=lambda r: float(r.get('stability_score') or -1e99), reverse=True)
print('n_all', len(allr), 'with_stab_fields', n_stab_fields, 'pass_stable_gates(all)', n_pass_stable, 'pass+pos_pnl', n_pass_stable_pos)
for r in scored[:5]:
    print(' top stab', r.get('optuna_trial_number'), 'score', r.get('stability_score'), 'pnl', r.get('total_pnl'), 'wr', r.get('win_rate'), 'ndays', r.get('n_active_days'), 'nlose', r.get('n_losing_days'), 'worst', r.get('worst_day_pnl'))
# why fail stable
fail_reasons={}
max_lose,min_worst,_,min_days=optuna_stable_gate_defaults()
for r in allr:
    if r.get('daily_pnl') is None and r.get('n_active_days') is None:
        fail_reasons['no_daily'] = fail_reasons.get('no_daily',0)+1; continue
    n_days=int(r.get('n_active_days') or 0); n_lose=int(r.get('n_losing_days') or 0); worst=float(r.get('worst_day_pnl') or 0)
    if n_days < min_days: fail_reasons['min_days']=fail_reasons.get('min_days',0)+1
    elif n_lose > max_lose: fail_reasons['max_lose']=fail_reasons.get('max_lose',0)+1
    elif worst < min_worst: fail_reasons['worst']=fail_reasons.get('worst',0)+1
    else: fail_reasons['ok']=fail_reasons.get('ok',0)+1
print('fail_reasons', fail_reasons)
PY

Analyze stable empty + mode_combo backtest


python3 - <<'PY'
import json
from pathlib import Path
d=json.loads(Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json').read_text())
topn=d.get('postprocess_topn') or {}
print('keys', sorted(topn.keys()))
print('run_ob', topn.get('run_ob_whipsaw'))
print('note', topn.get('note'))
print('n_anchors', len(topn.get('postprocess_by_anchor') or []))
# log path?
print('job fields', [k for k in d if 'log' in k.lower() or 'post' in k.lower()][:20])
PY

Inspect postprocess_topn meta on momentum JSON


원인: 안정 Top이 `사후합격(gated) ∩ 게이트`라 −PnL·gated=0이면 무조건 비고, mode는 표에서 의도적으로 빠져 버튼만 있습니다. 안정은 학습풀 기준으로 채우고, mode 행을 보이게 고치겠습니다.


StrReplace

def build_optuna_result_tiers(
    rows: List[Dict[str, Any]],
    *,
    sort_by: str,
    top_n: int = 5000,
) -> Dict[str, Any]:
    """
    탐색 전체 vs 리포트/apply 후보 분리.

    - results_all: 완료·게이트통과(탐색게이트) trial 전부 정렬
    - results: 하위호환 — 플러스 PnL 우선(없으면 all)
    - results_gated: 승률·PF 사후 필터 (apply 후보)
    - results_stable: gated ∩ 일별 안정성 게이트 (들쭉날쭉 완화 후보)
    """
    rep_wr, rep_pf, rep_tr = optuna_report_gate_defaults()
    max_lose, min_worst, lam, min_days = optuna_stable_gate_defaults()
    all_sorted = _sort_optuna_rows(rows, sort_by)
    profitable = [r for r in all_sorted if float(r.get("total_pnl") or 0) > 0]
    learning = profitable if profitable else all_sorted
    gated = [
        r for r in all_sorted
        if row_passes_report_gates(
            r, min_win_rate=rep_wr, min_pf=rep_pf, min_trades=rep_tr,
        )
        and float(r.get("total_pnl") or 0) > 0
    ]
    stable_pool = [r for r in gated if row_passes_stable_gates(r)]
    stable = _sort_optuna_rows(stable_pool, "stability")
    return {
        "results_all": all_sorted[:top_n],
        "results": learning[:top_n],
        "results_gated": gated[:top_n],
        "results_stable": stable[:top_n],
        "report_gates": {
            "min_win_rate": rep_wr,
            "min_pf": rep_pf,
            "min_trades": rep_tr,
        },
        "stable_gates": {
            "max_losing_days": max_lose,
            "min_worst_day_pnl": min_worst,
            "stability_lambda": lam,
            "min_active_days": min_days,
            "score_note": "stability_score = mean(일PnL) − λ × std(일PnL)",
        },
        "search_gates_note": (
            "탐색 min_win_rate/min_pf 기본 0 — TPE가 PnL 차이를 학습. "
            "적용·운영 후보는 results_gated(report_gates). "
            "들쭉날쭉 완화 후보는 results_stable(stable_gates)."
        ),
        "n_results_all": len(all_sorted),
        "n_results_learning": len(learning),
        "n_results_gated": len(gated),
        "n_results_stable": len(stable),
    }

def build_results_stable_tier(
    rows: List[Dict[str, Any]],
    *,
    top_n: int = 10,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    """
    안정 Top — 사후합격(gated)·플러스 PnL 과 독립 (마이너스도 상대 순위).

    1) 일별 안정 게이트 통과분 → 안정점수순
    2) 0건이면 학습풀 전체를 안정점수순 TopN (게이트는 참고·폴백 표시)
       → 3일 전패장에서 max_losing_days=1 이면 게이트 0이어도 표가 비지 않음
    """
    max_lose, min_worst, lam, min_days = optuna_stable_gate_defaults()
    n = max(1, int(top_n or 10))
    all_rows = [r for r in (rows or []) if isinstance(r, dict)]
    pool = [r for r in all_rows if row_passes_stable_gates(r)]
    fallback = False
    if pool:
        stable = _sort_optuna_rows(pool, "stability")[:n]
    else:
        fallback = True
        with_stab = [
            r for r in all_rows
            if r.get("stability_score") is not None
            or r.get("n_active_days") is not None
            or r.get("daily_pnl") is not None
        ]
        src = with_stab if with_stab else all_rows
        stable = _sort_optuna_rows(src, "stability")[:n]
    meta = {
        "max_losing_days": max_lose,
        "min_worst_day_pnl": min_worst,
        "stability_lambda": lam,
        "min_active_days": min_days,
        "score_note": "stability_score = mean(일PnL) − λ × std(일PnL)",
        "fallback_rank_only": bool(fallback),
        "n_gate_pass": len(pool),
        "fallback_note": (
            "안정 게이트 0건 → 학습풀 안정점수순 TopN (마이너스 PnL 포함 · 상대비교)"
            if fallback else ""
        ),
    }
    return stable, meta

def resolve_results_stable(
    data: Optional[Dict[str, Any]],
    *,
    top_n: Optional[int] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    """JSON results_stable 우선 · 비면 학습풀에서 즉시 재구성 (구잡·gated=0 공용)."""
    data = data or {}
    try:
        n = int(top_n) if top_n is not None else 10
    except (TypeError, ValueError):
        n = 10
    n = max(1, n)
    stored = [r for r in list(data.get("results_stable") or []) if isinstance(r, dict)]
    gates = dict(data.get("stable_gates") or {})
    if stored:
        return stored[:n], gates
    allr = list(data.get("results_all") or data.get("results") or [])
    stable, meta = build_results_stable_tier(allr, top_n=n)
    gates.update(meta)
    return stable, gates

def build_optuna_result_tiers(
    rows: List[Dict[str, Any]],
    *,
    sort_by: str,
    top_n: int = 5000,
) -> Dict[str, Any]:
    """
    탐색 전체 vs 리포트/apply 후보 분리.

    - results_all: 완료·게이트통과(탐색게이트) trial 전부 정렬
    - results: 하위호환 — 플러스 PnL 우선(없으면 all)
    - results_gated: 승률·PF 사후 필터 (apply 후보, PnL>0)
    - results_stable: 학습풀 일별 안정성 (gated·플러스 독립 · 게이트0이면 점수순 폴백)
    """
    rep_wr, rep_pf, rep_tr = optuna_report_gate_defaults()
    all_sorted = _sort_optuna_rows(rows, sort_by)
    profitable = [r for r in all_sorted if float(r.get("total_pnl") or 0) > 0]
    learning = profitable if profitable else all_sorted
    gated = [
        r for r in all_sorted
        if row_passes_report_gates(
            r, min_win_rate=rep_wr, min_pf=rep_pf, min_trades=rep_tr,
        )
        and float(r.get("total_pnl") or 0) > 0
    ]
    # 안정 TopN 표용 — 전체 풀에서 상위 (gated 잘림과 무관)
    stable_top_n = min(int(top_n or 5000), 5000)
    try:
        from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
        stable_ui_n = resolve_post_top_n(10)
    except Exception:
        stable_ui_n = 10
    stable, stable_meta = build_results_stable_tier(all_sorted, top_n=max(stable_ui_n, min(50, stable_top_n)))
    # JSON 저장은 UI Top 과 동일 길이로 맞춤 (과도한 팽창 방지)
    stable = stable[: max(1, int(stable_ui_n))]
    return {
        "results_all": all_sorted[:top_n],
        "results": learning[:top_n],
        "results_gated": gated[:top_n],
        "results_stable": stable,
        "report_gates": {
            "min_win_rate": rep_wr,
            "min_pf": rep_pf,
            "min_trades": rep_tr,
        },
        "stable_gates": stable_meta,
        "search_gates_note": (
            "탐색 min_win_rate/min_pf 기본 0 — TPE가 PnL 차이를 학습. "
            "적용·운영 후보는 results_gated(report_gates). "
            "들쭉날쭉 완화·상대비교는 results_stable (gated/플러스 독립 · 게이트0이면 점수순 폴백)."
        ),
        "n_results_all": len(all_sorted),
        "n_results_learning": len(learning),
        "n_results_gated": len(gated),
        "n_results_stable": len(stable),
    }


    # 안정 TopN 표용 — 전체 풀에서 상위 (gated 잘림·플러스와 무관)
    try:
        stable_ui_n = max(1, int(get_env_int("OPTUNA_POST_TOP_N", 10)))
    except Exception:
        stable_ui_n = 10
    stable, stable_meta = build_results_stable_tier(all_sorted, top_n=stable_ui_n)

    # 안정 TopN 표용 — 전체 풀에서 상위 (gated 잘림과 무관)
    stable_top_n = min(int(top_n or 5000), 5000)
    try:
        from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
        stable_ui_n = resolve_post_top_n(10)
    except Exception:
        stable_ui_n = 10
    stable, stable_meta = build_results_stable_tier(all_sorted, top_n=max(stable_ui_n, min(50, stable_top_n)))
    # JSON 저장은 UI Top 과 동일 길이로 맞춤 (과도한 팽창 방지)
    stable = stable[: max(1, int(stable_ui_n))]


    top_n = resolve_post_top_n(10)
    gated = list(data.get("results_gated") or [])
    from kis_trader.backtest.optuna_common import resolve_results_stable
    stable, stable_gates_resolved = resolve_results_stable(data, top_n=top_n)
    allr = list(data.get("results") or data.get("results_all") or [])
    learn = allr[0] if allr else None
    gate0 = gated[0] if gated else None
    stab0 = stable[0] if stable else None
    top = gate0 or learn

    mc = data.get("mode_combo") or {}
    mc_bt = mc.get("backtest") or {}
    vs = mc.get("vs_best") or {}

    top_n = resolve_post_top_n(10)
    gated = list(data.get("results_gated") or [])
    stable = list(data.get("results_stable") or [])
    allr = list(data.get("results") or data.get("results_all") or [])
    learn = allr[0] if allr else None
    gate0 = gated[0] if gated else None
    stab0 = stable[0] if stable else None
    top = gate0 or learn

    mc = data.get("mode_combo") or {}
    mc_bt = mc.get("backtest") or {}
    vs = mc.get("vs_best") or {}

        "n_gated": len(gated),
        "n_stable": len(stable),
        "n_all": len(allr),
        "optuna_best_trial_number": data.get("optuna_best_trial_number"),
        "stable_gates": stable_gates_resolved or data.get("stable_gates"),
        "mode_combo_summary": {
            "ok": bool(mc_bt.get("ok") or mc_bt.get("total_pnl") is not None),
            "total_pnl": mc_bt.get("total_pnl"),
            "total_trades": mc_bt.get("total_trades"),
            "win_rate": mc_bt.get("win_rate"),
            "pf": mc_bt.get("pf"),
            "note": mc.get("note"),
            "method": mc.get("method"),
            "top_n": mc.get("top_n"),
            "pool_size": mc.get("pool_size"),
            "vs_best": vs if vs else None,
            "has_params": bool(mc.get("params")),
        },
        "top": _row_metrics(
            top,
            label="적용후보(gated우선)",
            source="gated" if gate0 else "learn",
            data=data,
        ),
        "compare_rows": compare_rows,
        "vs_best": vs if vs else None,
        "top5_gated": top5,
        "top5_learn": top5_learn,
        "top5_stable": top5_stable,
        "mode_combo_note": mc.get("note"),

        "n_gated": len(gated),
        "n_stable": len(stable),
        "n_all": len(allr),
        "optuna_best_trial_number": data.get("optuna_best_trial_number"),
        "stable_gates": data.get("stable_gates"),
        "top": _row_metrics(
            top,
            label="적용후보(gated우선)",
            source="gated" if gate0 else "learn",
            data=data,
        ),
        "compare_rows": compare_rows,
        "vs_best": vs if vs else None,
        "top5_gated": top5,
        "top5_learn": top5_learn,
        "top5_stable": top5_stable,
        "mode_combo_note": mc.get("note"),


        if src == "stable":
            from kis_trader.backtest.optuna_common import resolve_results_stable
            from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
            pool, _ = resolve_results_stable(data, top_n=resolve_post_top_n(10))
        elif src == "gated":
            pool = data.get("results_gated")
        else:
            pool = data.get("results") or []
        pool = list(pool or [])
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            params = merge_param_search_apply_source(item, data)
        except Exception:
            params = dict(item.get("merged_params") or item.get("params") or {})
        metrics = {
            "label": f"{src} #{rank}",
            "optuna_trial_number": item.get("optuna_trial_number"),
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "score": item.get("score"),
        }

        if src == "stable":
            pool = data.get("results_stable")
        elif src == "gated":
            pool = data.get("results_gated")
        else:
            pool = data.get("results") or []
        pool = list(pool or [])
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            params = merge_param_search_apply_source(item, data)
        except Exception:
            params = dict(item.get("merged_params") or item.get("params") or {})
        metrics = {
            "label": f"{src} #{rank}",
            "optuna_trial_number": item.get("optuna_trial_number"),
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "score": item.get("score"),
        }


def _pool_for_optuna_source(data: Dict[str, Any], src: str) -> List[Dict[str, Any]]:
    """gated / learn(results) / stable(비면 학습풀 재구성) 후보 풀."""
    s = str(src or "gated").strip().lower()
    if s == "stable":
        from kis_trader.backtest.optuna_common import resolve_results_stable
        from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
        pool, _ = resolve_results_stable(data, top_n=resolve_post_top_n(10))
        return list(pool or [])
    if s == "gated":
        return list(data.get("results_gated") or [])
    return list(data.get("results") or data.get("results_all") or [])

def get_candidate_detail(
    *,
    job_id: Optional[str] = None,
    result_json: Optional[str] = None,
    source: str = "gated",
    rank: int = 1,
    hist_src: str = "",
    candle_source: str = "",
    tick_source: str = "",
    ob_source: str = "",
) -> Dict[str, Any]:
    # NOTE: body continues below — signature kept; helper used in pool branch
    raise NotImplementedError  # placeholder to be removed

def get_candidate_detail(
    *,
    job_id: Optional[str] = None,
    result_json: Optional[str] = None,
    source: str = "gated",
    rank: int = 1,
    hist_src: str = "",
    candle_source: str = "",
    tick_source: str = "",
    ob_source: str = "",
) -> Dict[str, Any]:


def get_candidate_detail(
    *,
    job_id: Optional[str] = None,
    result_json: Optional[str] = None,
    source: str = "gated",
    rank: int = 1,
    hist_src: str = "",
    candle_source: str = "",
    tick_source: str = "",
    ob_source: str = "",
) -> Dict[str, Any]:
    """보기용: gated/learn/mode 후보 메트릭 + params 미리보기."""
    path = result_json
    meta = None
    if job_id:
        meta = load_job(job_id)
        if not meta:
            raise FileNotFoundError(f"job not found: {job_id}")
        path = path or meta.get("result_json")
    data, path = _load_out_data_for_job(meta, path)
    src = str(source or "gated").strip().lower()
    rank = max(1, int(rank or 1))
    params: Dict[str, Any] = {}
    metrics: Dict[str, Any] = {}

    if src == "mode":
        mc = data.get("mode_combo") or {}
        params = dict(mc.get("params") or {})
        bt = mc.get("backtest") or {}
        metrics = {
            "label": "mode_combo",
            "optuna_trial_number": None,
            "total_pnl": bt.get("total_pnl"),
            "total_trades": bt.get("total_trades"),
            "win_rate": bt.get("win_rate"),
            "pf": bt.get("pf"),
        }
    else:
        pool = _pool_for_optuna_source(data, src)
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            params = merge_param_search_apply_source(item, data)
        except Exception:
            params = dict(item.get("merged_params") or item.get("params") or {})
        metrics = {
            "label": f"{src} #{rank}",
            "optuna_trial_number": item.get("optuna_trial_number"),
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "score": item.get("score"),
            "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

    # 보기: 전체 파라미터 (키 정렬). 예전 [:40] 잘림 → ratchet/rsi 등이 “없는 것처럼” 보임
    preview = {k: params[k] for k in sorted(params.keys(), key=lambda x: str(x))}
    return {

def get_candidate_detail(
    *,
    job_id: Optional[str] = None,
    result_json: Optional[str] = None,
    source: str = "gated",
    rank: int = 1,
    hist_src: str = "",
    candle_source: str = "",
    tick_source: str = "",
    ob_source: str = "",
) -> Dict[str, Any]:
    # NOTE: body continues below — signature kept; helper used in pool branch
    raise NotImplementedError  # placeholder to be removed
    """보기용: gated/learn/mode 후보 메트릭 + params 미리보기."""
    path = result_json
    meta = None
    if job_id:
        meta = load_job(job_id)
        if not meta:
            raise FileNotFoundError(f"job not found: {job_id}")
        path = path or meta.get("result_json")
    data, path = _load_out_data_for_job(meta, path)
    src = str(source or "gated").strip().lower()
    rank = max(1, int(rank or 1))
    params: Dict[str, Any] = {}
    metrics: Dict[str, Any] = {}

    if src == "mode":
        mc = data.get("mode_combo") or {}
        params = dict(mc.get("params") or {})
        bt = mc.get("backtest") or {}
        metrics = {
            "label": "mode_combo",
            "optuna_trial_number": None,
            "total_pnl": bt.get("total_pnl"),
            "total_trades": bt.get("total_trades"),
            "win_rate": bt.get("win_rate"),
            "pf": bt.get("pf"),
        }
    else:
        if src == "stable":
            pool = data.get("results_stable")
        elif src == "gated":
            pool = data.get("results_gated")
        else:
            pool = data.get("results") or []
        pool = list(pool or [])
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        try:
            from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
            params = merge_param_search_apply_source(item, data)
        except Exception:
            params = dict(item.get("merged_params") or item.get("params") or {})
        metrics = {
            "label": f"{src} #{rank}",
            "optuna_trial_number": item.get("optuna_trial_number"),
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "score": item.get("score"),
            "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

    # 보기: 전체 파라미터 (키 정렬). 예전 [:40] 잘림 → ratchet/rsi 등이 “없는 것처럼” 보임
    preview = {k: params[k] for k in sorted(params.keys(), key=lambda x: str(x))}
    return {

        if not merged:
            raise RuntimeError("mode_combo.params 없음")
    else:
        pool = _pool_for_optuna_source(data, src)
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
        merged = merge_param_search_apply_source(item, data)
        pnl = float(item.get("total_pnl") or 0)
        metrics = {
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "optuna_trial_number": item.get("optuna_trial_number"),
            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
        }

        if not merged:
            raise RuntimeError("mode_combo.params 없음")
    else:
        if src == "stable":
            pool = data.get("results_stable")
        elif src == "gated":
            pool = data.get("results_gated")
        else:
            pool = data.get("results") or []
        pool = list(pool or [])
        if not pool:
            raise RuntimeError(f"{src} 결과 없음")
        if rank > len(pool):
            raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
        item = pool[rank - 1]
        from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
        merged = merge_param_search_apply_source(item, data)
        pnl = float(item.get("total_pnl") or 0)
        metrics = {
            "total_pnl": item.get("total_pnl"),
            "total_trades": item.get("total_trades"),
            "win_rate": item.get("win_rate"),
            "pf": item.get("pf"),
            "optuna_trial_number": item.get("optuna_trial_number"),
            "stability_score": item.get("stability_score"),
            "n_losing_days": item.get("n_losing_days"),
            "worst_day_pnl": item.get("worst_day_pnl"),
        }

    if any(str(a.get("role") or "") == "stable" for a in anchors):
        return
    from kis_trader.backtest.optuna_common import resolve_results_stable
    stable, _meta = resolve_results_stable(data, top_n=max(1, int(top_n or 5)))
    # 후처리 중 JSON에 비어 있으면 재구성분 반영 (다음 요약·앵커 일치)
    if not list((data or {}).get("results_stable") or []) and stable:
        data["results_stable"] = list(stable)
        if _meta:
            data["stable_gates"] = dict(_meta)
    for i, row in enumerate(stable, start=1):

    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):


    stable_preview: List[Any] = []
    if _include_stable():
        from kis_trader.backtest.optuna_common import resolve_results_stable
        stable_preview, _sg = resolve_results_stable(data, top_n=max(1, int(top_n or 5)))
        if not list((data or {}).get("results_stable") or []) and stable_preview:
            data["results_stable"] = list(stable_preview)
            if _sg:
                data["stable_gates"] = dict(_sg)
    learn_preview: List[Any] = []
    if not gated:
        learn_preview = list(
            (data or {}).get("results") or (data or {}).get("results_all") or []
        )[: max(1, int(top_n or 5))]
    n_units = len(gated) + len(learn_preview) + (len(stable_preview) if _include_stable() else 0)

    stable_preview = list((data or {}).get("results_stable") or [])[: max(1, int(top_n or 5))] if _include_stable() else []
    learn_preview: List[Any] = []
    if not gated:
        learn_preview = list(
            (data or {}).get("results") or (data or {}).get("results_all") or []
        )[: max(1, int(top_n or 5))]
    n_units = len(gated) + len(learn_preview) + (len(stable_preview) if _include_stable() else 0)

mode_combo 축별 최빈 조립 · trial 번호 없음 · 탐색(WR/PF=0) TopN 합의 1회 실측

완료 후 표시

안정 Top10 results_stable · gated/플러스와 독립 · 게이트0이면 안정점수순 폴백

—

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

—

function optunaRenderCompare(sum) {
  const gatesEl = $('opt_stable_gates');
  if (gatesEl) {
    const g = sum && sum.stable_gates;
    if (g) {
      let t =
        `안정점수 = 일평균PnL − λ×일표준편차 (원). 높을수록 좋음 · 만점 없음. ` +
        `게이트: 손실일≤${g.max_losing_days} · 최악일≥${Number(g.min_worst_day_pnl).toLocaleString()} · ` +
        `활성일≥${g.min_active_days} · λ=${g.stability_lambda}`;
      if (g.fallback_rank_only || g.fallback_note) {
        t += ` · <span class="text-warning">${g.fallback_note || '게이트 0건 → 점수순 폴백(마이너스 포함)'}</span>`;
      } else if (g.n_gate_pass != null) {
        t += ` · 게이트통과 ${g.n_gate_pass}건`;
      }
      gatesEl.innerHTML = t;
    } else if (sum) {
      gatesEl.textContent = '안정점수 = 일평균PnL − λ×일표준편차(원). 높을수록 좋음 · 만점 없음. (구 JSON은 게이트 미기록)';
    } else {
      gatesEl.textContent = '—';
    }
  }
  optunaRenderModeCombo(sum);
  optunaRenderTrailRec(sum);
  optunaRenderPostprocess(sum);
  optunaRenderOverfitBadges(sum);
  optunaRenderOverfit(sum);
}

function optunaRenderModeCombo(sum) {
  const el = $('opt_mode_combo_body');
  if (!el) return;
  const m = sum && sum.mode_combo_summary;
  if (!m || !m.ok) {
    const note = (sum && sum.mode_combo_note) || '';
    el.innerHTML = sum
      ? (`<span class="text-muted">mode_combo 실측 없음</span>`
        + (note ? `<div class="text-muted" style="font-size:11px">${note}</div>` : '')
        + `<div class="mt-1"><button type="button" class="btn btn-sm btn-outline-secondary py-0" id="opt_btn_mode_detail2" onclick="optunaSelectPostprocess('mode',1)" disabled>mode 상세</button></div>`)
      : '완료 후 표시';
    return;
  }
  const pnl = Number(m.total_pnl);
  const pnlCls = Number.isFinite(pnl) ? (pnl > 0 ? 'text-pnl-pos' : (pnl < 0 ? 'text-pnl-neg' : '')) : '';
  const pnlStr = Number.isFinite(pnl)
    ? ((pnl > 0 ? '+' : '') + Math.trunc(pnl).toLocaleString() + '원')
    : '—';
  const wr = m.win_rate != null ? Number(m.win_rate) : NaN;
  const wrCls = Number.isFinite(wr) ? (wr >= 40 ? 'text-pnl-pos' : 'text-pnl-neg') : '';
  const vs = m.vs_best || {};
  const vsLine = (vs.best_pnl != null && vs.mode_pnl != null)
    ? ` · vs학습1위 PnL ${Number(vs.mode_pnl).toLocaleString()} / ${Number(vs.best_pnl).toLocaleString()}`
    : '';
  el.innerHTML =
    `<div>` +
    `Top${m.top_n != null ? m.top_n : 'N'} 축최빈 조립` +
    (m.pool_size != null ? ` (pool ${m.pool_size})` : '') +
    ` · 거래 <b>${optunaFmtNum(m.total_trades, 0)}</b>` +
    ` · 승률 <b class="${wrCls}">${Number.isFinite(wr) ? wr.toFixed(1) + '%' : '—'}</b>` +
    ` · PF <b>${optunaFmtNum(m.pf, 2)}</b>` +
    ` · PnL <b class="${pnlCls}">${pnlStr}</b>` +
    `${vsLine}</div>` +
    (m.note ? `<div class="text-muted" style="font-size:11px">${m.note}</div>` : '') +
    `<div class="mt-1">` +
    `<button type="button" class="btn btn-sm btn-outline-secondary py-0" onclick="optunaSelectPostprocess('mode',1)">mode 상세(후처리 8방)</button>` +
    ` <button type="button" class="btn btn-sm btn-outline-warning py-0 ms-1" id="opt_btn_apply_mode_card" onclick="optunaApplyUpto('mode',1,'base')">mode 타점 DB적용</button>` +
    `</div>`;
}

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

    'stable 후보 없음 (일별 필드 없는 구 JSON · 재실행 필요)',
  );
}

    'stable 후보 없음 (구 JSON은 재실행 필요 / 게이트 미충족)',
  );
}

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

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


Shell

python3 -m py_compile kis_trader/backtest/optuna_common.py kis_trader/backtest/optuna_web_jobs.py kis_trader/backtest/optuna_postprocess_topn.py && python3 - <<'PY'
from kis_trader.backtest.optuna_common import resolve_results_stable
from kis_trader.backtest.optuna_web_jobs import _summarize_result_data
import json
from pathlib import Path
d=json.loads(Path('kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json').read_text())
st, meta = resolve_results_stable(d, top_n=10)
print('stable_n', len(st), 'fallback', meta.get('fallback_rank_only'), 'n_gate', meta.get('n_gate_pass'))
print('top1', st[0].get('optuna_trial_number'), st[0].get('stability_score'), st[0].get('total_pnl'))
summ=_summarize_result_data(d, path=str(Path('kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json')))
print('sum n_stable', summ['n_stable'], 'top5_stable', len(summ['top5_stable']))
print('mode', summ.get('mode_combo_summary'))
PY

Compile and verify stable/mode summary rebuild


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/ && curl -s 'http://127.0.0.1:5050/' | rg -n '20260821stableMode|opt_mode_combo_body' | head -5

Restart web; verify mode card + JS version


맞습니다. 비는 게 말이 안 됐고, 원인은 두 가지였습니다.

**안정 Top이 비던 이유**
1. 예전에 `results_stable = 사후합격(gated) ∩ 안정게이트` 였음 → gated가 0(WR≥40·PF≥1·**PnL>0**)이면 안정도 무조건 0
2. 이 잡(8/18~20)은 학습 185건이 **3일 전부 손실일**이라, 게이트 `손실일≤1`도 전부 탈락

탐색은 WR/PF=0부터 넓게 보는데, 안정만 gated에 묶여 있던 게 모순이었습니다.

**mode는 어디 갔나**
- JSON·후처리 앵커에는 있음 (이 잡: PnL **-26,067** · WR 47.9% · 48건)
- UI Top 표에서는 일부러 빠져 있고, 비교표는 `d-none`, 버튼만 있어서 “안 나온 것처럼” 보였음

**고친 것**
- 안정 Top = **gated/플러스와 독립** · 게이트 0이면 **안정점수순 Top10 폴백**(마이너스 포함)
- **mode_combo 카드**를 Top 표 위에 표시 (실측 PnL·승률·상세/적용)
- 구 JSON도 웹 새로고침만으로 안정 Top 재구성

하드새로고침: `http://192.168.0.149:5050/#optuna` (`?v=20260821stableMode`)
안정「상세」의 호가 8방까지 쓰려면 그 잡에서 **후처리 재실행** 한 번 하면 stable# 앵커가 붙습니다.


<timestamp>Friday, Aug 21, 2026, 7:02 PM (UTC+9)</timestamp>
<user_query>
또 적용하려고 보니까 8방에 안정점수가 없고 과적합이 없는데 흠 이건 없는게 맞나 호가가 과적합이라는게 말이 안되긴해 그치?
</user_query>


8방 표에 안정·과적합이 안 나오는 게 설계인지, 데이터만 빠진 건지 코드에서 확인하겠습니다.


맞아요. **없는 게 맞습니다.**

**안정점수**
일별 PnL로 “들쭉날쭉”을 보는 점수라서, **타점 trial 한 줄**(학습/사후/안정 Top)에만 의미가 있습니다.
호가 8방은 그 trial 체결을 갖고 **스프레드·잔량 필터만** 바꿔 본 후처리라, 일별 안정 시리즈를 다시 안 만듭니다. 그래서 8방 칸에 안정점수가 없는 게 정상입니다.

**과적합%**
그건 Optuna가 **타점 축을 좁혀 가며** 생긴 위험 추정(표본 장일·거래 수·게이트 커버 등)입니다.
호가 8방은 “이 필터 조합이 이 샘플에서 성적 좋음” 정도고, 그걸 칸마다 과적합%로 붙이면 오히려 헷갈립니다.
(후처리 전체 합의에 가점이 조금 붙을 수는 있어도, **8방 행 = 과적합**은 아닙니다.)

정리하면: **안정·과적합은 위 Top/후보 표 기준**, 8방은 **그 후보에 호가 필터를 얼마나 얹을지**만 보면 됩니다.


<timestamp>Friday, Aug 21, 2026, 7:16 PM (UTC+9)</timestamp>
<user_query>
$env:PYTHONUNBUFFERED = '1'
$env:PYTHONPATH = (Get-Location).Path
$env:DB_HOST = '192.168.0.141'
python -u kis_trader/backtest/param_search_optuna.py --strategy momentum --mode tpe --start 2026-08-18 --end 2026-08-20 --trials 200 --min_trades 1 --min_win_rate 0 --min_pf 0 --orderbook-filter off --no-progress --study-name momentum_tpe_20260818_20260820_20260821_145331 --sort-by score --universe-history-source kiwoom --study-trials 200

이거 되고 잇는거 맞냐
</user_query>


해당 Optuna 잡이 실제로 돌고 있는지 프로세스·로그·잡 상태를 확인합니다.

ps aux | rg -i 'param_search_optuna|momentum_tpe_20260818|optuna' | rg -v 'rg -i' | head -30; echo '---'; ls -lt /home/hoon/kis_bot/logs/optuna*momentum* 2>/dev/null | head -15; ls -lt /home/hoon/kis_bot/logs/*145331* 2>/dev/null | head -10; ls -lt /home/hoon/.cursor/projects/home-hoon-kis-bot/terminals/*.txt 2>/dev/null | head -5

Check Optuna processes and recent logs


tail -n 40 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_145331.log; echo '===='; python3 - <<'PY'
import json
from pathlib import Path
p=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json')
d=json.loads(p.read_text())
print('study', d.get('optuna_study_name'))
print('n_complete', d.get('optuna_n_complete'), 'n_finished', d.get('optuna_n_finished'), 'study_trials', d.get('optuna_study_trials'))
print('start-end', d.get('start'), d.get('end'))
print('json mtime', p.stat().st_mtime)
# web jobs
jobs_dir=Path('/home/hoon/kis_bot/kis_trader/backtest/results/optuna_web_jobs')
# find job
import os
cands=[]
for root,dirs,files in os.walk('/home/hoon/kis_bot'):
    for f in files:
        if '145331' in f and f.endswith(('.json','.meta','.log')):
            cands.append(os.path.join(root,f))
print('files', cands[:20])
PY
ps -eo pid,etime,cmd | rg -i 'param_search_optuna|optuna_web' | rg -v rg || echo 'NO_RUNNING_OPTUNA'

Confirm job finished vs still running


**지금 돌고 있는 건 아닙니다.** `param_search_optuna` 프로세스 없음.

그 커맨드(study `…145331`)는 **오늘 15:12에 이미 끝남**.
- trials **200/200** (COMPLETE 185)
- 로그: `logs/optuna_web_momentum_20260821_145331.log`
- 결과: `kis_trader/backtest/results/optuna_momentum_tpe_20260821_151059.json`

지금 PowerShell에서 그대로 다시 치면 **같은 study 이어쓰기/재실행**이 됩니다. 새로 돌리려면 study-name을 바꾸세요.


<timestamp>Friday, Aug 21, 2026, 7:31 PM (UTC+9)</timestamp>
<user_query>
Python
PS C:\project\kis_bot> $env:PYTHONUNBUFFERED = '1'
PS C:\project\kis_bot> $env:PYTHONPATH = (Get-Location).Path
PS C:\project\kis_bot> $env:DB_HOST = '192.168.0.141'
PS C:\project\kis_bot> python -u kis_trader/backtest/param_search_optuna.py --strategy momentum --mode tpe --start 2026-08-18 --end 2026-08-21 --trials 100 --min_trades 1 --min_win_rate 0 --min_pf 0 --orderbook-filter off --no-progress --study-name momentum_tpe_20260818_20260821_20260821_193005 --sort-by score --universe-history-source kiwoom --study-trials 200
Python
PS C:\project\kis_bot>
이건 돌고있냐?
</user_query>


ps -eo pid,etime,user,cmd | rg -i 'param_search_optuna|momentum_tpe_20260818_20260821' | rg -v rg || echo 'NO_OPTUNA_PROC_ON_SERVER'; echo '---'; ls -lt /home/hoon/kis_bot/logs/*193005* /home/hoon/kis_bot/logs/*20260821_19* 2>/dev/null | head -20; ls -lt /home/hoon/kis_bot/logs/optuna*momentum* 2>/dev/null | head -8; echo '---'; rg -l 'momentum_tpe_20260818_20260821_20260821_193005' /home/hoon/kis_bot/logs /home/hoon/kis_bot/kis_trader/backtest/results 2>/dev/null | head -10; date; python3 - <<'PY'
from datetime import date
try:
    from kis_trader.utils.kr_trading_day import kr_trading_day, is_kr_trading_day
except Exception:
    try:
        from database import TradeDB
        print('no helper')
    except Exception as e:
        print('import fail', e)
# quick: weekday
print('today', date.today(), 'weekday', date.today().weekday())  # Fri=4
PY

Check if new Optuna study is running


tail -n 35 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log; echo '===='; ps -p 51365 -o pid,etime,pcpu,pmem,cmd --no-headers 2>/dev/null || echo 'PID_GONE'

Tail Optuna log and confirm PID alive


**돌고 있습니다.** PID `51365`, CPU ~74%, 방금 웜업 끝내고 `ws_ticks` 로딩 중.

- study: `momentum_tpe_20260818_20260821_20260821_193005`
- 로그: `logs/optuna_web_momentum_20260821_193005.log`
  → `tail -f logs/optuna_web_momentum_20260821_193005.log`

PowerShell에 프롬프트가 바로 돌아온 건, 서버(리눅스)에서 백그라운드/웹잡으로 뜬 걸로 보이면 됩니다. 로컬 Windows 창이 비어도 **서버 쪽은 진행 중**입니다.


<timestamp>Friday, Aug 21, 2026, 7:32 PM (UTC+9)</timestamp>
<user_query>
아니 윈도우는 그냥 python하고 끝나서 이게 진행중인지 모르겠어
</user_query>


Windows에서 바로 끝난 건지, 서버에서만 도는지 로그·프로세스로 바로 확인하겠습니다.

ps -p 51365 -o pid,etime,pcpu,cmd --no-headers 2>/dev/null || echo 'PID_GONE'; echo '---'; head -n 40 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log; echo '===='; tail -n 25 /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log; echo '===='; ls -lt /home/hoon/kis_bot/logs/optuna_web_jobs/*193005* 2>/dev/null; python3 - <<'PY'
import json
from pathlib import Path
p=Path('/home/hoon/kis_bot/logs/optuna_web_jobs/opt_20260821_193005_mome.json')
if p.exists():
    d=json.loads(p.read_text())
    print('job status', d.get('status'), 'pid', d.get('pid'), 'progress', d.get('progress'), 'log', d.get('log_path'))
else:
    print('no web job meta')
    for f in Path('/home/hoon/kis_bot/logs/optuna_web_jobs').glob('*193005*'):
        print(f)
PY

Check job origin, status, log progress


Windows에서 `python` 치고 바로 `PS>`로 돌아온 건 **Windows 쪽은 끝난(또는 거의 바로 죽은) 상태**입니다. 그 창만 보면 진행 여부를 알 수 없는 게 정상이에요.

지금 돌아가는 건 **리눅스 서버(웹 Optuna 잡)** 입니다.
- 상태: `running` · trial **6/100**
- 로그: `logs/optuna_web_momentum_20260821_193005.log`
- 웹: `http://192.168.0.149:5050/#optuna` → 최근 잡에서 해당 잡「보기」

**진행 보는 법 (택1)**
1. 웹 Optuna 탭 프로그레스바
2. SSH/서버에서
   `tail -f /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log`

Windows에서 직접 돌리려면 프롬프트가 **끝날 때까지 안 돌아와야** 합니다. 바로 돌아오면 에러로 종료된 거라, 그때는 출력 에러 메시지를 보시면 됩니다.


<timestamp>Friday, Aug 21, 2026, 7:33 PM (UTC+9)</timestamp>
<user_query>
파워쉘에서 그럼 왜 멈췄는지 보려면?
</user_query>


PowerShell에서 **왜 바로 끝났는지** 보려면, 아래 순서로 하면 됩니다.

### 1) 종료 코드 확인
같은 창에서 명령 직후:

```powershell
echo $LASTEXITCODE
```

- `0` = 정상 종료 (일이 짧게 끝났거나, 이미 끝난 study를 바로 마친 경우)
- `0`이 아님 = 에러로 죽음

### 2) 출력을 파일로 남기기 (가장 확실)
에러가 스크롤로 날아가면 안 보이니까:

```powershell
$env:PYTHONUNBUFFERED = '1'
$env:PYTHONPATH = (Get-Location).Path
$env:DB_HOST = '192.168.0.141'

python -u kis_trader/backtest/param_search_optuna.py `
  --strategy momentum --mode tpe `
  --start 2026-08-18 --end 2026-08-21 `
  --trials 100 --min_trades 1 --min_win_rate 0 --min_pf 0 `
  --orderbook-filter off --no-progress `
  --study-name momentum_tpe_win_test_$(Get-Date -Format 'yyyyMMdd_HHmmss') `
  --sort-by score --universe-history-source kiwoom --study-trials 200 `
  *>&1 | Tee-Object -FilePath .\logs\optuna_win_console.log

echo "EXIT=$LASTEXITCODE"
```

그다음:

```powershell
Get-Content .\logs\optuna_win_console.log -Tail 80
```

`Traceback`, `ModuleNotFoundError`, `Connection refused`, DB 접속 실패 같은 줄이 원인입니다.

### 3) “바로 프롬프트만 돌아옴”일 때
- **한 줄씩** 치고, `python -u ...` 치는 동안 창이 멈추는지 보세요.
- `python`이 다른 파이썬이면 바로 죽을 수 있음:

```powershell
where.exe python
python -c "import sys; print(sys.executable); print(sys.version)"
```

### 참고
지금 서버에서 도는 잡(`…193005`)은 **웹/리눅스**라서, Windows PowerShell이 멈춘 것과는 별개입니다.
Windows에서 왜 죽었는지는 **위 로그 파일**이 진실이고, 서버 진행은 웹 Optuna 또는 `tail -f` 로그로 보시면 됩니다.


<timestamp>Friday, Aug 21, 2026, 7:35 PM (UTC+9)</timestamp>
<user_query>
Get-Content .\logs\optuna_win_console.log -Tail 80
</user_query>


ls -la /home/hoon/kis_bot/logs/optuna_win_console.log 2>/dev/null; ls -la /mnt/c/project/kis_bot/logs/optuna_win_console.log 2>/dev/null; ls -la /home/hoon/kis_bot/logs/optuna_win* 2>/dev/null; find /home/hoon -name 'optuna_win_console.log' 2>/dev/null | head -5

Find Windows Optuna console log if synced


<timestamp>Friday, Aug 21, 2026, 7:35 PM (UTC+9)</timestamp>
<user_query>
PS C:\project\kis_bot> python -u kis_trader/backtest/param_search_optuna.py --strategy momentum --mode tpe --start 2026-08-18 --end 2026-08-21 --trials 100 --min_trades 1 --min_win_rate 0 --min_pf 0 --orderbook-filter off --no-progress --study-name momentum_tpe_20260818_20260821_20260821_193005 --sort-by score --universe-history-source kiwoom --study-trials 200
Python
PS C:\project\kis_bot> echo "EXIT=$LASTEXITCODE"
EXIT=9009
PS C:\project\kis_bot> Get-Content .\logs\optuna_win_console.log -Tail 80
python : Python
위치 줄:1 문자:1
+ python -u kis_trader/backtest/param_search_optuna.py `
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (Python :String) [], RemoteException
    + FullyQualifiedErrorId : NativeCommandError

PS C:\project\kis_bot>
</user_query>


**원인: Windows에서 `python` 실행 파일이 없음** (`EXIT=9009` = 명령을 못 찾음).

로그에 `Python`만 찍히고 스크립트가 안 도는 건, 보통 **Microsoft Store용 `python` 가짜 실행 파일(앱 실행 별칭)** 이거나 PATH에 진짜 `python.exe`가 없을 때입니다. Optuna가 실패한 게 아니라 **파이썬 자체가 안 뜬 것**입니다.

### 확인
```powershell
where.exe python
where.exe py
py -3 -c "import sys; print(sys.executable); print(sys.version)"
```

### 해결 (되는 쪽 쓰기)
Store 별칭이면:
- 설정 → 앱 → 고급 앱 설정 → **앱 실행 별칭** → `python.exe` / `python3.exe` **끄기**
- 그다음 [python.org](https://www.python.org/downloads/) 설치 시 **Add python.exe to PATH** 체크

또는 바로:
```powershell
py -3 -u kis_trader/backtest/param_search_optuna.py --strategy momentum ...
```

또는 venv 풀경로 (서버와 맞추려면 로컬에 `.venv`가 있어야 함):
```powershell
.\.venv\Scripts\python.exe -u kis_trader\backtest\param_search_optuna.py ...
```

참고: 서버에서 `…193005` 잡은 **이미 리눅스에서 돌아가는 중**이라, Windows에서 같은 study-name으로 또 돌릴 필요는 없습니다. Windows에서 돌릴 거면 **새 study-name** + 위처럼 **진짜 python**으로 실행하세요.


<timestamp>Friday, Aug 21, 2026, 7:37 PM (UTC+9)</timestamp>
<user_query>
venv 들어가서 pip install좀 해야겠어
</user_query>


프로젝트 의존성 파일을 확인한 뒤, Windows venv에서 쓸 pip 명령을 정리하겠습니다.

ls -la /home/hoon/kis_bot/.venv/bin/python 2>/dev/null; ls -la /home/hoon/kis_bot/requirements*.txt /home/hoon/kis_bot/pyproject.toml 2>/dev/null; head -5 /home/hoon/kis_bot/requirements.txt 2>/dev/null; wc -l /home/hoon/kis_bot/requirements*.txt 2>/dev/null

Locate venv and requirements files


Windows PowerShell에서 이렇게 하면 됩니다.

```powershell
cd C:\project\kis_bot

# venv 없으면 생성 (이미 있으면 스킵)
py -3 -m venv .venv

# 활성화
.\.venv\Scripts\Activate.ps1

# 실행 정책 막히면 한 번만:
# Set-ExecutionPolicy -Scope CurrentUser RemoteSigned

python -c "import sys; print(sys.executable)"

python -m pip install -U pip
python -m pip install -r requirements.txt
```

키움 관련까지 필요하면:

```powershell
python -m pip install -r requirements-kiwoom.txt
```

그다음 Optuna는 **활성화된 상태**에서:

```powershell
python -u kis_trader\backtest\param_search_optuna.py ...
```

또는 활성화 없이:

```powershell
.\.venv\Scripts\python.exe -u kis_trader\backtest\param_search_optuna.py ...
```

참고: 서버 `…193005` 잡은 이미 리눅스에서 도는 중이라, Windows venv 설치와는 별개입니다. Windows에서 새로 돌릴 때만 새 `--study-name` 쓰세요.


<timestamp>Friday, Aug 21, 2026, 7:40 PM (UTC+9)</timestamp>
<user_query>
엥 이 두 파일은 형상관리에 안뜨지
</user_query>


cd /home/hoon/kis_bot && git check-ignore -v requirements.txt requirements-kiwoom.txt 2>&1; echo '---'; git ls-files requirements.txt requirements-kiwoom.txt 2>&1; echo '---'; git status --short requirements.txt requirements-kiwoom.txt 2>&1; rg -n 'requirements' .gitignore 2>/dev/null | head -20

Check if requirements files are gitignored/tracked


cd /home/hoon/kis_bot && git log -1 --oneline -- requirements.txt requirements-kiwoom.txt; git ls-files -s requirements.txt requirements-kiwoom.txt

Show last commit tracking requirements files


둘 다 **이미 형상관리(git)에 들어가 있습니다.** ignore도 아닙니다.

- `requirements.txt`
- `requirements-kiwoom.txt`

소스 컨트롤에 **안 뜨는 이유**는 보통 “변경이 없어서”입니다. 수정·추가된 파일만 Changes에 보이거든요.

확인:
```powershell
git ls-files requirements.txt requirements-kiwoom.txt
git status -- requirements.txt requirements-kiwoom.txt
```

내용 보려면:
```powershell
git show HEAD:requirements.txt | Select-Object -First 20
```


<timestamp>Friday, Aug 21, 2026, 7:41 PM (UTC+9)</timestamp>
<user_query>
  Using cached multidict-6.7.1-cp312-cp312-win_amd64.whl.metadata (5.5 kB)
Collecting multitasking==0.0.12 (from -r requirements.txt (line 90))
  Using cached multitasking-0.0.12-py3-none-any.whl
ERROR: Could not install packages due to an OSError: [Errno 13] Permission denied: 'C:\\users\\hjhvk\\appdata\\local\\pip\\cache\\wheels\\cc\\bd\\6f\\664d62c99327abeef7d86489e6631cbf45b56fbf7ef1d6ef00\\multitasking-0.0.12-py3-none-any.whl'
Check the permissions.

(.venv) PS C:\project\kis_bot> 오류나서 안된듯
</user_query>


pip **캐시 폴더 권한** 문제입니다. 패키지/venv 문제가 아닙니다.

같은 venv에서 이렇게 다시 하세요:

```powershell
# 캐시 안 쓰고 설치 (가장 빠름)
python -m pip install -U pip
python -m pip install --no-cache-dir -r requirements.txt
```

그래도 같은 오류면 캐시 지우고:

```powershell
python -m pip cache purge
# 안 지워지면 탐색기/관리자 PowerShell에서:
# Remove-Item -Recurse -Force "$env:LOCALAPPDATA\pip\cache"

python -m pip install --no-cache-dir -r requirements.txt
```

참고: `Permission denied`가 `...\pip\cache\wheels\...` 이면 **캐시만** 막힌 거라 `--no-cache-dir`로 대부분 통과합니다.


<timestamp>Friday, Aug 21, 2026, 7:48 PM (UTC+9)</timestamp>
<user_query>
오 이쯤되면 궁금한거 지금 양쪽 돌렸어 근데 웹이 끝났어 윈도우 돌아가는중 여기서 이어돌리기를 누른다면?
</user_query>


웹「이어돌리기」가 같은 study를 어떻게 이어받는지 코드에서 확인합니다.

ps -eo pid,etime,cmd | rg -i 'param_search_optuna|193005' | rg -v rg || echo 'NO_PROC'; ls -lt /home/hoon/kis_bot/logs/optuna_web_jobs/*193005* 2>/dev/null; python3 - <<'PY'
import json
from pathlib import Path
for p in sorted(Path('/home/hoon/kis_bot/logs/optuna_web_jobs').glob('*193005*')):
    d=json.loads(p.read_text())
    print(p.name, d.get('status'), d.get('study_name') or d.get('study'), 'trials', (d.get('progress') or {}).get('trials_done'), '/', (d.get('progress') or {}).get('trials_total'))
# latest momentum jobs
jobs=sorted(Path('/home/hoon/kis_bot/logs/optuna_web_jobs').glob('opt_*.json'), key=lambda x: x.stat().st_mtime, reverse=True)[:5]
for p in jobs:
    d=json.loads(p.read_text())
    if 'momentum' in str(d.get('strategy') or d.get('label') or p.name).lower() or 'mome' in p.name:
        print('recent', p.name, d.get('status'), d.get('study_name'), (d.get('progress') or {}) )
PY

Check resume semantics and current job status


python3 - <<'PY'
from kis_trader.backtest.optuna_study_store import flags_for_web
name='momentum_tpe_20260818_20260821_20260821_193005'
fl=flags_for_web(name)
print({k: fl.get(k) for k in ('n_complete','n_trials','study_trials','leftover_trials','can_continue','status')})
PY

Check leftover trials for shared study


**이어 돌리기**를 누르면 이렇게 됩니다.

1. **같은 study 이름** (`…193005`)으로
2. **남은 횟수만큼** 웹 잡을 **새로** 띄웁니다.
   지금 DB 기준: 완료 **100** / 목표 **200** → 남은 **100회**
3. 결과·trial은 MariaDB(`kis_optuna`)에 **이어서** 쌓입니다.

### 윈도우가 같은 study로 아직 도는 중이면
- 웹 이어돌리기 = **윈도우와 동시에 같은 study에 trial을 씀** (병렬 워커)
- Optuna RDB는 여러 프로세스가 같은 study에 쓰는 건 가능하지만,
  **CPU/DB/틱로드가 두 배** · 진행률이 서로 꼬여 보일 수 있음
- **권장**: 한쪽만 돌리기
  - 윈도우 끝날 때까지 웹 이어돌리기 **누르지 않기**, 또는
  - 윈도우 중단 후 웹에서만 이어돌리기

### 「확정」과의 차이
- **이어 돌리기**: 목표 200 유지, 남은 100 더 돌림
- **확정**: 지금 완료 수(100)로 목표를 줄이고 후처리 — 확인창에도 “윈도우/추가 웹 이어가기는 포기”라고 나옴

정리: 웹이 끝났다 = **이 웹 잡의 100회는 끝**. study 목표는 200이라 이어돌리기는 가능. 윈도우가 같은 이름을 쓰면 **지금 누르지 말고** 한쪽만 쓰는 게 안전합니다.


<timestamp>Friday, Aug 21, 2026, 7:49 PM (UTC+9)</timestamp>
<user_query>
0 1회 재시도, DB 미기록)
[19:47:32] [KISWebSocket] ✅ 키움 토큰 발급 완료 [실전] (앞8자: lFocqrCQ…, 만료: 2026-08-22 19:30:52)
[19:47:33] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 003480 700봉
[19:47:33] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 003480: n=700 → n=1500 (전일 종가 미확보)
[19:47:34] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 003480 1500봉
[19:47:35] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 017800 700봉
[19:47:35] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 017800: n=700 → n=1500 (전일 종가 미확보)
[19:47:36] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 017800 1500봉
[19:47:36] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 036800 700봉
[19:47:36] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 036800: n=700 → n=1500 (전일 종가 미확보)
[19:47:38] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 036800 1500봉
[19:47:38] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 038290 700봉
[19:47:38] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 038290: n=700 → n=1500 (전일 종가 미확보)
[19:47:39] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 038290 1500봉
[19:47:40] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 039860 700봉
[19:47:40] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 039860: n=700 → n=1500 (전일 종가 미확보)
[19:47:41] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 039860 1500봉
[19:47:42] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 041190 700봉
[19:47:42] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 041190: n=700 → n=1500 (전일 종가 미확보)
[19:47:43] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 041190 1500봉
[19:47:44] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 047770 700봉
[19:47:44] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 047770: n=700 → n=1500 (전일 종가 미확보)
[19:47:45] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 047770 1500봉
[19:47:45] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 053030 700봉
[19:47:45] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 053030: n=700 → n=1500 (전일 종가 미확보)
[19:47:47] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 053030 1500봉
[19:47:47] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 054930 700봉
[19:47:47] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 054930: n=700 → n=1500 (전일 종가 미확보)
[19:47:48] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 054930 1500봉
[19:47:49] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 064550 700봉
[19:47:49] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 064550: n=700 → n=1500 (전일 종가 미확보)
[19:47:50] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 064550 1500봉
[19:47:51] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 083310 700봉
[19:47:51] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 083310: n=700 → n=1500 (전일 종가 미확보)
[19:47:52] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 083310 1500봉
[19:47:53] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 094480 700봉
[19:47:53] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 094480: n=700 → n=1500 (전일 종가 미확보)
[19:47:54] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 094480 1500봉
[19:47:55] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 104620 700봉
[19:47:55] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 104620: n=700 → n=1500 (전일 종가 미확보)
[19:47:56] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 104620 1500봉
[19:47:56] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 123420 700봉
[19:47:57] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 138610 700봉
[19:47:57] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 138610: n=700 → n=1500 (전일 종가 미확보)
[19:47:58] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 138610 1500봉
[19:47:59] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 163280 700봉
[19:47:59] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 163280: n=700 → n=1500 (전일 종가 미확보)
[19:48:00] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 163280 1500봉
[19:48:01] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 226320 700봉
[19:48:01] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 226320: n=700 → n=1500 (전일 종가 미확보)
[19:48:02] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 226320 1500봉
[19:48:03] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 377300 700봉
[19:48:03] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 377300: n=700 → n=1500 (전일 종가 미확보)
[19:48:04] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 377300 1500봉
[19:48:04] [KISWebSocket] ✅ 키움 1M 갭보정 데이터: 408470 700봉
[19:48:04] [kis_trader.momentum_backtest] 📡 REST 웜업 재시도 408470: n=700 → n=1500 (전일 종가 미확보)

이게 뭐야 잘못 받아온거같은데 빼먹고 돌리느거 아냐? 할수 잇는데
</user_query>


REST 웜업 700→1500 재시도가 ‘빼먹고 돌리는지’ 코드에서 확인합니다.

Grep

REST 웜업 재시도|전일 종가 미확보|n=700|n=1500|모멘텀 REST 웜업

Shell

rg -n 'ok=|fail=|REST 웜업' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log | tail -20; echo '---'; rg -n '전일 종가 미확보|웜업 완료|틱 전무' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log | head -30; rg -n '전일 종가 미확보|웜업 완료|틱 전무' /home/hoon/kis_bot/logs/optuna_web_momentum_20260821_193005.log | tail -15

Count warmup ok/fail in finished job log


잘못 받아온 게 아니라, **설계된 정상 동작**입니다.

### 이게 뭐냐
모멘텀 백테 시작 전에 DB 분봉에 **전일(직전 세션) 종가**가 없는 종목만, 키움 REST(`ka10080`)로 **메모리에만** 보강합니다. DB에는 안 씁니다.

1. **1차 n=700** 받아봄
2. 그래도 전일 종가가 안 잡히면 → **2차 n=1500 한 번만** 재시도
3. 로그의 `전일 종가 미확보` = 1차 700으로는 부족해서 늘려 받는 중

규칙상도 “평소 700, 전일 안 잡힐 때만 1500”이 맞습니다. 전 종목을 처음부터 1500으로 때리면 API만 폭주합니다.

### 빼먹고 도냐?
- **629종목 전체**를 REST로 도는 게 아님. “전일봉 부족한 종목”만 (그 잡: need≈23).
- 웹 잡 결과 예: `ok=18 fail=5` → 18개는 보강 성공, **5개만** 전일 종가 끝까지 못 잡음.
- 그 5개는 전일 기준이 필요한 신호에서 불리할 수 있지만, **나머지·틱 있는 종목은 그대로 trial 진행**합니다.
- 별도로 `틱 전무 종목 8개 제외`는 REST와 다른 이야기(틱 없는 종목 스킵).

### “할 수 있는데”에 대해
700에 전일이 안 보이는 건 종종 **기간 앞쪽에 빈 슬롯/휴장·상장 직후·REST 구간이 짧음** 때문입니다. 그래서 1500으로 한 번 더 보는 겁니다.
그래도 fail인 종목은 키움이 더 안 주거나, 파싱 기준으로 전일이 안 잡히는 케이스입니다.

**정리:** 저 로그 줄줄이 = 버그로 빼먹는 중이 아니라, **부족한 종목만 700→1500으로 보강 중**. 윈도우도 같은 준비 단계라 같은 로그가 나옵니다.


<timestamp>Friday, Aug 21, 2026, 7:54 PM (UTC+9)</timestamp>
<user_query>
1500봉이나 받아왔는데 전일종가 못가져오는거면 매우 활발한 종목이라는건데 무조건 가져와야지 봉을 더 가져와야지 그리고 맨처음 틱 채우는것도 어디 저장해놔야 윈도우에서도 가져다 쓰고 하지않나 윈도우에서 또 틱 수집하면 또 시간낭비 아닌가
</user_query>


전일종가 실패 원인과 틱/웜업 공유 여부를 코드에서 확인한 뒤, 설계부터 짧게 보고하겠습니다.


맞아요, 그 두 가지는 각각 이렇게 이해하면 됩니다.

### 1) 1500인데도 전일종가 실패
활발해서 못 가져오는 게 **아닙니다.**
`ka10080`은 “최근 N봉”이라, 1차로 부족하면 1500으로 **한 번만** 더 보고, 그래도 안 되면 **영구 실패로 찍고 스킵**합니다. (API 폭주 막으려고 상한을 둔 설계)

그래서:
- 로그의 `700→1500` = 잘못 받은 게 아니라 **재시도 중**
- `fail=5` = 1500까지 해도 전일 종가 판정이 안 된 **소수 종목** (전량 스킵이 아님)

원하시는 “무조건 더 받아서라도 확보”는 **가능**합니다. 다만 지금은 2단(700→1500) 고정이라,
예를 들어 **실패 종목만 3차(예: 3000) env** 를 두는 식으로 바꿔야 합니다.
(처음부터 전 종목 3000은 규칙상 비추 — 실패 시에만 증량)

### 2) 틱 — 윈도우가 “또 수집”하냐?
**시세 수집(WS)을 다시 하는 게 아닙니다.**
양쪽 다 MariaDB `ws_ticks`에서 **SELECT로 RAM에 올리는** 겁니다.
서버가 이미 DB에 넣어 둔 틱을, 윈도우가 **같은 DB에서 또 읽느라** 시간이 드는 거예요.

- Optuna study/trial → DB 공유 ✅
- 틱 **원본** → DB 공유 ✅
- 프로세스 안 공유메모리 → **한 PC 안** Optuna 워커용 (윈도우↔리눅스 공유 ❌)
- REST 웜업 prefix → **메모리만**, DB 미기록 → 윈도우가 **또 REST** 함 ❌

### 정리 / 다음에 손대려면
| 항목 | 지금 | 개선 방향 (승인 후) |
|------|------|-------------------|
| 전일종가 | 1500 후 포기 | 실패 종목만 3차 더 받기 |
| REST 결과 | 프로세스 메모리 | 공유 캐시(파일/DB)로 Win·서버 재사용 |
| 틱 | DB에서 매번 로드 | (선택) 기간 해시별 디스크 캐시 — 이득은 크지만 용량·정합 주의 |

원하시면 **① 실패 종목만 봉 더 받기**부터 할지, **② REST/로드 결과 공유 캐시**까지 할지 정해 주시면 그 범위로 설계·패치하겠습니다.


<timestamp>Friday, Aug 21, 2026, 7:57 PM (UTC+9)</timestamp>
<user_query>
오메이 진행 종가 받을때까지 시도해야겠는데 어떤가?
</user_query>


**방향은 맞습니다.** 전일종가 나올 때까지 보강하는 게 모멘텀(HTS K) 정합에 맞고, “활발해서 실패”가 아니라 **봉 구간이 짧아서**인 경우가 많아요.

다만 **무한 재시도는 비추**입니다. 키움 유량·한도 때문에 안 되는 종목을 끝까지 때리면 Optuna 준비가 멈추거나 계정이 막힙니다.

### 추천 설계
1. **1차 700** (지금과 동일)
2. 전일종가 없으면 **2차 1500** (지금과 동일)
3. 그래도 없으면 **실패 종목만** `n`을 올려 가며 추가 시도
   - 예: 2500 → 4000 (값은 전부 `get_env_int`)
4. **상한**: 최대 시도 횟수 / 최대 봉수 / 종목당 실패 후 정지
5. 그래도 안 되면 지금처럼 perm fail + 로그만 (trial은 계속)

즉 “나올 때까지” ≈ **합리적 상한 안에서 끝까지**, 무한 루프는 아님.

### 같이 하면 좋은 것
성공한 REST prefix를 **프로세스만이 아니라 짧은 공유 캐시**(기간일×종목)에 두면, 윈도우가 같은 보강을 또 REST로 안 해도 됩니다. 틱은 이미 DB라 “수집”이 아니라 **로드 시간**만 중복입니다.

원하시면 이 범위로 패치 들어가겠습니다.
(기본: 3~4단 증량 + env 상한, DB 강제 INSERT는 기본 OFF)