Write

#!/usr/bin/env python3
"""Optuna 스터디 결과 · 총 횟수 후처리 게이트 (kis_quant_db.optuna_study_result).

kis_optuna 공식 studies/trials 는 건드리지 않는다.
실매 봇 루프에 넣지 않는다.
"""
from __future__ import annotations

import json
import logging
import os
import socket
from typing import Any, Callable, Dict, Optional, Tuple

logger = logging.getLogger("optuna_study_store")

KIS_STUDY_TRIALS_ATTR = "kis_study_trials"

_DDL = """
CREATE TABLE IF NOT EXISTS optuna_study_result (
  study_name          VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)',
  strategy            VARCHAR(32)  NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp',
  mode                VARCHAR(32)  NOT NULL DEFAULT '' COMMENT 'tpe 등',
  start_date          VARCHAR(10)  NULL COMMENT '백테 시작 YYYY-MM-DD',
  end_date            VARCHAR(10)  NULL COMMENT '백테 종료 YYYY-MM-DD',
  job_id              VARCHAR(64)  NULL COMMENT '웹 잡 id (참고)',
  symbol              VARCHAR(16)  NULL COMMENT '해외 종목핀일 때만',
  study_trials        INT          NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정',
  n_complete          INT          NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시',
  n_running           INT          NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)',
  payload_json        LONGTEXT     NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit (웹 요약과 동일 키)',
  briefing_md         MEDIUMTEXT   NULL COMMENT '브리핑 마크다운',
  pp_needed           TINYINT      NOT NULL DEFAULT 0 COMMENT '1=목표 도달, 후처리 대기',
  pp_status           VARCHAR(16)  NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error',
  pp_for_n            INT          NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete',
  pp_lock_until       DATETIME     NULL COMMENT '락 만료 (죽은 워커 복구)',
  pp_lock_owner       VARCHAR(64)  NULL COMMENT '잠근 호스트/pid',
  pp_error            VARCHAR(500) NULL COMMENT '후처리 실패 메시지',
  leftover_note       VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시',
  updated_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신',
  created_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성',
  PRIMARY KEY (study_name),
  KEY idx_pp_needed (pp_needed, pp_status),
  KEY idx_job (job_id),
  KEY idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)'
"""

def _db():
    from database import TradeDB

    return TradeDB()

def ensure_optuna_study_result_table() -> None:
    db = _db()
    db.conn.execute_long(_DDL, timeout_sec=120)

def count_study_states(study: Any) -> Tuple[int, int]:
    n_complete = 0
    n_running = 0
    try:
        import optuna
        complete = optuna.trial.TrialState.COMPLETE
        running = optuna.trial.TrialState.RUNNING
    except Exception:
        complete = running = None
    for t in list(getattr(study, "trials", None) or []):
        st = getattr(t, "state", None)
        if complete is not None and st == complete:
            n_complete += 1
        elif running is not None and st == running:
            n_running += 1
    return n_complete, n_running

def parse_study_trials_value(raw: Any) -> int:
    """빈값/0 = 미사용. 음수 없음."""
    if raw is None:
        return 0
    s = str(raw).strip()
    if not s or s.lower() in ("none", "null"):
        return 0
    try:
        n = int(float(s))
    except (TypeError, ValueError):
        return 0
    return max(0, n)

def resolve_cli_study_trials(cli_value: Any = None) -> int:
    """CLI > 프로세스 env. DB 전역 기본 200 넣지 않음."""
    n = parse_study_trials_value(cli_value)
    if n > 0:
        return n
    n = parse_study_trials_value(os.environ.get("KIS_OPTUNA_STUDY_TRIALS"))
    if n > 0:
        return n
    n = parse_study_trials_value(os.environ.get("PARAM_SEARCH_OPTUNA_STUDY_TRIALS"))
    if n > 0:
        return n
    try:
        from kis_trader.utils.env import get_env_from_db

        n = parse_study_trials_value(get_env_from_db("PARAM_SEARCH_OPTUNA_STUDY_TRIALS", "") or "")
    except Exception:
        n = 0
    return n

def apply_kis_study_trials(
    study: Any,
    *,
    n_trials: int,
    log: Optional[logging.Logger] = None,
    cli_study_trials: Optional[int] = None,
) -> int:
    """첫 워커가 user_attr 기록. 이후 워커는 저장된 목표 유지(더 작은 값으로 덮지 않음)."""
    lg = log or logger
    requested = parse_study_trials_value(
        cli_study_trials if cli_study_trials is not None else resolve_cli_study_trials()
    )
    fallback = max(1, int(n_trials or 0))
    stored = 0
    try:
        raw = (study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR)
        stored = parse_study_trials_value(raw)
    except Exception:
        stored = 0
    if stored > 0:
        if requested > 0 and requested != stored:
            lg.warning(
                "⚠ kis_study_trials=%s 유지 (요청 %s 무시 — 목표를 낮춰 조기 후처리하지 않음)",
                stored, requested,
            )
        return stored
    target = requested if requested > 0 else fallback
    try:
        study.set_user_attr(KIS_STUDY_TRIALS_ATTR, int(target))
    except Exception as exc:
        lg.warning("⚠ kis_study_trials user_attr 저장 실패: %s", exc)
    lg.info("📌 스터디 총 횟수 목표 kis_study_trials=%s (이번 --trials=%s)", target, n_trials)
    return int(target)

def bind_study_trials(study: Any, *, n_trials: int, log: Optional[logging.Logger] = None) -> int:
    return apply_kis_study_trials(study, n_trials=n_trials, log=log)

def leftover_trials(study_trials: int, n_complete: int) -> int:
    t = int(study_trials or 0)
    c = int(n_complete or 0)
    if t <= 0:
        return 0
    return max(0, t - c)

def leftover_note(study_trials: int, n_complete: int) -> str:
    left = leftover_trials(study_trials, n_complete)
    t = int(study_trials or 0)
    c = int(n_complete or 0)
    if t <= 0:
        return ""
    if left > 0:
        return "남은 %s회 (완료 %s / 목표 %s)" % (left, c, t)
    return "목표 도달 (완료 %s / 목표 %s)" % (c, t)

def _owner() -> str:
    host = (socket.gethostname() or "host")[:40]
    return ("%s:%s" % (host, os.getpid()))[:64]

def _lock_minutes() -> int:
    try:
        from kis_trader.utils.env import get_env_int

        return max(1, int(get_env_int("OPTUNA_STUDY_PP_LOCK_MINUTES", 30)))
    except Exception:
        return 30

def load_row(study_name: str) -> Optional[Dict[str, Any]]:
    name = str(study_name or "").strip()
    if not name:
        return None
    try:
        ensure_optuna_study_result_table()
        cur = _db().conn.execute(
            "SELECT * FROM optuna_study_result WHERE study_name=%s",
            (name,),
        )
        row = cur.fetchone()
        return dict(row) if row else None
    except Exception as exc:
        logger.warning("⚠ optuna_study_result 조회 실패: %s", exc)
        return None

def load_payload_dict(study_name: str) -> Optional[Dict[str, Any]]:
    row = load_row(study_name)
    if not row:
        return None
    raw = row.get("payload_json")
    if not raw:
        return None
    try:
        data = json.loads(raw)
        return data if isinstance(data, dict) else None
    except Exception:
        return None

def upsert_counts(
    *,
    study_name: str,
    strategy: str = "",
    mode: str = "",
    start: str = "",
    end: str = "",
    job_id: str = "",
    symbol: str = "",
    study_trials: int = 0,
    n_complete: int = 0,
    n_running: int = 0,
) -> None:
    name = str(study_name or "").strip()
    if not name:
        return
    note = leftover_note(study_trials, n_complete)
    needed = 1 if leftover_trials(study_trials, n_complete) > 0 else 0
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = (symbol or "")[:16] or None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, n_running, leftover_note, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=VALUES(n_complete),
              n_running=VALUES(n_running),
              leftover_note=VALUES(leftover_note),
              pp_needed=VALUES(pp_needed)
            """,
            (
                name,
                str(strategy or "")[:32],
                str(mode or "")[:32],
                (start or "")[:10] or None,
                (end or "")[:10] or None,
                jid,
                sym,
                int(study_trials or 0),
                int(n_complete or 0),
                int(n_running or 0),
                note[:200] if note else None,
                int(needed),
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result upsert 실패: %s", exc)

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET payload_json=%s,
                briefing_md=%s,
                pp_status=%s,
                pp_error=%s,
                pp_for_n=IF(%s='done', %s, pp_for_n),
                pp_needed=IF(%s='done', 0, pp_needed),
                pp_lock_until=IF(%s='done' OR %s='error', NULL, pp_lock_until),
                pp_lock_owner=IF(%s='done' OR %s='error', NULL, pp_lock_owner)
            WHERE study_name=%s
            """,
            (
                payload,
                brief,
                str(pp_status or "idle")[:16],
                (str(pp_error)[:500] if pp_error else None),
                str(pp_status or ""),
                int(n_complete or 0),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                name,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def try_lock_postprocess(study_name: str) -> bool:
    name = str(study_name or "").strip()
    if not name:
        return False
    owner = _owner()
    mins = _lock_minutes()
    try:
        ensure_optuna_study_result_table()
        cur = _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET pp_status='running',
                pp_lock_owner=%s,
                pp_lock_until=DATE_ADD(NOW(), INTERVAL %s MINUTE),
                pp_error=NULL
            WHERE study_name=%s
              AND pp_status<>'running'
              AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
            """,
            (owner, int(mins), name),
        )
        return int(getattr(cur, "rowcount", 0) or 0) > 0
    except Exception as exc:
        logger.warning("⚠ 후처리 락 실패: %s", exc)
        return False

def set_study_trials_target(study_name: str, n: int, storage_url: Optional[str] = None) -> None:
    """확정: 목표를 현재 완료 수로 내린다."""
    name = str(study_name or "").strip()
    target = max(0, int(n or 0))
    if not name:
        return
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET study_trials=%s,
                leftover_note=%s,
                pp_needed=0
            WHERE study_name=%s
            """,
            (target, leftover_note(target, target), name),
        )
    except Exception as exc:
        logger.warning("⚠ study_trials 확정 실패: %s", exc)
    if storage_url:
        try:
            import optuna

            st = optuna.load_study(study_name=name, storage=storage_url)
            st.set_user_attr(KIS_STUDY_TRIALS_ATTR, target)
        except Exception as exc:
            logger.warning("⚠ kis_study_trials user_attr 확정 실패: %s", exc)

def should_run_postprocess(n_complete: int, study_trials: int) -> bool:
    t = int(study_trials or 0)
    if t <= 0:
        return True
    return int(n_complete or 0) >= t

def finalize_optuna_export(
    study: Any,
    *,
    out_data: Dict[str, Any],
    out_path: str,
    strategy: str,
    mode: str,
    enrich_fn: Callable[[], None],
    log: Optional[logging.Logger] = None,
    symbol: str = "",
) -> bool:
    """optimize 이후. 목표 미달이면 후처리 스킵, 도달이면 락 후 기존 enrich."""
    lg = log or logger
    name = str(getattr(study, "study_name", "") or out_data.get("optuna_study_name") or "")
    n_c, n_r = count_study_states(study)
    try:
        target = parse_study_trials_value((study.user_attrs or {}).get(KIS_STUDY_TRIALS_ATTR))
    except Exception:
        target = 0
    if target <= 0:
        target = max(1, int(out_data.get("optuna_n_trials_requested") or 0) or n_c)
    start = str(out_data.get("start") or "")
    end = str(out_data.get("end") or "")
    upsert_counts(
        study_name=name,
        strategy=str(out_data.get("strategy") or strategy or ""),
        mode=str(out_data.get("mode") or mode or ""),
        start=start,
        end=end,
        symbol=str(out_data.get("symbol") or symbol or ""),
        study_trials=target,
        n_complete=n_c,
        n_running=n_r,
    )
    out_data["optuna_study_trials"] = target
    out_data["optuna_n_complete"] = n_c
    row = load_row(name) or {}
    if str(row.get("pp_status") or "") == "done" and row.get("payload_json") and int(row.get("pp_for_n") or 0) >= target:
        lg.info("📌 후처리 이미 완료 (pp_for_n=%s, 목표=%s) — 스킵", row.get("pp_for_n"), target)
        return False
    if not should_run_postprocess(n_c, target):
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="idle",
        )
        lg.info(
            "📌 스터디 총 횟수 미달 — 후처리 스킵 (완료 %s / 목표 %s). 이어 돌리기 또는 확정.",
            n_c, target,
        )
        return False
    if not try_lock_postprocess(name):
        lg.info("📌 다른 워커가 후처리 중/완료 — 이 프로세스는 후처리 생략")
        return False
    try:
        enrich_fn()
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="done",
        )
        lg.info("📌 optuna_study_result payload 저장 study=%s n_complete=%s", name, n_c)
        return True
    except Exception as exc:
        save_payload(
            study_name=name,
            out_data=out_data,
            n_complete=n_c,
            pp_status="error",
            pp_error=str(exc),
        )
        raise

def flags_for_web(study_name: str) -> Dict[str, Any]:
    row = load_row(study_name) or {}
    t = int(row.get("study_trials") or 0)
    c = int(row.get("n_complete") or 0)
    left = leftover_trials(t, c)
    pp = str(row.get("pp_status") or "idle")
    has_payload = bool(row.get("payload_json"))
    return {
        "study_name": study_name,
        "study_trials": t,
        "n_complete": c,
        "n_running": int(row.get("n_running") or 0),
        "leftover_trials": left,
        "leftover_note": row.get("leftover_note") or leftover_note(t, c),
        "pp_status": pp,
        "pp_needed": int(row.get("pp_needed") or 0),
        "has_payload": has_payload,
        "can_continue": left > 0,
        "can_confirm": left > 0,
    }


StrReplace

            # 8b. env_config 컬럼 한도(Row size) 초과 키 — KIWOOM_WS_* · WHIPSAW_* 등
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS env_config_ext (
                    env_key VARCHAR(128) NOT NULL PRIMARY KEY,
                    env_value TEXT,
                    updated_at VARCHAR(30) NOT NULL
                ) CHARACTER SET utf8mb4
            """)

            # 8b. env_config 컬럼 한도(Row size) 초과 키 — KIWOOM_WS_* · WHIPSAW_* 등
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS env_config_ext (
                    env_key VARCHAR(128) NOT NULL PRIMARY KEY,
                    env_value TEXT,
                    updated_at VARCHAR(30) NOT NULL
                ) CHARACTER SET utf8mb4
            """)
            # 8b-2. Optuna 스터디별 웹 결과·후처리 (kis_optuna 공식 테이블과 분리)
            self.conn.execute_long("""
                CREATE TABLE IF NOT EXISTS optuna_study_result (
                  study_name          VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)',
                  strategy            VARCHAR(32)  NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp',
                  mode                VARCHAR(32)  NOT NULL DEFAULT '' COMMENT 'tpe 등',
                  start_date          VARCHAR(10)  NULL COMMENT '백테 시작 YYYY-MM-DD',
                  end_date            VARCHAR(10)  NULL COMMENT '백테 종료 YYYY-MM-DD',
                  job_id              VARCHAR(64)  NULL COMMENT '웹 잡 id (참고)',
                  symbol              VARCHAR(16)  NULL COMMENT '해외 종목핀일 때만',
                  study_trials        INT          NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정',
                  n_complete          INT          NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시',
                  n_running           INT          NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)',
                  payload_json        LONGTEXT     NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit (웹 요약과 동일 키)',
                  briefing_md         MEDIUMTEXT   NULL COMMENT '브리핑 마크다운',
                  pp_needed           TINYINT      NOT NULL DEFAULT 0 COMMENT '1=목표 도달, 후처리 대기',
                  pp_status           VARCHAR(16)  NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error',
                  pp_for_n            INT          NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete',
                  pp_lock_until       DATETIME     NULL COMMENT '락 만료 (죽은 워커 복구)',
                  pp_lock_owner       VARCHAR(64)  NULL COMMENT '잠근 호스트/pid',
                  pp_error            VARCHAR(500) NULL COMMENT '후처리 실패 메시지',
                  leftover_note       VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시',
                  updated_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신',
                  created_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성',
                  PRIMARY KEY (study_name),
                  KEY idx_pp_needed (pp_needed, pp_status),
                  KEY idx_job (job_id),
                  KEY idx_updated (updated_at)
                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
                  COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)'
            """, timeout_sec=120)

- [📋 주문 실행 로그](#-주문-실행-로그)

- [📋 주문 실행 로그](#-주문-실행-로그)
- [🔬 Optuna 스터디 결과](#-optuna-스터디-결과)

</details>

### 📡 실시간 마켓 데이터 (WebSocket / Market Data)

</details>

### 🔬 Optuna 스터디 결과

kis_optuna 의 `studies`/`trials` 와 분리. 웹 Top5·후처리 산출은 `kis_quant_db.optuna_study_result`.

#### `optuna_study_result`

**PK**: `study_name`

| 컬럼 | 타입 | NULL | DEFAULT | 비고 |
|------|------|------|---------|------|
| `study_name` 🔑 | `varchar(255)` | NOT NULL |  | Optuna study 이름 (kis_optuna 과 동일) |
| `strategy` | `varchar(32)` | NOT NULL | '' | tail\|momentum\|us_momentum\|breakout\|scalp |
| `mode` | `varchar(32)` | NOT NULL | '' | tpe 등 |
| `start_date` | `varchar(10)` | NULL | NULL | 백테 시작 YYYY-MM-DD |
| `end_date` | `varchar(10)` | NULL | NULL | 백테 종료 YYYY-MM-DD |
| `job_id` | `varchar(64)` | NULL | NULL | 웹 잡 id (참고) |
| `symbol` | `varchar(16)` | NULL | NULL | 해외 종목핀일 때만 |
| `study_trials` | `int` | NOT NULL | 0 | 스터디 총 완료 목표. 0이면 미설정 |
| `n_complete` | `int` | NOT NULL | 0 | 완료 trial 수 캐시 |
| `n_running` | `int` | NOT NULL | 0 | 진행 중 trial 수 캐시 |
| `payload_json` | `longtext` | NULL | NULL | 웹 요약과 동일 키 (results_*/mode_combo/postprocess) |
| `briefing_md` | `mediumtext` | NULL | NULL | 브리핑 마크다운 |
| `pp_needed` | `tinyint` | NOT NULL | 0 | 1=목표 미달(이어 돌리기/확정) |
| `pp_status` | `varchar(16)` | NOT NULL | idle | idle\|running\|done\|error |
| `pp_for_n` | `int` | NOT NULL | 0 | 이 후처리가 반영한 n_complete |
| `pp_lock_until` | `datetime` | NULL | NULL | 락 만료 |
| `pp_lock_owner` | `varchar(64)` | NULL | NULL | 잠근 호스트/pid |
| `pp_error` | `varchar(500)` | NULL | NULL | 후처리 실패 메시지 |
| `leftover_note` | `varchar(200)` | NULL | NULL | 남은횟수 안내 |
| `updated_at` | `datetime` | NOT NULL | CURRENT_TIMESTAMP | 행 갱신 |
| `created_at` | `datetime` | NOT NULL | CURRENT_TIMESTAMP | 최초 생성 |

<details>
<summary>DDL 원문</summary>

```sql
CREATE TABLE IF NOT EXISTS optuna_study_result (
  study_name VARCHAR(255) NOT NULL COMMENT 'Optuna study 이름 (kis_optuna 과 동일)',
  strategy VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tail|momentum|us_momentum|breakout|scalp',
  mode VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'tpe 등',
  start_date VARCHAR(10) NULL COMMENT '백테 시작 YYYY-MM-DD',
  end_date VARCHAR(10) NULL COMMENT '백테 종료 YYYY-MM-DD',
  job_id VARCHAR(64) NULL COMMENT '웹 잡 id (참고)',
  symbol VARCHAR(16) NULL COMMENT '해외 종목핀일 때만',
  study_trials INT NOT NULL DEFAULT 0 COMMENT '스터디 총 완료 목표. 0이면 미설정',
  n_complete INT NOT NULL DEFAULT 0 COMMENT 'kis_optuna 완료 trial 수 캐시',
  n_running INT NOT NULL DEFAULT 0 COMMENT '진행 중 trial 수 캐시 (표시용)',
  payload_json LONGTEXT NULL COMMENT 'results_all/gated/stable, mode_combo, postprocess_topn, overfit',
  briefing_md MEDIUMTEXT NULL COMMENT '브리핑 마크다운',
  pp_needed TINYINT NOT NULL DEFAULT 0 COMMENT '1=목표 도달 전 후처리 대기',
  pp_status VARCHAR(16) NOT NULL DEFAULT 'idle' COMMENT 'idle|running|done|error',
  pp_for_n INT NOT NULL DEFAULT 0 COMMENT '이 후처리가 반영한 n_complete',
  pp_lock_until DATETIME NULL COMMENT '락 만료 (죽은 워커 복구)',
  pp_lock_owner VARCHAR(64) NULL COMMENT '잠근 호스트/pid',
  pp_error VARCHAR(500) NULL COMMENT '후처리 실패 메시지',
  leftover_note VARCHAR(200) NULL COMMENT '웹 남은횟수 안내 캐시',
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '행 갱신',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '최초 생성',
  PRIMARY KEY (study_name),
  KEY idx_pp_needed (pp_needed, pp_status),
  KEY idx_job (job_id),
  KEY idx_updated (updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
  COMMENT='Optuna 스터디별 웹 결과·후처리 산출 (파일 JSON 대체)';
```

</details>

### 📡 실시간 마켓 데이터 (WebSocket / Market Data)


    parser.add_argument(
        "--study-name", default=None, dest="study_name",
        help="Study 이름 (미지정 시 OPTUNA_TAIL_STUDY_NAME 또는 tail_{mode}_{start}_{end})",
    )

    parser.add_argument(
        "--study-name", default=None, dest="study_name",
        help="Study 이름 (미지정 시 OPTUNA_TAIL_STUDY_NAME 또는 tail_{mode}_{start}_{end})",
    )
    parser.add_argument(
        "--study-trials", default=None, type=int, dest="study_trials",
        help="이 study 완료 목표. 미지정/0=이번 --trials. 완료 수 < 목표면 후처리 스킵",
    )

    n_trials = args.trials
    if n_trials is None:
        n_trials = get_env_int("PARAM_SEARCH_OPTUNA_N_TRIALS", 200)
    n_trials = max(1, int(n_trials))

    n_trials = args.trials
    if n_trials is None:
        n_trials = get_env_int("PARAM_SEARCH_OPTUNA_N_TRIALS", 200)
    n_trials = max(1, int(n_trials))

    from kis_trader.backtest.optuna_study_store import resolve_cli_study_trials
    _st_goal = resolve_cli_study_trials(getattr(args, "study_trials", None))
    if _st_goal > 0:
        os.environ["KIS_OPTUNA_STUDY_TRIALS"] = str(_st_goal)

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction=direction,
        sampler=sampler,
    )

    def objective(trial: optuna.Trial) -> float:

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction=direction,
        sampler=sampler,
    )
    from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export
    bind_study_trials(study, n_trials=n_trials, log=logger)

    def objective(trial: optuna.Trial) -> float:

        enrich_out_data_with_mode_combo(
            out_data,
            evaluate_fn=_eval_mode,
            grid_keys=ctx.grid_keys,
            log=logger,
            on_partial_save=_save_partial,
        )
        try:
            with open(out_path, "w", encoding="utf-8") as f:
                json.dump(out_data, f, indent=2, ensure_ascii=False)
        except OSError as exc:
            logger.warning("⚠ mode_combo 반영 재저장 실패: %s", exc)
        announce_optuna_json_path(
            out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger,
        )

        def _enrich() -> None:
            enrich_out_data_with_mode_combo(
                out_data,
                evaluate_fn=_eval_mode,
                grid_keys=ctx.grid_keys,
                log=logger,
                on_partial_save=_save_partial,
            )
            try:
                with open(out_path, "w", encoding="utf-8") as f:
                    json.dump(out_data, f, indent=2, ensure_ascii=False)
            except OSError as exc:
                logger.warning("⚠ mode_combo 반영 재저장 실패: %s", exc)
            announce_optuna_json_path(
                out_path, strategy="tail", mode=ctx.mode, note="최종 JSON", log=logger,
            )

        finalize_optuna_export(
            study,
            out_data=out_data,
            out_path=out_path,
            strategy="tail",
            mode=ctx.mode,
            enrich_fn=_enrich,
            log=logger,
        )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )
    from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export
    bind_study_trials(study, n_trials=n_trials, log=logger)

        enrich_out_data_with_mode_combo(
            out_data,
            evaluate_fn=_eval_mode,
            grid_keys=ctx.grid_keys,
            log=logger,
            on_partial_save=_save_partial,
        )
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(out_data, f, indent=2, ensure_ascii=False)
        announce_optuna_json_path(
            out_path, strategy=_ann_strat, mode=ctx.mode, note="최종 JSON", log=logger,
        )

        def _enrich() -> None:
            enrich_out_data_with_mode_combo(
                out_data,
                evaluate_fn=_eval_mode,
                grid_keys=ctx.grid_keys,
                log=logger,
                on_partial_save=_save_partial,
            )
            with open(out_path, "w", encoding="utf-8") as f:
                json.dump(out_data, f, indent=2, ensure_ascii=False)
            announce_optuna_json_path(
                out_path, strategy=_ann_strat, mode=ctx.mode, note="최종 JSON", log=logger,
            )

        finalize_optuna_export(
            study,
            out_data=out_data,
            out_path=out_path,
            strategy=_ann_strat,
            mode=ctx.mode,
            enrich_fn=_enrich,
            log=logger,
            symbol=_sym,
        )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )
    from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export
    bind_study_trials(study, n_trials=n_trials, log=logger)

        enrich_out_data_with_mode_combo(
            out_data,
            evaluate_fn=_eval_mode,
            grid_keys=ctx.grid_keys,
            log=logger,
            on_partial_save=_save_partial,
        )
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(out_data, f, indent=2, ensure_ascii=False)
        announce_optuna_json_path(
            out_path, strategy="breakout", mode=ctx.mode, note="최종 JSON", log=logger,
        )

        def _enrich() -> None:
            enrich_out_data_with_mode_combo(
                out_data,
                evaluate_fn=_eval_mode,
                grid_keys=ctx.grid_keys,
                log=logger,
                on_partial_save=_save_partial,
            )
            with open(out_path, "w", encoding="utf-8") as f:
                json.dump(out_data, f, indent=2, ensure_ascii=False)
            announce_optuna_json_path(
                out_path, strategy="breakout", mode=ctx.mode, note="최종 JSON", log=logger,
            )

        finalize_optuna_export(
            study,
            out_data=out_data,
            out_path=out_path,
            strategy="breakout",
            mode=ctx.mode,
            enrich_fn=_enrich,
            log=logger,
        )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )

    study = optuna.create_study(
        study_name=study_name,
        storage=storage_url,
        load_if_exists=True,
        direction="maximize",
        sampler=_make_sampler(sampler_name, seed),
    )
    from kis_trader.backtest.optuna_study_store import bind_study_trials, finalize_optuna_export
    bind_study_trials(study, n_trials=n_trials, log=logger)

        enrich_out_data_with_mode_combo(
            out_data,
            evaluate_fn=_eval_mode,
            grid_keys=ctx.grid_keys,
            log=logger,
            on_partial_save=_save_partial,
        )
        with open(out_path, "w", encoding="utf-8") as f:
            json.dump(out_data, f, indent=2, ensure_ascii=False)
        announce_optuna_json_path(
            out_path, strategy="scalp", mode=ctx.mode, note="최종 JSON", log=logger,
        )

        def _enrich() -> None:
            enrich_out_data_with_mode_combo(
                out_data,
                evaluate_fn=_eval_mode,
                grid_keys=ctx.grid_keys,
                log=logger,
                on_partial_save=_save_partial,
            )
            with open(out_path, "w", encoding="utf-8") as f:
                json.dump(out_data, f, indent=2, ensure_ascii=False)
            announce_optuna_json_path(
                out_path, strategy="scalp", mode=ctx.mode, note="최종 JSON", log=logger,
            )

        finalize_optuna_export(
            study,
            out_data=out_data,
            out_path=out_path,
            strategy="scalp",
            mode=ctx.mode,
            enrich_fn=_enrich,
            log=logger,
        )


    argv = [
        py_bin, "-u", "kis_trader/backtest/param_search_optuna.py",
        "--strategy", strat,
        "--mode", mode,
        "--start", start,
        "--end", end,
        "--trials", trials,
        "--min_trades", "1",
        "--min_win_rate", "0",
        "--min_pf", "0",
        "--orderbook-filter", "off",
        "--no-progress",
        "--study-name", study,
        "--sort-by", sort_by,
        "--universe-history-source", hist,
    ]
    st_goal = 0
    try:
        st_goal = int(meta.get("study_trials") or 0)
    except (TypeError, ValueError):
        st_goal = 0
    leftover = 0
    try:
        leftover = int(meta.get("leftover_trials") or 0)
    except (TypeError, ValueError):
        leftover = 0
    if st_goal <= 0:
        try:
            from kis_trader.backtest.optuna_study_store import flags_for_web
            fl = flags_for_web(study)
            st_goal = int(fl.get("study_trials") or 0)
            leftover = int(fl.get("leftover_trials") or leftover)
        except Exception:
            pass
    if st_goal > 0:
        argv.extend(["--study-trials", str(st_goal)])
        if leftover > 0:
            # --trials 는 이 프로세스 추가분 = 남은 횟수
            try:
                idx = argv.index("--trials")
                argv[idx + 1] = str(leftover)
            except (ValueError, IndexError):
                argv.extend(["--trials", str(leftover)])

    argv = [
        py_bin, "-u", "kis_trader/backtest/param_search_optuna.py",
        "--strategy", strat,
        "--mode", mode,
        "--start", start,
        "--end", end,
        "--trials", trials,
        "--min_trades", "1",
        "--min_win_rate", "0",
        "--min_pf", "0",
        "--orderbook-filter", "off",
        "--no-progress",
        "--study-name", study,
        "--sort-by", sort_by,
        "--universe-history-source", hist,
    ]

"PowerShell 은 python (python3 아님). --trials 는 이 PC 추가분, --study-trials 는 스터디 총 완료 목표.",

"PowerShell 은 python (python3 아님). --trials 는 이 PC 추가분 (웹 200+여기 200≈400).",

        "job_id": m.get("job_id"),
        "label": m.get("label") or m.get("strategy") or "",
        "strategy": m.get("strategy"),
        "start": m.get("start"),
        "end": m.get("end"),
        "status": m.get("status"),
        "phase": m.get("phase"),
        "trials": m.get("trials"),
        "study_trials": m.get("study_trials"),
        "n_complete": m.get("n_complete"),
        "leftover_trials": m.get("leftover_trials"),
        "leftover_note": m.get("leftover_note") or "",
        "can_continue": bool(m.get("can_continue")),
        "can_confirm": bool(m.get("can_confirm")),

        "job_id": m.get("job_id"),
        "label": m.get("label") or m.get("strategy") or "",
        "strategy": m.get("strategy"),
        "start": m.get("start"),
        "end": m.get("end"),
        "status": m.get("status"),
        "phase": m.get("phase"),
        "trials": m.get("trials"),


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

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

def _result_study_name(meta: Optional[Dict[str, Any]]) -> str:
    """테이블 PK. 순차 잡의 seq_* 이름은 쓰지 않고 현재/활성 study."""
    m = meta or {}
    kind = str(m.get("kind") or "")
    active = str(m.get("active_study_name") or "").strip()
    study = str(m.get("study_name") or "").strip()
    if kind in ("seq", "seq4") or study.startswith("seq_"):
        return active
    return active or study

def _attach_study_result_flags(m: Dict[str, Any]) -> None:
    name = _result_study_name(m)
    if not name:
        m["can_continue"] = False
        m["can_confirm"] = False
        return
    try:
        from kis_trader.backtest.optuna_study_store import flags_for_web

        fl = flags_for_web(name)
    except Exception:
        fl = {}
    m["study_trials"] = fl.get("study_trials") or m.get("study_trials") or 0
    m["n_complete"] = fl.get("n_complete")
    m["leftover_trials"] = fl.get("leftover_trials") or 0
    m["leftover_note"] = fl.get("leftover_note") or ""
    st = str(m.get("status") or "").strip().lower()
    leftover_ok = st == "done" and int(m.get("leftover_trials") or 0) > 0
    seq = str(m.get("kind") or "") in ("seq", "seq4")
    m["can_continue"] = leftover_ok and not seq
    m["can_confirm"] = leftover_ok and not seq

def _load_out_data_for_job(
    meta: Optional[Dict[str, Any]],
    path: Optional[str],
) -> tuple:
    """테이블 payload 우선, 없으면 JSON 파일. (data, path)."""
    name = _result_study_name(meta)
    data = None
    if name:
        try:
            from kis_trader.backtest.optuna_study_store import load_payload_dict

            data = load_payload_dict(name)
        except Exception:
            data = None
    used = str(path or (meta or {}).get("result_json") or "")
    if data is None and used and Path(used).is_file():
        data = json.loads(Path(used).read_text(encoding="utf-8"))
        if not name:
            name = str((data or {}).get("optuna_study_name") or "")
            if name:
                try:
                    from kis_trader.backtest.optuna_study_store import load_payload_dict

                    tbl = load_payload_dict(name)
                    if tbl:
                        data = tbl
                except Exception:
                    pass
    if data is None:
        raise FileNotFoundError("result_json 없음")
    if used and Path(used).is_file():
        return data, used
    # 적용/후처리 재실행용 임시 파일
    stem = name or "optuna_study"
    safe = re.sub(r"[^A-Za-z0-9._-]+", "_", stem)[:80]
    outp = RESULTS_DIR / f"{safe}_from_db.json"
    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    outp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    if meta is not None:
        meta["result_json"] = str(outp)
        try:
            save_job(meta)
        except Exception:
            pass
    return data, str(outp)


def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
    """잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외."""
    m = dict(meta or {})
    _attach_study_result_flags(m)

def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
    """잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외."""
    m = meta or {}


    elif not alive:
        # 프로세스 종료
        _has_tbl = False
        try:
            from kis_trader.backtest.optuna_study_store import load_payload_dict
            _has_tbl = bool(load_payload_dict(_result_study_name(m)))
        except Exception:
            _has_tbl = False
        if m.get("result_json") and Path(str(m.get("result_json"))).is_file():
            m["status"] = "done"
        elif _has_tbl:
            m["status"] = "done"

    elif not alive:
        # 프로세스 종료
        if m.get("result_json") and Path(str(m["result_json"])).is_file():
            m["status"] = "done"

    try:
        _data, _pth = _load_out_data_for_job(m, m.get("result_json"))
        m["result_json"] = _pth or m.get("result_json")
        m["result_summary"] = _summarize_result_json(m.get("result_json"))
    except Exception:
        m["result_summary"] = _summarize_result_json(m.get("result_json"))
    _attach_study_result_flags(m)

        path = path or meta.get("result_json")
    data, path = _load_out_data_for_job(meta, path)
    src = str(source or "gated").strip().lower()

        path = path or meta.get("result_json")
    if not path or not Path(path).is_file():
        raise FileNotFoundError("result_json 없음")
    data = json.loads(Path(path).read_text(encoding="utf-8"))
    src = str(source or "gated").strip().lower()

        path = path or meta.get("result_json")
    data, path = _load_out_data_for_job(meta, path)
    strat = _resolve_single_strategy(meta, data, path)

        path = path or meta.get("result_json")
    if not path or not Path(path).is_file():
        raise FileNotFoundError("result_json 없음")

    data = json.loads(Path(path).read_text(encoding="utf-8"))
    strat = _resolve_single_strategy(meta, data, path)


    entry_modes: Optional[Any] = None,
    sl_modes: Optional[Any] = None,
    study_trials: Optional[int] = None,
    study_name_override: Optional[str] = None,
) -> Dict[str, Any]:

    entry_modes: Optional[Any] = None,
    sl_modes: Optional[Any] = None,
) -> Dict[str, Any]:


    trials = max(1, min(2000, int(trials or 200)))
    from kis_trader.backtest.optuna_study_store import parse_study_trials_value
    st_goal = parse_study_trials_value(study_trials)
    reuse_study = str(study_name_override or "").strip()
    start = str(start or "").strip()

    trials = max(1, min(2000, int(trials or 200)))
    start = str(start or "").strip()

        env["TRIALS"] = str(trials)
        env["OPTUNA_SEQ_ACTIVE_FILE"] = str(seq_active)
        if st_goal > 0:
            env["STUDY_TRIALS"] = str(st_goal)
            env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
            env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal)

        env["TRIALS"] = str(trials)
        env["OPTUNA_SEQ_ACTIVE_FILE"] = str(seq_active)

            "--universe-history-source", hist_src,
        ]
        if st_goal > 0:
            cmd.extend(["--study-trials", str(st_goal)])
            env["KIS_OPTUNA_STUDY_TRIALS"] = str(st_goal)
            env["PARAM_SEARCH_OPTUNA_STUDY_TRIALS"] = str(st_goal)
        if strat == "tail":

            "--universe-history-source", hist_src,
        ]
        if strat == "tail":

        kind = "single"
        strat_field = strat

    if reuse_study:
        if use_seq:
            raise ValueError("이어 돌리기는 단일 전략만 가능 (시작 폼의 새 study 가 아님)")
        study_name = reuse_study
        if "--study-name" in cmd:
            _i = cmd.index("--study-name")
            if _i + 1 < len(cmd):
                cmd[_i + 1] = study_name

    log_f = open(log_path, "w", encoding="utf-8")

        kind = "single"
        strat_field = strat

    log_f = open(log_path, "w", encoding="utf-8")


    log_f = open(log_path, "w", encoding="utf-8")
    env["OPTUNA_WEB_JOB_ID"] = job_id
    # start_new_session: 세션 분리. 부모 wait 필수(reaper) — 없으면 좀비(Z).

    log_f = open(log_path, "w", encoding="utf-8")
    # start_new_session: 세션 분리. 부모 wait 필수(reaper) — 없으면 좀비(Z).

        "trials": trials,
        "study_trials": st_goal,
        "study_name": study_name,

        "trials": trials,
        "study_name": study_name,


    return refresh_job_status(meta)

def continue_optuna_job(job_id: str) -> Dict[str, Any]:
    """같은 study_name 으로 남은 횟수만 웹 잡 새로 띄움."""
    meta = load_job(job_id)
    if not meta:
        raise FileNotFoundError(f"job not found: {job_id}")
    if str(meta.get("kind") or "") in ("seq", "seq4"):
        raise RuntimeError("순차 잡은 이어 돌리기 불가 — 전략별 보기 잡에서 하세요")
    if str(meta.get("status") or "") != "done":
        raise RuntimeError("끝난 잡만 이어 돌리기 가능")
    name = _result_study_name(meta)
    if not name:
        raise RuntimeError("study 이름 없음")
    from kis_trader.backtest.optuna_study_store import flags_for_web

    fl = flags_for_web(name)
    left = int(fl.get("leftover_trials") or 0)
    goal = int(fl.get("study_trials") or 0)
    if left <= 0 or goal <= 0:
        raise RuntimeError("남은 횟수 없음 (이미 목표 도달)")
    picked = list(meta.get("strategies") or [])
    strat = str(meta.get("strategy") or "")
    return start_optuna_job(
        strategy=strat if ("," not in strat and strat not in ("all", "seq")) else None,
        strategies=picked or None,
        start=str(meta.get("start") or ""),
        end=str(meta.get("end") or ""),
        trials=left,
        mode=str(meta.get("mode") or "tpe"),
        symbol=meta.get("symbol"),
        universe_history_source=meta.get("universe_history_source"),
        candle_source=meta.get("candle_source"),
        tick_source=meta.get("tick_source"),
        ob_source=meta.get("ob_source"),
        entry_modes=meta.get("tail_entry_modes"),
        sl_modes=meta.get("breakout_sl_modes"),
        study_trials=goal,
        study_name_override=name,
    )

def confirm_optuna_study(job_id: str) -> Dict[str, Any]:
    """현재 완료 수로 목표를 줄이고 후처리 재실행."""
    meta = load_job(job_id)
    if not meta:
        raise FileNotFoundError(f"job not found: {job_id}")
    if str(meta.get("kind") or "") in ("seq", "seq4"):
        raise RuntimeError("순차 잡은 확정 불가 — 전략별 보기 잡에서 하세요")
    if str(meta.get("status") or "") != "done":
        raise RuntimeError("끝난 잡만 확정 가능")
    name = _result_study_name(meta)
    if not name:
        raise RuntimeError("study 이름 없음")
    from kis_trader.backtest.optuna_study_store import flags_for_web, set_study_trials_target
    from kis_trader.backtest.optuna_common import resolve_optuna_storage_url

    fl = flags_for_web(name)
    n_c = int(fl.get("n_complete") or 0)
    if n_c <= 0:
        raise RuntimeError("완료 trial 이 없어 확정할 수 없음")
    set_study_trials_target(name, n_c, storage_url=resolve_optuna_storage_url(None))
    _data, path = _load_out_data_for_job(meta, meta.get("result_json"))
    meta["result_json"] = path
    meta["study_trials"] = n_c
    save_job(meta)
    return start_postprocess_rerun(job_id)

def start_postprocess_rerun(job_id: str) -> Dict[str, Any]:

    return refresh_job_status(meta)

def start_postprocess_rerun(job_id: str) -> Dict[str, Any]:

    if str(pp.get("status") or "") == "running" and _pid_alive(pp.get("pid")):
        raise RuntimeError("이미 이 잡 후처리 재실행 중")
    _data, path = _load_out_data_for_job(meta, meta.get("result_json"))
    meta["result_json"] = path
    save_job(meta)
    if not path or not Path(path).is_file():
        raise FileNotFoundError("result_json 없음")

    if str(pp.get("status") or "") == "running" and _pid_alive(pp.get("pid")):
        raise RuntimeError("이미 이 잡 후처리 재실행 중")
    path = str(meta.get("result_json") or "")
    if not path or not Path(path).is_file():
        raise FileNotFoundError("result_json 없음")

        tmp.replace(p)
        topn = data.get("postprocess_topn") or {}
        n = len(topn.get("postprocess_by_anchor") or [])
        logger.info("📌 후처리 재저장 %s anchors=%d overfit=%s", p, n, topn.get("apply_overfit_pct"))
        try:
            from kis_trader.backtest.optuna_study_store import (
                count_study_states,
                save_payload,
                parse_study_trials_value,
            )
            import optuna
            from kis_trader.backtest.optuna_common import resolve_optuna_storage_url

            sname = str(data.get("optuna_study_name") or "")
            n_c = int(data.get("optuna_n_complete") or 0)
            if sname:
                try:
                    st = optuna.load_study(
                        study_name=sname,
                        storage=resolve_optuna_storage_url(None),
                    )
                    n_c, _nr = count_study_states(st)
                except Exception:
                    pass
                save_payload(
                    study_name=sname,
                    out_data=data,
                    n_complete=n_c,
                    pp_status="done",
                )
        except Exception as exc:
            logger.warning("⚠ optuna_study_result 반영 실패: %s", exc)
        return {"ok": True, "path": str(p), "anchors": n, "run_ob_whipsaw": True}

        tmp.replace(p)
        topn = data.get("postprocess_topn") or {}
        n = len(topn.get("postprocess_by_anchor") or [])
        logger.info("📌 후처리 재저장 %s anchors=%d overfit=%s", p, n, topn.get("apply_overfit_pct"))
        return {"ok": True, "path": str(p), "anchors": n, "run_ob_whipsaw": True}

    --universe-history-source "$UNIVERSE_HISTORY_SOURCE"
  )
  if [[ -n "${STUDY_TRIALS:-}" && "${STUDY_TRIALS}" != "0" ]]; then
    cmd_args+=(--study-trials "$STUDY_TRIALS")
  fi
  if [[ "$strat" == "tail" && -n "$entry_mode" ]]; then

    --universe-history-source "$UNIVERSE_HISTORY_SOURCE"
  )
  if [[ "$strat" == "tail" && -n "$entry_mode" ]]; then

echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS STUDY_TRIALS=${STUDY_TRIALS:-}"

echo "START=$START END=$END MODE=$MODE TRIALS=$TRIALS"


            from kis_trader.backtest.optuna_study_store import (
                count_study_states,
                save_payload,
            )

            from kis_trader.backtest.optuna_study_store import (
                count_study_states,
                save_payload,
                parse_study_trials_value,
            )

          trials
          <input type="number" id="opt_trials" class="form-control form-control-sm" style="width:90px" value="200" min="1" max="2000">

          스터디 총 횟수
          <input type="number" id="opt_study_trials" class="form-control form-control-sm" style="width:90px" min="0" max="5000" placeholder="비움=이번">

          trials
          <input type="number" id="opt_trials" class="form-control form-control-sm" style="width:90px" value="200" min="1" max="2000">

            trials
            <input type="number" class="form-control form-control-sm" id="usmom_opt_trials" value="100" min="1" max="2000" style="width:90px">

            스터디 총 횟수
            <input type="number" class="form-control form-control-sm" id="usmom_opt_study_trials" min="0" max="5000" placeholder="비움" style="width:90px">

            trials
            <input type="number" class="form-control form-control-sm" id="usmom_opt_trials" value="100" min="1" max="2000" style="width:90px">

    try:
        trials = int(body.get("trials") or request.form.get("trials") or 200)
    except (TypeError, ValueError):
        trials = 200
    study_trials = None
    raw_st = body.get("study_trials")
    if raw_st is None:
        raw_st = request.form.get("study_trials")
    try:
        if raw_st is not None and str(raw_st).strip() != "":
            study_trials = int(raw_st)
    except (TypeError, ValueError):
        study_trials = None
    study_name_override = (
        body.get("study_name") or request.form.get("study_name") or ""
    ).strip() or None

    try:
        trials = int(body.get("trials") or request.form.get("trials") or 200)
    except (TypeError, ValueError):
        trials = 200

            sl_modes=sl_modes,
            study_trials=study_trials,
            study_name_override=study_name_override,
        )

            sl_modes=sl_modes,
        )

@app.route("/api/optuna/continue", methods=["POST"])
def api_optuna_continue():
    """같은 study 로 남은 횟수 웹 잡."""
    from kis_trader.backtest import optuna_web_jobs as owj

    body = request.get_json(silent=True) or {}
    job_id = (body.get("job_id") or "").strip()
    if not job_id:
        return jsonify({"ok": False, "error": "job_id 필요"}), 400
    try:
        meta = owj.continue_optuna_job(job_id)
        return jsonify({"ok": True, "job": meta})
    except Exception as e:
        logger.warning("optuna continue failed: %s", e)
        return jsonify({"ok": False, "error": str(e)}), 400

@app.route("/api/optuna/confirm", methods=["POST"])
def api_optuna_confirm():
    """현재 완료 수로 목표 확정 + 후처리."""
    from kis_trader.backtest import optuna_web_jobs as owj

    body = request.get_json(silent=True) or {}
    job_id = (body.get("job_id") or "").strip()
    if not job_id:
        return jsonify({"ok": False, "error": "job_id 필요"}), 400
    try:
        out = owj.confirm_optuna_study(job_id)
        return jsonify(out if isinstance(out, dict) else {"ok": True, "job": out})
    except Exception as e:
        logger.warning("optuna confirm failed: %s", e)
        return jsonify({"ok": False, "error": str(e)}), 400

@app.route("/api/optuna/stop/<job_id>", methods=["POST"])


    m["postprocess"] = post
    if str(m.get("status") or "") == "done" and int(m.get("leftover_trials") or 0) > 0:
        m["phase"] = "leftover"
        post["ready"] = False
        post["hint"] = m.get("leftover_note") or "목표 미달 · 이어 돌리기 또는 확정"
        m["postprocess"] = post

  const trials = parseInt($('opt_trials')?.value || '200', 10);
  let studyTrials = parseInt($('opt_study_trials')?.value, 10);
  if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null;

const trials = parseInt($('opt_trials')?.value || '200', 10);

  if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}` +
    (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') +
    `\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return;

if (!confirm(`Optuna 시작?\n${how}: ${strategies.join(', ')}\n${start}~${end} trials=${trials}\n이력소스=${srcLabel}${emLabel}${slLabel}\n(DB 미적용)`)) return;

        strategies, start, end, trials, mode,
        study_trials: studyTrials || undefined,

  const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100;
  let studyTrials = parseInt($('usmom_opt_study_trials')?.value, 10);
  if (!Number.isFinite(studyTrials) || studyTrials <= 0) studyTrials = null;

const trials = parseInt($('usmom_opt_trials')?.value || '100', 10) || 100;

    `종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}` +
    (studyTrials ? `\n스터디 총 횟수=${studyTrials}` : '') +
    `\n` +

`종목 Optuna 시작?\n${code} (1종목 유니버스)\n${start}~${end} trials=${trials}\n` +

        start, end, trials, mode,
        study_trials: studyTrials || undefined,

const extraBtns = (st === 'done' && job.can_continue)
        ? `<button class="btn btn-sm btn-outline-primary py-0 ms-1" onclick="optunaContinue('${jid}')">이어 돌리기</button>` +
          `<button class="btn btn-sm btn-outline-warning py-0 ms-1" onclick="optunaConfirmStudy('${jid}')">확정</button>`
        : '';
      return `

        ${jid}
        ${label}
        ${job.start || ''}~${job.end || ''}
        ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}${job.leftover_note ? ' ·' + job.leftover_note : ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.study_trials ? ' ·목표 ' + (job.n_complete ?? '—') + '/' + job.study_trials : ''}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}
        ${job.started_at || ''}
        ${job.finished_at || '—'}

          <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button>
          ${extraBtns}
          <button class="btn btn-sm btn-outline-info py-0 ms-1" onclick="optunaOpenJoinCmd('${jid}')">명령</button>

`;

return `

        ${jid}
        ${label}
        ${job.start || ''}~${job.end || ''}
        ${stCell}${job.phase === 'postprocess' ? ' ·후처리' : ''}
        ${prog.trials_done ?? '—'}/${prog.trials_total ?? job.trials ?? '—'}${job.postprocess && job.postprocess.pct != null && job.phase === 'postprocess' ? ' ·후 ' + job.postprocess.pct + '%' : ''}
        ${job.started_at || ''}
        ${job.finished_at || '—'}

          <button class="btn btn-sm btn-outline-secondary py-0" onclick="optunaWatch('${jid}')">보기</button>
          <button class="btn btn-sm btn-outline-info py-0 ms-1" onclick="optunaOpenJoinCmd('${jid}')">명령</button>

`;

async function optunaContinue(jobId) {
  const jid = jobId || _optunaJobId;
  if (!jid) { alert('잡 없음'); return; }
  if (!confirm('같은 study 이름으로 남은 횟수를 웹에서 이어 돌릴까요?\n(시작 버튼의 새 이름은 쓰지 않습니다)')) return;
  try {
    const r = await fetch('/api/optuna/continue', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ job_id: jid }),
    });
    const j = await r.json();
    if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; }
    _optunaJobId = j.job.job_id;
    localStorage.setItem(OPTUNA_LS_KEY, _optunaJobId);
    optunaRenderJob(j.job);
    optunaStartPolling();
    optunaRefreshJobs();
  } catch (e) { alert('오류: ' + e); }
}

async function optunaConfirmStudy(jobId) {
  const jid = jobId || _optunaJobId;
  if (!jid) { alert('잡 없음'); return; }
  if (!confirm('지금 완료 수로 목표를 줄이고 후처리를 돌릴까요?\n윈도우/추가 웹 이어가기는 포기합니다. 실매 DB는 안 바뀝니다.')) return;
  try {
    const r = await fetch('/api/optuna/confirm', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ job_id: jid }),
    });
    const j = await r.json();
    if (!j.ok) { alert('❌ ' + (j.error || '실패')); return; }
    alert('확정 · 후처리 시작\n' + (j.log_path || j.note || ''));
    optunaRefreshJobs();
    if (_optunaJobId) optunaPollOnce();
  } catch (e) { alert('오류: ' + e); }
}

async function optunaStop() {


                pp_lock_until=DATE_ADD(NOW(), INTERVAL {int(mins)} MINUTE),
                pp_error=NULL
            WHERE study_name=%s
              AND pp_status<>'running'
              AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
            """,
            (owner, name),

                pp_lock_until=DATE_ADD(NOW(), INTERVAL %s MINUTE),
                pp_error=NULL
            WHERE study_name=%s
              AND pp_status<>'running'
              AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
            """,
            (owner, int(mins), name),


        cur = _db().conn.execute(
            f"""
            UPDATE optuna_study_result
            SET pp_status='running',
                pp_lock_owner=%s,
                pp_lock_until=DATE_ADD(NOW(), INTERVAL {int(mins)} MINUTE),
                pp_error=NULL
            WHERE study_name=%s
              AND pp_status<>'running'
              AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
            """,
            (owner, name),
        )

        cur = _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET pp_status='running',
                pp_lock_owner=%s,
                pp_lock_until=DATE_ADD(NOW(), INTERVAL {int(mins)} MINUTE),
                pp_error=NULL
            WHERE study_name=%s
              AND pp_status<>'running'
              AND (pp_lock_until IS NULL OR pp_lock_until < NOW())
            """,
            (owner, name),
        )

| `optuna_web_jobs.py` | 1,121 | 🔬 백테스트 | `backtest_portfolio_common`, `optuna_common`, `optuna_study_store` +6 |
| `optuna_study_store.py` | — | 🔬 백테스트 | `optuna_study_result` 테이블 (JSON 대체, 총 횟수 후처리 게이트) |

| `optuna_web_jobs.py` | 1,121 | 🔬 백테스트 | `backtest_portfolio_common`, `optuna_common`, `optuna_daily_trail_recommend` +6 |


Shell

python3 -m py_compile kis_trader/backtest/optuna_study_store.py kis_trader/backtest/param_search_optuna.py kis_trader/backtest/optuna_momentum.py kis_trader/backtest/optuna_breakout.py kis_trader/backtest/optuna_scalping.py kis_trader/backtest/optuna_web_jobs.py kis_trader/backtest/optuna_rerun_postprocess.py database.py backtest_web.py && python3 - <<'PY'
from kis_trader.backtest.optuna_study_store import ensure_optuna_study_result_table
ensure_optuna_study_result_table()
from database import TradeDB
db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM optuna_study_result").fetchall()]
print("ok", len(cols), cols[:6], "...", cols[-3:])
PY

Compile Python and create optuna_study_result table


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

Restart backtest web service and health-check


CallMcpTool

List open browser tabs


Open backtest web with cache-bust query


Lock browser for Optuna tab clicks


Click Optuna navigation tab


Grep

스터디 총 횟수|이어 돌리기|확정|보기|명령

CallMcpTool

(function(){const e=window.document.getElementById('opt_study_trials'); const btns=[...document.querySelectorAll('#opt_jobs_tbody button')].map(b=>b.textContent.trim()); const jobs=document.querySelector('#opt_jobs_tbody')?.innerText?.slice(0,400); const errs=[]; return JSON.stringify({hasStudyTrials:!!e, placeholder:e&&e.placeholder, btns:btns.slice(0,20), jobsHead:jobs});})()

Check Optuna study-trials input and job buttons


(function(){const b=document.querySelector('#opt_jobs_tbody button'); if(b) b.click(); const pp=document.getElementById('opt_btn_pp_rerun'); return JSON.stringify({clicked:b&&b.textContent, hasPpRerun:!!pp, ppText:pp&&pp.textContent});})()

Click first job 보기 and check 후처리 재실행 button

(function(){const us=document.getElementById('usmom_opt_study_trials'); return JSON.stringify({usStudyTrials:!!us, ph:us&&us.placeholder});})()

Check US momentum study-trials field exists


(function(){const btns=[...document.querySelectorAll('#opt_jobs_tbody button')]; const cmd=btns.find(b=>b.textContent.trim()==='시령'); if(cmd) cmd.click(); const ov=document.getElementById('opt_join_overlay'); const hasStudy=!!document.getElementById('opt_study_trials'); const txt=(document.getElementById('opt_join_cmd')||{}).textContent||''; return JSON.stringify({cmdClicked:!!cmd, overlayOpen:ov&&ov.classList.contains('is-open'), joinHasStudyTrials:txt.includes('--study-trials'), joinHead:txt.slice(0,180), hasStudyInput:hasStudy});})()

Open join overlay and inspect --study-trials

(function(){const logs=[]; try { /* recent errors from performance */ } catch(e) {} return JSON.stringify({optunaContinue:typeof optunaContinue, optunaConfirmStudy:typeof optunaConfirmStudy, uncaught: window.onerror});})()

Confirm JS continue/confirm functions exist


(function(){const cmd=[...document.querySelectorAll('#opt_jobs_tbody button')].find(b=>b.textContent.trim()==='명령'); if(cmd) cmd.click(); const ov=document.getElementById('opt_join_overlay'); const txt=(document.getElementById('opt_join_cmd')||{}).textContent||''; const ps=(document.getElementById('opt_join_cmd_ps')||{}).textContent||''; return JSON.stringify({clicked:!!cmd, overlay:ov&&ov.classList.contains('is-open'), shHas:txt.includes('--study-trials'), psHas:ps.includes('--study-trials'), shSlice:txt.slice(0,220)});})()

Click 명령 button and inspect join command


(function(){optunaCloseJoinCmd(); const errs=[]; return 'ok';})()

Close join overlay

Unlock browser after Optuna UI check


Optuna 결과는 이제 **파일 JSON이 아니라** `kis_quant_db.optuna_study_result`가 기준입니다. 최빈값·호가 후처리는 **스터디 완료 수가 총 횟수에 닿은 프로세스만** 지금과 같은 코드로 돌립니다.

**영향:** 실매 봇 없음 (`kis_trader_main` 미수정). 웹·파람만.

### 동작
- `--trials` / 웹 trials = **이번 프로세스가 더 돌릴 횟수**
- `--study-trials` / 「스터디 총 횟수」(비움=이번 trials) = **그 study의 완료 목표**
- 목표가 이미 `kis_study_trials`에 있으면 **더 작은 값으로 덮지 않음**
- 완료 수 < 목표 → 후처리 스킵, 테이블에 완료 수·중간 payload
- 완료 수 ≥ 목표 → 기존 `mode_combo`+호가 후처리 후 `payload_json` (DB 락으로 한 번만)
- 잡 목록 **보기** 옆: 목표 미달일 때만 **이어 돌리기**(같은 study) / **확정**(지금 수로 줄이고 후처리). **명령**은 항상. **이 잡 후처리 재실행**은 비교표 자리 유지

### 검증
- 웹: `kis_backtest_web.service` **active**, curl **200**
- 브라우저: `http://192.168.0.149:5050/` → Optuna 탭
  - 「스터디 총 횟수」칸(placeholder `비움=이번`)
  - 해외 모멘텀에도 동일 칸
  - 보기 / 명령 / 후처리 재실행 버튼
  - `optunaContinue` / `optunaConfirmStudy` 로드됨
  - 콘솔 오류 없음
- 기존 완료 잡(목표 미설정)에는 이어 돌리기/확정이 안 뜨는 것이 정상입니다
- live 스모크는 생략했습니다 (실매 코어 미수정)


<timestamp>Thursday, Aug 20, 2026, 7:52 AM (UTC+9)</timestamp>
<user_query>
Implement the plan as specified, it is attached for your reference. Do NOT edit the plan file itself.

To-do's from the plan have already been created. Do not create them again. Mark them as in_progress as you work, starting with the first one. Don't stop until you have completed all the to-dos.
</user_query>


<timestamp>Thursday, Aug 20, 2026, 7:52 AM (UTC+9)</timestamp>
<user_query>
기존 json 마이그레이션해줘야겠는데 기존 결과에 아무것도 안나옴?
</user_query>


기존 JSON이 화면에 안 뜨는 원인을 먼저 확인하고, 테이블로 마이그레이션하겠습니다.

TodoWrite

기존 JSON 안 보이는 원인 확인 + payload 미그레이션

JSON → optuna_study_result 마이그레이션

웹 읽기: 테이블 비어있으면 JSON 폴백

백테 재시작+브라우저로 기존 결과 표시 확인

Grep

def save_payload|def load_payload|def upsert_counts|def flags_for_web


웹이 테이블만 보고 JSON을 건너뛰는지, 기존 잡·파일 상태를 확인합니다.


Shell

python3 - <<'PY'
from database import TradeDB
db = TradeDB()
cols = [r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM optuna_study_result").fetchall()]
print("cols", cols)
n = db.conn.execute("SELECT COUNT(*) AS n FROM optuna_study_result").fetchone()
print("rows", n)
rows = db.conn.execute(
    "SELECT study_name, strategy, n_complete, study_trials, pp_status, "
    "IF(payload_json IS NULL,0,CHAR_LENGTH(payload_json)) AS plen "
    "FROM optuna_study_result ORDER BY updated_at DESC LIMIT 20"
).fetchall()
for r in rows:
    print(dict(r))
PY

Inspect optuna_study_result table row counts


Grep

async function optunaWatch|function optunaWatch|optunaPollOnce


Shell

python3 - <<'PY'
from pathlib import Path
root = Path("/home/hoon/kis_bot/kis_trader/backtest/results")
files = sorted(root.glob("optuna_*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
print("n", len(files))
for p in files[:8]:
    print(f"{p.stat().st_size/1e6:.2f}MB {p.name}")
print("max", max(p.stat().st_size for p in files)/1e6 if files else 0)
PY

Check Optuna JSON file sizes for migration


원인은 테이블이 비어 있는데 웹이 그걸 우선 본다는 점입니다. JSON을 넣고, 테이블에 행이 없으면 파일을 쓰도록 고칩니다.

StrReplace

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET payload_json=%s,
                briefing_md=%s,
                pp_status=%s,
                pp_error=%s,
                pp_for_n=IF(%s='done', %s, pp_for_n),
                pp_needed=IF(%s='done', 0, pp_needed),
                pp_lock_until=IF(%s='done' OR %s='error', NULL, pp_lock_until),
                pp_lock_owner=IF(%s='done' OR %s='error', NULL, pp_lock_owner)
            WHERE study_name=%s
            """,
            (
                payload,
                brief,
                str(pp_status or "idle")[:16],
                (str(pp_error)[:500] if pp_error else None),
                str(pp_status or ""),
                int(n_complete or 0),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                name,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def payload_has_rows(data: Optional[Dict[str, Any]]) -> bool:
    if not isinstance(data, dict):
        return False
    if data.get("results_gated") or data.get("results_all") or data.get("results") or data.get("results_stable"):
        return True
    mc = data.get("mode_combo") or {}
    return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))

def payload_row_count(data: Optional[Dict[str, Any]]) -> int:
    if not isinstance(data, dict):
        return 0
    return max(
        len(list(data.get("results_all") or [])),
        len(list(data.get("results") or [])),
        len(list(data.get("results_gated") or [])),
        len(list(data.get("results_stable") or [])),
    )

def _payload_has_postprocess(data: Optional[Dict[str, Any]]) -> bool:
    if not isinstance(data, dict):
        return False
    topn = data.get("postprocess_topn") or {}
    if isinstance(topn, dict) and topn.get("postprocess_by_anchor"):
        return True
    mc = data.get("mode_combo") or {}
    return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
    job_id: str = "",
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
        md = str(out_data.get("briefing_md") or "")
        if brief is None and md and md.endswith(".md") and os.path.isfile(md):
            try:
                with open(md, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    strat = str(out_data.get("strategy") or "")[:32]
    mode = str(out_data.get("mode") or "")[:32]
    start = (str(out_data.get("start") or "")[:10] or None)
    end = (str(out_data.get("end") or "")[:10] or None)
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = str(out_data.get("symbol") or "")[:16] or None
    st_goal = parse_study_trials_value(out_data.get("optuna_study_trials"))
    n_c = int(n_complete or out_data.get("optuna_n_complete") or out_data.get("optuna_trials_completed") or 0)
    st = str(pp_status or "idle")[:16]
    needed = 0 if st == "done" else 1
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, payload_json, briefing_md,
              pp_status, pp_error, pp_for_n, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete),
              payload_json=VALUES(payload_json),
              briefing_md=COALESCE(VALUES(briefing_md), briefing_md),
              pp_status=VALUES(pp_status),
              pp_error=VALUES(pp_error),
              pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n),
              pp_needed=IF(VALUES(pp_status)='done', 0, pp_needed),
              pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until),
              pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner)
            """,
            (
                name, strat, mode, start, end, jid, sym,
                int(st_goal or 0), n_c, payload, brief,
                st, (str(pp_error)[:500] if pp_error else None),
                n_c if st == "done" else 0, needed,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def ingest_out_data(
    data: Dict[str, Any],
    *,
    job_id: str = "",
    replace: bool = False,
) -> Dict[str, Any]:
    """JSON/out_data 한 건을 optuna_study_result 에 넣는다. 더 빈약한 파일로 덮지 않음."""
    if not isinstance(data, dict):
        return {"ok": False, "reason": "not_dict"}
    name = str(data.get("optuna_study_name") or data.get("study_name") or "").strip()
    if not name:
        return {"ok": False, "reason": "no_study_name"}
    old = load_payload_dict(name)
    if old and not replace:
        old_n = payload_row_count(old)
        new_n = payload_row_count(data)
        old_pp = _payload_has_postprocess(old)
        new_pp = _payload_has_postprocess(data)
        if old_pp and not new_pp and old_n >= new_n:
            return {"ok": True, "skipped": "keep_richer", "study_name": name}
        if old_n > new_n and not new_pp:
            return {"ok": True, "skipped": "keep_more_rows", "study_name": name}
    n_c = int(
        data.get("optuna_n_complete")
        or data.get("optuna_trials_completed")
        or payload_row_count(data)
        or 0
    )
    st_goal = parse_study_trials_value(data.get("optuna_study_trials"))
    upsert_counts(
        study_name=name,
        strategy=str(data.get("strategy") or ""),
        mode=str(data.get("mode") or ""),
        start=str(data.get("start") or ""),
        end=str(data.get("end") or ""),
        job_id=job_id,
        symbol=str(data.get("symbol") or ""),
        study_trials=st_goal,
        n_complete=n_c,
        n_running=0,
    )
    pp_done = _payload_has_postprocess(data)
    save_payload(
        study_name=name,
        out_data=data,
        n_complete=n_c,
        pp_status="done" if pp_done else "idle",
        job_id=job_id,
    )
    return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done}

def migrate_optuna_json_dir(
    results_dir: str,
    *,
    jobs_dir: str = "",
) -> Dict[str, Any]:
    """results/*.json + 웹 잡 result_json 을 테이블로 이관."""
    from pathlib import Path

    seen = set()
    ok_n = 0
    skip_n = 0
    err_n = 0
    paths = []
    rd = Path(results_dir)
    if rd.is_dir():
        paths.extend(sorted(rd.glob("optuna_*.json"), key=lambda p: p.stat().st_mtime))
    jd = Path(jobs_dir) if jobs_dir else None
    if jd and jd.is_dir():
        for jp in jd.glob("*.json"):
            try:
                meta = json.loads(jp.read_text(encoding="utf-8"))
            except Exception:
                continue
            rp = str((meta or {}).get("result_json") or "")
            if rp and Path(rp).is_file():
                paths.append(Path(rp))
            for extra in list((meta or {}).get("result_jsons") or []):
                if extra and Path(str(extra)).is_file():
                    paths.append(Path(str(extra)))
    for p in paths:
        try:
            key = str(p.resolve())
        except Exception:
            key = str(p)
        if key in seen or p.name.endswith("_from_db.json"):
            continue
        seen.add(key)
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
        except Exception as exc:
            err_n += 1
            logger.warning("⚠ migrate JSON 읽기 실패 %s: %s", p, exc)
            continue
        out = ingest_out_data(data, job_id="")
        if out.get("skipped"):
            skip_n += 1
        elif out.get("ok"):
            ok_n += 1
        else:
            err_n += 1
            logger.warning("⚠ migrate skip %s: %s", p.name, out.get("reason"))
    return {"ok": True, "ingested": ok_n, "skipped": skip_n, "errors": err_n, "files": len(seen)}


def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            UPDATE optuna_study_result
            SET payload_json=%s,
                briefing_md=%s,
                pp_status=%s,
                pp_error=%s,
                pp_for_n=IF(%s='done', %s, pp_for_n),
                pp_needed=IF(%s='done', 0, pp_needed),
                pp_lock_until=IF(%s='done' OR %s='error', NULL, pp_lock_until),
                pp_lock_owner=IF(%s='done' OR %s='error', NULL, pp_lock_owner)
            WHERE study_name=%s
            """,
            (
                payload,
                brief,
                str(pp_status or "idle")[:16],
                (str(pp_error)[:500] if pp_error else None),
                str(pp_status or ""),
                int(n_complete or 0),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                str(pp_status or ""),
                name,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def payload_has_rows(data: Optional[Dict[str, Any]]) -> bool:
    if not isinstance(data, dict):
        return False
    if data.get("results_gated") or data.get("results_all") or data.get("results") or data.get("results_stable"):
        return True
    mc = data.get("mode_combo") or {}
    return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))

def payload_row_count(data: Optional[Dict[str, Any]]) -> int:
    if not isinstance(data, dict):
        return 0
    return max(
        len(list(data.get("results_all") or [])),
        len(list(data.get("results") or [])),
        len(list(data.get("results_gated") or [])),
        len(list(data.get("results_stable") or [])),
    )

def _payload_has_postprocess(data: Optional[Dict[str, Any]]) -> bool:
    if not isinstance(data, dict):
        return False
    topn = data.get("postprocess_topn") or {}
    if isinstance(topn, dict) and topn.get("postprocess_by_anchor"):
        return True
    mc = data.get("mode_combo") or {}
    return bool(isinstance(mc, dict) and (mc.get("params") or mc.get("backtest")))

def save_payload(
    *,
    study_name: str,
    out_data: Dict[str, Any],
    n_complete: int = 0,
    briefing_text: Optional[str] = None,
    pp_status: str = "idle",
    pp_error: Optional[str] = None,
    job_id: str = "",
) -> None:
    name = str(study_name or "").strip()
    if not name or not isinstance(out_data, dict):
        return
    payload = json.dumps(out_data, ensure_ascii=False)
    brief = briefing_text
    if brief is None:
        bp = str(out_data.get("briefing_md_path") or "")
        if bp and os.path.isfile(bp):
            try:
                with open(bp, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
        md = str(out_data.get("briefing_md") or "")
        if brief is None and md and str(md).endswith(".md") and os.path.isfile(md):
            try:
                with open(md, encoding="utf-8") as f:
                    brief = f.read()
            except Exception:
                brief = None
    strat = str(out_data.get("strategy") or "")[:32]
    mode = str(out_data.get("mode") or "")[:32]
    start = (str(out_data.get("start") or "")[:10] or None)
    end = (str(out_data.get("end") or "")[:10] or None)
    jid = (job_id or os.environ.get("OPTUNA_WEB_JOB_ID") or "")[:64] or None
    sym = str(out_data.get("symbol") or "")[:16] or None
    st_goal = parse_study_trials_value(out_data.get("optuna_study_trials"))
    n_c = int(n_complete or out_data.get("optuna_n_complete") or out_data.get("optuna_trials_completed") or 0)
    st = str(pp_status or "idle")[:16]
    needed = 0 if st == "done" else 1
    try:
        ensure_optuna_study_result_table()
        _db().conn.execute(
            """
            INSERT INTO optuna_study_result (
              study_name, strategy, mode, start_date, end_date, job_id, symbol,
              study_trials, n_complete, payload_json, briefing_md,
              pp_status, pp_error, pp_for_n, pp_needed
            ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
            ON DUPLICATE KEY UPDATE
              strategy=IF(VALUES(strategy)='', strategy, VALUES(strategy)),
              mode=IF(VALUES(mode)='', mode, VALUES(mode)),
              start_date=COALESCE(VALUES(start_date), start_date),
              end_date=COALESCE(VALUES(end_date), end_date),
              job_id=COALESCE(VALUES(job_id), job_id),
              symbol=COALESCE(VALUES(symbol), symbol),
              study_trials=IF(VALUES(study_trials)>0, VALUES(study_trials), study_trials),
              n_complete=IF(VALUES(n_complete)>0, VALUES(n_complete), n_complete),
              payload_json=VALUES(payload_json),
              briefing_md=COALESCE(VALUES(briefing_md), briefing_md),
              pp_status=VALUES(pp_status),
              pp_error=VALUES(pp_error),
              pp_for_n=IF(VALUES(pp_status)='done', VALUES(pp_for_n), pp_for_n),
              pp_needed=IF(VALUES(pp_status)='done', 0, pp_needed),
              pp_lock_until=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_until),
              pp_lock_owner=IF(VALUES(pp_status)='done' OR VALUES(pp_status)='error', NULL, pp_lock_owner)
            """,
            (
                name, strat, mode, start, end, jid, sym,
                int(st_goal or 0), n_c, payload, brief,
                st, (str(pp_error)[:500] if pp_error else None),
                n_c if st == "done" else 0, needed,
            ),
        )
    except Exception as exc:
        logger.warning("⚠ optuna_study_result payload 저장 실패: %s", exc)

def ingest_out_data(
    data: Dict[str, Any],
    *,
    job_id: str = "",
    replace: bool = False,
) -> Dict[str, Any]:
    """JSON/out_data 한 건을 optuna_study_result 에 넣는다. 더 빈약한 파일로 덮지 않음."""
    if not isinstance(data, dict):
        return {"ok": False, "reason": "not_dict"}
    name = str(data.get("optuna_study_name") or data.get("study_name") or "").strip()
    if not name:
        return {"ok": False, "reason": "no_study_name"}
    old = load_payload_dict(name)
    if old and not replace:
        old_n = payload_row_count(old)
        new_n = payload_row_count(data)
        old_pp = _payload_has_postprocess(old)
        new_pp = _payload_has_postprocess(data)
        if old_pp and not new_pp and old_n >= new_n:
            return {"ok": True, "skipped": "keep_richer", "study_name": name}
        if old_n > new_n and not new_pp:
            return {"ok": True, "skipped": "keep_more_rows", "study_name": name}
    n_c = int(
        data.get("optuna_n_complete")
        or data.get("optuna_trials_completed")
        or payload_row_count(data)
        or 0
    )
    st_goal = parse_study_trials_value(data.get("optuna_study_trials"))
    upsert_counts(
        study_name=name,
        strategy=str(data.get("strategy") or ""),
        mode=str(data.get("mode") or ""),
        start=str(data.get("start") or ""),
        end=str(data.get("end") or ""),
        job_id=job_id,
        symbol=str(data.get("symbol") or ""),
        study_trials=st_goal,
        n_complete=n_c,
        n_running=0,
    )
    pp_done = _payload_has_postprocess(data)
    save_payload(
        study_name=name,
        out_data=data,
        n_complete=n_c,
        pp_status="done" if pp_done else "idle",
        job_id=job_id,
    )
    return {"ok": True, "study_name": name, "n": payload_row_count(data), "pp": pp_done}

def migrate_optuna_json_dir(
    results_dir: str,
    *,
    jobs_dir: str = "",
) -> Dict[str, Any]:
    """results/*.json + 웹 잡 result_json 을 테이블로 이관."""
    from pathlib import Path

    seen = set()
    ok_n = 0
    skip_n = 0
    err_n = 0
    paths = []
    rd = Path(results_dir)
    if rd.is_dir():
        paths.extend(sorted(rd.glob("optuna_*.json"), key=lambda p: p.stat().st_mtime))
    jd = Path(jobs_dir) if jobs_dir else None
    if jd and jd.is_dir():
        for jp in jd.glob("*.json"):
            try:
                meta = json.loads(jp.read_text(encoding="utf-8"))
            except Exception:
                continue
            rp = str((meta or {}).get("result_json") or "")
            if rp and Path(rp).is_file():
                paths.append(Path(rp))
            for extra in list((meta or {}).get("result_jsons") or []):
                if extra and Path(str(extra)).is_file():
                    paths.append(Path(str(extra)))
    for p in paths:
        try:
            key = str(p.resolve())
        except Exception:
            key = str(p)
        if key in seen or str(p.name).endswith("_from_db.json"):
            continue
        seen.add(key)
        try:
            data = json.loads(p.read_text(encoding="utf-8"))
        except Exception as exc:
            err_n += 1
            logger.warning("⚠ migrate JSON 읽기 실패 %s: %s", p, exc)
            continue
        out = ingest_out_data(data, job_id="")
        if out.get("skipped"):
            skip_n += 1
        elif out.get("ok"):
            ok_n += 1
        else:
            err_n += 1
            logger.warning("⚠ migrate skip %s: %s", p.name, out.get("reason"))
    return {"ok": True, "ingested": ok_n, "skipped": skip_n, "errors": err_n, "files": len(seen)}


def _load_out_data_for_job(
    meta: Optional[Dict[str, Any]],
    path: Optional[str],
) -> tuple:
    """테이블 payload(행 있음) 우선, 없으면 JSON 파일. (data, path)."""
    from kis_trader.backtest.optuna_study_store import load_payload_dict, payload_has_rows

    name = _result_study_name(meta)
    used = str(path or (meta or {}).get("result_json") or "")
    file_data = None
    if used and Path(used).is_file():
        try:
            file_data = json.loads(Path(used).read_text(encoding="utf-8"))
        except Exception:
            file_data = None
    tbl = None
    if name:
        try:
            tbl = load_payload_dict(name)
        except Exception:
            tbl = None
    if not name and isinstance(file_data, dict):
        name = str(file_data.get("optuna_study_name") or file_data.get("study_name") or "").strip()
        if name:
            try:
                tbl = load_payload_dict(name)
            except Exception:
                tbl = None
    data = tbl if payload_has_rows(tbl) else None
    if data is None:
        data = file_data if payload_has_rows(file_data) else file_data
    if not isinstance(data, dict):
        raise FileNotFoundError("result_json 없음")
    if used and Path(used).is_file():
        return data, used
    stem = name or "optuna_study"
    safe = re.sub(r"[^A-Za-z0-9._-]+", "_", stem)[:80]
    outp = RESULTS_DIR / f"{safe}_from_db.json"
    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    outp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    if meta is not None:
        meta["result_json"] = str(outp)
        try:
            save_job(meta)
        except Exception:
            pass
    return data, str(outp)

def _load_out_data_for_job(
    meta: Optional[Dict[str, Any]],
    path: Optional[str],
) -> tuple:
    """테이블 payload 우선, 없으면 JSON 파일. (data, path)."""
    name = _result_study_name(meta)
    data = None
    if name:
        try:
            from kis_trader.backtest.optuna_study_store import load_payload_dict

            data = load_payload_dict(name)
        except Exception:
            data = None
    used = str(path or (meta or {}).get("result_json") or "")
    if data is None and used and Path(used).is_file():
        data = json.loads(Path(used).read_text(encoding="utf-8"))
        if not name:
            name = str((data or {}).get("optuna_study_name") or "")
            if name:
                try:
                    from kis_trader.backtest.optuna_study_store import load_payload_dict

                    tbl = load_payload_dict(name)
                    if tbl:
                        data = tbl
                except Exception:
                    pass
    if data is None:
        raise FileNotFoundError("result_json 없음")
    if used and Path(used).is_file():
        return data, used
    # 적용/후처리 재실행용 임시 파일
    stem = name or "optuna_study"
    safe = re.sub(r"[^A-Za-z0-9._-]+", "_", stem)[:80]
    outp = RESULTS_DIR / f"{safe}_from_db.json"
    RESULTS_DIR.mkdir(parents=True, exist_ok=True)
    outp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    if meta is not None:
        meta["result_json"] = str(outp)
        try:
            save_job(meta)
        except Exception:
            pass
    return data, str(outp)

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 [])

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
    gated = list(data.get("results_gated") or [])

    prev_sum = m.get("result_summary") if isinstance(m.get("result_summary"), dict) else None
    try:
        _data, _pth = _load_out_data_for_job(m, m.get("result_json"))
        if _pth:
            m["result_json"] = _pth
        sm = _summarize_result_data(_data, path=_pth)
        if sm and (sm.get("top") or sm.get("n_all") or sm.get("n_gated")):
            m["result_summary"] = sm
        elif prev_sum:
            m["result_summary"] = prev_sum
        else:
            m["result_summary"] = sm
    except Exception:
        sm = _summarize_result_json(m.get("result_json"))
        m["result_summary"] = sm or prev_sum

    try:
        _data, _pth = _load_out_data_for_job(m, m.get("result_json"))
        m["result_json"] = _pth or m.get("result_json")
        m["result_summary"] = _summarize_result_json(m.get("result_json"))
    except Exception:
        m["result_summary"] = _summarize_result_json(m.get("result_json"))

    meta["result_summary"] = _summarize_result_json(str(path))
    try:
        from kis_trader.backtest.optuna_study_store import ingest_out_data

        ingest_out_data(data, job_id=job_id)
    except Exception:
        pass


Shell

python3 -m py_compile kis_trader/backtest/optuna_study_store.py kis_trader/backtest/optuna_web_jobs.py && python3 -u - <<'PY'
from kis_trader.backtest.optuna_study_store import migrate_optuna_json_dir
out = migrate_optuna_json_dir(
    "/home/hoon/kis_bot/kis_trader/backtest/results",
    jobs_dir="/home/hoon/kis_bot/logs/optuna_web_jobs",
)
print(out)
from database import TradeDB
db = TradeDB()
n = db.conn.execute("SELECT COUNT(*) AS n FROM optuna_study_result").fetchone()
nz = db.conn.execute(
    "SELECT COUNT(*) AS n FROM optuna_study_result WHERE payload_json IS NOT NULL AND CHAR_LENGTH(payload_json)>10"
).fetchone()
print("table_rows", dict(n), "with_payload", dict(nz))
row = db.conn.execute(
    "SELECT study_name, strategy, n_complete, CHAR_LENGTH(payload_json) plen, pp_status "
    "FROM optuna_study_result WHERE study_name LIKE %s LIMIT 3",
    ("breakout_atr_tpe_20260817%%",),
).fetchall()
print("sample", [dict(r) for r in row])
PY

Compile and migrate existing Optuna JSON into table


python3 - <<'PY'
from kis_trader.backtest.optuna_web_jobs import load_job, _load_out_data_for_job, _summarize_result_data
m = load_job("import_optuna_breakout_tpe_20260819_092934")
data, path = _load_out_data_for_job(m, m.get("result_json"))
sm = _summarize_result_data(data, path=path)
print("n_gated", sm.get("n_gated"), "n_all", sm.get("n_all"), "top_pnl", (sm.get("top") or {}).get("total_pnl"), "from", "table" if data.get("optuna_study_name") else "?")
print("has_pp", bool((data.get("postprocess_topn") or {}).get("postprocess_by_anchor")))
PY

Verify migrated payload summarizes Top5 for a known job


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 migration fix


CallMcpTool

Reload backtest dashboard after migration


Lock browser for Optuna verification


Open Optuna tab


(async function(){const r=await fetch('/api/optuna/status/'+encodeURIComponent('import_optuna_breakout_tpe_20260819_092934')); const j=await r.json(); const sum=j.job&&j.job.result_summary; return JSON.stringify({ok:j.ok, n_gated:sum&&sum.n_gated, n_all:sum&&sum.n_all, top:sum&&sum.top&&sum.top.total_pnl, top5:(sum&&sum.top5_gated||[]).length});})()

Fetch Optuna job status and check result_summary


(async function(){optunaWatch('import_optuna_breakout_tpe_20260819_092934'); await new Promise(r=>setTimeout(r,1500)); const top=document.getElementById('opt_st_top')?.innerText||''; const tb=document.getElementById('opt_top5_tbody')?.innerText||''; return JSON.stringify({top:top.slice(0,220), rows:tb.slice(0,300), tr:(document.querySelectorAll('#opt_top5_tbody tr')||[]).length});})()

Click 보기 and read rendered Top5 text


Unlock browser after verifying Top5


맞습니다. 웹이 결과를 **테이블 `payload_json` 기준**으로 보게 바뀌었는데, 기존 JSON은 한 건도 안 들어가 있어서 표가 비었습니다.

**한 일**
- `results/optuna_*.json` **163개**를 `optuna_study_result`로 이관했습니다. 스터디 **159행** (같은 study 이름은 1행).
- `save_payload`가 UPDATE만 해서 행이 없으면 저장이 안 되던 구멍을 **INSERT … ON DUPLICATE**로 고쳤습니다.
- 테이블에 후보 행이 없으면 **JSON 파일로 다시 읽습니다.** 빈 요약으로 예전 잡을 덮어쓰지 않습니다.

**확인**
- 웹 재시작: `active` / HTTP 200
- `http://192.168.0.149:5050/` Optuna → 보기
  돌파(atr) **gated 25/25 · Top5 5행 · PnL 7,337원**

CLI JSON이 새로 등록되면 그때도 테이블에 같이 넣습니다. 실매 봇은 그대로입니다.


<timestamp>Thursday, Aug 20, 2026, 8:01 AM (UTC+9)</timestamp>
<user_query>
후처리 비교표 (사후합격·안정 Top5 「상세」 · 열까지 적용=뒤쪽 축은 OFF · live 참고만)
  후처리 끝 · 「상세」 가능
합의(gated+mode, 실매 제외)
호가: 8방 미산출(구JSON) · 진입 없음 · 익절 없음 · 손절 없음 · 휩쏘: 있음 · 트레일: ARM 10,000
합의=구JSON 축분리 median(8방 없음). 후처리 재실행 권장.
gated#1 · trial #184 · 과적합 가능도 26% · 상대적으로 낮음
과적합%=추정. 사후합격 후보 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial #184
6건 · 승률 83.3% · 9,221원
·
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	구JSON	후처리 재실행 필요
100 진입만	구JSON	후처리 재실행 필요
8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.
8방 밖	성적	적용
휩쏘	sub=20 · lb=102 · dip=0.0056
필터후 6건 · 승률 83.3% · 9,221원
필터전 승률 83.3%
live · 실매 참고 (적용 없음)
과적합%=추정. 실매 trade_history 참고 — Optuna 칸과 섞지 않음 · 과적합% 제외 · 호가 경우의수=진입×익절×손절=8 · 휩쏘는 8방 밖
차트 타점: trial —
3건 · 승률 0.0% · 0원
· 참고
호가 8방 (진입×익절×손절)	trial	성적	적용
000 타점만(호가OFF)	구JSON	후처리 재실행 필요	참고
100 진입만	구JSON	후처리 재실행 필요	참고
8=2³. 휩쏘는 아래 별도. 구JSON이면 「후처리 재실행」 후 방별 숫자·적용이 채워집니다.
8방 밖	성적	적용
휩쏘	sub=29 · lb=75 · dip=0.005
필터후 3건 · 승률 0.0% · 0원
필터전 승률 0.0%
참고
선택 후보 상세 Top5「보기」선택
아직 선택 없음
—
브리핑 미리보기
# Optuna 브리핑 — 스캘핑 (tpe)
기간: 2026-08-17 ~ 2026-08-19 (거래일≈3일) · trials 200/200
탐색 게이트: WR≥0% PF≥0 · min_trades≥1 | 후보 게이트: WR≥40% PF≥1 · min_trades≥1
- min_trades=1: **합격 인정 최소 거래 건수**(표본 부족·우연 승률 방지). 이보다 적은 조합은 학습/후보 집계에서 제외됩니다.
학습용 통과 162건 · 사후합격(results_gated) 87건

## 과적합·적용 가능도 (휴리스틱)
- **과적합 위험 26.0%** · **적용 가능도 74.0%** · 판정: **상대적으로낮음** — 휴리스틱상 위험 상대적 낮음 — 그래도 다일 확인 권장
  - 표본 장일: +16.0p — 거래일≈3일 — 다일 재검증 권장(≥5일)
  - 거래 표본: +10.0p — 상위 후보 거래 6건 — 해석 시 주의
  - 승률·PF 이상치: +0.0p — 이상치 없음
  - 게이트 커버: +0.0p — 사후합격 87건 · stable 0건
  - PnL 고원: +0.0p — 고원 비율 1% (1건)
- 임계값 분포 표본: results_gated 상위 30건
| 파라미터 | median | p25~p75 | mode(비율) |
|---|---:|---|---|
| `rsi_period` | 12.0 | 10.25 ~ 14.0 | 14 (30%) |
| `rsi_oversold` | 23.5 | 20.625 ~ 25.375 | 24.0 (13%) |
| `rsi_overbought` | 82.0 | 78.75 ~ 83.0 | 85.0 (13%) |
| `sl_pct` | 3.0 | 2.7 ~ 3.5 | 3.5 (10%) |
| `tp_pct` | 1.0 | 0.7 ~ 1.4 | 0.7 (20%) |
| `tp_max_pct` | 8.25 | 7.5 ~ 9.5 | 7.5 (17%) |
| `drop_rate` | 5.7 | 5.5 ~ 5.9 | 5.7 (17%) |
| `shoulder_min_high` | 2.9 | 2.4 ~ 3.175 | 3.0 (17%) |
| `shoulder_cut_pct` | 0.325 | 0.25 ~ 0.5 | 0.25 (27%) |
| `cooldown_min` | 8.5 | 7.0 ~ 9.0 | 9 (30%) |
| `high_chase_thr` | 0.955 | 0.955 ~ 0.96 | 0.955 (37%) |
| `max_daily_chg` | 20.0 | 16.5 ~ 22.75 | 20.0 (13%) |
- _참고: 과적합%는 교차검증 점수가 아니라 표본·이상치·고원 휴리스틱입니다. 적용 가능도%=100−과적합위험%. DB 적용 버튼 활성 조건(gated PnL>0)과는 별개입니다._

## 이전 장에서는
- 실매 동기간 청산 8건 · 승률 0% · 합산 PnL -32,761원 (백테 Optuna와 숫자 달라도 정상 — 고스트·유니버스·틱 경로 차이).
- 사후합격 1위: trial #184 · 거래 6건 · 승률 83.3% · PF 10.96 · PnL 9,221원

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

_생성: 2026-08-19 02:53:02 (rule)_

## AI 코멘트

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

시작↓
job_id	전략	기간	상태	trial	시작	종료
import_optuna_breakout_tpe_20260819_092934	돌파(atr)	2026-08-17~2026-08-19	done	50/50	2026-08-19T09:30:00	2026-08-19T09:30:00
import_optuna_breakout_tpe_20260819_092802	돌파(fixed)	2026-08-17~2026-08-19	done	50/50	2026-08-19T09:28:22	2026-08-19T09:28:22
opt_20260819_092648_seq	순차(돌파(fixed+atr))	2026-08-17~2026-08-19	done	50/50	2026-08-19T09:26:48	2026-08-19T09:30:00
import_optuna_scalp_tpe_20260819_025229	스캘핑	2026-08-17~2026-08-19	done	—/200	2026-08-19T09:25:10	2026-08-19T09:25:10
opt_20260819_024852_scal	스캘핑	2026-08-17~2026-08-19	done	200/200	2026-08-19T02:48:52	2026-08-19T02:53:05
import_optuna_scalp_tpe_20260819_024351	스캘핑	2026-08-17~2026-08-18	done	50/50	2026-08-19T02:45:38	2026-08-19T02:45:38
opt_20260819_024238_scal	스캘핑	2026-08-17~2026-08-18	done	50/50	2026-08-19T02:42:38	2026-08-19T02:44:28
import_optuna_scalp_tpe_20260816_044543	스캘핑	2026-08-12~2026-08-14	done	250/250	2026-08-16T04:47:46	2026-08-16T04:47:46

안나오잖아 그렇다고 후처리 재실행도 없고
</user_query>


구JSON으로 뜨고 후처리 재실행 버튼이 없는 원인을 코드와 실제 payload에서 확인합니다.


Shell

python3 - <<'PY'
from database import TradeDB
import json
db = TradeDB()
# SHOW COLUMNS already known
rows = db.conn.execute(
    "SELECT study_name, strategy, CHAR_LENGTH(payload_json) plen, pp_status FROM optuna_study_result WHERE strategy=%s ORDER BY updated_at DESC LIMIT 5",
    ("scalp",),
).fetchall()
print("scalp rows", [dict(r) for r in rows])
name = "scalp_tpe_20260817_20260819"
# find study
alln = db.conn.execute(
    "SELECT study_name FROM optuna_study_result WHERE strategy=%s AND start_date=%s AND end_date=%s",
    ("scalp", "2026-08-17", "2026-08-19"),
).fetchall()
print("names", [dict(r) for r in alln])
if alln:
    sn = alln[0]["study_name"]
    raw = db.conn.execute(
        "SELECT payload_json FROM optuna_study_result WHERE study_name=%s", (sn,)
    ).fetchone()["payload_json"]
    data = json.loads(raw)
    topn = data.get("postprocess_topn") or {}
    print("study", sn)
    print("run_ob_whipsaw", topn.get("run_ob_whipsaw"))
    print("keys", list(topn.keys())[:20])
    anchors = topn.get("postprocess_by_anchor") or []
    print("n_anchors", len(anchors))
    if anchors:
        a = anchors[0]
        print("anchor0 keys", list(a.keys()))
        ob = a.get("orderbook") or {}
        print("ob keys", list(ob.keys()) if isinstance(ob, dict) else type(ob))
        print("combos", list((ob.get("combos") or {}).keys()) if isinstance(ob, dict) else None)
        print("ob.ok", ob.get("ok") if isinstance(ob, dict) else None)
        print("reason", ob.get("reason") if isinstance(ob, dict) else None)
    cons = topn.get("postprocess_consensus") or {}
    print("cons keys", list(cons.keys()))
    print("cons.combo", (cons.get("combo") or {}).get("ok"), (cons.get("combo") or {}).get("combo_id"))
    print("cons.note", cons.get("note"))
PY

Inspect scalp payload postprocess combos vs 구JSON


[REDACTED]


원인은 구JSON이 아닙니다. 후처리는 돌았고, 호가 스냅이 6건 중 2건이라 8방 TPE가 생략됐습니다. 화면이 ‘후처리 끝’과 ‘재실행 필요’를 동시에 말하고, 재실행은 job이 안 묶여 실패한 쪽으로 고칩니다.

[REDACTED]